From f81b1b931221c49162724730c223713860555aac Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 29 May 2020 15:50:10 +0200 Subject: [PATCH 01/13] Unified Attr and Relationship into common base class; matched-up namespaces --- benchmarks/BenchmarkResource.cs | 1 + src/Examples/GettingStarted/Models/Article.cs | 1 + src/Examples/GettingStarted/Models/Person.cs | 1 + .../Models/Article.cs | 1 + .../Models/ArticleTag.cs | 1 + .../JsonApiDotNetCoreExample/Models/Author.cs | 1 + .../Models/KebabCasedModel.cs | 1 + .../JsonApiDotNetCoreExample/Models/Model.cs | 1 + .../Models/Passport.cs | 1 + .../JsonApiDotNetCoreExample/Models/Person.cs | 1 + .../JsonApiDotNetCoreExample/Models/Tag.cs | 1 + .../Models/ThrowingResource.cs | 1 + .../Models/TodoItem.cs | 1 + .../Models/TodoItemCollection.cs | 1 + .../JsonApiDotNetCoreExample/Models/User.cs | 1 + .../JsonApiDotNetCoreExample/Models/Visa.cs | 2 +- .../Models/WorkItem.cs | 1 + src/Examples/ReportsExample/Models/Report.cs | 1 + .../Builders/ResourceGraphBuilder.cs | 19 +- .../Configuration/ILinksConfiguration.cs | 2 +- .../Controllers/BaseJsonApiController.cs | 1 - .../Data/DefaultResourceRepository.cs | 7 +- .../Data/IResourceReadRepository.cs | 1 + .../Data/IResourceWriteRepository.cs | 1 + .../Exceptions/InvalidModelStateException.cs | 4 +- .../Extensions/QueryableExtensions.cs | 22 +-- .../Graph/ResourceNameFormatter.cs | 2 +- src/JsonApiDotNetCore/Graph/TypeLocator.cs | 6 +- .../Hooks/Execution/DiffableEntityHashSet.cs | 1 + .../Hooks/Execution/EntityHashSet.cs | 1 + .../Hooks/Execution/HookExecutorHelper.cs | 1 + .../Hooks/Execution/IHookExecutorHelper.cs | 1 + .../Execution/RelationshipsDictionary.cs | 3 +- .../Hooks/ResourceHookExecutor.cs | 1 + .../Hooks/Traversal/RelationshipProxy.cs | 1 + .../RelationshipsFromPreviousLayer.cs | 1 + .../Hooks/Traversal/RootNode.cs | 1 + .../Hooks/Traversal/TraversalHelper.cs | 1 + .../Contracts/IResourceGraphExplorer.cs | 5 +- .../Internal/DefaultRoutingConvention.cs | 1 - .../Generics/GenericServiceFactory.cs | 12 +- .../RepositoryRelationshipUpdateHelper.cs | 5 +- .../Internal/InverseRelationships.cs | 4 +- .../Internal/Query/BaseQueryContext.cs | 6 +- .../Internal/ResourceContext.cs | 5 +- .../Internal/ResourceGraph.cs | 17 +- src/JsonApiDotNetCore/Internal/TypeHelper.cs | 3 +- .../Middleware/JsonApiMiddleware.cs | 2 +- .../Models/Annotation/AttrAttribute.cs | 56 ++---- .../Models/Annotation/EagerLoadAttribute.cs | 2 +- .../Models/Annotation/HasManyAttribute.cs | 10 +- .../Annotation/HasManyThroughAttribute.cs | 178 +++++++++--------- .../Models/Annotation/HasOneAttribute.cs | 33 ++-- .../Models/Annotation/LinksAttribute.cs | 3 +- .../Annotation/RelationshipAttribute.cs | 66 +++---- .../{ => Annotation}/ResourceAttribute.cs | 2 +- .../Annotation/ResourceFieldAttribute.cs | 36 ++++ .../Models/AttrCapabilities.cs | 1 + .../Models/IResourceField.cs | 7 - .../Models/ResourceDefinition.cs | 7 +- .../Common/QueryParameterService.cs | 8 +- .../Contracts/IIncludeService.cs | 2 +- .../Contracts/ISparseFieldsService.cs | 2 +- .../QueryParameterServices/FilterService.cs | 5 +- .../QueryParameterServices/IncludeService.cs | 4 +- .../QueryParameterServices/SortService.cs | 4 +- .../SparseFieldsService.cs | 19 +- .../Contracts/ICurrentRequest.cs | 2 +- .../Contracts/ITargetedFields.cs | 2 +- .../RequestServices/CurrentRequest.cs | 2 +- .../DefaultResourceChangeTracker.cs | 3 +- .../RequestServices/TargetedFields.cs | 2 +- .../Client/IRequestSerializer.cs | 2 +- .../Serialization/Client/RequestSerializer.cs | 2 +- .../Client/ResponseDeserializer.cs | 5 +- .../Common/BaseDocumentParser.cs | 13 +- .../Serialization/Common/DocumentBuilder.cs | 2 +- .../Common/IResourceObjectBuilder.cs | 1 + .../Common/ResourceObjectBuilder.cs | 9 +- .../Builders/IncludedResourceObjectBuilder.cs | 3 +- .../Server/Builders/LinkBuilder.cs | 5 +- .../Builders/ResponseResourceObjectBuilder.cs | 1 + .../Server/Contracts/IFieldsToSerialize.cs | 1 + .../IIncludedResourceObjectBuilder.cs | 1 + .../Server/Contracts/ILinkBuilder.cs | 3 +- .../Server/Contracts/IResponseSerializer.cs | 2 +- .../Serialization/Server/FieldsToSerialize.cs | 2 +- .../Server/RequestDeserializer.cs | 5 +- .../Server/ResponseSerializer.cs | 3 +- .../Services/DefaultResourceService.cs | 7 +- .../Data/EntityRepositoryTests.cs | 3 +- .../Acceptance/Spec/SparseFieldSetTests.cs | 2 +- .../Helpers/Models/TodoItemClient.cs | 5 +- .../Builders/ContextGraphBuilder_Tests.cs | 7 +- test/UnitTests/Builders/LinkBuilderTests.cs | 4 +- .../BaseJsonApiController_Tests.cs | 1 + .../Middleware/JsonApiMiddlewareTests.cs | 2 +- .../UnitTests/Models/AttributesEqualsTests.cs | 2 +- .../Models/ResourceDefinitionTests.cs | 12 +- .../QueryParameters/IncludeServiceTests.cs | 8 +- .../SparseFieldsServiceTests.cs | 12 +- .../AffectedEntitiesHelperTests.cs | 7 +- .../Read/BeforeReadTests.cs | 2 +- .../Update/BeforeUpdate_WithDbValues_Tests.cs | 2 +- .../ResourceHooks/ResourceHooksTestsSetup.cs | 3 +- .../Common/DocumentBuilderTests.cs | 1 + .../Common/DocumentParserTests.cs | 3 +- .../Serialization/DeserializerTestsSetup.cs | 5 +- .../Serialization/SerializerTestsSetup.cs | 2 +- .../IncludedResourceObjectBuilderTests.cs | 4 +- .../Server/RequestDeserializerTests.cs | 1 + .../ResponseResourceObjectBuilderTests.cs | 2 +- .../Server/ResponseSerializerTests.cs | 4 +- .../Services/EntityResourceService_Tests.cs | 2 +- test/UnitTests/TestModels.cs | 1 + 115 files changed, 415 insertions(+), 361 deletions(-) rename src/JsonApiDotNetCore/Models/{ => Annotation}/ResourceAttribute.cs (88%) create mode 100644 src/JsonApiDotNetCore/Models/Annotation/ResourceFieldAttribute.cs delete mode 100644 src/JsonApiDotNetCore/Models/IResourceField.cs diff --git a/benchmarks/BenchmarkResource.cs b/benchmarks/BenchmarkResource.cs index 6fe5eea7ca..d7dc1bcce6 100644 --- a/benchmarks/BenchmarkResource.cs +++ b/benchmarks/BenchmarkResource.cs @@ -1,4 +1,5 @@ using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; namespace Benchmarks { diff --git a/src/Examples/GettingStarted/Models/Article.cs b/src/Examples/GettingStarted/Models/Article.cs index b10ede2fb7..3c0226ec2c 100644 --- a/src/Examples/GettingStarted/Models/Article.cs +++ b/src/Examples/GettingStarted/Models/Article.cs @@ -1,4 +1,5 @@ using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; namespace GettingStarted.Models { diff --git a/src/Examples/GettingStarted/Models/Person.cs b/src/Examples/GettingStarted/Models/Person.cs index afbdc091ba..2bb49bfb2b 100644 --- a/src/Examples/GettingStarted/Models/Person.cs +++ b/src/Examples/GettingStarted/Models/Person.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; namespace GettingStarted.Models { diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Article.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Article.cs index bde8b8f310..13123fcda0 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Article.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Article.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations.Schema; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; namespace JsonApiDotNetCoreExample.Models { diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/ArticleTag.cs b/src/Examples/JsonApiDotNetCoreExample/Models/ArticleTag.cs index 57232e3b34..f5ae2cc7a5 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/ArticleTag.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/ArticleTag.cs @@ -1,5 +1,6 @@ using System; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; using JsonApiDotNetCoreExample.Data; namespace JsonApiDotNetCoreExample.Models diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Author.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Author.cs index 7af14d5235..88e413d562 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Author.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Author.cs @@ -1,5 +1,6 @@ using JsonApiDotNetCore.Models; using System.Collections.Generic; +using JsonApiDotNetCore.Models.Annotation; namespace JsonApiDotNetCoreExample.Models { diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/KebabCasedModel.cs b/src/Examples/JsonApiDotNetCoreExample/Models/KebabCasedModel.cs index ad36d928f3..85502e17ff 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/KebabCasedModel.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/KebabCasedModel.cs @@ -1,4 +1,5 @@ using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; namespace JsonApiDotNetCoreExample.Models { diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Model.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Model.cs index 977292d0a7..22da2c19fb 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Model.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Model.cs @@ -1,4 +1,5 @@ using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; namespace JsonApiDotNetCoreExample.Models { diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Passport.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Passport.cs index b9c37f447c..f73bfbd458 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Passport.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Passport.cs @@ -3,6 +3,7 @@ using System.ComponentModel.DataAnnotations.Schema; using System.Linq; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; using JsonApiDotNetCoreExample.Data; using Microsoft.AspNetCore.Authentication; diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs index 7c6a171854..8ee4c8aa62 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Linq; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; using JsonApiDotNetCore.Models.Links; namespace JsonApiDotNetCoreExample.Models diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs index e63df2b9ad..55c3f7c3de 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs @@ -1,6 +1,7 @@ using System; using System.ComponentModel.DataAnnotations; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; using JsonApiDotNetCoreExample.Data; namespace JsonApiDotNetCoreExample.Models diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/ThrowingResource.cs b/src/Examples/JsonApiDotNetCoreExample/Models/ThrowingResource.cs index 01eda5d7d0..935dcffb29 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/ThrowingResource.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/ThrowingResource.cs @@ -3,6 +3,7 @@ using System.Linq; using JsonApiDotNetCore.Formatters; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; namespace JsonApiDotNetCoreExample.Models { diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs index 000e60d238..c2385fd20f 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; namespace JsonApiDotNetCoreExample.Models { diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItemCollection.cs b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItemCollection.cs index edb6e98692..bd2aed6b10 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItemCollection.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItemCollection.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; namespace JsonApiDotNetCoreExample.Models { diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/User.cs b/src/Examples/JsonApiDotNetCoreExample/Models/User.cs index 89018b997b..2d70d12881 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/User.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/User.cs @@ -1,5 +1,6 @@ using System; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; using JsonApiDotNetCoreExample.Data; using Microsoft.AspNetCore.Authentication; diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Visa.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Visa.cs index a7b31743e2..7647647f73 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Visa.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Visa.cs @@ -1,5 +1,5 @@ using System; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; namespace JsonApiDotNetCoreExample.Models { diff --git a/src/Examples/NoEntityFrameworkExample/Models/WorkItem.cs b/src/Examples/NoEntityFrameworkExample/Models/WorkItem.cs index d2d1b274e6..2c00c8df6d 100644 --- a/src/Examples/NoEntityFrameworkExample/Models/WorkItem.cs +++ b/src/Examples/NoEntityFrameworkExample/Models/WorkItem.cs @@ -1,5 +1,6 @@ using System; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; namespace NoEntityFrameworkExample.Models { diff --git a/src/Examples/ReportsExample/Models/Report.cs b/src/Examples/ReportsExample/Models/Report.cs index b7433e89cc..66a5b21a48 100644 --- a/src/Examples/ReportsExample/Models/Report.cs +++ b/src/Examples/ReportsExample/Models/Report.cs @@ -1,4 +1,5 @@ using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; namespace ReportsExample.Models { diff --git a/src/JsonApiDotNetCore/Builders/ResourceGraphBuilder.cs b/src/JsonApiDotNetCore/Builders/ResourceGraphBuilder.cs index 558db0ad0a..c7b91130d3 100644 --- a/src/JsonApiDotNetCore/Builders/ResourceGraphBuilder.cs +++ b/src/JsonApiDotNetCore/Builders/ResourceGraphBuilder.cs @@ -8,9 +8,8 @@ using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Models.Links; +using JsonApiDotNetCore.Models.Annotation; using Microsoft.Extensions.Logging; -using Newtonsoft.Json.Serialization; namespace JsonApiDotNetCore.Builders { @@ -99,8 +98,8 @@ protected virtual List GetAttributes(Type entityType) { var idAttr = new AttrAttribute { - PublicAttributeName = FormatPropertyName(property), - PropertyInfo = property, + PublicName = FormatPropertyName(property), + Property = property, Capabilities = _options.DefaultAttrCapabilities }; attributes.Add(idAttr); @@ -110,8 +109,8 @@ protected virtual List GetAttributes(Type entityType) if (attribute == null) continue; - attribute.PublicAttributeName ??= FormatPropertyName(property); - attribute.PropertyInfo = property; + attribute.PublicName ??= FormatPropertyName(property); + attribute.Property = property; if (!attribute.HasExplicitCapabilities) { @@ -132,8 +131,8 @@ protected virtual List GetRelationships(Type entityType) var attribute = (RelationshipAttribute)prop.GetCustomAttribute(typeof(RelationshipAttribute)); if (attribute == null) continue; - attribute.PropertyInfo = prop; - attribute.PublicRelationshipName ??= FormatPropertyName(prop); + attribute.Property = prop; + attribute.PublicName ??= FormatPropertyName(prop); attribute.RightType = GetRelationshipType(attribute, prop); attribute.LeftType = entityType; attributes.Add(attribute); @@ -142,11 +141,11 @@ protected virtual List GetRelationships(Type entityType) { var throughProperty = properties.SingleOrDefault(p => p.Name == hasManyThroughAttribute.ThroughPropertyName); if (throughProperty == null) - throw new JsonApiSetupException($"Invalid {nameof(HasManyThroughAttribute)} on '{entityType}.{attribute.PropertyInfo.Name}': Resource does not contain a property named '{hasManyThroughAttribute.ThroughPropertyName}'."); + throw new JsonApiSetupException($"Invalid {nameof(HasManyThroughAttribute)} on '{entityType}.{attribute.Property.Name}': Resource does not contain a property named '{hasManyThroughAttribute.ThroughPropertyName}'."); var throughType = TryGetThroughType(throughProperty); if (throughType == null) - throw new JsonApiSetupException($"Invalid {nameof(HasManyThroughAttribute)} on '{entityType}.{attribute.PropertyInfo.Name}': Referenced property '{throughProperty.Name}' does not implement 'ICollection'."); + throw new JsonApiSetupException($"Invalid {nameof(HasManyThroughAttribute)} on '{entityType}.{attribute.Property.Name}': Referenced property '{throughProperty.Name}' does not implement 'ICollection'."); // ICollection hasManyThroughAttribute.ThroughProperty = throughProperty; diff --git a/src/JsonApiDotNetCore/Configuration/ILinksConfiguration.cs b/src/JsonApiDotNetCore/Configuration/ILinksConfiguration.cs index 7d1c1b38d8..5e665c47a7 100644 --- a/src/JsonApiDotNetCore/Configuration/ILinksConfiguration.cs +++ b/src/JsonApiDotNetCore/Configuration/ILinksConfiguration.cs @@ -1,4 +1,4 @@ -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; using JsonApiDotNetCore.Models.Links; namespace JsonApiDotNetCore.Configuration diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index e9464fb269..8e71f596b3 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs @@ -6,7 +6,6 @@ using JsonApiDotNetCore.Services; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -using Newtonsoft.Json.Serialization; namespace JsonApiDotNetCore.Controllers { diff --git a/src/JsonApiDotNetCore/Data/DefaultResourceRepository.cs b/src/JsonApiDotNetCore/Data/DefaultResourceRepository.cs index 28ba02f1bf..967139ca53 100644 --- a/src/JsonApiDotNetCore/Data/DefaultResourceRepository.cs +++ b/src/JsonApiDotNetCore/Data/DefaultResourceRepository.cs @@ -9,6 +9,7 @@ using JsonApiDotNetCore.Internal.Generics; using JsonApiDotNetCore.Internal.Query; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; using JsonApiDotNetCore.Serialization; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Query.Internal; @@ -166,7 +167,7 @@ private void LoadInverseRelationships(object trackedRelationshipValue, Relations private bool IsHasOneRelationship(string internalRelationshipName, Type type) { - var relationshipAttr = _resourceGraph.GetRelationships(type).FirstOrDefault(r => r.PropertyInfo.Name == internalRelationshipName); + var relationshipAttr = _resourceGraph.GetRelationships(type).FirstOrDefault(r => r.Property.Name == internalRelationshipName); if (relationshipAttr != null) { if (relationshipAttr is HasOneAttribute) @@ -268,7 +269,7 @@ private IEnumerable GetTrackedManyRelationshipValue(IEnumerable r if (tracked != null) newWasAlreadyAttached = true; return Convert.ChangeType(tracked ?? pointer, relationshipAttr.RightType); }) - .CopyToTypedCollection(relationshipAttr.PropertyInfo.PropertyType); + .CopyToTypedCollection(relationshipAttr.Property.PropertyType); if (newWasAlreadyAttached) wasAlreadyAttached = true; return trackedPointerCollection; @@ -439,7 +440,7 @@ protected void LoadCurrentRelationships(TResource oldEntity, RelationshipAttribu } else if (relationshipAttribute is HasManyAttribute hasManyAttribute) { - _context.Entry(oldEntity).Collection(hasManyAttribute.PropertyInfo.Name).Load(); + _context.Entry(oldEntity).Collection(hasManyAttribute.Property.Name).Load(); } } diff --git a/src/JsonApiDotNetCore/Data/IResourceReadRepository.cs b/src/JsonApiDotNetCore/Data/IResourceReadRepository.cs index d77b57e0ae..8cfeb0e3eb 100644 --- a/src/JsonApiDotNetCore/Data/IResourceReadRepository.cs +++ b/src/JsonApiDotNetCore/Data/IResourceReadRepository.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using JsonApiDotNetCore.Internal.Query; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; namespace JsonApiDotNetCore.Data { diff --git a/src/JsonApiDotNetCore/Data/IResourceWriteRepository.cs b/src/JsonApiDotNetCore/Data/IResourceWriteRepository.cs index 54d43367dd..f1fe5e9672 100644 --- a/src/JsonApiDotNetCore/Data/IResourceWriteRepository.cs +++ b/src/JsonApiDotNetCore/Data/IResourceWriteRepository.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Threading.Tasks; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; namespace JsonApiDotNetCore.Data { diff --git a/src/JsonApiDotNetCore/Exceptions/InvalidModelStateException.cs b/src/JsonApiDotNetCore/Exceptions/InvalidModelStateException.cs index e227ab30ce..ddd1e790c2 100644 --- a/src/JsonApiDotNetCore/Exceptions/InvalidModelStateException.cs +++ b/src/JsonApiDotNetCore/Exceptions/InvalidModelStateException.cs @@ -3,7 +3,7 @@ using System.Linq; using System.Net; using System.Reflection; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; using JsonApiDotNetCore.Models.JsonApiDocuments; using Microsoft.AspNetCore.Mvc.ModelBinding; using Newtonsoft.Json.Serialization; @@ -34,7 +34,7 @@ private static List FromModelState(ModelStateDictionary modelState, Type PropertyInfo property = resourceType.GetProperty(propertyName); string attributeName = - property.GetCustomAttribute().PublicAttributeName ?? namingStrategy.GetPropertyName(property.Name, false); + property.GetCustomAttribute().PublicName ?? namingStrategy.GetPropertyName(property.Name, false); foreach (var modelError in pair.Value.Errors) { diff --git a/src/JsonApiDotNetCore/Extensions/QueryableExtensions.cs b/src/JsonApiDotNetCore/Extensions/QueryableExtensions.cs index c4c4cee0ba..01ed0e30e2 100644 --- a/src/JsonApiDotNetCore/Extensions/QueryableExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/QueryableExtensions.cs @@ -191,18 +191,18 @@ private static Expression GetFilterExpressionLambda(Expression left, Expression private static IQueryable CallGenericWhereContainsMethod(IQueryable source, FilterQueryContext filter) { var concreteType = typeof(TSource); - var property = concreteType.GetProperty(filter.Attribute.PropertyInfo.Name); + var property = concreteType.GetProperty(filter.Attribute.Property.Name); var propertyValues = filter.Value.Split(QueryConstants.COMMA); ParameterExpression entity = Expression.Parameter(concreteType, "entity"); MemberExpression member; if (filter.IsAttributeOfRelationship) { - var relation = Expression.PropertyOrField(entity, filter.Relationship.PropertyInfo.Name); - member = Expression.Property(relation, filter.Attribute.PropertyInfo.Name); + var relation = Expression.PropertyOrField(entity, filter.Relationship.Property.Name); + member = Expression.Property(relation, filter.Attribute.Property.Name); } else - member = Expression.Property(entity, filter.Attribute.PropertyInfo.Name); + member = Expression.Property(entity, filter.Attribute.Property.Name); var method = ContainsMethod.MakeGenericMethod(member.Type); var list = TypeHelper.CreateListFor(member.Type); @@ -262,25 +262,25 @@ private static IQueryable CallGenericWhereMethod(IQueryableThe open generic type, e.g. `typeof(IResourceService<>)` /// Parameters to the generic type /// - /// - /// GetGenericInterfaceImplementation(assembly, typeof(IResourceService<>), typeof(Article), typeof(Guid)); - /// + /// ), typeof(Article), typeof(Guid)); + /// ]]> /// public static (Type implementation, Type registrationInterface) GetGenericInterfaceImplementation(Assembly assembly, Type openGenericInterfaceType, params Type[] genericInterfaceArguments) { diff --git a/src/JsonApiDotNetCore/Hooks/Execution/DiffableEntityHashSet.cs b/src/JsonApiDotNetCore/Hooks/Execution/DiffableEntityHashSet.cs index 62f2d3c16a..c42537a884 100644 --- a/src/JsonApiDotNetCore/Hooks/Execution/DiffableEntityHashSet.cs +++ b/src/JsonApiDotNetCore/Hooks/Execution/DiffableEntityHashSet.cs @@ -7,6 +7,7 @@ using JsonApiDotNetCore.Extensions; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; using JsonApiDotNetCore.Serialization; namespace JsonApiDotNetCore.Hooks diff --git a/src/JsonApiDotNetCore/Hooks/Execution/EntityHashSet.cs b/src/JsonApiDotNetCore/Hooks/Execution/EntityHashSet.cs index a29b30e391..a5c6d039a5 100644 --- a/src/JsonApiDotNetCore/Hooks/Execution/EntityHashSet.cs +++ b/src/JsonApiDotNetCore/Hooks/Execution/EntityHashSet.cs @@ -4,6 +4,7 @@ using JsonApiDotNetCore.Internal; using System; using System.Linq.Expressions; +using JsonApiDotNetCore.Models.Annotation; namespace JsonApiDotNetCore.Hooks { diff --git a/src/JsonApiDotNetCore/Hooks/Execution/HookExecutorHelper.cs b/src/JsonApiDotNetCore/Hooks/Execution/HookExecutorHelper.cs index 1af718f817..1173bf4bad 100644 --- a/src/JsonApiDotNetCore/Hooks/Execution/HookExecutorHelper.cs +++ b/src/JsonApiDotNetCore/Hooks/Execution/HookExecutorHelper.cs @@ -11,6 +11,7 @@ using RightType = System.Type; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Models.Annotation; namespace JsonApiDotNetCore.Hooks { diff --git a/src/JsonApiDotNetCore/Hooks/Execution/IHookExecutorHelper.cs b/src/JsonApiDotNetCore/Hooks/Execution/IHookExecutorHelper.cs index 046d12609c..379b2c0d0a 100644 --- a/src/JsonApiDotNetCore/Hooks/Execution/IHookExecutorHelper.cs +++ b/src/JsonApiDotNetCore/Hooks/Execution/IHookExecutorHelper.cs @@ -2,6 +2,7 @@ using System.Collections; using System.Collections.Generic; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; namespace JsonApiDotNetCore.Hooks { diff --git a/src/JsonApiDotNetCore/Hooks/Execution/RelationshipsDictionary.cs b/src/JsonApiDotNetCore/Hooks/Execution/RelationshipsDictionary.cs index 5ff77c35aa..35317a868f 100644 --- a/src/JsonApiDotNetCore/Hooks/Execution/RelationshipsDictionary.cs +++ b/src/JsonApiDotNetCore/Hooks/Execution/RelationshipsDictionary.cs @@ -5,6 +5,7 @@ using System.Linq.Expressions; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; namespace JsonApiDotNetCore.Hooks { @@ -94,7 +95,7 @@ public Dictionary> GetByRelationship(T public HashSet GetAffected(Expression> navigationAction) { var property = TypeHelper.ParseNavigationExpression(navigationAction); - return this.Where(p => p.Key.PropertyInfo.Name == property.Name).Select(p => p.Value).SingleOrDefault(); + return this.Where(p => p.Key.Property.Name == property.Name).Select(p => p.Value).SingleOrDefault(); } } } diff --git a/src/JsonApiDotNetCore/Hooks/ResourceHookExecutor.cs b/src/JsonApiDotNetCore/Hooks/ResourceHookExecutor.cs index a1b5bf71b8..e03c992bf0 100644 --- a/src/JsonApiDotNetCore/Hooks/ResourceHookExecutor.cs +++ b/src/JsonApiDotNetCore/Hooks/ResourceHookExecutor.cs @@ -9,6 +9,7 @@ using RightType = System.Type; using JsonApiDotNetCore.Extensions; using JsonApiDotNetCore.Internal.Contracts; +using JsonApiDotNetCore.Models.Annotation; using JsonApiDotNetCore.Serialization; using JsonApiDotNetCore.Query; diff --git a/src/JsonApiDotNetCore/Hooks/Traversal/RelationshipProxy.cs b/src/JsonApiDotNetCore/Hooks/Traversal/RelationshipProxy.cs index f0babe9dd7..17d4f83415 100644 --- a/src/JsonApiDotNetCore/Hooks/Traversal/RelationshipProxy.cs +++ b/src/JsonApiDotNetCore/Hooks/Traversal/RelationshipProxy.cs @@ -4,6 +4,7 @@ using JsonApiDotNetCore.Extensions; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; namespace JsonApiDotNetCore.Hooks { diff --git a/src/JsonApiDotNetCore/Hooks/Traversal/RelationshipsFromPreviousLayer.cs b/src/JsonApiDotNetCore/Hooks/Traversal/RelationshipsFromPreviousLayer.cs index 386cf75e9f..77b8c4b56b 100644 --- a/src/JsonApiDotNetCore/Hooks/Traversal/RelationshipsFromPreviousLayer.cs +++ b/src/JsonApiDotNetCore/Hooks/Traversal/RelationshipsFromPreviousLayer.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; namespace JsonApiDotNetCore.Hooks { diff --git a/src/JsonApiDotNetCore/Hooks/Traversal/RootNode.cs b/src/JsonApiDotNetCore/Hooks/Traversal/RootNode.cs index 3e8f368a41..190a082cf9 100644 --- a/src/JsonApiDotNetCore/Hooks/Traversal/RootNode.cs +++ b/src/JsonApiDotNetCore/Hooks/Traversal/RootNode.cs @@ -4,6 +4,7 @@ using System.Linq; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; namespace JsonApiDotNetCore.Hooks { diff --git a/src/JsonApiDotNetCore/Hooks/Traversal/TraversalHelper.cs b/src/JsonApiDotNetCore/Hooks/Traversal/TraversalHelper.cs index 46da95ef28..5eaacb26c1 100644 --- a/src/JsonApiDotNetCore/Hooks/Traversal/TraversalHelper.cs +++ b/src/JsonApiDotNetCore/Hooks/Traversal/TraversalHelper.cs @@ -7,6 +7,7 @@ using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; using JsonApiDotNetCore.Serialization; using RightType = System.Type; using LeftType = System.Type; diff --git a/src/JsonApiDotNetCore/Internal/Contracts/IResourceGraphExplorer.cs b/src/JsonApiDotNetCore/Internal/Contracts/IResourceGraphExplorer.cs index 9a21c6a726..8fb6e9f318 100644 --- a/src/JsonApiDotNetCore/Internal/Contracts/IResourceGraphExplorer.cs +++ b/src/JsonApiDotNetCore/Internal/Contracts/IResourceGraphExplorer.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq.Expressions; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; namespace JsonApiDotNetCore.Internal.Contracts { @@ -18,7 +19,7 @@ public interface IResourceGraph : IResourceContextProvider /// /// The resource for which to retrieve fields /// Should be of the form: (TResource e) => new { e.Field1, e.Field2 } - List GetFields(Expression> selector = null) where TResource : IIdentifiable; + List GetFields(Expression> selector = null) where TResource : IIdentifiable; /// /// Gets all attributes for /// that are targeted by the selector. If no selector is provided, all @@ -39,7 +40,7 @@ public interface IResourceGraph : IResourceContextProvider /// Gets all exposed fields (attributes and relationships) for type /// /// The resource type. Must extend IIdentifiable. - List GetFields(Type type); + List GetFields(Type type); /// /// Gets all exposed attributes for type /// diff --git a/src/JsonApiDotNetCore/Internal/DefaultRoutingConvention.cs b/src/JsonApiDotNetCore/Internal/DefaultRoutingConvention.cs index 9a327ebff2..d29d3d1222 100644 --- a/src/JsonApiDotNetCore/Internal/DefaultRoutingConvention.cs +++ b/src/JsonApiDotNetCore/Internal/DefaultRoutingConvention.cs @@ -9,7 +9,6 @@ using JsonApiDotNetCore.Models; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ApplicationModels; -using Newtonsoft.Json.Serialization; namespace JsonApiDotNetCore.Internal { diff --git a/src/JsonApiDotNetCore/Internal/Generics/GenericServiceFactory.cs b/src/JsonApiDotNetCore/Internal/Generics/GenericServiceFactory.cs index acd3f88b06..dd51b55532 100644 --- a/src/JsonApiDotNetCore/Internal/Generics/GenericServiceFactory.cs +++ b/src/JsonApiDotNetCore/Internal/Generics/GenericServiceFactory.cs @@ -14,9 +14,9 @@ public interface IGenericServiceFactory /// Constructs the generic type and locates the service, then casts to TInterface /// /// - /// - /// Get<IGenericProcessor>(typeof(GenericProcessor<>), typeof(TResource)); - /// + /// (typeof(GenericProcessor<>), typeof(TResource)); + /// ]]> /// TInterface Get(Type openGenericType, Type resourceType); @@ -24,9 +24,9 @@ public interface IGenericServiceFactory /// Constructs the generic type and locates the service, then casts to TInterface /// /// - /// - /// Get<IGenericProcessor>(typeof(GenericProcessor<,>), typeof(TResource), typeof(TId)); - /// + /// (typeof(GenericProcessor<>), typeof(TResource), typeof(TId)); + /// ]]> /// TInterface Get(Type openGenericType, Type resourceType, Type keyType); } diff --git a/src/JsonApiDotNetCore/Internal/Generics/RepositoryRelationshipUpdateHelper.cs b/src/JsonApiDotNetCore/Internal/Generics/RepositoryRelationshipUpdateHelper.cs index ccd734a9bd..28d34e76b4 100644 --- a/src/JsonApiDotNetCore/Internal/Generics/RepositoryRelationshipUpdateHelper.cs +++ b/src/JsonApiDotNetCore/Internal/Generics/RepositoryRelationshipUpdateHelper.cs @@ -7,6 +7,7 @@ using JsonApiDotNetCore.Data; using JsonApiDotNetCore.Extensions; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; using Microsoft.EntityFrameworkCore; namespace JsonApiDotNetCore.Internal.Generics @@ -76,7 +77,7 @@ private async Task UpdateOneToManyAsync(IIdentifiable parent, RelationshipAttrib IEnumerable value; if (!relationshipIds.Any()) { - var collectionType = relationship.PropertyInfo.PropertyType.ToConcreteCollectionType(); + var collectionType = relationship.Property.PropertyType.ToConcreteCollectionType(); value = (IEnumerable)TypeHelper.CreateInstance(collectionType); } else @@ -95,7 +96,7 @@ private async Task UpdateOneToManyAsync(IIdentifiable parent, RelationshipAttrib var containsLambda = Expression.Lambda>(callContains, parameter); var resultSet = await _context.Set().Where(containsLambda).ToListAsync(); - value = resultSet.CopyToTypedCollection(relationship.PropertyInfo.PropertyType); + value = resultSet.CopyToTypedCollection(relationship.Property.PropertyType); } relationship.SetValue(parent, value, _resourceFactory); diff --git a/src/JsonApiDotNetCore/Internal/InverseRelationships.cs b/src/JsonApiDotNetCore/Internal/InverseRelationships.cs index ebef063067..52326ab10a 100644 --- a/src/JsonApiDotNetCore/Internal/InverseRelationships.cs +++ b/src/JsonApiDotNetCore/Internal/InverseRelationships.cs @@ -1,6 +1,6 @@ using JsonApiDotNetCore.Data; using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata; @@ -52,7 +52,7 @@ public void Resolve() foreach (var attr in ce.Relationships) { if (attr is HasManyThroughAttribute) continue; - INavigation inverseNavigation = meta.FindNavigation(attr.PropertyInfo.Name)?.FindInverse(); + INavigation inverseNavigation = meta.FindNavigation(attr.Property.Name)?.FindInverse(); attr.InverseNavigation = inverseNavigation?.Name; } } diff --git a/src/JsonApiDotNetCore/Internal/Query/BaseQueryContext.cs b/src/JsonApiDotNetCore/Internal/Query/BaseQueryContext.cs index 61a342b14e..0f6d7cca8c 100644 --- a/src/JsonApiDotNetCore/Internal/Query/BaseQueryContext.cs +++ b/src/JsonApiDotNetCore/Internal/Query/BaseQueryContext.cs @@ -1,4 +1,4 @@ -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; namespace JsonApiDotNetCore.Internal.Query { @@ -23,9 +23,9 @@ protected BaseQueryContext(TQuery query) public string GetPropertyPath() { if (IsAttributeOfRelationship) - return $"{Relationship.PropertyInfo.Name}.{Attribute.PropertyInfo.Name}"; + return $"{Relationship.Property.Name}.{Attribute.Property.Name}"; - return Attribute.PropertyInfo.Name; + return Attribute.Property.Name; } } } diff --git a/src/JsonApiDotNetCore/Internal/ResourceContext.cs b/src/JsonApiDotNetCore/Internal/ResourceContext.cs index 2ebf4c1d64..694ae89f35 100644 --- a/src/JsonApiDotNetCore/Internal/ResourceContext.cs +++ b/src/JsonApiDotNetCore/Internal/ResourceContext.cs @@ -3,6 +3,7 @@ using System.Linq; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; using JsonApiDotNetCore.Models.Links; namespace JsonApiDotNetCore.Internal @@ -47,8 +48,8 @@ public class ResourceContext /// public List EagerLoads { get; set; } - private List _fields; - public List Fields { get { return _fields ??= Attributes.Cast().Concat(Relationships).ToList(); } } + private List _fields; + public List Fields { get { return _fields ??= Attributes.Cast().Concat(Relationships).ToList(); } } /// /// Configures which links to show in the diff --git a/src/JsonApiDotNetCore/Internal/ResourceGraph.cs b/src/JsonApiDotNetCore/Internal/ResourceGraph.cs index ee056ad4c2..548d1d24f8 100644 --- a/src/JsonApiDotNetCore/Internal/ResourceGraph.cs +++ b/src/JsonApiDotNetCore/Internal/ResourceGraph.cs @@ -4,6 +4,7 @@ using System.Linq.Expressions; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; namespace JsonApiDotNetCore.Internal { @@ -31,7 +32,7 @@ public ResourceContext GetResourceContext(Type resourceType) public ResourceContext GetResourceContext() where TResource : class, IIdentifiable => GetResourceContext(typeof(TResource)); /// - public List GetFields(Expression> selector = null) where T : IIdentifiable + public List GetFields(Expression> selector = null) where T : IIdentifiable { return Getter(selector).ToList(); } @@ -46,7 +47,7 @@ public List GetRelationships(Expression().ToList(); } /// - public List GetFields(Type type) + public List GetFields(Type type) { return GetResourceContext(type).Fields.ToList(); } @@ -66,12 +67,12 @@ public RelationshipAttribute GetInverse(RelationshipAttribute relationship) if (relationship.InverseNavigation == null) return null; return GetResourceContext(relationship.RightType) .Relationships - .SingleOrDefault(r => r.PropertyInfo.Name == relationship.InverseNavigation); + .SingleOrDefault(r => r.Property.Name == relationship.InverseNavigation); } - private IEnumerable Getter(Expression> selector = null, FieldFilterType type = FieldFilterType.None) where T : IIdentifiable + private IEnumerable Getter(Expression> selector = null, FieldFilterType type = FieldFilterType.None) where T : IIdentifiable { - IEnumerable available; + IEnumerable available; if (type == FieldFilterType.Attribute) available = GetResourceContext(typeof(T)).Attributes; else if (type == FieldFilterType.Relationship) @@ -82,7 +83,7 @@ private IEnumerable Getter(Expression> selec if (selector == null) return available; - var targeted = new List(); + var targeted = new List(); var selectorBody = RemoveConvert(selector.Body); @@ -90,7 +91,7 @@ private IEnumerable Getter(Expression> selec { // model => model.Field1 try { - targeted.Add(available.Single(f => f.PropertyName == memberExpression.Member.Name)); + targeted.Add(available.Single(f => f.Property.Name == memberExpression.Member.Name)); return targeted; } catch (InvalidOperationException) @@ -110,7 +111,7 @@ private IEnumerable Getter(Expression> selec foreach (var member in newExpression.Members) { memberName = member.Name; - targeted.Add(available.Single(f => f.PropertyName == memberName)); + targeted.Add(available.Single(f => f.Property.Name == memberName)); } return targeted; } diff --git a/src/JsonApiDotNetCore/Internal/TypeHelper.cs b/src/JsonApiDotNetCore/Internal/TypeHelper.cs index 2288c35f71..94f1b9998d 100644 --- a/src/JsonApiDotNetCore/Internal/TypeHelper.cs +++ b/src/JsonApiDotNetCore/Internal/TypeHelper.cs @@ -6,6 +6,7 @@ using System.Linq.Expressions; using JsonApiDotNetCore.Extensions; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; namespace JsonApiDotNetCore.Internal { @@ -139,7 +140,7 @@ public static Dictionary> ConvertRelat /// public static Dictionary> ConvertAttributeDictionary(List attributes, HashSet entities) { - return attributes?.ToDictionary(attr => attr.PropertyInfo, attr => entities); + return attributes?.ToDictionary(attr => attr.Property, attr => entities); } /// diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs b/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs index 8aaa401dca..8add1495c2 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs @@ -162,7 +162,7 @@ private static void SetupCurrentRequest(ICurrentRequest currentRequest, Resource { currentRequest.RequestRelationship = resourceContext.Relationships.SingleOrDefault(relationship => - relationship.PublicRelationshipName == (string) relationshipName); + relationship.PublicName == (string) relationshipName); } } diff --git a/src/JsonApiDotNetCore/Models/Annotation/AttrAttribute.cs b/src/JsonApiDotNetCore/Models/Annotation/AttrAttribute.cs index cd701f64eb..217191df4a 100644 --- a/src/JsonApiDotNetCore/Models/Annotation/AttrAttribute.cs +++ b/src/JsonApiDotNetCore/Models/Annotation/AttrAttribute.cs @@ -1,12 +1,15 @@ using System; -using System.Reflection; using JsonApiDotNetCore.Internal; -namespace JsonApiDotNetCore.Models +namespace JsonApiDotNetCore.Models.Annotation { [AttributeUsage(AttributeTargets.Property)] - public sealed class AttrAttribute : Attribute, IResourceField + public sealed class AttrAttribute : ResourceFieldAttribute { + internal bool HasExplicitCapabilities { get; } + + public AttrCapabilities Capabilities { get; internal set; } + /// /// Exposes a resource property as a json:api attribute using the configured casing convention and capabilities. /// @@ -26,19 +29,13 @@ public AttrAttribute() /// /// Exposes a resource property as a json:api attribute with an explicit name, using configured capabilities. /// - public AttrAttribute(string publicName) + public AttrAttribute(string publicName) + : base(publicName) { if (publicName == null) { throw new ArgumentNullException(nameof(publicName)); } - - if (string.IsNullOrWhiteSpace(publicName)) - { - throw new ArgumentException("Exposed name cannot be empty or contain only whitespace.", nameof(publicName)); - } - - PublicAttributeName = publicName; } /// @@ -62,27 +59,13 @@ public AttrAttribute(AttrCapabilities capabilities) /// /// Exposes a resource property as a json:api attribute with an explicit name and capabilities. /// - public AttrAttribute(string publicName, AttrCapabilities capabilities) : this(publicName) + public AttrAttribute(string publicName, AttrCapabilities capabilities) + : this(publicName) { HasExplicitCapabilities = true; Capabilities = capabilities; } - string IResourceField.PropertyName => PropertyInfo.Name; - - /// - /// The publicly exposed name of this json:api attribute. - /// - public string PublicAttributeName { get; internal set; } - - internal bool HasExplicitCapabilities { get; } - public AttrCapabilities Capabilities { get; internal set; } - - /// - /// The resource property that this attribute is declared on. - /// - public PropertyInfo PropertyInfo { get; internal set; } - /// /// Get the value of the attribute for the given object. /// Returns null if the attribute does not belong to the @@ -95,12 +78,12 @@ public object GetValue(object entity) throw new ArgumentNullException(nameof(entity)); } - if (PropertyInfo.GetMethod == null) + if (Property.GetMethod == null) { - throw new InvalidOperationException($"Property '{PropertyInfo.DeclaringType?.Name}.{PropertyInfo.Name}' is write-only."); + throw new InvalidOperationException($"Property '{Property.DeclaringType?.Name}.{Property.Name}' is write-only."); } - return PropertyInfo.GetValue(entity); + return Property.GetValue(entity); } /// @@ -113,19 +96,14 @@ public void SetValue(object entity, object newValue) throw new ArgumentNullException(nameof(entity)); } - if (PropertyInfo.SetMethod == null) + if (Property.SetMethod == null) { throw new InvalidOperationException( - $"Property '{PropertyInfo.DeclaringType?.Name}.{PropertyInfo.Name}' is read-only."); + $"Property '{Property.DeclaringType?.Name}.{Property.Name}' is read-only."); } - var convertedValue = TypeHelper.ConvertType(newValue, PropertyInfo.PropertyType); - PropertyInfo.SetValue(entity, convertedValue); + var convertedValue = TypeHelper.ConvertType(newValue, Property.PropertyType); + Property.SetValue(entity, convertedValue); } - - /// - /// Whether or not the provided exposed name is equivalent to the one defined in on the model - /// - public bool Is(string publicRelationshipName) => publicRelationshipName == PublicAttributeName; } } diff --git a/src/JsonApiDotNetCore/Models/Annotation/EagerLoadAttribute.cs b/src/JsonApiDotNetCore/Models/Annotation/EagerLoadAttribute.cs index e5ff487083..78a599757a 100644 --- a/src/JsonApiDotNetCore/Models/Annotation/EagerLoadAttribute.cs +++ b/src/JsonApiDotNetCore/Models/Annotation/EagerLoadAttribute.cs @@ -2,7 +2,7 @@ using System.Collections.Generic; using System.Reflection; -namespace JsonApiDotNetCore.Models +namespace JsonApiDotNetCore.Models.Annotation { /// /// Used to unconditionally load a related entity that is not exposed as a json:api relationship. diff --git a/src/JsonApiDotNetCore/Models/Annotation/HasManyAttribute.cs b/src/JsonApiDotNetCore/Models/Annotation/HasManyAttribute.cs index 540f0dba00..aa63f90613 100644 --- a/src/JsonApiDotNetCore/Models/Annotation/HasManyAttribute.cs +++ b/src/JsonApiDotNetCore/Models/Annotation/HasManyAttribute.cs @@ -1,7 +1,7 @@ using System; using JsonApiDotNetCore.Models.Links; -namespace JsonApiDotNetCore.Models +namespace JsonApiDotNetCore.Models.Annotation { [AttributeUsage(AttributeTargets.Property)] public class HasManyAttribute : RelationshipAttribute @@ -15,15 +15,13 @@ public class HasManyAttribute : RelationshipAttribute /// Whether or not this relationship can be included using the ?include=public-name query string /// /// - /// - /// + /// Articles { get; set; } /// } - /// - /// + /// ]]> /// public HasManyAttribute(string publicName = null, Link relationshipLinks = Link.All, bool canInclude = true, string inverseNavigationProperty = null) : base(publicName, relationshipLinks, canInclude) diff --git a/src/JsonApiDotNetCore/Models/Annotation/HasManyThroughAttribute.cs b/src/JsonApiDotNetCore/Models/Annotation/HasManyThroughAttribute.cs index d4ad24c2f7..1cb0e980ed 100644 --- a/src/JsonApiDotNetCore/Models/Annotation/HasManyThroughAttribute.cs +++ b/src/JsonApiDotNetCore/Models/Annotation/HasManyThroughAttribute.cs @@ -7,7 +7,7 @@ using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models.Links; -namespace JsonApiDotNetCore.Models +namespace JsonApiDotNetCore.Models.Annotation { /// /// Create a HasMany relationship through a many-to-many join relationship. @@ -19,97 +19,16 @@ namespace JsonApiDotNetCore.Models /// through the navigation property `ArticleTags`. /// The `Tags` property is decorated as `NotMapped` so that EF does not try /// to map this to a database relationship. - /// + /// + /// public ICollection Tags { get; set; } + /// public ICollection ArticleTags { get; set; } + /// ]]> /// [AttributeUsage(AttributeTargets.Property)] public sealed class HasManyThroughAttribute : HasManyAttribute { - /// - /// Create a HasMany relationship through a many-to-many join relationship. - /// The public name exposed through the API will be based on the configured convention. - /// - /// - /// The name of the navigation property that will be used to get the HasMany relationship - /// Which links are available. Defaults to - /// Whether or not this relationship can be included using the ?include=public-name query string - /// - /// - /// - /// [HasManyThrough(nameof(ArticleTags), relationshipLinks: Link.All, canInclude: true)] - /// - /// - public HasManyThroughAttribute(string throughPropertyName, Link relationshipLinks = Link.All, bool canInclude = true) - : base(null, relationshipLinks, canInclude) - { - ThroughPropertyName = throughPropertyName; - } - - /// - /// Create a HasMany relationship through a many-to-many join relationship. - /// - /// - /// The relationship name as exposed by the API - /// The name of the navigation property that will be used to get the HasMany relationship - /// Which links are available. Defaults to - /// Whether or not this relationship can be included using the ?include=public-name query string - /// - /// - /// - /// [HasManyThrough("tags", nameof(ArticleTags), relationshipLinks: Link.All, canInclude: true)] - /// - /// - public HasManyThroughAttribute(string publicName, string throughPropertyName, Link relationshipLinks = Link.All, bool canInclude = true) - : base(publicName, relationshipLinks, canInclude) - { - ThroughPropertyName = throughPropertyName; - } - - /// - /// Traverses through the provided entity and returns the - /// value of the relationship on the other side of a join entity - /// (e.g. Articles.ArticleTags.Tag). - /// - public override object GetValue(object entity) - { - IEnumerable joinEntities = (IEnumerable)ThroughProperty.GetValue(entity) ?? Array.Empty(); - - IEnumerable rightEntities = joinEntities - .Cast() - .Select(rightEntity => RightProperty.GetValue(rightEntity)); - - return rightEntities.CopyToTypedCollection(PropertyInfo.PropertyType); - } - - /// - public override void SetValue(object entity, object newValue, IResourceFactory resourceFactory) - { - base.SetValue(entity, newValue, resourceFactory); - - if (newValue == null) - { - ThroughProperty.SetValue(entity, null); - } - else - { - List joinEntities = new List(); - foreach (IIdentifiable resource in (IEnumerable)newValue) - { - object joinEntity = resourceFactory.CreateInstance(ThroughType); - LeftProperty.SetValue(joinEntity, entity); - RightProperty.SetValue(joinEntity, resource); - joinEntities.Add(joinEntity); - } - - var typedCollection = joinEntities.CopyToTypedCollection(ThroughProperty.PropertyType); - ThroughProperty.SetValue(entity, typedCollection); - } - } - /// /// The name of the join property on the parent resource. /// @@ -196,9 +115,9 @@ public override void SetValue(object entity, object newValue, IResourceFactory r /// In the `[HasManyThrough("tags", nameof(ArticleTags))]` example /// this would point to the `Article.ArticleTags` property /// - /// - /// public ICollection<ArticleTags> ArticleTags { get; set; } - /// + /// ArticleTags { get; set; } + /// ]]> /// /// public PropertyInfo ThroughProperty { get; internal set; } @@ -208,5 +127,86 @@ public override void SetValue(object entity, object newValue, IResourceFactory r /// "ArticleTags.Tag" /// public override string RelationshipPath => $"{ThroughProperty.Name}.{RightProperty.Name}"; + + /// + /// Create a HasMany relationship through a many-to-many join relationship. + /// The public name exposed through the API will be based on the configured convention. + /// + /// + /// The name of the navigation property that will be used to get the HasMany relationship + /// Which links are available. Defaults to + /// Whether or not this relationship can be included using the ?include=public-name query string + /// + /// + /// + /// [HasManyThrough(nameof(ArticleTags), relationshipLinks: Link.All, canInclude: true)] + /// + /// + public HasManyThroughAttribute(string throughPropertyName, Link relationshipLinks = Link.All, bool canInclude = true) + : base(null, relationshipLinks, canInclude) + { + ThroughPropertyName = throughPropertyName; + } + + /// + /// Create a HasMany relationship through a many-to-many join relationship. + /// + /// + /// The relationship name as exposed by the API + /// The name of the navigation property that will be used to get the HasMany relationship + /// Which links are available. Defaults to + /// Whether or not this relationship can be included using the ?include=public-name query string + /// + /// + /// + /// [HasManyThrough("tags", nameof(ArticleTags), relationshipLinks: Link.All, canInclude: true)] + /// + /// + public HasManyThroughAttribute(string publicName, string throughPropertyName, Link relationshipLinks = Link.All, bool canInclude = true) + : base(publicName, relationshipLinks, canInclude) + { + ThroughPropertyName = throughPropertyName; + } + + /// + /// Traverses through the provided entity and returns the + /// value of the relationship on the other side of a join entity + /// (e.g. Articles.ArticleTags.Tag). + /// + public override object GetValue(object entity) + { + IEnumerable joinEntities = (IEnumerable)ThroughProperty.GetValue(entity) ?? Array.Empty(); + + IEnumerable rightEntities = joinEntities + .Cast() + .Select(rightEntity => RightProperty.GetValue(rightEntity)); + + return rightEntities.CopyToTypedCollection(Property.PropertyType); + } + + /// + public override void SetValue(object entity, object newValue, IResourceFactory resourceFactory) + { + base.SetValue(entity, newValue, resourceFactory); + + if (newValue == null) + { + ThroughProperty.SetValue(entity, null); + } + else + { + List joinEntities = new List(); + foreach (IIdentifiable resource in (IEnumerable)newValue) + { + object joinEntity = resourceFactory.CreateInstance(ThroughType); + LeftProperty.SetValue(joinEntity, entity); + RightProperty.SetValue(joinEntity, resource); + joinEntities.Add(joinEntity); + } + + var typedCollection = joinEntities.CopyToTypedCollection(ThroughProperty.PropertyType); + ThroughProperty.SetValue(entity, typedCollection); + } + } } } diff --git a/src/JsonApiDotNetCore/Models/Annotation/HasOneAttribute.cs b/src/JsonApiDotNetCore/Models/Annotation/HasOneAttribute.cs index 9afed2438e..477e82b30f 100644 --- a/src/JsonApiDotNetCore/Models/Annotation/HasOneAttribute.cs +++ b/src/JsonApiDotNetCore/Models/Annotation/HasOneAttribute.cs @@ -3,11 +3,21 @@ using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models.Links; -namespace JsonApiDotNetCore.Models +namespace JsonApiDotNetCore.Models.Annotation { [AttributeUsage(AttributeTargets.Property)] public sealed class HasOneAttribute : RelationshipAttribute { + private readonly string _explicitIdentifiablePropertyName; + + /// + /// The independent resource identifier. + /// + public string IdentifiablePropertyName => + string.IsNullOrWhiteSpace(_explicitIdentifiablePropertyName) + ? JsonApiOptions.RelatedIdMapper.GetRelatedIdPropertyName(Property.Name) + : _explicitIdentifiablePropertyName; + /// /// Create a HasOne relational link to another entity /// @@ -37,30 +47,23 @@ public HasOneAttribute(string publicName = null, Link links = Link.NotConfigured InverseNavigation = inverseNavigationProperty; } - private readonly string _explicitIdentifiablePropertyName; - - /// - /// The independent resource identifier. - /// - public string IdentifiablePropertyName => string.IsNullOrWhiteSpace(_explicitIdentifiablePropertyName) - ? JsonApiOptions.RelatedIdMapper.GetRelatedIdPropertyName(PropertyInfo.Name) - : _explicitIdentifiablePropertyName; - /// public override void SetValue(object entity, object newValue, IResourceFactory resourceFactory) { - string propertyName = PropertyInfo.Name; - // 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. - if (newValue == null) propertyName = IdentifiablePropertyName; + // 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 = entity.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(entity, newValue); } } diff --git a/src/JsonApiDotNetCore/Models/Annotation/LinksAttribute.cs b/src/JsonApiDotNetCore/Models/Annotation/LinksAttribute.cs index 251f569b43..d5f39996b1 100644 --- a/src/JsonApiDotNetCore/Models/Annotation/LinksAttribute.cs +++ b/src/JsonApiDotNetCore/Models/Annotation/LinksAttribute.cs @@ -1,7 +1,8 @@ using System; using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Models.Links; -namespace JsonApiDotNetCore.Models.Links +namespace JsonApiDotNetCore.Models.Annotation { [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Interface)] public sealed class LinksAttribute : Attribute diff --git a/src/JsonApiDotNetCore/Models/Annotation/RelationshipAttribute.cs b/src/JsonApiDotNetCore/Models/Annotation/RelationshipAttribute.cs index d5a742ead3..157e4e2239 100644 --- a/src/JsonApiDotNetCore/Models/Annotation/RelationshipAttribute.cs +++ b/src/JsonApiDotNetCore/Models/Annotation/RelationshipAttribute.cs @@ -1,41 +1,29 @@ using System; -using System.Reflection; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models.Links; -namespace JsonApiDotNetCore.Models +namespace JsonApiDotNetCore.Models.Annotation { - public abstract class RelationshipAttribute : Attribute, IResourceField + public abstract class RelationshipAttribute : ResourceFieldAttribute { - protected RelationshipAttribute(string publicName, Link relationshipLinks, bool canInclude) - { - if (relationshipLinks == Link.Paging) - throw new JsonApiSetupException($"{Link.Paging:g} not allowed for argument {nameof(relationshipLinks)}"); - - PublicRelationshipName = publicName; - RelationshipLinks = relationshipLinks; - CanInclude = canInclude; - } - - string IResourceField.PropertyName => PropertyInfo.Name; - - public string PublicRelationshipName { get; internal set; } public string InverseNavigation { get; internal set; } /// - /// The resource property that this attribute is declared on. + /// The internal navigation property path to the related entity. /// - public PropertyInfo PropertyInfo { get; internal set; } + /// + /// In all cases except the HasManyThrough relationships, this will just be the property name. + /// + public virtual string RelationshipPath => Property.Name; /// /// The related entity type. This does not necessarily match the navigation property type. /// In the case of a HasMany relationship, this value will be the generic argument type. /// - /// /// - /// - /// public List<Tag> Tags { get; set; } // Type => Tag - /// + /// Tags { get; set; } // Type => Tag + /// ]]> /// public Type RightType { get; internal set; } @@ -49,14 +37,25 @@ protected RelationshipAttribute(string publicName, Link relationshipLinks, bool /// object for this relationship. /// public Link RelationshipLinks { get; } + public bool CanInclude { get; } + protected RelationshipAttribute(string publicName, Link relationshipLinks, bool canInclude) + : base(publicName) + { + if (relationshipLinks == Link.Paging) + throw new JsonApiSetupException($"{Link.Paging:g} not allowed for argument {nameof(relationshipLinks)}"); + + RelationshipLinks = relationshipLinks; + CanInclude = canInclude; + } + /// /// Gets the value of the resource property this attributes was declared on. /// public virtual object GetValue(object entity) { - return PropertyInfo.GetValue(entity); + return Property.GetValue(entity); } /// @@ -64,12 +63,12 @@ public virtual object GetValue(object entity) /// public virtual void SetValue(object entity, object newValue, IResourceFactory resourceFactory) { - PropertyInfo.SetValue(entity, newValue); + Property.SetValue(entity, newValue); } public override string ToString() { - return base.ToString() + ":" + PublicRelationshipName; + return base.ToString() + ":" + PublicName; } public override bool Equals(object obj) @@ -81,26 +80,13 @@ public override bool Equals(object obj) var other = (RelationshipAttribute) obj; - return PublicRelationshipName == other.PublicRelationshipName && LeftType == other.LeftType && + return PublicName == other.PublicName && LeftType == other.LeftType && RightType == other.RightType; } public override int GetHashCode() { - return HashCode.Combine(PublicRelationshipName, LeftType, RightType); + return HashCode.Combine(PublicName, LeftType, RightType); } - - /// - /// Whether or not the provided exposed name is equivalent to the one defined in the model - /// - public virtual bool Is(string publicRelationshipName) => publicRelationshipName == PublicRelationshipName; - - /// - /// The internal navigation property path to the related entity. - /// - /// - /// In all cases except the HasManyThrough relationships, this will just be the property name. - /// - public virtual string RelationshipPath => PropertyInfo.Name; } } diff --git a/src/JsonApiDotNetCore/Models/ResourceAttribute.cs b/src/JsonApiDotNetCore/Models/Annotation/ResourceAttribute.cs similarity index 88% rename from src/JsonApiDotNetCore/Models/ResourceAttribute.cs rename to src/JsonApiDotNetCore/Models/Annotation/ResourceAttribute.cs index 2f43e830a2..4178f27dad 100644 --- a/src/JsonApiDotNetCore/Models/ResourceAttribute.cs +++ b/src/JsonApiDotNetCore/Models/Annotation/ResourceAttribute.cs @@ -1,6 +1,6 @@ using System; -namespace JsonApiDotNetCore.Models +namespace JsonApiDotNetCore.Models.Annotation { [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Interface)] public sealed class ResourceAttribute : Attribute diff --git a/src/JsonApiDotNetCore/Models/Annotation/ResourceFieldAttribute.cs b/src/JsonApiDotNetCore/Models/Annotation/ResourceFieldAttribute.cs new file mode 100644 index 0000000000..6a3b9ec174 --- /dev/null +++ b/src/JsonApiDotNetCore/Models/Annotation/ResourceFieldAttribute.cs @@ -0,0 +1,36 @@ +using System; +using System.Reflection; + +namespace JsonApiDotNetCore.Models.Annotation +{ + /// + /// Used to expose a resource property as a json:api field (attribute or relationship). + /// + public abstract class ResourceFieldAttribute : Attribute + { + /// + /// The publicly exposed name of this json:api field. + /// + public string PublicName { get; internal set; } + + /// + /// The resource property that this attribute is declared on. + /// + public PropertyInfo Property { get; internal set; } + + protected ResourceFieldAttribute() + { + } + + protected ResourceFieldAttribute(string publicName) + { + if (publicName != null && string.IsNullOrWhiteSpace(publicName)) + { + throw new ArgumentException("Exposed name cannot be empty or contain only whitespace.", + nameof(publicName)); + } + + PublicName = publicName; + } + } +} diff --git a/src/JsonApiDotNetCore/Models/AttrCapabilities.cs b/src/JsonApiDotNetCore/Models/AttrCapabilities.cs index 102ba3893a..876601beca 100644 --- a/src/JsonApiDotNetCore/Models/AttrCapabilities.cs +++ b/src/JsonApiDotNetCore/Models/AttrCapabilities.cs @@ -1,4 +1,5 @@ using System; +using JsonApiDotNetCore.Models.Annotation; namespace JsonApiDotNetCore.Models { diff --git a/src/JsonApiDotNetCore/Models/IResourceField.cs b/src/JsonApiDotNetCore/Models/IResourceField.cs deleted file mode 100644 index 1e64f2b64f..0000000000 --- a/src/JsonApiDotNetCore/Models/IResourceField.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace JsonApiDotNetCore.Models -{ - public interface IResourceField - { - string PropertyName { get; } - } -} diff --git a/src/JsonApiDotNetCore/Models/ResourceDefinition.cs b/src/JsonApiDotNetCore/Models/ResourceDefinition.cs index 7a519d3b19..75166357f9 100644 --- a/src/JsonApiDotNetCore/Models/ResourceDefinition.cs +++ b/src/JsonApiDotNetCore/Models/ResourceDefinition.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Linq; using System.Linq.Expressions; +using JsonApiDotNetCore.Models.Annotation; namespace JsonApiDotNetCore.Models { @@ -73,18 +74,18 @@ public void HideFields(Expression> selector) /// /// If the logic is simply too complex for an in-line expression, you can /// delegate to a private method: - /// + /// new QueryFilters { /// { "is-active", FilterIsActive } /// }; /// - /// private IQueryable<Model> FilterIsActive(IQueryable<Model> query, string value) + /// private IQueryable FilterIsActive(IQueryable query, string value) /// { /// // some complex logic goes here... /// return query.Where(x => x.IsActive == computedValue); /// } - /// + /// ]]> /// public virtual QueryFilters GetQueryFilters() => null; diff --git a/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterService.cs b/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterService.cs index a2f1bb6425..7967119434 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterService.cs @@ -3,7 +3,7 @@ using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Managers.Contracts; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; namespace JsonApiDotNetCore.Query { @@ -33,8 +33,8 @@ protected QueryParameterService(IResourceGraph resourceGraph, ICurrentRequest cu protected AttrAttribute GetAttribute(string queryParameterName, string target, RelationshipAttribute relationship = null) { var attribute = relationship != null - ? _resourceGraph.GetAttributes(relationship.RightType).FirstOrDefault(a => a.Is(target)) - : _requestResource.Attributes.FirstOrDefault(attr => attr.Is(target)); + ? _resourceGraph.GetAttributes(relationship.RightType).FirstOrDefault(a => target == a.PublicName) + : _requestResource.Attributes.FirstOrDefault(attr => target == attr.PublicName); if (attribute == null) { @@ -52,7 +52,7 @@ protected AttrAttribute GetAttribute(string queryParameterName, string target, R protected RelationshipAttribute GetRelationship(string queryParameterName, string propertyName) { if (propertyName == null) return null; - var relationship = _requestResource.Relationships.FirstOrDefault(r => r.Is(propertyName)); + var relationship = _requestResource.Relationships.FirstOrDefault(r => propertyName == r.PublicName); if (relationship == null) { throw new InvalidQueryStringParameterException(queryParameterName, diff --git a/src/JsonApiDotNetCore/QueryParameterServices/Contracts/IIncludeService.cs b/src/JsonApiDotNetCore/QueryParameterServices/Contracts/IIncludeService.cs index 0de79a3d17..246d021e06 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/Contracts/IIncludeService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/Contracts/IIncludeService.cs @@ -1,5 +1,5 @@ using System.Collections.Generic; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; namespace JsonApiDotNetCore.Query { diff --git a/src/JsonApiDotNetCore/QueryParameterServices/Contracts/ISparseFieldsService.cs b/src/JsonApiDotNetCore/QueryParameterServices/Contracts/ISparseFieldsService.cs index 99e5232598..080d272de0 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/Contracts/ISparseFieldsService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/Contracts/ISparseFieldsService.cs @@ -1,5 +1,5 @@ using System.Collections.Generic; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; namespace JsonApiDotNetCore.Query { diff --git a/src/JsonApiDotNetCore/QueryParameterServices/FilterService.cs b/src/JsonApiDotNetCore/QueryParameterServices/FilterService.cs index ae9fbfaa15..7703c7cd9a 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/FilterService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/FilterService.cs @@ -7,6 +7,7 @@ using JsonApiDotNetCore.Internal.Query; using JsonApiDotNetCore.Managers.Contracts; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; using Microsoft.Extensions.Primitives; namespace JsonApiDotNetCore.Query @@ -67,13 +68,13 @@ private FilterQueryContext GetQueryContexts(FilterQuery query, string parameterN { throw new InvalidQueryStringParameterException(parameterName, "Filtering on one-to-many and many-to-many relationships is currently not supported.", - $"Filtering on the relationship '{queryContext.Relationship.PublicRelationshipName}.{attribute.PublicAttributeName}' is currently not supported."); + $"Filtering on the relationship '{queryContext.Relationship.PublicName}.{attribute.PublicName}' is currently not supported."); } if (!attribute.Capabilities.HasFlag(AttrCapabilities.AllowFilter)) { throw new InvalidQueryStringParameterException(parameterName, "Filtering on the requested attribute is not allowed.", - $"Filtering on attribute '{attribute.PublicAttributeName}' is not allowed."); + $"Filtering on attribute '{attribute.PublicName}' is not allowed."); } queryContext.Attribute = attribute; diff --git a/src/JsonApiDotNetCore/QueryParameterServices/IncludeService.cs b/src/JsonApiDotNetCore/QueryParameterServices/IncludeService.cs index f00bd9680b..ac1c91ac61 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/IncludeService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/IncludeService.cs @@ -5,7 +5,7 @@ using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Internal.Query; using JsonApiDotNetCore.Managers.Contracts; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; using Microsoft.Extensions.Primitives; namespace JsonApiDotNetCore.Query @@ -53,7 +53,7 @@ private void ParseChain(string chain, string parameterName) var resourceContext = _requestResource; foreach (var relationshipName in chainParts) { - var relationship = resourceContext.Relationships.SingleOrDefault(r => r.PublicRelationshipName == relationshipName); + var relationship = resourceContext.Relationships.SingleOrDefault(r => r.PublicName == relationshipName); if (relationship == null) { throw new InvalidQueryStringParameterException(parameterName, "The requested relationship to include does not exist.", diff --git a/src/JsonApiDotNetCore/QueryParameterServices/SortService.cs b/src/JsonApiDotNetCore/QueryParameterServices/SortService.cs index e5a4a76cf7..830c87a360 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/SortService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/SortService.cs @@ -39,7 +39,7 @@ public List Get() if (defaultSort != null) { return defaultSort - .Select(d => BuildQueryContext(new SortQuery(d.Attribute.PublicAttributeName, d.SortDirection))) + .Select(d => BuildQueryContext(new SortQuery(d.Attribute.PublicName, d.SortDirection))) .ToList(); } @@ -102,7 +102,7 @@ private SortQueryContext BuildQueryContext(SortQuery query) if (!attribute.Capabilities.HasFlag(AttrCapabilities.AllowSort)) { throw new InvalidQueryStringParameterException("sort", "Sorting on the requested attribute is not allowed.", - $"Sorting on attribute '{attribute.PublicAttributeName}' is not allowed."); + $"Sorting on attribute '{attribute.PublicName}' is not allowed."); } return new SortQueryContext(query) diff --git a/src/JsonApiDotNetCore/QueryParameterServices/SparseFieldsService.cs b/src/JsonApiDotNetCore/QueryParameterServices/SparseFieldsService.cs index bafa6976d4..c48c95a72f 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/SparseFieldsService.cs +++ b/src/JsonApiDotNetCore/QueryParameterServices/SparseFieldsService.cs @@ -7,6 +7,7 @@ using JsonApiDotNetCore.Internal.Query; using JsonApiDotNetCore.Managers.Contracts; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; using Microsoft.Extensions.Primitives; namespace JsonApiDotNetCore.Query @@ -43,12 +44,12 @@ public List Get(RelationshipAttribute relationship = null) public ISet GetAll() { var properties = new HashSet(); - properties.AddRange(_selectedFields.Select(x => x.PropertyInfo.Name)); + properties.AddRange(_selectedFields.Select(x => x.Property.Name)); foreach (var pair in _selectedRelationshipFields) { string pathPrefix = pair.Key.RelationshipPath + "."; - properties.AddRange(pair.Value.Select(x => pathPrefix + x.PropertyInfo.Name)); + properties.AddRange(pair.Value.Select(x => pathPrefix + x.Property.Name)); } return properties; @@ -92,7 +93,7 @@ public virtual void Parse(string parameterName, StringValues parameterValue) // it is possible that the request resource has a relationship // that is equal to the resource name, like with self-referencing data types (eg directory structures) // if not, no longer support this type of sparse field selection. - if (navigation == _requestResource.ResourceName && !_requestResource.Relationships.Any(a => a.Is(navigation))) + if (navigation == _requestResource.ResourceName && !_requestResource.Relationships.Any(a => navigation == a.PublicName)) { throw new InvalidQueryStringParameterException(parameterName, "Square bracket notation in 'filter' is now reserved for relationships only. See https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/555#issuecomment-543100865 for details.", @@ -106,7 +107,7 @@ public virtual void Parse(string parameterName, StringValues parameterValue) $"Parameter fields[{navigation}] is currently not supported."); } - var relationship = _requestResource.Relationships.SingleOrDefault(a => a.Is(navigation)); + var relationship = _requestResource.Relationships.SingleOrDefault(a => navigation == a.PublicName); if (relationship == null) { throw new InvalidQueryStringParameterException(parameterName, "Sparse field navigation path refers to an invalid relationship.", @@ -127,15 +128,15 @@ private void RegisterRelatedResourceFields(IEnumerable fields, Relations foreach (var field in fields) { var relationProperty = _resourceGraph.GetResourceContext(relationship.RightType); - var attr = relationProperty.Attributes.SingleOrDefault(a => a.Is(field)); + var attr = relationProperty.Attributes.SingleOrDefault(a => field == a.PublicName); if (attr == null) { throw new InvalidQueryStringParameterException(parameterName, "The specified field does not exist on the requested related resource.", - $"The field '{field}' does not exist on related resource '{relationship.PublicRelationshipName}' of type '{relationProperty.ResourceName}'."); + $"The field '{field}' does not exist on related resource '{relationship.PublicName}' of type '{relationProperty.ResourceName}'."); } - if (attr.PropertyInfo.SetMethod == null) + if (attr.Property.SetMethod == null) { // A read-only property was selected. Its value likely depends on another property, so include all related fields. return; @@ -160,7 +161,7 @@ private void RegisterRequestResourceFields(IEnumerable fields, string pa foreach (var field in fields) { - var attr = _requestResource.Attributes.SingleOrDefault(a => a.Is(field)); + var attr = _requestResource.Attributes.SingleOrDefault(a => field == a.PublicName); if (attr == null) { throw new InvalidQueryStringParameterException(parameterName, @@ -168,7 +169,7 @@ private void RegisterRequestResourceFields(IEnumerable fields, string pa $"The field '{field}' does not exist on resource '{_requestResource.ResourceName}'."); } - if (attr.PropertyInfo.SetMethod == null) + if (attr.Property.SetMethod == null) { // A read-only property was selected. Its value likely depends on another property, so include all resource fields. return; diff --git a/src/JsonApiDotNetCore/RequestServices/Contracts/ICurrentRequest.cs b/src/JsonApiDotNetCore/RequestServices/Contracts/ICurrentRequest.cs index 15eddf495e..a6096b74e3 100644 --- a/src/JsonApiDotNetCore/RequestServices/Contracts/ICurrentRequest.cs +++ b/src/JsonApiDotNetCore/RequestServices/Contracts/ICurrentRequest.cs @@ -1,5 +1,5 @@ using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; namespace JsonApiDotNetCore.Managers.Contracts { diff --git a/src/JsonApiDotNetCore/RequestServices/Contracts/ITargetedFields.cs b/src/JsonApiDotNetCore/RequestServices/Contracts/ITargetedFields.cs index 556ea57094..f00e4386f2 100644 --- a/src/JsonApiDotNetCore/RequestServices/Contracts/ITargetedFields.cs +++ b/src/JsonApiDotNetCore/RequestServices/Contracts/ITargetedFields.cs @@ -1,5 +1,5 @@ using System.Collections.Generic; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; namespace JsonApiDotNetCore.Serialization { diff --git a/src/JsonApiDotNetCore/RequestServices/CurrentRequest.cs b/src/JsonApiDotNetCore/RequestServices/CurrentRequest.cs index a5bbc31a1b..ac88dcd6e5 100644 --- a/src/JsonApiDotNetCore/RequestServices/CurrentRequest.cs +++ b/src/JsonApiDotNetCore/RequestServices/CurrentRequest.cs @@ -1,6 +1,6 @@ using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Managers.Contracts; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; namespace JsonApiDotNetCore.Managers { diff --git a/src/JsonApiDotNetCore/RequestServices/DefaultResourceChangeTracker.cs b/src/JsonApiDotNetCore/RequestServices/DefaultResourceChangeTracker.cs index f61a7a4e07..6f35745276 100644 --- a/src/JsonApiDotNetCore/RequestServices/DefaultResourceChangeTracker.cs +++ b/src/JsonApiDotNetCore/RequestServices/DefaultResourceChangeTracker.cs @@ -2,6 +2,7 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; using JsonApiDotNetCore.Serialization; using Newtonsoft.Json; @@ -81,7 +82,7 @@ private IDictionary CreateAttributeDictionary(TResource resource { object value = attribute.GetValue(resource); var json = JsonConvert.SerializeObject(value, _options.SerializerSettings); - result.Add(attribute.PublicAttributeName, json); + result.Add(attribute.PublicName, json); } return result; diff --git a/src/JsonApiDotNetCore/RequestServices/TargetedFields.cs b/src/JsonApiDotNetCore/RequestServices/TargetedFields.cs index d5e3d2919a..3ba1db54eb 100644 --- a/src/JsonApiDotNetCore/RequestServices/TargetedFields.cs +++ b/src/JsonApiDotNetCore/RequestServices/TargetedFields.cs @@ -1,5 +1,5 @@ using System.Collections.Generic; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; namespace JsonApiDotNetCore.Serialization { diff --git a/src/JsonApiDotNetCore/Serialization/Client/IRequestSerializer.cs b/src/JsonApiDotNetCore/Serialization/Client/IRequestSerializer.cs index 244b30f70e..69efe7da01 100644 --- a/src/JsonApiDotNetCore/Serialization/Client/IRequestSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/Client/IRequestSerializer.cs @@ -1,7 +1,7 @@ -using System.Collections; using System.Collections.Generic; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Internal.Contracts; +using JsonApiDotNetCore.Models.Annotation; namespace JsonApiDotNetCore.Serialization.Client { diff --git a/src/JsonApiDotNetCore/Serialization/Client/RequestSerializer.cs b/src/JsonApiDotNetCore/Serialization/Client/RequestSerializer.cs index fff4639d73..2cc39aa6a4 100644 --- a/src/JsonApiDotNetCore/Serialization/Client/RequestSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/Client/RequestSerializer.cs @@ -1,9 +1,9 @@ using System; -using System.Collections; using System.Collections.Generic; using System.Linq; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; using Newtonsoft.Json; namespace JsonApiDotNetCore.Serialization.Client diff --git a/src/JsonApiDotNetCore/Serialization/Client/ResponseDeserializer.cs b/src/JsonApiDotNetCore/Serialization/Client/ResponseDeserializer.cs index c3210eb131..bd01ddb0d4 100644 --- a/src/JsonApiDotNetCore/Serialization/Client/ResponseDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/Client/ResponseDeserializer.cs @@ -5,6 +5,7 @@ using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; namespace JsonApiDotNetCore.Serialization.Client { @@ -51,7 +52,7 @@ public DeserializedListResponse DeserializeList(string bod /// The entity that was constructed from the document's body /// The metadata for the exposed field /// Relationship data for . Is null when is not a - protected override void AfterProcessField(IIdentifiable entity, IResourceField field, RelationshipEntry data = null) + protected override void AfterProcessField(IIdentifiable entity, ResourceFieldAttribute field, RelationshipEntry data = null) { // Client deserializers do not need additional processing for attributes. if (field is AttrAttribute) @@ -70,7 +71,7 @@ protected override void AfterProcessField(IIdentifiable entity, IResourceField f else if (field is HasManyAttribute hasManyAttr) { // add attributes and relationships of a parsed HasMany relationship var items = data.ManyData.Select(rio => ParseIncludedRelationship(hasManyAttr, rio)); - var values = items.CopyToTypedCollection(hasManyAttr.PropertyInfo.PropertyType); + var values = items.CopyToTypedCollection(hasManyAttr.Property.PropertyType); hasManyAttr.SetValue(entity, values, _resourceFactory); } } diff --git a/src/JsonApiDotNetCore/Serialization/Common/BaseDocumentParser.cs b/src/JsonApiDotNetCore/Serialization/Common/BaseDocumentParser.cs index 7169686f03..b8f8282449 100644 --- a/src/JsonApiDotNetCore/Serialization/Common/BaseDocumentParser.cs +++ b/src/JsonApiDotNetCore/Serialization/Common/BaseDocumentParser.cs @@ -8,6 +8,7 @@ using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; using JsonApiDotNetCore.Serialization.Client; using JsonApiDotNetCore.Serialization.Server; using Newtonsoft.Json; @@ -43,7 +44,7 @@ protected BaseDocumentParser(IResourceContextProvider contextProvider, IResource /// The entity that was constructed from the document's body /// The metadata for the exposed field /// Relationship data for . Is null when is not a - protected abstract void AfterProcessField(IIdentifiable entity, IResourceField field, RelationshipEntry data = null); + protected abstract void AfterProcessField(IIdentifiable entity, ResourceFieldAttribute field, RelationshipEntry data = null); /// protected object Deserialize(string body) @@ -76,9 +77,9 @@ protected virtual IIdentifiable SetAttributes(IIdentifiable entity, Dictionary att var ro = new ResourceObject { Type = resourceContext.ResourceName, Id = entity.StringId == string.Empty ? null : entity.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.PropertyInfo.Name != _identifiablePropertyName)).Any()) + if (attributes != null && (attributes = attributes.Where(attr => attr.Property.Name != _identifiablePropertyName)).Any()) ProcessAttributes(entity, attributes, ro); // populating the top-level "relationship" member of a resource object. @@ -128,7 +129,7 @@ private void ProcessRelationships(IIdentifiable entity, IEnumerable()).Add(rel.PublicRelationshipName, relData); + (ro.Relationships ??= new Dictionary()).Add(rel.PublicName, relData); } } @@ -147,12 +148,12 @@ private void ProcessAttributes(IIdentifiable entity, IEnumerable return; } - if (_settings.SerializerDefaultValueHandling == DefaultValueHandling.Ignore && value == attr.PropertyInfo.PropertyType.GetDefaultValue()) + if (_settings.SerializerDefaultValueHandling == DefaultValueHandling.Ignore && value == attr.Property.PropertyType.GetDefaultValue()) { return; } - ro.Attributes.Add(attr.PublicAttributeName, value); + ro.Attributes.Add(attr.PublicName, value); } } } diff --git a/src/JsonApiDotNetCore/Serialization/Server/Builders/IncludedResourceObjectBuilder.cs b/src/JsonApiDotNetCore/Serialization/Server/Builders/IncludedResourceObjectBuilder.cs index 5e1608fd3f..7c1ed7d09c 100644 --- a/src/JsonApiDotNetCore/Serialization/Server/Builders/IncludedResourceObjectBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Server/Builders/IncludedResourceObjectBuilder.cs @@ -4,6 +4,7 @@ using JsonApiDotNetCore.Builders; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; namespace JsonApiDotNetCore.Serialization.Server.Builders { @@ -77,7 +78,7 @@ private void ProcessRelationship(RelationshipAttribute originRelationship, IIden var chainRemainder = inclusionChain.ToList(); chainRemainder.RemoveAt(0); - var nextRelationshipName = nextRelationship.PublicRelationshipName; + var nextRelationshipName = nextRelationship.PublicName; var relationshipsObject = resourceObject.Relationships; // add the relationship entry in the relationship object. if (!relationshipsObject.TryGetValue(nextRelationshipName, out var relationshipEntry)) diff --git a/src/JsonApiDotNetCore/Serialization/Server/Builders/LinkBuilder.cs b/src/JsonApiDotNetCore/Serialization/Server/Builders/LinkBuilder.cs index 9cd27888a9..cccc400e58 100644 --- a/src/JsonApiDotNetCore/Serialization/Server/Builders/LinkBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Server/Builders/LinkBuilder.cs @@ -7,6 +7,7 @@ using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Managers.Contracts; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; using JsonApiDotNetCore.Models.Links; using JsonApiDotNetCore.Query; using JsonApiDotNetCore.QueryParameterServices.Common; @@ -106,7 +107,7 @@ private string GetSelfTopLevelLink(ResourceContext resourceContext) if (_currentRequest.RequestRelationship != null) { builder.Append("/"); - builder.Append(_currentRequest.RequestRelationship.PublicRelationshipName); + builder.Append(_currentRequest.RequestRelationship.PublicName); } builder.Append(_queryStringAccessor.QueryString.Value); @@ -156,7 +157,7 @@ public ResourceLinks GetResourceLinks(string resourceName, string id) public RelationshipLinks GetRelationshipLinks(RelationshipAttribute relationship, IIdentifiable parent) { var parentResourceContext = _provider.GetResourceContext(parent.GetType()); - var childNavigation = relationship.PublicRelationshipName; + var childNavigation = relationship.PublicName; RelationshipLinks links = null; if (ShouldAddRelationshipLink(parentResourceContext, relationship, Link.Related)) { diff --git a/src/JsonApiDotNetCore/Serialization/Server/Builders/ResponseResourceObjectBuilder.cs b/src/JsonApiDotNetCore/Serialization/Server/Builders/ResponseResourceObjectBuilder.cs index 2346632e60..8c9512b5ae 100644 --- a/src/JsonApiDotNetCore/Serialization/Server/Builders/ResponseResourceObjectBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Server/Builders/ResponseResourceObjectBuilder.cs @@ -2,6 +2,7 @@ using System.Linq; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; using JsonApiDotNetCore.Query; using JsonApiDotNetCore.Serialization.Server.Builders; diff --git a/src/JsonApiDotNetCore/Serialization/Server/Contracts/IFieldsToSerialize.cs b/src/JsonApiDotNetCore/Serialization/Server/Contracts/IFieldsToSerialize.cs index 2dfd261ebd..366a842276 100644 --- a/src/JsonApiDotNetCore/Serialization/Server/Contracts/IFieldsToSerialize.cs +++ b/src/JsonApiDotNetCore/Serialization/Server/Contracts/IFieldsToSerialize.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; namespace JsonApiDotNetCore.Serialization.Server { diff --git a/src/JsonApiDotNetCore/Serialization/Server/Contracts/IIncludedResourceObjectBuilder.cs b/src/JsonApiDotNetCore/Serialization/Server/Contracts/IIncludedResourceObjectBuilder.cs index fdaace9d28..7d717a1bee 100644 --- a/src/JsonApiDotNetCore/Serialization/Server/Contracts/IIncludedResourceObjectBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Server/Contracts/IIncludedResourceObjectBuilder.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; namespace JsonApiDotNetCore.Serialization.Server.Builders { diff --git a/src/JsonApiDotNetCore/Serialization/Server/Contracts/ILinkBuilder.cs b/src/JsonApiDotNetCore/Serialization/Server/Contracts/ILinkBuilder.cs index 6a0b59042f..36fbc94931 100644 --- a/src/JsonApiDotNetCore/Serialization/Server/Contracts/ILinkBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Server/Contracts/ILinkBuilder.cs @@ -1,4 +1,5 @@ -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; using JsonApiDotNetCore.Models.Links; namespace JsonApiDotNetCore.Serialization.Server.Builders diff --git a/src/JsonApiDotNetCore/Serialization/Server/Contracts/IResponseSerializer.cs b/src/JsonApiDotNetCore/Serialization/Server/Contracts/IResponseSerializer.cs index f69e1ce096..06c9946898 100644 --- a/src/JsonApiDotNetCore/Serialization/Server/Contracts/IResponseSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/Server/Contracts/IResponseSerializer.cs @@ -1,4 +1,4 @@ -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; namespace JsonApiDotNetCore.Serialization.Server { diff --git a/src/JsonApiDotNetCore/Serialization/Server/FieldsToSerialize.cs b/src/JsonApiDotNetCore/Serialization/Server/FieldsToSerialize.cs index 8bf89c4727..722795ce35 100644 --- a/src/JsonApiDotNetCore/Serialization/Server/FieldsToSerialize.cs +++ b/src/JsonApiDotNetCore/Serialization/Server/FieldsToSerialize.cs @@ -3,7 +3,7 @@ using System.Collections.Generic; using JsonApiDotNetCore.Query; using System.Linq; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; namespace JsonApiDotNetCore.Serialization.Server { diff --git a/src/JsonApiDotNetCore/Serialization/Server/RequestDeserializer.cs b/src/JsonApiDotNetCore/Serialization/Server/RequestDeserializer.cs index 9c37c960ac..a8f5c4a475 100644 --- a/src/JsonApiDotNetCore/Serialization/Server/RequestDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/Server/RequestDeserializer.cs @@ -2,6 +2,7 @@ using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; using Microsoft.AspNetCore.Http; using System.Collections.Generic; using System.Reflection; @@ -38,7 +39,7 @@ public RequestDeserializer(IResourceContextProvider contextProvider, IResourceFa /// The entity that was constructed from the document's body /// The metadata for the exposed field /// Relationship data for . Is null when is not a - protected override void AfterProcessField(IIdentifiable entity, IResourceField field, RelationshipEntry data = null) + protected override void AfterProcessField(IIdentifiable entity, ResourceFieldAttribute field, RelationshipEntry data = null) { if (field is AttrAttribute attr) { @@ -50,7 +51,7 @@ protected override void AfterProcessField(IIdentifiable entity, IResourceField f { throw new InvalidRequestBodyException( "Changing the value of the requested attribute is not allowed.", - $"Changing the value of '{attr.PublicAttributeName}' is not allowed.", null); + $"Changing the value of '{attr.PublicName}' is not allowed.", null); } } else if (field is RelationshipAttribute relationship) diff --git a/src/JsonApiDotNetCore/Serialization/Server/ResponseSerializer.cs b/src/JsonApiDotNetCore/Serialization/Server/ResponseSerializer.cs index 0b74f0863d..35690bcd3a 100644 --- a/src/JsonApiDotNetCore/Serialization/Server/ResponseSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/Server/ResponseSerializer.cs @@ -1,12 +1,11 @@ using System; -using System.Collections; using System.Collections.Generic; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Extensions; -using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models; using Newtonsoft.Json; using JsonApiDotNetCore.Managers.Contracts; +using JsonApiDotNetCore.Models.Annotation; using JsonApiDotNetCore.Serialization.Server.Builders; using JsonApiDotNetCore.Models.JsonApiDocuments; diff --git a/src/JsonApiDotNetCore/Services/DefaultResourceService.cs b/src/JsonApiDotNetCore/Services/DefaultResourceService.cs index dc060a0e64..f217af4916 100644 --- a/src/JsonApiDotNetCore/Services/DefaultResourceService.cs +++ b/src/JsonApiDotNetCore/Services/DefaultResourceService.cs @@ -12,6 +12,7 @@ using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Query; using JsonApiDotNetCore.Extensions; +using JsonApiDotNetCore.Models.Annotation; using JsonApiDotNetCore.RequestServices; using Microsoft.EntityFrameworkCore; @@ -359,8 +360,8 @@ private void EnsureResourcesWithoutSparseFieldSetAreAddedToSelect(ISet p if (!hasTopLevelSparseFieldSet) { var topPropertyNames = _currentRequestResource.Attributes - .Where(x => x.PropertyInfo.SetMethod != null) - .Select(x => x.PropertyInfo.Name); + .Where(x => x.Property.SetMethod != null) + .Select(x => x.Property.Name); propertyNames.AddRange(topPropertyNames); } @@ -410,7 +411,7 @@ private bool IsNull(params object[] values) private RelationshipAttribute GetRelationship(string relationshipName) { - var relationship = _currentRequestResource.Relationships.SingleOrDefault(r => r.Is(relationshipName)); + var relationship = _currentRequestResource.Relationships.SingleOrDefault(r => relationshipName == r.PublicName); if (relationship == null) { throw new RelationshipNotFoundException(relationshipName, _currentRequestResource.ResourceName); diff --git a/test/IntegrationTests/Data/EntityRepositoryTests.cs b/test/IntegrationTests/Data/EntityRepositoryTests.cs index 2182ac475c..4d62b0525c 100644 --- a/test/IntegrationTests/Data/EntityRepositoryTests.cs +++ b/test/IntegrationTests/Data/EntityRepositoryTests.cs @@ -13,6 +13,7 @@ using IntegrationTests; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Models.Annotation; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.Extensions.Logging.Abstractions; using Xunit; @@ -48,7 +49,7 @@ public async Task UpdateAsync_AttributesUpdated_ShouldHaveSpecificallyThoseAttri var descAttr = new AttrAttribute("description") { - PropertyInfo = typeof(TodoItem).GetProperty(nameof(TodoItem.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()); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/SparseFieldSetTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/SparseFieldSetTests.cs index 1b575be29a..0dbfb5a99e 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/SparseFieldSetTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/SparseFieldSetTests.cs @@ -63,7 +63,7 @@ public async Task Can_Select_Sparse_Fieldsets() var properties = _resourceGraph .GetAttributes(e => new {e.Id, e.Description, e.CreatedDate, e.AchievedDate}) - .Select(x => x.PropertyInfo.Name); + .Select(x => x.Property.Name); var resourceFactory = new DefaultResourceFactory(new ServiceContainer()); diff --git a/test/JsonApiDotNetCoreExampleTests/Helpers/Models/TodoItemClient.cs b/test/JsonApiDotNetCoreExampleTests/Helpers/Models/TodoItemClient.cs index e6e7e761a4..e8db5d53f5 100644 --- a/test/JsonApiDotNetCoreExampleTests/Helpers/Models/TodoItemClient.cs +++ b/test/JsonApiDotNetCoreExampleTests/Helpers/Models/TodoItemClient.cs @@ -1,6 +1,7 @@ -using System; -using System.Collections.Generic; +using System; +using System.Collections.Generic; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; using JsonApiDotNetCoreExample.Models; namespace JsonApiDotNetCoreExampleTests.Helpers.Models diff --git a/test/UnitTests/Builders/ContextGraphBuilder_Tests.cs b/test/UnitTests/Builders/ContextGraphBuilder_Tests.cs index bb0ff76114..ab5d9f8dc2 100644 --- a/test/UnitTests/Builders/ContextGraphBuilder_Tests.cs +++ b/test/UnitTests/Builders/ContextGraphBuilder_Tests.cs @@ -6,6 +6,7 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; @@ -78,7 +79,7 @@ public void Attrs_Without_Names_Specified_Will_Use_Configured_Formatter() // Assert var resource = resourceGraph.GetResourceContext(typeof(TestResource)); - Assert.Contains(resource.Attributes, (i) => i.PublicAttributeName == "compoundAttribute"); + Assert.Contains(resource.Attributes, (i) => i.PublicName == "compoundAttribute"); } [Fact] @@ -93,8 +94,8 @@ public void Relationships_Without_Names_Specified_Will_Use_Configured_Formatter( // Assert var resource = resourceGraph.GetResourceContext(typeof(TestResource)); - Assert.Equal("relatedResource", resource.Relationships.Single(r => r is HasOneAttribute).PublicRelationshipName); - Assert.Equal("relatedResources", resource.Relationships.Single(r => !(r is HasOneAttribute)).PublicRelationshipName); + Assert.Equal("relatedResource", resource.Relationships.Single(r => r is HasOneAttribute).PublicName); + Assert.Equal("relatedResources", resource.Relationships.Single(r => !(r is HasOneAttribute)).PublicName); } public sealed class TestResource : Identifiable diff --git a/test/UnitTests/Builders/LinkBuilderTests.cs b/test/UnitTests/Builders/LinkBuilderTests.cs index 89df84eb82..b36af50d12 100644 --- a/test/UnitTests/Builders/LinkBuilderTests.cs +++ b/test/UnitTests/Builders/LinkBuilderTests.cs @@ -2,7 +2,7 @@ using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Managers.Contracts; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; using JsonApiDotNetCore.Models.Links; using JsonApiDotNetCoreExample.Models; using Moq; @@ -100,7 +100,7 @@ public void BuildRelationshipLinks_GlobalResourceAndAttrConfiguration_ExpectedLi var primaryResource = GetArticleResourceContext(relationshipLinks: resource); _provider.Setup(m => m.GetResourceContext(typeof(Article))).Returns(primaryResource); var builder = new LinkBuilder(config, GetRequestManager(), null, _provider.Object, _queryStringAccessor); - var attr = new HasOneAttribute(links: relationship) { RightType = typeof(Author), PublicRelationshipName = "author" }; + var attr = new HasOneAttribute(links: relationship) { RightType = typeof(Author), PublicName = "author" }; // Act var links = builder.GetRelationshipLinks(attr, new Article { Id = _baseId }); diff --git a/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs b/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs index c61dfddb3a..64157e3fae 100644 --- a/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs +++ b/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Exceptions; +using JsonApiDotNetCore.Models.Annotation; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; diff --git a/test/UnitTests/Middleware/JsonApiMiddlewareTests.cs b/test/UnitTests/Middleware/JsonApiMiddlewareTests.cs index d3d446e211..851450d390 100644 --- a/test/UnitTests/Middleware/JsonApiMiddlewareTests.cs +++ b/test/UnitTests/Middleware/JsonApiMiddlewareTests.cs @@ -3,7 +3,6 @@ using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Managers; using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Models; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; using Moq; @@ -11,6 +10,7 @@ using System.IO; using System.Linq; using System.Threading.Tasks; +using JsonApiDotNetCore.Models.Annotation; using Xunit; namespace UnitTests.Middleware diff --git a/test/UnitTests/Models/AttributesEqualsTests.cs b/test/UnitTests/Models/AttributesEqualsTests.cs index 5aa4ba1bd1..35c9b3c0c8 100644 --- a/test/UnitTests/Models/AttributesEqualsTests.cs +++ b/test/UnitTests/Models/AttributesEqualsTests.cs @@ -1,4 +1,4 @@ -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; using Xunit; namespace UnitTests.Models diff --git a/test/UnitTests/Models/ResourceDefinitionTests.cs b/test/UnitTests/Models/ResourceDefinitionTests.cs index 9beb4d6de9..e40142774c 100644 --- a/test/UnitTests/Models/ResourceDefinitionTests.cs +++ b/test/UnitTests/Models/ResourceDefinitionTests.cs @@ -1,10 +1,10 @@ using System; -using System.Collections.Generic; using JsonApiDotNetCore.Builders; using JsonApiDotNetCore.Internal.Query; using JsonApiDotNetCore.Models; using System.Linq; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Models.Annotation; using Microsoft.Extensions.Logging.Abstractions; using Xunit; @@ -24,10 +24,10 @@ public void Property_Sort_Order_Uses_NewExpression() // Assert Assert.Equal(2, sorts.Count); - Assert.Equal(nameof(Model.CreatedAt), sorts[0].Attribute.PropertyInfo.Name); + Assert.Equal(nameof(Model.CreatedAt), sorts[0].Attribute.Property.Name); Assert.Equal(SortDirection.Ascending, sorts[0].SortDirection); - Assert.Equal(nameof(Model.Password), sorts[1].Attribute.PropertyInfo.Name); + Assert.Equal(nameof(Model.Password), sorts[1].Attribute.Property.Name); Assert.Equal(SortDirection.Descending, sorts[1].SortDirection); } @@ -41,7 +41,7 @@ public void Request_Filter_Uses_Member_Expression() var attrs = resource.GetAllowedAttributes(); // Assert - Assert.DoesNotContain(attrs, a => a.PropertyInfo.Name == nameof(Model.AlwaysExcluded)); + Assert.DoesNotContain(attrs, a => a.Property.Name == nameof(Model.AlwaysExcluded)); } [Fact] @@ -54,8 +54,8 @@ public void Request_Filter_Uses_NewExpression() var attrs = resource.GetAllowedAttributes(); // Assert - Assert.DoesNotContain(attrs, a => a.PropertyInfo.Name == nameof(Model.AlwaysExcluded)); - Assert.DoesNotContain(attrs, a => a.PropertyInfo.Name == nameof(Model.Password)); + Assert.DoesNotContain(attrs, a => a.Property.Name == nameof(Model.AlwaysExcluded)); + Assert.DoesNotContain(attrs, a => a.Property.Name == nameof(Model.Password)); } } diff --git a/test/UnitTests/QueryParameters/IncludeServiceTests.cs b/test/UnitTests/QueryParameters/IncludeServiceTests.cs index ad3aee7d83..9183ed394a 100644 --- a/test/UnitTests/QueryParameters/IncludeServiceTests.cs +++ b/test/UnitTests/QueryParameters/IncludeServiceTests.cs @@ -58,11 +58,11 @@ public void Parse_MultipleNestedChains_CanParse() var chains = service.Get(); Assert.Equal(2, chains.Count); var firstChain = chains[0]; - Assert.Equal("author", firstChain.First().PublicRelationshipName); - Assert.Equal("favoriteFood", firstChain.Last().PublicRelationshipName); + Assert.Equal("author", firstChain.First().PublicName); + Assert.Equal("favoriteFood", firstChain.Last().PublicName); var secondChain = chains[1]; - Assert.Equal("reviewer", secondChain.First().PublicRelationshipName); - Assert.Equal("favoriteSong", secondChain.Last().PublicRelationshipName); + Assert.Equal("reviewer", secondChain.First().PublicName); + Assert.Equal("favoriteSong", secondChain.Last().PublicName); } [Fact] diff --git a/test/UnitTests/QueryParameters/SparseFieldsServiceTests.cs b/test/UnitTests/QueryParameters/SparseFieldsServiceTests.cs index 4a5158de98..59967e2901 100644 --- a/test/UnitTests/QueryParameters/SparseFieldsServiceTests.cs +++ b/test/UnitTests/QueryParameters/SparseFieldsServiceTests.cs @@ -2,7 +2,7 @@ using System.Net; using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; using JsonApiDotNetCore.Query; using JsonApiDotNetCoreExample.Models; using Microsoft.Extensions.Primitives; @@ -50,8 +50,8 @@ public void Parse_ValidSelection_CanParse() // Arrange const string type = "articles"; const string attrName = "name"; - var attribute = new AttrAttribute(attrName) {PropertyInfo = typeof(Article).GetProperty(nameof(Article.Name))}; - var idAttribute = new AttrAttribute("id") {PropertyInfo = typeof(Article).GetProperty(nameof(Article.Id))}; + var attribute = new AttrAttribute(attrName) {Property = typeof(Article).GetProperty(nameof(Article.Name))}; + var idAttribute = new AttrAttribute("id") {Property = typeof(Article).GetProperty(nameof(Article.Id))}; var query = new KeyValuePair("fields", attrName); @@ -140,7 +140,7 @@ public void Parse_InvalidField_ThrowsJsonApiException() // Arrange const string type = "articles"; const string attrName = "dne"; - var idAttribute = new AttrAttribute("id") {PropertyInfo = typeof(Article).GetProperty(nameof(Article.Id))}; + var idAttribute = new AttrAttribute("id") {Property = typeof(Article).GetProperty(nameof(Article.Id))}; var query = new KeyValuePair("fields", attrName); @@ -167,7 +167,7 @@ public void Parse_InvalidField_ThrowsJsonApiException() public void Parse_InvalidRelatedField_ThrowsJsonApiException() { // Arrange - var idAttribute = new AttrAttribute("id") {PropertyInfo = typeof(Article).GetProperty(nameof(Article.Id))}; + var idAttribute = new AttrAttribute("id") {Property = typeof(Article).GetProperty(nameof(Article.Id))}; var query = new KeyValuePair("fields[author]", "invalid"); @@ -179,7 +179,7 @@ public void Parse_InvalidRelatedField_ThrowsJsonApiException() { new HasOneAttribute("author") { - PropertyInfo = typeof(Article).GetProperty(nameof(Article.Author)), + Property = typeof(Article).GetProperty(nameof(Article.Author)), RightType = typeof(Person) } } diff --git a/test/UnitTests/ResourceHooks/AffectedEntitiesHelperTests.cs b/test/UnitTests/ResourceHooks/AffectedEntitiesHelperTests.cs index a30d928581..8e0b95c29e 100644 --- a/test/UnitTests/ResourceHooks/AffectedEntitiesHelperTests.cs +++ b/test/UnitTests/ResourceHooks/AffectedEntitiesHelperTests.cs @@ -4,6 +4,7 @@ using Xunit; using System.Linq; using System.Reflection; +using JsonApiDotNetCore.Models.Annotation; namespace UnitTests.ResourceHooks.AffectedEntities { @@ -42,19 +43,19 @@ public RelationshipDictionaryTests() { LeftType = typeof(Dummy), RightType = typeof(ToOne), - PropertyInfo = typeof(Dummy).GetProperty(nameof(Dummy.FirstToOne)) + Property = typeof(Dummy).GetProperty(nameof(Dummy.FirstToOne)) }; SecondToOneAttr = new HasOneAttribute("secondToOne") { LeftType = typeof(Dummy), RightType = typeof(ToOne), - PropertyInfo = typeof(Dummy).GetProperty(nameof(Dummy.SecondToOne)) + Property = typeof(Dummy).GetProperty(nameof(Dummy.SecondToOne)) }; ToManyAttr = new HasManyAttribute("toManies") { LeftType = typeof(Dummy), RightType = typeof(ToMany), - PropertyInfo = typeof(Dummy).GetProperty(nameof(Dummy.ToManies)) + Property = typeof(Dummy).GetProperty(nameof(Dummy.ToManies)) }; Relationships.Add(FirstToOneAttr, FirstToOnesEntities); Relationships.Add(SecondToOneAttr, SecondToOnesEntities); diff --git a/test/UnitTests/ResourceHooks/ResourceHookExecutor/Read/BeforeReadTests.cs b/test/UnitTests/ResourceHooks/ResourceHookExecutor/Read/BeforeReadTests.cs index 19748c2c0d..9c1248453b 100644 --- a/test/UnitTests/ResourceHooks/ResourceHookExecutor/Read/BeforeReadTests.cs +++ b/test/UnitTests/ResourceHooks/ResourceHookExecutor/Read/BeforeReadTests.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; using JsonApiDotNetCore.Hooks; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; using JsonApiDotNetCoreExample.Models; using Moq; using Xunit; diff --git a/test/UnitTests/ResourceHooks/ResourceHookExecutor/Update/BeforeUpdate_WithDbValues_Tests.cs b/test/UnitTests/ResourceHooks/ResourceHookExecutor/Update/BeforeUpdate_WithDbValues_Tests.cs index 05f6108041..8cf140f55f 100644 --- a/test/UnitTests/ResourceHooks/ResourceHookExecutor/Update/BeforeUpdate_WithDbValues_Tests.cs +++ b/test/UnitTests/ResourceHooks/ResourceHookExecutor/Update/BeforeUpdate_WithDbValues_Tests.cs @@ -206,7 +206,7 @@ private bool TodoCheckDiff(IDiffableEntityHashSet entities, string che var reqCheck = diffPair.Entity.Description == null; var updatedRelationship = entities.GetByRelationship().Single(); - var diffCheck = updatedRelationship.Key.PublicRelationshipName == "oneToOnePerson"; + var diffCheck = updatedRelationship.Key.PublicName == "oneToOnePerson"; var getAffectedCheck = entities.GetAffected(e => e.OneToOnePerson).Any(); diff --git a/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs b/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs index 44547a7a9e..5801812f6e 100644 --- a/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs +++ b/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs @@ -17,6 +17,7 @@ using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Serialization; using JsonApiDotNetCore.Internal.Query; +using JsonApiDotNetCore.Models.Annotation; using JsonApiDotNetCore.Query; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.Extensions.Logging.Abstractions; @@ -406,7 +407,7 @@ protected List GetIncludedRelationshipsChain(string chain var splitPath = chain.Split(QueryConstants.DOT); foreach (var requestedRelationship in splitPath) { - var relationship = resourceContext.Relationships.Single(r => r.PublicRelationshipName == requestedRelationship); + var relationship = resourceContext.Relationships.Single(r => r.PublicName == requestedRelationship); parsedChain.Add(relationship); resourceContext = _resourceGraph.GetResourceContext(relationship.RightType); } diff --git a/test/UnitTests/Serialization/Common/DocumentBuilderTests.cs b/test/UnitTests/Serialization/Common/DocumentBuilderTests.cs index 6c498a7964..07ee1e9c9b 100644 --- a/test/UnitTests/Serialization/Common/DocumentBuilderTests.cs +++ b/test/UnitTests/Serialization/Common/DocumentBuilderTests.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; using JsonApiDotNetCore.Serialization; using Moq; using Xunit; diff --git a/test/UnitTests/Serialization/Common/DocumentParserTests.cs b/test/UnitTests/Serialization/Common/DocumentParserTests.cs index c6911cb1f5..058c9ba9af 100644 --- a/test/UnitTests/Serialization/Common/DocumentParserTests.cs +++ b/test/UnitTests/Serialization/Common/DocumentParserTests.cs @@ -5,6 +5,7 @@ using System.Linq; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; using Newtonsoft.Json; using Xunit; using UnitTests.TestModels; @@ -133,7 +134,7 @@ public void DeserializeAttributes_VariousDataTypes_CanDeserialize(string member, var entity = (TestResource)_deserializer.Deserialize(body); // Assert - var pi = _resourceGraph.GetResourceContext("testResource").Attributes.Single(attr => attr.PublicAttributeName == member).PropertyInfo; + var pi = _resourceGraph.GetResourceContext("testResource").Attributes.Single(attr => attr.PublicName == member).Property; var deserializedValue = pi.GetValue(entity); if (member == "intField") diff --git a/test/UnitTests/Serialization/DeserializerTestsSetup.cs b/test/UnitTests/Serialization/DeserializerTestsSetup.cs index 3d35316cf4..8e1290da4c 100644 --- a/test/UnitTests/Serialization/DeserializerTestsSetup.cs +++ b/test/UnitTests/Serialization/DeserializerTestsSetup.cs @@ -3,6 +3,7 @@ using JsonApiDotNetCore.Serialization; using System.Collections.Generic; using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Models.Annotation; using Microsoft.AspNetCore.Http; using Moq; @@ -26,7 +27,7 @@ public TestDocumentParser(IResourceGraph resourceGraph, IResourceFactory resourc return base.Deserialize(body); } - protected override void AfterProcessField(IIdentifiable entity, IResourceField field, RelationshipEntry data = null) { } + protected override void AfterProcessField(IIdentifiable entity, ResourceFieldAttribute field, RelationshipEntry data = null) { } } protected Document CreateDocumentWithRelationships(string mainType, string relationshipMemberName, string relatedType = null, bool isToManyData = false) @@ -67,7 +68,7 @@ protected RelationshipEntry CreateRelationshipData(string relatedType = null, bo } protected Document CreateTestResourceDocument() - { + { return new Document { Data = new ResourceObject diff --git a/test/UnitTests/Serialization/SerializerTestsSetup.cs b/test/UnitTests/Serialization/SerializerTestsSetup.cs index e7b5c0fe9f..5dec6461f5 100644 --- a/test/UnitTests/Serialization/SerializerTestsSetup.cs +++ b/test/UnitTests/Serialization/SerializerTestsSetup.cs @@ -1,8 +1,8 @@ using System; -using System.Collections; using System.Collections.Generic; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; using JsonApiDotNetCore.Models.Links; using JsonApiDotNetCore.Query; using JsonApiDotNetCore.Serialization; diff --git a/test/UnitTests/Serialization/Server/IncludedResourceObjectBuilderTests.cs b/test/UnitTests/Serialization/Server/IncludedResourceObjectBuilderTests.cs index ed5bca93b9..7e893ea2ac 100644 --- a/test/UnitTests/Serialization/Server/IncludedResourceObjectBuilderTests.cs +++ b/test/UnitTests/Serialization/Server/IncludedResourceObjectBuilderTests.cs @@ -1,8 +1,8 @@ -using JsonApiDotNetCore.Models; using Xunit; using System.Collections.Generic; using System.Linq; using JsonApiDotNetCore.Internal.Query; +using JsonApiDotNetCore.Models.Annotation; using JsonApiDotNetCore.Serialization.Server.Builders; using UnitTests.TestModels; using Person = UnitTests.TestModels.Person; @@ -158,7 +158,7 @@ private List GetIncludedRelationshipsChain(string chain) var splitPath = chain.Split(QueryConstants.DOT); foreach (var requestedRelationship in splitPath) { - var relationship = resourceContext.Relationships.Single(r => r.PublicRelationshipName == requestedRelationship); + var relationship = resourceContext.Relationships.Single(r => r.PublicName == requestedRelationship); parsedChain.Add(relationship); resourceContext = _resourceGraph.GetResourceContext(relationship.RightType); } diff --git a/test/UnitTests/Serialization/Server/RequestDeserializerTests.cs b/test/UnitTests/Serialization/Server/RequestDeserializerTests.cs index 9f31c2dc28..810a80a89c 100644 --- a/test/UnitTests/Serialization/Server/RequestDeserializerTests.cs +++ b/test/UnitTests/Serialization/Server/RequestDeserializerTests.cs @@ -4,6 +4,7 @@ using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; using JsonApiDotNetCore.Serialization; using JsonApiDotNetCore.Serialization.Server; using Microsoft.AspNetCore.Http; diff --git a/test/UnitTests/Serialization/Server/ResponseResourceObjectBuilderTests.cs b/test/UnitTests/Serialization/Server/ResponseResourceObjectBuilderTests.cs index 80ec869c01..d9b5ab527c 100644 --- a/test/UnitTests/Serialization/Server/ResponseResourceObjectBuilderTests.cs +++ b/test/UnitTests/Serialization/Server/ResponseResourceObjectBuilderTests.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; using System.Linq; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; using Xunit; using UnitTests.TestModels; diff --git a/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs b/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs index d210ab4ca2..7f5f1b8a36 100644 --- a/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs +++ b/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs @@ -2,7 +2,7 @@ using System.Linq; using System.Net; using System.Text.RegularExpressions; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; using JsonApiDotNetCore.Models.JsonApiDocuments; using Newtonsoft.Json; using Xunit; @@ -156,7 +156,7 @@ public void SerializeSingle_ResourceWithDeeplyIncludedRelationships_CanSerialize .Select(r => { var chain = new List { r }; - if (r.PublicRelationshipName != "populatedToManies") + if (r.PublicName != "populatedToManies") return new List { r }; chain.AddRange(_resourceGraph.GetRelationships()); return chain; diff --git a/test/UnitTests/Services/EntityResourceService_Tests.cs b/test/UnitTests/Services/EntityResourceService_Tests.cs index 4482a08db5..0b9b4bc5a4 100644 --- a/test/UnitTests/Services/EntityResourceService_Tests.cs +++ b/test/UnitTests/Services/EntityResourceService_Tests.cs @@ -8,7 +8,7 @@ using JsonApiDotNetCore.Data; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; using JsonApiDotNetCore.Query; using JsonApiDotNetCore.RequestServices; using JsonApiDotNetCore.Serialization; diff --git a/test/UnitTests/TestModels.cs b/test/UnitTests/TestModels.cs index c05d99df92..5fc9eb5027 100644 --- a/test/UnitTests/TestModels.cs +++ b/test/UnitTests/TestModels.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; namespace UnitTests.TestModels { From e9dc6393077d7d76e2d1eb362e9310e0c04c1536 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 29 May 2020 16:11:54 +0200 Subject: [PATCH 02/13] Initial support for compound filters and deeply nested queries --- Directory.Build.props | 1 + ...nkBuilderGetNamespaceFromPathBenchmarks.cs | 22 +- benchmarks/Query/QueryParserBenchmarks.cs | 79 +- .../JsonApiDeserializerBenchmarks.cs | 2 +- .../JsonApiSerializerBenchmarks.cs | 22 +- docs/api/index.md | 6 +- docs/index.md | 4 +- .../extensibility/custom-query-formats.md | 15 +- docs/usage/extensibility/layer-overview.md | 8 +- docs/usage/extensibility/repositories.md | 25 +- docs/usage/extensibility/services.md | 50 +- docs/usage/filtering.md | 150 ++-- docs/usage/including-relationships.md | 4 +- docs/usage/meta.md | 2 +- docs/usage/options.md | 24 +- docs/usage/pagination.md | 13 +- docs/usage/resources/attributes.md | 26 +- docs/usage/resources/hooks.md | 12 +- docs/usage/resources/relationships.md | 4 +- docs/usage/resources/resource-definitions.md | 144 +++- docs/usage/routing.md | 2 +- docs/usage/sorting.md | 36 +- docs/usage/sparse-field-selection.md | 25 - docs/usage/sparse-fieldset-selection.md | 33 + docs/usage/toc.md | 6 +- .../Controllers/ArticlesController.cs | 1 - .../Controllers/AuthorsController.cs | 18 + ...ModelsController.cs => BlogsController.cs} | 6 +- .../Controllers/CountriesController.cs | 19 + .../Controllers/PassportsController.cs | 8 +- .../Controllers/TodoCollectionsController.cs | 8 +- .../Controllers/TodoItemsCustomController.cs | 32 +- .../Controllers/TodoItemsTestController.cs | 16 +- .../Controllers/VisasController.cs | 18 + .../Data/AppDbContext.cs | 7 + .../Definitions/ArticleDefinition.cs | 6 +- .../Definitions/LockableDefinition.cs | 4 +- .../Definitions/ModelDefinition.cs | 19 - .../Definitions/PassportDefinition.cs | 6 +- .../Definitions/PersonDefinition.cs | 8 +- .../Definitions/TagDefinition.cs | 6 +- .../Definitions/UserDefinition.cs | 37 - .../Models/Address.cs | 17 + .../Models/Article.cs | 12 +- .../Models/ArticleTag.cs | 7 - .../JsonApiDotNetCoreExample/Models/Author.cs | 16 +- .../JsonApiDotNetCoreExample/Models/Blog.cs | 21 + .../Models/Country.cs | 7 +- .../JsonApiDotNetCoreExample/Models/Model.cs | 11 - .../Models/Passport.cs | 8 +- .../JsonApiDotNetCoreExample/Models/Person.cs | 6 +- .../Models/Revision.cs | 18 + .../JsonApiDotNetCoreExample/Models/Tag.cs | 15 +- .../Models/TodoItem.cs | 2 +- .../JsonApiDotNetCoreExample/Models/User.cs | 4 +- .../JsonApiDotNetCoreExample/Models/Visa.cs | 9 +- .../JsonApiDotNetCoreExample/Program.cs | 14 +- .../Services/CustomArticleService.cs | 23 +- ...=> SkipCacheQueryStringParameterReader.cs} | 8 +- .../Startups/EmptyStartup.cs | 26 + .../Startups/KebabCaseStartup.cs | 24 - .../Startups/MetaStartup.cs | 32 - .../Startups/NoDefaultPageSizeStartup.cs | 23 - .../Startups/NoNamespaceStartup.cs | 23 - .../Startups/Startup.cs | 52 +- .../Startups/TestStartup.cs | 15 +- .../Services/WorkItemService.cs | 18 +- .../ReportsExample/Services/ReportService.cs | 5 +- .../Builders/JsonApiApplicationBuilder.cs | 110 +-- .../Builders/ResourceGraphBuilder.cs | 38 +- .../Configuration/IJsonApiOptions.cs | 164 +++- .../Configuration/ILinksConfiguration.cs | 72 -- .../Configuration/JsonApiOptions.cs | 130 +-- .../Controllers/BaseJsonApiController.cs | 64 +- ...ollerMixin.cs => CoreJsonApiController.cs} | 4 +- .../Controllers/DisableQueryAttribute.cs | 20 +- .../Controllers/JsonApiCommandController.cs | 12 +- .../Controllers/JsonApiController.cs | 28 +- .../Controllers/JsonApiQueryController.cs | 8 +- ...ry.cs => EntityFrameworkCoreRepository.cs} | 306 +++---- .../Data/IResourceReadRepository.cs | 52 +- .../Data/IResourceWriteRepository.cs | 10 +- .../Exceptions/InvalidQueryException.cs | 22 + .../InvalidQueryStringParameterException.cs | 9 +- .../Extensions/DbContextExtensions.cs | 12 +- .../Extensions/EnumerableExtensions.cs | 18 - .../Extensions/QueryableExtensions.cs | 448 ---------- .../Formatters/JsonApiReader.cs | 6 +- .../Graph/ResourceIdMapper.cs | 4 +- .../Graph/ServiceDiscoveryFacade.cs | 4 +- .../Hooks/Discovery/IHooksDiscovery.cs | 8 +- ...yHashSet.cs => DiffableResourceHashSet.cs} | 46 +- .../Hooks/Execution/HookExecutorHelper.cs | 102 ++- .../Hooks/Execution/IHookExecutorHelper.cs | 24 +- .../Execution/RelationshipsDictionary.cs | 10 +- .../{EntityHashSet.cs => ResourceHashSet.cs} | 12 +- .../Hooks/Execution/ResourcePipelineEnum.cs | 8 +- .../Hooks/IResourceHookContainer.cs | 152 ++-- .../Hooks/IResourceHookExecutor.cs | 100 +-- .../Hooks/ResourceHookExecutor.cs | 230 ++--- .../Hooks/Traversal/ChildNode.cs | 14 +- .../{IEntityNode.cs => IResourceNode.cs} | 10 +- .../Hooks/Traversal/ITraversalHelper.cs | 8 +- .../Hooks/Traversal/RelationshipGroup.cs | 12 +- .../Hooks/Traversal/RelationshipProxy.cs | 60 +- .../RelationshipsFromPreviousLayer.cs | 20 +- .../Hooks/Traversal/RootNode.cs | 26 +- .../Hooks/Traversal/TraversalHelper.cs | 159 ++-- .../Contracts/IResourceContextProvider.cs | 2 +- ...urceGraphExplorer.cs => IResourceGraph.cs} | 0 .../RepositoryRelationshipUpdateHelper.cs | 2 +- .../Internal/IJsonApiRoutingConvention.cs | 4 +- .../Internal/IPaginationContext.cs | 34 + .../Internal/IResourceFactory.cs | 4 +- .../Internal/InverseRelationships.cs | 6 +- ...vention.cs => JsonApiRoutingConvention.cs} | 49 +- .../Internal/PaginationContext.cs | 22 + .../Internal/Queries/ExpressionInScope.cs | 22 + .../CollectionNotEmptyExpression.cs | 25 + .../Expressions/ComparisonExpression.cs | 29 + .../Queries/Expressions/ComparisonOperator.cs | 11 + .../Queries/Expressions/CountExpression.cs | 25 + .../Expressions/EqualsAnyOfExpression.cs | 40 + .../Queries/Expressions/FilterExpression.cs | 6 + .../Queries/Expressions/FunctionExpression.cs | 6 + .../Expressions/IdentifierExpression.cs | 6 + .../Queries/Expressions/IncludeExpression.cs | 40 + .../Expressions/LiteralConstantExpression.cs | 25 + .../Queries/Expressions/LogicalExpression.cs | 47 + .../Queries/Expressions/LogicalOperator.cs | 8 + .../Expressions/MatchTextExpression.cs | 38 + .../Queries/Expressions/NotExpression.cs | 25 + .../Expressions/NullConstantExpression.cs | 17 + ...nationElementQueryStringValueExpression.cs | 24 + .../Expressions/PaginationExpression.cs | 26 + .../PaginationQueryStringValueExpression.cs | 33 + .../Queries/Expressions/QueryExpression.cs | 8 + .../Expressions/QueryExpressionVisitor.cs | 110 +++ .../QueryStringParameterScopeExpression.cs | 26 + .../Expressions/QueryableHandlerExpression.cs | 36 + .../ResourceFieldChainExpression.cs | 68 ++ .../Expressions/SortElementExpression.cs | 50 ++ .../Queries/Expressions/SortExpression.cs | 31 + .../Expressions/SparseFieldSetExpression.cs | 32 + .../Queries/Expressions/TextMatchKind.cs | 9 + .../Queries/IQueryConstraintProvider.cs | 9 + .../Internal/Queries/Parsing/FilterParser.cs | 289 +++++++ .../Internal/Queries/Parsing/IncludeParser.cs | 44 + .../Internal/Queries/Parsing/Keywords.cs | 21 + .../Queries/Parsing/PaginationParser.cs | 91 ++ .../Queries/Parsing/QueryParseException.cs | 11 + .../Internal/Queries/Parsing/QueryParser.cs | 79 ++ .../QueryStringParameterScopeParser.cs | 44 + .../Queries/Parsing/QueryTokenizer.cs | 139 +++ .../Parsing/ResolveFieldChainCallback.cs | 18 + .../Internal/Queries/Parsing/SortParser.cs | 64 ++ .../Queries/Parsing/SparseFieldSetParser.cs | 44 + .../Internal/Queries/Parsing/Token.cs | 19 + .../Internal/Queries/Parsing/TokenKind.cs | 15 + .../Internal/Queries/QueryLayer.cs | 119 +++ .../Internal/Queries/QueryLayerComposer.cs | 221 +++++ .../QueryableBuilding/IncludeClauseBuilder.cs | 82 ++ .../LambdaParameterNameFactory.cs | 53 ++ .../LambdaParameterNameScope.cs | 22 + .../Queries/QueryableBuilding/LambdaScope.cs | 41 + .../QueryableBuilding/LambdaScopeFactory.cs | 28 + .../QueryableBuilding/OrderClauseBuilder.cs | 78 ++ .../QueryableBuilding/QueryClauseBuilder.cs | 114 +++ .../QueryableBuilding/QueryableBuilder.cs | 109 +++ .../QueryableBuilding/SelectClauseBuilder.cs | 231 +++++ .../SkipTakeClauseBuilder.cs | 58 ++ .../QueryableBuilding/WhereClauseBuilder.cs | 285 ++++++ .../Internal/Query/BaseQuery.cs | 26 - .../Internal/Query/BaseQueryContext.cs | 31 - .../Internal/Query/FilterOperations.cs | 18 - .../Internal/Query/FilterQuery.cs | 21 - .../Internal/Query/FilterQueryContext.cs | 24 - .../Internal/Query/QueryConstants.cs | 11 - .../Internal/Query/SortDirection.cs | 8 - .../Internal/Query/SortQuery.cs | 19 - .../Internal/Query/SortQueryContext.cs | 13 - .../DefaultsQueryStringParameterReader.cs} | 32 +- .../FilterQueryStringParameterReader.cs | 183 ++++ .../IQueryStringParameterReader.cs | 26 + .../QueryStrings/IQueryStringReader.cs | 18 + .../IRequestQueryStringAccessor.cs | 2 +- .../IncludeQueryStringParameterReader.cs | 85 ++ .../LegacyFilterNotationConverter.cs | 117 +++ .../NullsQueryStringParameterReader.cs} | 34 +- .../PaginationQueryStringParameterReader.cs | 206 +++++ .../QueryStringParameterReader.cs | 53 ++ .../QueryStrings/QueryStringReader.cs | 67 ++ .../RequestQueryStringAccessor.cs | 2 +- ...ourceDefinitionQueryableParameterReader.cs | 70 ++ .../ResourceFieldChainResolver.cs | 261 ++++++ .../SortQueryStringParameterReader.cs | 117 +++ ...parseFieldSetQueryStringParameterReader.cs | 105 +++ .../Internal/ResourceContext.cs | 28 +- .../Internal/ResourceGraph.cs | 8 +- src/JsonApiDotNetCore/Internal/TypeHelper.cs | 91 +- ...xceptionHandler.cs => ExceptionHandler.cs} | 6 +- .../IJsonApiExceptionFilterProvider.cs | 4 +- .../IJsonApiTypeMatchFilterProvider.cs | 4 +- ...nFilter.cs => IQueryStringActionFilter.cs} | 2 +- ...chFilter.cs => IncomingTypeMatchFilter.cs} | 4 +- ...ionFilter.cs => JsonApiExceptionFilter.cs} | 4 +- .../Middleware/JsonApiMiddleware.cs | 85 +- ...erFilter.cs => QueryStringActionFilter.cs} | 16 +- .../Models/Annotation/AttrAttribute.cs | 16 +- .../Models/Annotation/HasManyAttribute.cs | 8 +- .../Annotation/HasManyThroughAttribute.cs | 62 +- .../Models/Annotation/HasOneAttribute.cs | 16 +- .../Models/Annotation/LinksAttribute.cs | 22 +- .../Annotation/RelationshipAttribute.cs | 29 +- .../Annotation/ResourceFieldAttribute.cs | 5 + .../Models/AttrCapabilities.cs | 14 +- .../Models/JsonApiDocuments/Document.cs | 58 +- .../Models/JsonApiDocuments/ErrorSource.cs | 2 +- .../JsonApiDocuments/{Link.cs => Links.cs} | 4 +- .../JsonApiDocuments/RelationshipEntry.cs | 2 +- .../JsonApiDocuments/RelationshipLinks.cs | 2 +- .../Models/JsonApiDocuments/ResourceLinks.cs | 2 +- .../Models/JsonApiDocuments/ResourceObject.cs | 2 +- .../Models/JsonApiDocuments/TopLevelLinks.cs | 2 +- .../Models/ResourceDefinition.cs | 254 +++--- .../Models/SparseFieldSetExtensions.cs | 85 ++ src/JsonApiDotNetCore/PageNumber.cs | 51 ++ src/JsonApiDotNetCore/PageSize.cs | 49 ++ .../Common/IQueryParameterParser.cs | 19 - .../Common/IQueryParameterService.cs | 26 - .../Common/QueryParameterParser.cs | 65 -- .../Common/QueryParameterService.cs | 79 -- .../Contracts/IDefaultsService.cs | 15 - .../Contracts/IFilterService.cs | 16 - .../Contracts/IIncludeService.cs | 16 - .../Contracts/INullsService.cs | 15 - .../Contracts/IPageService.cs | 33 - .../Contracts/ISortService.cs | 16 - .../Contracts/ISparseFieldsService.cs | 22 - .../QueryParameterServices/FilterService.cs | 141 --- .../QueryParameterServices/IncludeService.cs | 75 -- .../QueryParameterServices/PageService.cs | 138 --- .../QueryParameterServices/SortService.cs | 115 --- .../SparseFieldsService.cs | 184 ---- .../RequestServices/Contracts/EndpointKind.cs | 20 + .../Contracts/ICurrentRequest.cs | 51 +- .../RequestServices/CurrentRequest.cs | 32 +- ...ngeTracker.cs => ResourceChangeTracker.cs} | 28 +- .../Client/DeserializedResponse.cs | 2 +- .../Client/IRequestSerializer.cs | 17 +- .../Serialization/Client/RequestSerializer.cs | 40 +- .../Client/ResponseDeserializer.cs | 18 +- .../Common/BaseDocumentParser.cs | 92 +- .../Serialization/Common/DocumentBuilder.cs | 22 +- .../Common/IResourceObjectBuilder.cs | 8 +- .../Common/ResourceObjectBuilder.cs | 64 +- .../Builders/IncludedResourceObjectBuilder.cs | 16 +- .../Server/Builders/LinkBuilder.cs | 88 +- .../Server/Builders/MetaBuilder.cs | 31 +- .../Builders/ResponseResourceObjectBuilder.cs | 47 +- .../Server/Contracts/IFieldsToSerialize.cs | 17 +- .../IIncludedResourceObjectBuilder.cs | 6 +- .../Server/Contracts/IJsonApiDeserializer.cs | 4 +- .../Server/Contracts/IJsonApiSerializer.cs | 6 +- .../Server/Contracts/ILinkBuilder.cs | 2 +- .../Serialization/Server/FieldsToSerialize.cs | 64 +- .../Server/RequestDeserializer.cs | 24 +- .../ResourceObjectBuilderSettingsProvider.cs | 14 +- .../Server/ResponseSerializer.cs | 67 +- .../Server/ResponseSerializerFactory.cs | 20 +- .../Services/Contract/ICreateService.cs | 2 +- .../Services/Contract/IGetAllService.cs | 2 +- .../Contract/IGetRelationshipService.cs | 2 +- ...hipsService.cs => IGetSecondaryService.cs} | 6 +- .../Contract/IResourceQueryService.cs | 6 +- .../Contract/IUpdateRelationshipService.cs | 2 +- .../Services/Contract/IUpdateService.cs | 2 +- .../Services/DefaultResourceService.cs | 448 ---------- .../Services/JsonApiResourceService.cs | 393 +++++++++ .../ServiceDiscoveryFacadeTests.cs | 30 +- .../EntityFrameworkCoreRepositoryTests.cs | 110 +++ .../Data/EntityRepositoryTests.cs | 202 ----- .../Extensibility/CustomControllerTests.cs | 11 +- .../Extensibility/CustomErrorHandlingTests.cs | 2 +- .../Extensibility/IgnoreDefaultValuesTests.cs | 13 +- .../Extensibility/IgnoreNullValuesTests.cs | 13 +- .../Extensibility/RequestMetaTests.cs | 67 +- .../HttpReadOnlyTests.cs | 3 +- .../NoHttpDeleteTests.cs | 3 +- .../NoHttpPatchTests.cs | 3 +- .../HttpMethodRestrictions/NoHttpPostTests.cs | 3 +- .../Acceptance/InjectableResourceTests.cs | 24 +- .../Acceptance/KebabCaseFormatterTests.cs | 156 +++- .../Acceptance/ManyToManyTests.cs | 130 +-- .../Acceptance/ModelStateValidationTests.cs | 47 +- .../Acceptance/NonJsonApiControllerTests.cs | 9 +- .../ResourceDefinitions/QueryFiltersTests.cs | 92 -- .../ResourceDefinitionTests.cs | 193 ++--- .../Acceptance/SerializationTests.cs | 4 +- .../Acceptance/Spec/AttributeFilterTests.cs | 349 -------- .../Acceptance/Spec/AttributeSortTests.cs | 108 --- .../Spec/ContentNegotiationTests.cs | 25 +- .../Acceptance/Spec/CreatingDataTests.cs | 89 +- ...CreatingDataWithClientGeneratedIdsTests.cs | 62 ++ .../Spec/DeeplyNestedInclusionTests.cs | 337 -------- .../Acceptance/Spec/DeletingDataTests.cs | 8 +- .../Spec/DisableQueryAttributeTests.cs | 2 +- .../Acceptance/Spec/DocumentTests/Included.cs | 468 ---------- .../DocumentTests/LinksWithNamespaceTests.cs | 10 +- .../LinksWithoutNamespaceTests.cs | 83 +- .../Acceptance/Spec/DocumentTests/Meta.cs | 59 +- .../Spec/DocumentTests/Relationships.cs | 13 +- .../Acceptance/Spec/EagerLoadTests.cs | 39 +- .../Acceptance/Spec/EndToEndTest.cs | 116 +-- .../Acceptance/Spec/FetchingDataTests.cs | 29 +- .../Spec/FetchingRelationshipsTests.cs | 138 ++- .../Spec/FunctionalTestCollection.cs | 121 +++ .../Acceptance/Spec/PaginationLinkTests.cs | 111 +++ .../Acceptance/Spec/PagingTests.cs | 155 ---- .../Acceptance/Spec/QueryParameterTests.cs | 158 ---- .../Acceptance/Spec/SparseFieldSetTests.cs | 448 ---------- .../Acceptance/Spec/ThrowingResourceTests.cs | 2 +- .../Acceptance/Spec/UpdatingDataTests.cs | 71 +- .../Spec/UpdatingRelationshipsTests.cs | 69 +- .../Acceptance/TestFixture.cs | 5 +- .../Acceptance/TodoItemControllerTests.cs | 402 +++++++++ .../Acceptance/TodoItemsControllerTests.cs | 808 ------------------ .../AppDbContextExtensions.cs | 55 ++ ...> ClientGeneratedIdsApplicationFactory.cs} | 7 +- .../Factories/KebabCaseApplicationFactory.cs | 13 - .../NoNamespaceApplicationFactory.cs | 13 - .../ResourceHooksApplicationFactory.cs | 33 + .../Helpers/Extensions/DocumentExtensions.cs | 21 - .../HttpResponseMessageExtensions.cs | 59 ++ .../IntegrationTestContext.cs | 201 +++++ .../Filtering/FilterDataTypeTests.cs | 335 ++++++++ .../Filtering/FilterDbContext.cs | 13 + .../Filtering/FilterDepthTests.cs | 661 ++++++++++++++ .../Filtering/FilterOperatorTests.cs | 570 ++++++++++++ .../IntegrationTests/Filtering/FilterTests.cs | 197 +++++ .../Filtering/FilterableResource.cs | 47 + .../FilterableResourcesController.cs | 16 + .../IntegrationTests/Includes/IncludeTests.cs | 790 +++++++++++++++++ .../Pagination/PaginationRangeTests.cs | 155 ++++ .../PaginationRangeWithMaximumTests.cs | 147 ++++ .../Pagination/PaginationTests.cs | 505 +++++++++++ .../QueryStrings/QueryStringTests.cs | 90 ++ .../ResourceDefinitions/CallableDbContext.cs | 13 + .../ResourceDefinitions/CallableResource.cs | 31 + .../CallableResourceDefinition.cs | 94 ++ .../CallableResourcesController.cs | 16 + .../ResourceDefinitionQueryCallbackTests.cs | 513 +++++++++++ .../IntegrationTests/Sorting/SortTests.cs | 753 ++++++++++++++++ .../SparseFieldSets/ResourceCaptureStore.cs | 20 + .../ResultCapturingRepository.cs | 46 + .../SparseFieldSets/SparseFieldSetTests.cs | 664 ++++++++++++++ .../IntegrationTests/TestableStartup.cs | 37 + .../JsonApiDotNetCoreExampleTests.csproj | 1 + test/UnitTests/Builders/LinkBuilderTests.cs | 227 +++-- test/UnitTests/Builders/LinkTests.cs | 52 +- .../BaseJsonApiController_Tests.cs | 30 +- ...Tests.cs => CoreJsonApiControllerTests.cs} | 3 +- .../Data/DefaultEntityRepositoryTest.cs | 75 -- .../IServiceCollectionExtensionsTests.cs | 39 +- ..._Tests.cs => ResourceGraphBuilderTests.cs} | 4 +- .../Middleware/JsonApiMiddlewareTests.cs | 18 +- test/UnitTests/Models/LinkTests.cs | 62 +- .../ResourceConstructionExpressionTests.cs | 6 +- .../Models/ResourceConstructionTests.cs | 8 +- .../Models/ResourceDefinitionTests.cs | 93 -- .../QueryParameters/DefaultsServiceTests.cs | 92 -- .../QueryParameters/FilterServiceTests.cs | 79 -- .../QueryParameters/IncludeServiceTests.cs | 122 --- .../QueryParameters/NullsServiceTests.cs | 92 -- .../QueryParameters/PageServiceTests.cs | 110 --- .../QueryParametersUnitTestCollection.cs | 52 -- .../QueryParameters/SortServiceTests.cs | 63 -- .../SparseFieldsServiceTests.cs | 229 ----- .../DefaultsParseTests.cs | 129 +++ .../QueryStringParameters/FilterParseTests.cs | 141 +++ .../IncludeParseTests.cs | 94 ++ .../LegacyFilterParseTests.cs | 95 ++ .../QueryStringParameters/NullsParseTests.cs | 129 +++ .../PaginationParseTests.cs | 155 ++++ .../QueryStringParameters/ParseTestsBase.cs | 37 + .../QueryStringParameters/SortParseTests.cs | 113 +++ .../SparseFieldSetParseTests.cs | 100 +++ .../UnitTests/ResourceHooks/DiscoveryTests.cs | 16 +- ...ests.cs => RelationshipDictionaryTests.cs} | 122 +-- .../Create/BeforeCreateTests.cs | 6 +- .../Create/BeforeCreate_WithDbValues_Tests.cs | 12 +- .../Delete/BeforeDeleteTests.cs | 2 +- .../Delete/BeforeDelete_WithDbValue_Tests.cs | 4 +- .../IdentifiableManyToMany_OnReturnTests.cs | 1 - .../Read/BeforeReadTests.cs | 35 +- .../ResourceHookExecutor/ScenarioTests.cs | 10 +- .../Update/BeforeUpdateTests.cs | 4 +- .../Update/BeforeUpdate_WithDbValues_Tests.cs | 22 +- .../ResourceHooks/ResourceHooksTestsSetup.cs | 146 ++-- .../Client/RequestSerializerTests.cs | 28 +- .../Client/ResponseDeserializerTests.cs | 80 +- .../Common/DocumentBuilderTests.cs | 16 +- .../Common/DocumentParserTests.cs | 7 +- .../Common/ResourceObjectBuilderTests.cs | 66 +- .../Serialization/DeserializerTestsSetup.cs | 10 +- .../Serialization/SerializerTestsSetup.cs | 43 +- .../IncludedResourceObjectBuilderTests.cs | 3 +- .../Server/RequestDeserializerTests.cs | 3 +- .../ResponseResourceObjectBuilderTests.cs | 16 +- .../Server/ResponseSerializerTests.cs | 46 +- .../Services/DefaultResourceService_Tests.cs | 95 ++ .../Services/EntityResourceService_Tests.cs | 132 --- test/UnitTests/TestModels.cs | 2 +- test/UnitTests/UnitTests.csproj | 1 + 414 files changed, 17039 insertions(+), 10144 deletions(-) delete mode 100644 docs/usage/sparse-field-selection.md create mode 100644 docs/usage/sparse-fieldset-selection.md create mode 100644 src/Examples/JsonApiDotNetCoreExample/Controllers/AuthorsController.cs rename src/Examples/JsonApiDotNetCoreExample/Controllers/{ModelsController.cs => BlogsController.cs} (72%) create mode 100644 src/Examples/JsonApiDotNetCoreExample/Controllers/CountriesController.cs create mode 100644 src/Examples/JsonApiDotNetCoreExample/Controllers/VisasController.cs delete mode 100644 src/Examples/JsonApiDotNetCoreExample/Definitions/ModelDefinition.cs delete mode 100644 src/Examples/JsonApiDotNetCoreExample/Definitions/UserDefinition.cs create mode 100644 src/Examples/JsonApiDotNetCoreExample/Models/Address.cs create mode 100644 src/Examples/JsonApiDotNetCoreExample/Models/Blog.cs delete mode 100644 src/Examples/JsonApiDotNetCoreExample/Models/Model.cs create mode 100644 src/Examples/JsonApiDotNetCoreExample/Models/Revision.cs rename src/Examples/JsonApiDotNetCoreExample/Services/{SkipCacheQueryParameterService.cs => SkipCacheQueryStringParameterReader.cs} (78%) create mode 100644 src/Examples/JsonApiDotNetCoreExample/Startups/EmptyStartup.cs delete mode 100644 src/Examples/JsonApiDotNetCoreExample/Startups/KebabCaseStartup.cs delete mode 100644 src/Examples/JsonApiDotNetCoreExample/Startups/MetaStartup.cs delete mode 100644 src/Examples/JsonApiDotNetCoreExample/Startups/NoDefaultPageSizeStartup.cs delete mode 100644 src/Examples/JsonApiDotNetCoreExample/Startups/NoNamespaceStartup.cs delete mode 100644 src/JsonApiDotNetCore/Configuration/ILinksConfiguration.cs rename src/JsonApiDotNetCore/Controllers/{JsonApiControllerMixin.cs => CoreJsonApiController.cs} (83%) rename src/JsonApiDotNetCore/Data/{DefaultResourceRepository.cs => EntityFrameworkCoreRepository.cs} (56%) create mode 100644 src/JsonApiDotNetCore/Exceptions/InvalidQueryException.cs delete mode 100644 src/JsonApiDotNetCore/Extensions/EnumerableExtensions.cs delete mode 100644 src/JsonApiDotNetCore/Extensions/QueryableExtensions.cs rename src/JsonApiDotNetCore/Hooks/Execution/{DiffableEntityHashSet.cs => DiffableResourceHashSet.cs} (68%) rename src/JsonApiDotNetCore/Hooks/Execution/{EntityHashSet.cs => ResourceHashSet.cs} (79%) rename src/JsonApiDotNetCore/Hooks/Traversal/{IEntityNode.cs => IResourceNode.cs} (81%) rename src/JsonApiDotNetCore/Internal/Contracts/{IResourceGraphExplorer.cs => IResourceGraph.cs} (100%) create mode 100644 src/JsonApiDotNetCore/Internal/IPaginationContext.cs rename src/JsonApiDotNetCore/Internal/{DefaultRoutingConvention.cs => JsonApiRoutingConvention.cs} (71%) create mode 100644 src/JsonApiDotNetCore/Internal/PaginationContext.cs create mode 100644 src/JsonApiDotNetCore/Internal/Queries/ExpressionInScope.cs create mode 100644 src/JsonApiDotNetCore/Internal/Queries/Expressions/CollectionNotEmptyExpression.cs create mode 100644 src/JsonApiDotNetCore/Internal/Queries/Expressions/ComparisonExpression.cs create mode 100644 src/JsonApiDotNetCore/Internal/Queries/Expressions/ComparisonOperator.cs create mode 100644 src/JsonApiDotNetCore/Internal/Queries/Expressions/CountExpression.cs create mode 100644 src/JsonApiDotNetCore/Internal/Queries/Expressions/EqualsAnyOfExpression.cs create mode 100644 src/JsonApiDotNetCore/Internal/Queries/Expressions/FilterExpression.cs create mode 100644 src/JsonApiDotNetCore/Internal/Queries/Expressions/FunctionExpression.cs create mode 100644 src/JsonApiDotNetCore/Internal/Queries/Expressions/IdentifierExpression.cs create mode 100644 src/JsonApiDotNetCore/Internal/Queries/Expressions/IncludeExpression.cs create mode 100644 src/JsonApiDotNetCore/Internal/Queries/Expressions/LiteralConstantExpression.cs create mode 100644 src/JsonApiDotNetCore/Internal/Queries/Expressions/LogicalExpression.cs create mode 100644 src/JsonApiDotNetCore/Internal/Queries/Expressions/LogicalOperator.cs create mode 100644 src/JsonApiDotNetCore/Internal/Queries/Expressions/MatchTextExpression.cs create mode 100644 src/JsonApiDotNetCore/Internal/Queries/Expressions/NotExpression.cs create mode 100644 src/JsonApiDotNetCore/Internal/Queries/Expressions/NullConstantExpression.cs create mode 100644 src/JsonApiDotNetCore/Internal/Queries/Expressions/PaginationElementQueryStringValueExpression.cs create mode 100644 src/JsonApiDotNetCore/Internal/Queries/Expressions/PaginationExpression.cs create mode 100644 src/JsonApiDotNetCore/Internal/Queries/Expressions/PaginationQueryStringValueExpression.cs create mode 100644 src/JsonApiDotNetCore/Internal/Queries/Expressions/QueryExpression.cs create mode 100644 src/JsonApiDotNetCore/Internal/Queries/Expressions/QueryExpressionVisitor.cs create mode 100644 src/JsonApiDotNetCore/Internal/Queries/Expressions/QueryStringParameterScopeExpression.cs create mode 100644 src/JsonApiDotNetCore/Internal/Queries/Expressions/QueryableHandlerExpression.cs create mode 100644 src/JsonApiDotNetCore/Internal/Queries/Expressions/ResourceFieldChainExpression.cs create mode 100644 src/JsonApiDotNetCore/Internal/Queries/Expressions/SortElementExpression.cs create mode 100644 src/JsonApiDotNetCore/Internal/Queries/Expressions/SortExpression.cs create mode 100644 src/JsonApiDotNetCore/Internal/Queries/Expressions/SparseFieldSetExpression.cs create mode 100644 src/JsonApiDotNetCore/Internal/Queries/Expressions/TextMatchKind.cs create mode 100644 src/JsonApiDotNetCore/Internal/Queries/IQueryConstraintProvider.cs create mode 100644 src/JsonApiDotNetCore/Internal/Queries/Parsing/FilterParser.cs create mode 100644 src/JsonApiDotNetCore/Internal/Queries/Parsing/IncludeParser.cs create mode 100644 src/JsonApiDotNetCore/Internal/Queries/Parsing/Keywords.cs create mode 100644 src/JsonApiDotNetCore/Internal/Queries/Parsing/PaginationParser.cs create mode 100644 src/JsonApiDotNetCore/Internal/Queries/Parsing/QueryParseException.cs create mode 100644 src/JsonApiDotNetCore/Internal/Queries/Parsing/QueryParser.cs create mode 100644 src/JsonApiDotNetCore/Internal/Queries/Parsing/QueryStringParameterScopeParser.cs create mode 100644 src/JsonApiDotNetCore/Internal/Queries/Parsing/QueryTokenizer.cs create mode 100644 src/JsonApiDotNetCore/Internal/Queries/Parsing/ResolveFieldChainCallback.cs create mode 100644 src/JsonApiDotNetCore/Internal/Queries/Parsing/SortParser.cs create mode 100644 src/JsonApiDotNetCore/Internal/Queries/Parsing/SparseFieldSetParser.cs create mode 100644 src/JsonApiDotNetCore/Internal/Queries/Parsing/Token.cs create mode 100644 src/JsonApiDotNetCore/Internal/Queries/Parsing/TokenKind.cs create mode 100644 src/JsonApiDotNetCore/Internal/Queries/QueryLayer.cs create mode 100644 src/JsonApiDotNetCore/Internal/Queries/QueryLayerComposer.cs create mode 100644 src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/IncludeClauseBuilder.cs create mode 100644 src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/LambdaParameterNameFactory.cs create mode 100644 src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/LambdaParameterNameScope.cs create mode 100644 src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/LambdaScope.cs create mode 100644 src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/LambdaScopeFactory.cs create mode 100644 src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/OrderClauseBuilder.cs create mode 100644 src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/QueryClauseBuilder.cs create mode 100644 src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/QueryableBuilder.cs create mode 100644 src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/SelectClauseBuilder.cs create mode 100644 src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/SkipTakeClauseBuilder.cs create mode 100644 src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/WhereClauseBuilder.cs delete mode 100644 src/JsonApiDotNetCore/Internal/Query/BaseQuery.cs delete mode 100644 src/JsonApiDotNetCore/Internal/Query/BaseQueryContext.cs delete mode 100644 src/JsonApiDotNetCore/Internal/Query/FilterOperations.cs delete mode 100644 src/JsonApiDotNetCore/Internal/Query/FilterQuery.cs delete mode 100644 src/JsonApiDotNetCore/Internal/Query/FilterQueryContext.cs delete mode 100644 src/JsonApiDotNetCore/Internal/Query/QueryConstants.cs delete mode 100644 src/JsonApiDotNetCore/Internal/Query/SortDirection.cs delete mode 100644 src/JsonApiDotNetCore/Internal/Query/SortQuery.cs delete mode 100644 src/JsonApiDotNetCore/Internal/Query/SortQueryContext.cs rename src/JsonApiDotNetCore/{QueryParameterServices/DefaultsService.cs => Internal/QueryStrings/DefaultsQueryStringParameterReader.cs} (58%) create mode 100644 src/JsonApiDotNetCore/Internal/QueryStrings/FilterQueryStringParameterReader.cs create mode 100644 src/JsonApiDotNetCore/Internal/QueryStrings/IQueryStringParameterReader.cs create mode 100644 src/JsonApiDotNetCore/Internal/QueryStrings/IQueryStringReader.cs rename src/JsonApiDotNetCore/{QueryParameterServices/Common => Internal/QueryStrings}/IRequestQueryStringAccessor.cs (75%) create mode 100644 src/JsonApiDotNetCore/Internal/QueryStrings/IncludeQueryStringParameterReader.cs create mode 100644 src/JsonApiDotNetCore/Internal/QueryStrings/LegacyFilterNotationConverter.cs rename src/JsonApiDotNetCore/{QueryParameterServices/NullsService.cs => Internal/QueryStrings/NullsQueryStringParameterReader.cs} (57%) create mode 100644 src/JsonApiDotNetCore/Internal/QueryStrings/PaginationQueryStringParameterReader.cs create mode 100644 src/JsonApiDotNetCore/Internal/QueryStrings/QueryStringParameterReader.cs create mode 100644 src/JsonApiDotNetCore/Internal/QueryStrings/QueryStringReader.cs rename src/JsonApiDotNetCore/{QueryParameterServices/Common => Internal/QueryStrings}/RequestQueryStringAccessor.cs (90%) create mode 100644 src/JsonApiDotNetCore/Internal/QueryStrings/ResourceDefinitionQueryableParameterReader.cs create mode 100644 src/JsonApiDotNetCore/Internal/QueryStrings/ResourceFieldChainResolver.cs create mode 100644 src/JsonApiDotNetCore/Internal/QueryStrings/SortQueryStringParameterReader.cs create mode 100644 src/JsonApiDotNetCore/Internal/QueryStrings/SparseFieldSetQueryStringParameterReader.cs rename src/JsonApiDotNetCore/Middleware/{DefaultExceptionHandler.cs => ExceptionHandler.cs} (91%) rename src/JsonApiDotNetCore/Middleware/{IQueryParameterActionFilter.cs => IQueryStringActionFilter.cs} (82%) rename src/JsonApiDotNetCore/Middleware/{DefaultTypeMatchFilter.cs => IncomingTypeMatchFilter.cs} (92%) rename src/JsonApiDotNetCore/Middleware/{DefaultExceptionFilter.cs => JsonApiExceptionFilter.cs} (87%) rename src/JsonApiDotNetCore/Middleware/{QueryParameterFilter.cs => QueryStringActionFilter.cs} (53%) rename src/JsonApiDotNetCore/Models/JsonApiDocuments/{Link.cs => Links.cs} (73%) create mode 100644 src/JsonApiDotNetCore/Models/SparseFieldSetExtensions.cs create mode 100644 src/JsonApiDotNetCore/PageNumber.cs create mode 100644 src/JsonApiDotNetCore/PageSize.cs delete mode 100644 src/JsonApiDotNetCore/QueryParameterServices/Common/IQueryParameterParser.cs delete mode 100644 src/JsonApiDotNetCore/QueryParameterServices/Common/IQueryParameterService.cs delete mode 100644 src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterParser.cs delete mode 100644 src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterService.cs delete mode 100644 src/JsonApiDotNetCore/QueryParameterServices/Contracts/IDefaultsService.cs delete mode 100644 src/JsonApiDotNetCore/QueryParameterServices/Contracts/IFilterService.cs delete mode 100644 src/JsonApiDotNetCore/QueryParameterServices/Contracts/IIncludeService.cs delete mode 100644 src/JsonApiDotNetCore/QueryParameterServices/Contracts/INullsService.cs delete mode 100644 src/JsonApiDotNetCore/QueryParameterServices/Contracts/IPageService.cs delete mode 100644 src/JsonApiDotNetCore/QueryParameterServices/Contracts/ISortService.cs delete mode 100644 src/JsonApiDotNetCore/QueryParameterServices/Contracts/ISparseFieldsService.cs delete mode 100644 src/JsonApiDotNetCore/QueryParameterServices/FilterService.cs delete mode 100644 src/JsonApiDotNetCore/QueryParameterServices/IncludeService.cs delete mode 100644 src/JsonApiDotNetCore/QueryParameterServices/PageService.cs delete mode 100644 src/JsonApiDotNetCore/QueryParameterServices/SortService.cs delete mode 100644 src/JsonApiDotNetCore/QueryParameterServices/SparseFieldsService.cs create mode 100644 src/JsonApiDotNetCore/RequestServices/Contracts/EndpointKind.cs rename src/JsonApiDotNetCore/RequestServices/{DefaultResourceChangeTracker.cs => ResourceChangeTracker.cs} (78%) rename src/JsonApiDotNetCore/Services/Contract/{IGetRelationshipsService.cs => IGetSecondaryService.cs} (50%) delete mode 100644 src/JsonApiDotNetCore/Services/DefaultResourceService.cs create mode 100644 src/JsonApiDotNetCore/Services/JsonApiResourceService.cs create mode 100644 test/IntegrationTests/Data/EntityFrameworkCoreRepositoryTests.cs delete mode 100644 test/IntegrationTests/Data/EntityRepositoryTests.cs delete mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/ResourceDefinitions/QueryFiltersTests.cs delete mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/AttributeFilterTests.cs delete mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/AttributeSortTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataWithClientGeneratedIdsTests.cs delete mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeeplyNestedInclusionTests.cs delete mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Included.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FunctionalTestCollection.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/PaginationLinkTests.cs delete mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/PagingTests.cs delete mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/QueryParameterTests.cs delete mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/SparseFieldSetTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemControllerTests.cs delete mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/AppDbContextExtensions.cs rename test/JsonApiDotNetCoreExampleTests/Factories/{ClientEnabledIdsApplicationFactory.cs => ClientGeneratedIdsApplicationFactory.cs} (77%) delete mode 100644 test/JsonApiDotNetCoreExampleTests/Factories/KebabCaseApplicationFactory.cs delete mode 100644 test/JsonApiDotNetCoreExampleTests/Factories/NoNamespaceApplicationFactory.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/Factories/ResourceHooksApplicationFactory.cs delete mode 100644 test/JsonApiDotNetCoreExampleTests/Helpers/Extensions/DocumentExtensions.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/HttpResponseMessageExtensions.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTestContext.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterDataTypeTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterDbContext.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterDepthTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterOperatorTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterableResource.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterableResourcesController.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Includes/IncludeTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationRangeTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationRangeWithMaximumTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/QueryStringTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableDbContext.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableResource.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableResourceDefinition.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableResourcesController.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/ResourceDefinitionQueryCallbackTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Sorting/SortTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/ResourceCaptureStore.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/ResultCapturingRepository.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/SparseFieldSetTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/TestableStartup.cs rename test/UnitTests/Controllers/{JsonApiControllerMixin_Tests.cs => CoreJsonApiControllerTests.cs} (96%) delete mode 100644 test/UnitTests/Data/DefaultEntityRepositoryTest.cs rename test/UnitTests/Internal/{ResourceGraphBuilder_Tests.cs => ResourceGraphBuilderTests.cs} (91%) delete mode 100644 test/UnitTests/Models/ResourceDefinitionTests.cs delete mode 100644 test/UnitTests/QueryParameters/DefaultsServiceTests.cs delete mode 100644 test/UnitTests/QueryParameters/FilterServiceTests.cs delete mode 100644 test/UnitTests/QueryParameters/IncludeServiceTests.cs delete mode 100644 test/UnitTests/QueryParameters/NullsServiceTests.cs delete mode 100644 test/UnitTests/QueryParameters/PageServiceTests.cs delete mode 100644 test/UnitTests/QueryParameters/QueryParametersUnitTestCollection.cs delete mode 100644 test/UnitTests/QueryParameters/SortServiceTests.cs delete mode 100644 test/UnitTests/QueryParameters/SparseFieldsServiceTests.cs create mode 100644 test/UnitTests/QueryStringParameters/DefaultsParseTests.cs create mode 100644 test/UnitTests/QueryStringParameters/FilterParseTests.cs create mode 100644 test/UnitTests/QueryStringParameters/IncludeParseTests.cs create mode 100644 test/UnitTests/QueryStringParameters/LegacyFilterParseTests.cs create mode 100644 test/UnitTests/QueryStringParameters/NullsParseTests.cs create mode 100644 test/UnitTests/QueryStringParameters/PaginationParseTests.cs create mode 100644 test/UnitTests/QueryStringParameters/ParseTestsBase.cs create mode 100644 test/UnitTests/QueryStringParameters/SortParseTests.cs create mode 100644 test/UnitTests/QueryStringParameters/SparseFieldSetParseTests.cs rename test/UnitTests/ResourceHooks/{AffectedEntitiesHelperTests.cs => RelationshipDictionaryTests.cs} (57%) create mode 100644 test/UnitTests/Services/DefaultResourceService_Tests.cs delete mode 100644 test/UnitTests/Services/EntityResourceService_Tests.cs diff --git a/Directory.Build.props b/Directory.Build.props index 2ef7f513f1..6eb8cf3e1f 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -15,6 +15,7 @@ 2.4.1 + 5.10.3 29.0.1 4.13.1 diff --git a/benchmarks/LinkBuilder/LinkBuilderGetNamespaceFromPathBenchmarks.cs b/benchmarks/LinkBuilder/LinkBuilderGetNamespaceFromPathBenchmarks.cs index 5524f83e5f..47751425d9 100644 --- a/benchmarks/LinkBuilder/LinkBuilderGetNamespaceFromPathBenchmarks.cs +++ b/benchmarks/LinkBuilder/LinkBuilderGetNamespaceFromPathBenchmarks.cs @@ -8,23 +8,23 @@ namespace Benchmarks.LinkBuilder public class LinkBuilderGetNamespaceFromPathBenchmarks { private const string RequestPath = "/api/some-really-long-namespace-path/resources/current/articles/?some"; - private const string EntityName = "articles"; + private const string ResourceName = "articles"; private const char PathDelimiter = '/'; [Benchmark] - public void UsingStringSplit() => GetNamespaceFromPathUsingStringSplit(RequestPath, EntityName); + public void UsingStringSplit() => GetNamespaceFromPathUsingStringSplit(RequestPath, ResourceName); [Benchmark] - public void UsingReadOnlySpan() => GetNamespaceFromPathUsingReadOnlySpan(RequestPath, EntityName); + public void UsingReadOnlySpan() => GetNamespaceFromPathUsingReadOnlySpan(RequestPath, ResourceName); - public static string GetNamespaceFromPathUsingStringSplit(string path, string entityName) + public static string GetNamespaceFromPathUsingStringSplit(string path, string resourceName) { StringBuilder namespaceBuilder = new StringBuilder(path.Length); string[] segments = path.Split('/'); for (int index = 1; index < segments.Length; index++) { - if (segments[index] == entityName) + if (segments[index] == resourceName) { break; } @@ -36,22 +36,22 @@ public static string GetNamespaceFromPathUsingStringSplit(string path, string en return namespaceBuilder.ToString(); } - public static string GetNamespaceFromPathUsingReadOnlySpan(string path, string entityName) + public static string GetNamespaceFromPathUsingReadOnlySpan(string path, string resourceName) { - ReadOnlySpan entityNameSpan = entityName.AsSpan(); + ReadOnlySpan resourceNameSpan = resourceName.AsSpan(); ReadOnlySpan pathSpan = path.AsSpan(); for (int index = 0; index < pathSpan.Length; index++) { if (pathSpan[index].Equals(PathDelimiter)) { - if (pathSpan.Length > index + entityNameSpan.Length) + if (pathSpan.Length > index + resourceNameSpan.Length) { - ReadOnlySpan possiblePathSegment = pathSpan.Slice(index + 1, entityNameSpan.Length); + ReadOnlySpan possiblePathSegment = pathSpan.Slice(index + 1, resourceNameSpan.Length); - if (entityNameSpan.SequenceEqual(possiblePathSegment)) + if (resourceNameSpan.SequenceEqual(possiblePathSegment)) { - int lastCharacterIndex = index + 1 + entityNameSpan.Length; + int lastCharacterIndex = index + 1 + resourceNameSpan.Length; bool isAtEnd = lastCharacterIndex == pathSpan.Length; bool hasDelimiterAfterSegment = pathSpan.Length >= lastCharacterIndex + 1 && pathSpan[lastCharacterIndex].Equals(PathDelimiter); diff --git a/benchmarks/Query/QueryParserBenchmarks.cs b/benchmarks/Query/QueryParserBenchmarks.cs index 79b9caabf1..e2b34c1904 100644 --- a/benchmarks/Query/QueryParserBenchmarks.cs +++ b/benchmarks/Query/QueryParserBenchmarks.cs @@ -1,12 +1,12 @@ using System; using System.Collections.Generic; +using System.ComponentModel.Design; using BenchmarkDotNet.Attributes; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Managers; -using JsonApiDotNetCore.Query; -using JsonApiDotNetCore.QueryParameterServices.Common; -using JsonApiDotNetCore.Services; +using JsonApiDotNetCore.Internal.QueryStrings; +using JsonApiDotNetCore.RequestServices; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.Logging.Abstractions; @@ -17,56 +17,59 @@ namespace Benchmarks.Query public class QueryParserBenchmarks { private readonly FakeRequestQueryStringAccessor _queryStringAccessor = new FakeRequestQueryStringAccessor(); - private readonly QueryParameterParser _queryParameterParserForSort; - private readonly QueryParameterParser _queryParameterParserForAll; + private readonly QueryStringReader _queryStringReaderForSort; + private readonly QueryStringReader _queryStringReaderForAll; public QueryParserBenchmarks() { - IJsonApiOptions options = new JsonApiOptions(); + IJsonApiOptions options = new JsonApiOptions + { + EnableLegacyFilterNotation = true + }; + IResourceGraph resourceGraph = DependencyFactory.CreateResourceGraph(options); - - var currentRequest = new CurrentRequest(); - currentRequest.SetRequestResource(resourceGraph.GetResourceContext(typeof(BenchmarkResource))); - IResourceDefinitionProvider resourceDefinitionProvider = DependencyFactory.CreateResourceDefinitionProvider(resourceGraph); + var currentRequest = new CurrentRequest + { + PrimaryResource = resourceGraph.GetResourceContext(typeof(BenchmarkResource)) + }; - _queryParameterParserForSort = CreateQueryParameterDiscoveryForSort(resourceGraph, currentRequest, resourceDefinitionProvider, options, _queryStringAccessor); - _queryParameterParserForAll = CreateQueryParameterDiscoveryForAll(resourceGraph, currentRequest, resourceDefinitionProvider, options, _queryStringAccessor); + _queryStringReaderForSort = CreateQueryParameterDiscoveryForSort(resourceGraph, currentRequest, options, _queryStringAccessor); + _queryStringReaderForAll = CreateQueryParameterDiscoveryForAll(resourceGraph, currentRequest, options, _queryStringAccessor); } - private static QueryParameterParser CreateQueryParameterDiscoveryForSort(IResourceGraph resourceGraph, - CurrentRequest currentRequest, IResourceDefinitionProvider resourceDefinitionProvider, + private static QueryStringReader CreateQueryParameterDiscoveryForSort(IResourceGraph resourceGraph, + CurrentRequest currentRequest, IJsonApiOptions options, FakeRequestQueryStringAccessor queryStringAccessor) { - ISortService sortService = new SortService(resourceDefinitionProvider, resourceGraph, currentRequest); - - var queryServices = new List + var sortReader = new SortQueryStringParameterReader(currentRequest, resourceGraph); + + var readers = new List { - sortService + sortReader }; - return new QueryParameterParser(options, queryStringAccessor, queryServices, NullLoggerFactory.Instance); + return new QueryStringReader(options, queryStringAccessor, readers, NullLoggerFactory.Instance); } - private static QueryParameterParser CreateQueryParameterDiscoveryForAll(IResourceGraph resourceGraph, - CurrentRequest currentRequest, IResourceDefinitionProvider resourceDefinitionProvider, - IJsonApiOptions options, FakeRequestQueryStringAccessor queryStringAccessor) + private static QueryStringReader CreateQueryParameterDiscoveryForAll(IResourceGraph resourceGraph, + CurrentRequest currentRequest, IJsonApiOptions options, FakeRequestQueryStringAccessor queryStringAccessor) { - IIncludeService includeService = new IncludeService(resourceGraph, currentRequest); - IFilterService filterService = new FilterService(resourceDefinitionProvider, resourceGraph, currentRequest); - ISortService sortService = new SortService(resourceDefinitionProvider, resourceGraph, currentRequest); - ISparseFieldsService sparseFieldsService = new SparseFieldsService(resourceGraph, currentRequest); - IPageService pageService = new PageService(options, resourceGraph, currentRequest); - IDefaultsService defaultsService = new DefaultsService(options); - INullsService nullsService = new NullsService(options); - - var queryServices = new List + var resourceFactory = new ResourceFactory(new ServiceContainer()); + + var filterReader = new FilterQueryStringParameterReader(currentRequest, resourceGraph, resourceFactory, options); + var sortReader = new SortQueryStringParameterReader(currentRequest, resourceGraph); + var sparseFieldSetReader = new SparseFieldSetQueryStringParameterReader(currentRequest, resourceGraph); + var paginationReader = new PaginationQueryStringParameterReader(currentRequest, resourceGraph, options); + var defaultsReader = new DefaultsQueryStringParameterReader(options); + var nullsReader = new NullsQueryStringParameterReader(options); + + var readers = new List { - includeService, filterService, sortService, sparseFieldsService, pageService, defaultsService, - nullsService + filterReader, sortReader, sparseFieldSetReader, paginationReader, defaultsReader, nullsReader }; - return new QueryParameterParser(options, queryStringAccessor, queryServices, NullLoggerFactory.Instance); + return new QueryStringReader(options, queryStringAccessor, readers, NullLoggerFactory.Instance); } [Benchmark] @@ -75,7 +78,7 @@ public void AscendingSort() var queryString = $"?sort={BenchmarkResourcePublicNames.NameAttr}"; _queryStringAccessor.SetQueryString(queryString); - _queryParameterParserForSort.Parse(null); + _queryStringReaderForSort.ReadAll(null); } [Benchmark] @@ -84,7 +87,7 @@ public void DescendingSort() var queryString = $"?sort=-{BenchmarkResourcePublicNames.NameAttr}"; _queryStringAccessor.SetQueryString(queryString); - _queryParameterParserForSort.Parse(null); + _queryStringReaderForSort.ReadAll(null); } [Benchmark] @@ -93,7 +96,7 @@ public void ComplexQuery() => Run(100, () => var queryString = $"?filter[{BenchmarkResourcePublicNames.NameAttr}]=abc,eq:abc&sort=-{BenchmarkResourcePublicNames.NameAttr}&include=child&page[size]=1&fields={BenchmarkResourcePublicNames.NameAttr}"; _queryStringAccessor.SetQueryString(queryString); - _queryParameterParserForAll.Parse(null); + _queryStringReaderForAll.ReadAll(null); }); private void Run(int iterations, Action action) { diff --git a/benchmarks/Serialization/JsonApiDeserializerBenchmarks.cs b/benchmarks/Serialization/JsonApiDeserializerBenchmarks.cs index 0ff4c7fbbd..5908110d56 100644 --- a/benchmarks/Serialization/JsonApiDeserializerBenchmarks.cs +++ b/benchmarks/Serialization/JsonApiDeserializerBenchmarks.cs @@ -39,7 +39,7 @@ public JsonApiDeserializerBenchmarks() var options = new JsonApiOptions(); IResourceGraph resourceGraph = DependencyFactory.CreateResourceGraph(options); var targetedFields = new TargetedFields(); - _jsonApiDeserializer = new RequestDeserializer(resourceGraph, new DefaultResourceFactory(new ServiceContainer()), targetedFields, new HttpContextAccessor()); + _jsonApiDeserializer = new RequestDeserializer(resourceGraph, new ResourceFactory(new ServiceContainer()), targetedFields, new HttpContextAccessor()); } [Benchmark] diff --git a/benchmarks/Serialization/JsonApiSerializerBenchmarks.cs b/benchmarks/Serialization/JsonApiSerializerBenchmarks.cs index dd1af5aff2..47c80a6444 100644 --- a/benchmarks/Serialization/JsonApiSerializerBenchmarks.cs +++ b/benchmarks/Serialization/JsonApiSerializerBenchmarks.cs @@ -2,8 +2,9 @@ using BenchmarkDotNet.Attributes; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Managers; -using JsonApiDotNetCore.Query; +using JsonApiDotNetCore.Internal.Queries; +using JsonApiDotNetCore.Internal.QueryStrings; +using JsonApiDotNetCore.RequestServices; using JsonApiDotNetCore.Serialization; using JsonApiDotNetCore.Serialization.Server; using JsonApiDotNetCore.Serialization.Server.Builders; @@ -14,7 +15,7 @@ namespace Benchmarks.Serialization [MarkdownExporter] public class JsonApiSerializerBenchmarks { - private static readonly BenchmarkResource Content = new BenchmarkResource + private static readonly BenchmarkResource _content = new BenchmarkResource { Id = 123, Name = Guid.NewGuid().ToString() @@ -40,14 +41,19 @@ public JsonApiSerializerBenchmarks() private static FieldsToSerialize CreateFieldsToSerialize(IResourceGraph resourceGraph) { - var resourceDefinitionProvider = DependencyFactory.CreateResourceDefinitionProvider(resourceGraph); var currentRequest = new CurrentRequest(); - var sparseFieldsService = new SparseFieldsService(resourceGraph, currentRequest); - - return new FieldsToSerialize(resourceGraph, sparseFieldsService, resourceDefinitionProvider); + + var constraintProviders = new IQueryConstraintProvider[] + { + new SparseFieldSetQueryStringParameterReader(currentRequest, resourceGraph) + }; + + var resourceDefinitionProvider = DependencyFactory.CreateResourceDefinitionProvider(resourceGraph); + + return new FieldsToSerialize(resourceGraph, constraintProviders, resourceDefinitionProvider); } [Benchmark] - public object SerializeSimpleObject() => _jsonApiSerializer.Serialize(Content); + public object SerializeSimpleObject() => _jsonApiSerializer.Serialize(_content); } } diff --git a/docs/api/index.md b/docs/api/index.md index c93ca94a89..51c288d491 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -4,6 +4,6 @@ This section documents the package API and is generated from the XML source comm ## Common APIs -- [`JsonApiOptions`](JsonApiDotNetCore.Configuration.JsonApiOptions.html) -- [`IResourceGraph`](JsonApiDotNetCore.Internal.Contracts.IResourceGraph.html) -- [`ResourceDefinition`](JsonApiDotNetCore.Models.ResourceDefinition-1.html) +- [`JsonApiOptions`](JsonApiDotNetCore.Configuration.JsonApiOptions.yml) +- [`IResourceGraph`](JsonApiDotNetCore.Internal.Contracts.IResourceGraph.yml) +- [`ResourceDefinition`](JsonApiDotNetCore.Models.ResourceDefinition-1.yml) diff --git a/docs/index.md b/docs/index.md index cb6a3abad0..c4518c8884 100644 --- a/docs/index.md +++ b/docs/index.md @@ -14,10 +14,10 @@ Eliminate CRUD boilerplate and provide the following features across your resour - Filtering - Sorting - Pagination -- Sparse field selection +- Sparse fieldset selection - Relationship inclusion and navigation -Checkout the [example requests](request-examples) to see the kind of features you will get out of the box. +Checkout the [example requests](request-examples/index.md) to see the kind of features you will get out of the box. ### 2. Extensibility diff --git a/docs/usage/extensibility/custom-query-formats.md b/docs/usage/extensibility/custom-query-formats.md index 896e569dab..2653682fe6 100644 --- a/docs/usage/extensibility/custom-query-formats.md +++ b/docs/usage/extensibility/custom-query-formats.md @@ -1,9 +1,16 @@ -# Custom Query Formats +# Custom QueryString parameters -For information on the default query string parameter formats, see the documentation for each query method. +For information on the built-in query string parameters, see the documentation for them. +In order to add parsing of custom query string parameters, you can implement the `IQueryStringParameterReader` interface and inject it. -In order to customize the query formats, you need to implement the `IQueryParameterParser` interface and inject it. +```c# +public class YourQueryStringParameterReader : IQueryStringParameterReader +{ + // ... +} +``` ```c# -services.AddScoped(); +services.AddScoped(); +services.AddScoped(sp => sp.GetService()); ``` diff --git a/docs/usage/extensibility/layer-overview.md b/docs/usage/extensibility/layer-overview.md index 4efed2c444..458d45b5a1 100644 --- a/docs/usage/extensibility/layer-overview.md +++ b/docs/usage/extensibility/layer-overview.md @@ -1,13 +1,13 @@ # Layer Overview -By default, data retrieval is distributed across 3 layers: +By default, data retrieval is distributed across three layers: ``` JsonApiController (required) -+-- DefaultResourceService: IResourceService ++-- JsonApiResourceService : IResourceService - +-- DefaultResourceRepository: IResourceRepository + +-- EntityFrameworkCoreRepository : IResourceRepository ``` Customization can be done at any of these layers. However, it is recommended that you make your customizations at the service or the repository layer when possible to keep the controllers free of unnecessary logic. @@ -15,7 +15,7 @@ You can use the following as a general rule of thumb for where to put business l - `Controller`: simple validation logic that should result in the return of specific HTTP status codes, such as model validation - `IResourceService`: advanced business logic and replacement of data access mechanisms -- `IResourceRepository`: custom logic that builds on the Entity Framework Core APIs, such as Authorization of data +- `IResourceRepository`: custom logic that builds on the Entity Framework Core APIs ## Replacing Services diff --git a/docs/usage/extensibility/repositories.md b/docs/usage/extensibility/repositories.md index e175f49524..6cae2ac05b 100644 --- a/docs/usage/extensibility/repositories.md +++ b/docs/usage/extensibility/repositories.md @@ -1,40 +1,42 @@ -# Entity Repositories +# Resource Repositories -If you want to use Entity Framework Core, but need additional data access logic (such as authorization), you can implement custom methods for accessing the data by creating an implementation of IResourceRepository. If you only need minor changes you can override the methods defined in DefaultResourceRepository. +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. ```c# public void ConfigureServices(IServiceCollection services) { - services.AddScoped, AuthorizedArticleRepository>(); + services.AddScoped, ArticleRepository>(); // ... } ``` -A sample implementation that performs data authorization might look like this. +A sample implementation that performs authorization might look like this. -All of the methods in the DefaultResourceRepository will use the Get() method to get the DbSet, so this is a good method to apply scoped filters such as user or tenant authorization. +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. ```c# -public class AuthorizedArticleRepository : DefaultResourceRepository
+public class ArticleRepository : EntityFrameworkCoreRepository
{ private readonly IAuthenticationService _authenticationService; - public AuthorizedArticleRepository( + public ArticleRepository( IAuthenticationService authenticationService, ITargetedFields targetedFields, IDbContextResolver contextResolver, IResourceGraph resourceGraph, IGenericServiceFactory genericServiceFactory, IResourceFactory resourceFactory, + IEnumerable constraintProviders, ILoggerFactory loggerFactory) - : base(targetedFields, contextResolver, resourceGraph, genericServiceFactory, resourceFactory, loggerFactory) + : base(targetedFields, contextResolver, resourceGraph, genericServiceFactory, resourceFactory, constraintProviders, loggerFactory) { _authenticationService = authenticationService; } - public override IQueryable
Get() + public override IQueryable
GetAll() { return base.Get().Where(article => article.UserId == _authenticationService.UserId); } @@ -83,7 +85,7 @@ services.AddScoped(); services.AddScoped(); -public class DbContextARepository : DefaultResourceRepository +public class DbContextARepository : EntityFrameworkCoreRepository where TResource : class, IIdentifiable { public DbContextARepository( @@ -92,8 +94,9 @@ public class DbContextARepository : DefaultResourceRepository constraintProviders, ILoggerFactory loggerFactory) - : base(targetedFields, contextResolver, resourceGraph, genericServiceFactory, resourceFactory, loggerFactory) + : base(targetedFields, contextResolver, resourceGraph, genericServiceFactory, resourceFactory, constraintProviders, loggerFactory) { } } diff --git a/docs/usage/extensibility/services.md b/docs/usage/extensibility/services.md index c4afc56ac8..91c41881e0 100644 --- a/docs/usage/extensibility/services.md +++ b/docs/usage/extensibility/services.md @@ -1,45 +1,45 @@ # Resource Services The `IResourceService` acts as a service layer between the controller and the data access layer. -This allows you to customize it however you want and not be dependent upon Entity Framework Core. -This is also a good place to implement custom business logic. +This allows you to customize it however you want. This is also a good place to implement custom business logic. ## Supplementing Default Behavior -If you don't need to alter the actual persistence mechanism, you can inherit from the DefaultResourceService and override the existing methods. + +If you don't need to alter the underlying mechanisms, you can inherit from `JsonApiResourceService` and override the existing methods. In simple cases, you can also just wrap the base implementation with your custom logic. -A simple example would be to send notifications when an entity gets created. +A simple example would be to send notifications when a resource gets created. ```c# -public class TodoItemService : DefaultResourceService +public class TodoItemService : JsonApiResourceService { private readonly INotificationService _notificationService; public TodoItemService( - INotificationService notificationService, - IEnumerable queryParameters, + IResourceRepository repository, + IQueryLayerComposer queryLayerComposer, + IPaginationContext paginationContext, IJsonApiOptions options, ILoggerFactory loggerFactory, - IResourceRepository repository, - IResourceContextProvider provider, - IResourceChangeTracker resourceChangeTracker, + ICurrentRequest currentRequest, + IResourceChangeTracker resourceChangeTracker, IResourceFactory resourceFactory, - IResourceHookExecutor hookExecutor) - : base(queryParameters, options, loggerFactory, repository, provider, resourceChangeTracker, resourceFactory, hookExecutor) + IResourceHookExecutor hookExecutor = null) + : base(repository, queryLayerComposer, paginationContext, options, loggerFactory, currentRequest, + resourceChangeTracker, resourceFactory, hookExecutor) { _notificationService = notificationService; } - public override async Task CreateAsync(TodoItem entity) + public override async Task CreateAsync(TodoItem resource) { - // Call the base implementation which uses Entity Framework Core - var newEntity = await base.CreateAsync(entity); + // Call the base implementation + var newResource = await base.CreateAsync(resource); // Custom code - _notificationService.Notify($"Entity created: {newEntity.Id}"); + _notificationService.Notify($"Resource created: {newResource.StringId}"); - // Don't forget to return the new entity - return newEntity; + return newResource; } } ``` @@ -47,7 +47,7 @@ public class TodoItemService : DefaultResourceService ## Not Using Entity Framework Core? As previously discussed, this library uses Entity Framework Core by default. -If you'd like to use another ORM that does not implement `IQueryable`, you can use a custom `IResourceService` implementation. +If you'd like to use another ORM that does not provide what JsonApiResourceService depends upon, you can use a custom `IResourceService` implementation. ```c# // Startup.cs @@ -82,7 +82,7 @@ public class MyModelService : IResourceService ## Limited Requirements -In some cases it may be necessary to only expose a few methods on the resource. For this reason, we have created a hierarchy of service interfaces that can be used to get the exact implementation you require. +In some cases it may be necessary to only expose a few methods on a resource. For this reason, we have created a hierarchy of service interfaces that can be used to get the exact implementation you require. This interface hierarchy is defined by this tree structure. @@ -97,10 +97,10 @@ IResourceService | +-- IGetByIdService | | GET /{id} | | -| +-- IGetRelationshipService +| +-- IGetSecondaryService | | GET /{id}/{relationship} | | -| +-- IGetRelationshipsService +| +-- IGetRelationshipService | GET /{id}/relationships/{relationship} | +-- IResourceCommandService @@ -123,7 +123,7 @@ In order to take advantage of these interfaces you first need to inject the serv ```c# public class ArticleService : ICreateService
, IDeleteService
{ - // ... + // ... } public class Startup @@ -156,9 +156,9 @@ public class ArticlesController : BaseJsonApiController
{ } [HttpPost] - public override async Task PostAsync([FromBody] Article entity) + public override async Task PostAsync([FromBody] Article resource) { - return await base.PostAsync(entity); + return await base.PostAsync(resource); } [HttpDelete("{id}")] diff --git a/docs/usage/filtering.md b/docs/usage/filtering.md index 4c0aea22d6..eb3c7a3da8 100644 --- a/docs/usage/filtering.md +++ b/docs/usage/filtering.md @@ -1,81 +1,123 @@ # Filtering +_since v4.0_ + Resources can be filtered by attributes using the `filter` query string parameter. By default, all attributes are filterable. The filtering strategy we have selected, uses the following form. ``` -?filter[attribute]=value +?filter=expression ``` -For operations other than equality, the query can be prefixed with an operation identifier. -Examples can be found in the table below. +Expressions are composed using the following functions: + +| Operation | Function | Example | +|-------------------------------|--------------------|-------------------------------------------------------| +| Equality | `equals` | `?filter=equals(lastName,'Smith')` | +| Less than | `lessThan` | `?filter=lessThan(age,'25')` | +| Less than or equal to | `lessOrEqual` | `?filter=lessOrEqual(lastModified,'2001-01-01')` | +| Greater than | `greaterThan` | `?filter=greaterThan(duration,'6:12:14')` | +| Greater than or equal to | `greaterOrEqual` | `?filter=greaterOrEqual(percentage,'33.33')` | +| Contains text | `contains` | `?filter=contains(description,'cooking')` | +| Starts with text | `startsWith` | `?filter=startsWith(description,'The')` | +| Ends with text | `endsWith` | `?filter=endsWith(description,'End')` | +| Equals one value from set | `any` | `?filter=any(chapter,'Intro','Summary','Conclusion')` | +| Collection contains items | `has` | `?filter=has(articles)` | +| Negation | `not` | `?filter=not(equals(lastName,null))` | +| Conditional logical OR | `or` | `?filter=or(has(orders),has(invoices))` | +| Conditional logical AND | `and` | `?filter=and(has(orders),has(invoices))` | + +Comparison operators compare an attribute against a constant value (between quotes), null or another attribute: + +```http +GET /users?filter=equals(displayName,'Brian O''Connor') HTTP/1.1 +``` +```http +GET /users?filter=equals(displayName,null) HTTP/1.1 +``` +```http +GET /users?filter=equals(displayName,lastName) HTTP/1.1 +``` -| Operation | Prefix | Example | -|-------------------------------|---------------|-------------------------------------------| -| Equals | `eq` | `?filter[attribute]=eq:value` | -| Not Equals | `ne` | `?filter[attribute]=ne:value` | -| Less Than | `lt` | `?filter[attribute]=lt:10` | -| Greater Than | `gt` | `?filter[attribute]=gt:10` | -| Less Than Or Equal To | `le` | `?filter[attribute]=le:10` | -| Greater Than Or Equal To | `ge` | `?filter[attribute]=ge:10` | -| Like (string comparison) | `like` | `?filter[attribute]=like:value` | -| In Set | `in` | `?filter[attribute]=in:value1,value2` | -| Not In Set | `nin` | `?filter[attribute]=nin:value1,value2` | -| Is Null | `isnull` | `?filter[attribute]=isnull:` | -| Is Not Null | `isnotnull` | `?filter[attribute]=isnotnull:` | - -Filters can be combined and will be applied using an AND operator. -The following are equivalent query forms to get articles whose ordinal values are between 1-100. +Comparison operators can be combined with the `count` function, which acts on HasMany relationships: ```http -GET /api/articles?filter[ordinal]=gt:1,lt:100 HTTP/1.1 +GET /blogs?filter=lessThan(count(owner.articles),'10') HTTP/1.1 ``` ```http -GET /api/articles?filter[ordinal]=gt:1&filter[ordinal]=lt:100 HTTP/1.1 +GET /customers?filter=greaterThan(count(orders),count(invoices)) HTTP/1.1 ``` -Aside from filtering on the resource being requested (top-level), filtering on single-depth related resources can be done too. +When filters are used multiple times on the same resource, they are combined using an OR operator. +The next request returns all customers that have orders -or- whose last name is Smith. ```http -GET /api/articles?include=author&filter[title]=like:marketing&filter[author.lastName]=Smith HTTP/1.1 +GET /customers?filter=has(orders)&filter=equals(lastName,'Smith') HTTP/1.1 ``` -Due to a [limitation](https://github.com/dotnet/efcore/issues/1833) in Entity Framework Core 3.x, filtering does **not** work on nested endpoints: +Aside from filtering on the resource being requested (which would be blogs in /blogs and articles in /blogs/1/articles), +filtering on included collections can be done using bracket notation: ```http -GET /api/blogs/1/articles?filter[title]=like:new HTTP/1.1 +GET /articles?include=author,tags&filter=equals(author.lastName,'Smith')&filter[tags]=contains(label,'tech','design') HTTP/1.1 ``` +In the above request, the first filter is applied on the collection of articles, while the second one is applied on the nested collection of tags. -## Custom Filters +Putting it all together, you can build quite complex filters, such as: + +```http +GET /blogs?include=owner.articles.revisions&filter=and(or(equals(title,'Technology'),has(owner.articles)),not(equals(owner.lastName,null)))&filter[owner.articles]=equals(caption,'Two')&filter[owner.articles.revisions]=greaterThan(publishTime,'2005-05-05') HTTP/1.1 +``` + +# Legacy filters + +The next section describes how filtering worked in versions prior to v4.0. They are always applied on the set of resources being requested (no nesting). +Legacy filters use the following form. + +``` +?filter[attribute]=value +``` + +For operations other than equality, the query can be prefixed with an operation identifier. +Examples can be found in the table below. + +| Operation | Prefix | Example | Equivalent form in v4.0 | +|-------------------------------|------------------|-------------------------------------------------|-------------------------------------------------------| +| Equality | `eq` | `?filter[lastName]=eq:Smith` | `?filter=equals(lastName,'Smith')` | +| Non-equality | `ne` | `?filter[lastName]=ne:Smith` | `?filter=not(equals(lastName,'Smith'))` | +| Less than | `lt` | `?filter[age]=lt:25` | `?filter=lessThan(age,'25')` | +| Less than or equal to | `le` | `?filter[lastModified]=le:2001-01-01` | `?filter=lessOrEqual(lastModified,'2001-01-01')` | +| Greater than | `gt` | `?filter[duration]=gt:6:12:14` | `?filter=greaterThan(duration,'6:12:14')` | +| Greater than or equal to | `ge` | `?filter[percentage]=ge:33.33` | `?filter=greaterOrEqual(percentage,'33.33')` | +| Contains text | `like` | `?filter[description]=like:cooking` | `?filter=contains(description,'cooking')` | +| Equals one value from set | `in` | `?filter[chapter]=in:Intro,Summary,Conclusion` | `?filter=any(chapter,'Intro','Summary','Conclusion')` | +| Equals none from set | `nin` | `?filter[chapter]=nin:one,two,three` | `?filter=not(any(chapter,'one','two','three'))` | +| Equal to null | `isnull` | `?filter[lastName]=isnull:` | `?filter=equals(lastName,null)` | +| Not equal to null | `isnotnull` | `?filter[lastName]=isnotnull:` | `?filter=not(equals(lastName,null))` | + +Filters can be combined and will be applied using an OR operator. This used to be AND in versions prior to v4.0. + +Attributes to filter on can optionally be prefixed with a HasOne relationship, for example: -There are two ways you can add custom filters: - -1. Creating a `ResourceDefinition` as [described previously](~/usage/resources/resource-definitions.html#custom-query-filters) -2. Overriding the `DefaultResourceRepository` shown below - -```c# -public class AuthorRepository : DefaultResourceRepository -{ - public AuthorRepository( - ITargetedFields targetedFields, - IDbContextResolver contextResolver, - IResourceGraph resourceGraph, - IGenericServiceFactory genericServiceFactory, - IResourceFactory resourceFactory, - ILoggerFactory loggerFactory) - : base(targetedFields, contextResolver, resourceGraph, genericServiceFactory, resourceFactory, loggerFactory) - { } - - public override IQueryable Filter(IQueryable authors, FilterQueryContext filterQueryContext) - { - // If the filter key is "name" (filter[name]), find authors with matching first or last names. - // For all other filter keys, use the base method. - return filterQueryContext.Attribute.Is("name") - ? authors.Where(author => - author.FirstName.Contains(filterQueryContext.Value) || - author.LastName.Contains(filterQueryContext.Value)) - : base.Filter(authors, filterQueryContext); - } +```http +GET /api/articles?include=author&filter[caption]=like:marketing&filter[author.lastName]=Smith HTTP/1.1 +``` + +Legacy filter notation can still be used in v4.0 by setting `options.EnableLegacyFilterNotation` to `true`. +If you want to use the new filter notation in that case, prefix the parameter value with `expr:`, for example: + +```http +GET /articles?filter[caption]=tech&filter=expr:equals(caption,'cooking')) HTTP/1.1 ``` + +## Custom Filters + +There are multiple ways you can add custom filters: + +1. Creating a `ResourceDefinition` using `OnApplyFilter` (see [here](~/usage/resources/resource-definitions.md#exclude-soft-deleted-resources)) and inject `IRequestQueryStringAccessor`, which works at all depths, but filter operations are constrained to what `FilterExpression` provides +2. Creating a `ResourceDefinition` using `OnRegisterQueryableHandlersForQueryStringParameters` as [described previously](~/usage/resources/resource-definitions.md#custom-query-string-parameters), which enables the full range of `IQueryable` functionality, but only works on primary endpoints +3. Add an implementation of `IQueryConstraintProvider` to supply additional `FilterExpression`s, which are combined with existing filters using AND operator +4. Override `EntityFrameworkCoreRepository.ApplyQueryLayer` to adapt the `IQueryable` expression just before execution +5. Take a deep dive and plug into reader/parser/tokenizer/visitor/builder for adding additional general-purpose filter operators diff --git a/docs/usage/including-relationships.md b/docs/usage/including-relationships.md index e5af295f00..2f061cd00e 100644 --- a/docs/usage/including-relationships.md +++ b/docs/usage/including-relationships.md @@ -45,11 +45,11 @@ GET /articles/1?include=comments HTTP/1.1 } ``` -## Deeply Nested Inclusions +## Nested Inclusions _since v3.0.0_ -JsonApiDotNetCore also supports deeply nested inclusions. +JsonApiDotNetCore also supports nested inclusions. This allows you to include data across relationships by using a period-delimited relationship path, for example: ```http diff --git a/docs/usage/meta.md b/docs/usage/meta.md index 890adcb962..830065bfa3 100644 --- a/docs/usage/meta.md +++ b/docs/usage/meta.md @@ -1,6 +1,6 @@ # Metadata -Non-standard metadata can be added to your API responses in 2 ways. Resource and Request meta. In the event of a key collision, the Request Meta will take precendence. +Non-standard metadata can be added to your API responses in two ways: Resource and Request Meta. In the event of a key collision, the Request Meta will take precendence. ## Resource Meta diff --git a/docs/usage/options.md b/docs/usage/options.md index 4f52a93883..63b3fcd3ff 100644 --- a/docs/usage/options.md +++ b/docs/usage/options.md @@ -28,15 +28,15 @@ options.AllowClientGeneratedIds = true; ## Pagination -The default page size used for all resources can be overridden in options (10 by default). To disable paging, set it to 0. -The maximum page size and maximum page number allowed from client requests can be set too (unconstrained by default). -You can also include the total number of records in each request. Note that when using this feature, it does add some query overhead since we have to also request the total number of records. +The default page size used for all resources can be overridden in options (10 by default). To disable paging, set it to `null`. +The maximum page size and number allowed from client requests can be set too (unconstrained by default). +You can also include the total number of resources in each request. Note that when using this feature, it does add some query overhead since we have to also request the total number of resources. ```c# -options.DefaultPageSize = 25; -options.MaximumPageSize = 100; -options.MaximumPageNumber = 50; -options.IncludeTotalRecordCount = true; +options.DefaultPageSize = new PageSize(25); +options.MaximumPageSize = new PageSize(100); +options.MaximumPageNumber = new PageNumber(50); +options.IncludeTotalResourceCount = true; ``` ## Relative Links @@ -44,7 +44,7 @@ options.IncludeTotalRecordCount = true; All links are absolute by default. However, you can configure relative links. ```c# -options.RelativeLinks = true; +options.UseRelativeLinks = true; ``` ```json @@ -62,12 +62,13 @@ options.RelativeLinks = true; } ``` -## Custom Query String Parameters +## Unknown Query String Parameters -If you would like to use custom query string parameters (parameters not reserved by the json:api specification), you can set `AllowCustomQueryStringParameters = true`. The default behavior is to return an HTTP 400 Bad Request for unknown query string parameters. +If you would like to use unknown query string parameters (parameters not reserved by the json:api specification or registered using ResourceDefinitions), you can set `AllowUnknownQueryStringParameters = true`. +When set, an HTTP 400 Bad Request for unknown query string parameters. ```c# -options.AllowCustomQueryStringParameters = true; +options.AllowUnknownQueryStringParameters = true; ``` ## Custom Serializer Settings @@ -88,6 +89,7 @@ If you would like to use ASP.NET Core ModelState validation into your controller ```c# options.ValidateModelState = true; ``` + You will need to use the JsonApiDotNetCore 'IsRequiredAttribute' instead of the built-in 'RequiredAttribute' because it contains modifications to enable partial patching. ```c# diff --git a/docs/usage/pagination.md b/docs/usage/pagination.md index ca84ac8051..7f06c30988 100644 --- a/docs/usage/pagination.md +++ b/docs/usage/pagination.md @@ -1,18 +1,25 @@ # Pagination -Resources can be paginated. This query would fetch the second page of 10 articles (articles 11 - 21). +Resources can be paginated. This request would fetch the second page of 10 articles (articles 11 - 21). ```http GET /articles?page[size]=10&page[number]=2 HTTP/1.1 ``` -Due to a [limitation](https://github.com/dotnet/efcore/issues/1833) in Entity Framework Core 3.x, paging does **not** work on nested endpoints: +## Nesting + +Pagination can be used on nested endpoints, such as: ```http GET /blogs/1/articles?page[number]=2 HTTP/1.1 ``` +and on included resources, for example: + +```http +GET /api/blogs/1/articles?include=revisions&page[size]=10,revisions:5&page[number]=2,revisions:3 HTTP/1.1 +``` ## Configuring Default Behavior -You can configure the global default behavior as [described previously](~/usage/options.html#pagination). +You can configure the global default behavior as [described previously](~/usage/options.md#pagination). diff --git a/docs/usage/resources/attributes.md b/docs/usage/resources/attributes.md index f5344dd32d..dfbef4aefd 100644 --- a/docs/usage/resources/attributes.md +++ b/docs/usage/resources/attributes.md @@ -13,13 +13,15 @@ public class Person : Identifiable ## Public name There are two ways the public attribute name is determined: -1. By convention, specified by @JsonApiDotNetCore.Configuration.JsonApiOptions#JsonApiDotNetCore_Configuration_JsonApiOptions_SerializerSettings + +1. By convention, specified in @JsonApiDotNetCore.Configuration.JsonApiOptions#JsonApiDotNetCore_Configuration_JsonApiOptions_SerializerSettings ```c# options.SerializerSettings.ContractResolver = new DefaultContractResolver { - NamingStrategy = new CamelCaseNamingStrategy() + NamingStrategy = new KebabCaseNamingStrategy() // default: CamelCaseNamingStrategy }; ``` + 2. Individually using the attribute's constructor ```c# public class Person : Identifiable @@ -33,7 +35,7 @@ public class Person : Identifiable _since v4.0_ -Default json:api attribute capabilities are specified by @JsonApiDotNetCore.Configuration.JsonApiOptions.html#JsonApiDotNetCore_Configuration_JsonApiOptions_DefaultAttrCapabilities: +Default json:api attribute capabilities are specified in @JsonApiDotNetCore.Configuration.JsonApiOptions#JsonApiDotNetCore_Configuration_JsonApiOptions_DefaultAttrCapabilities: ```c# options.DefaultAttrCapabilities = AttrCapabilities.None; // default: All @@ -41,7 +43,19 @@ options.DefaultAttrCapabilities = AttrCapabilities.None; // default: All This can be overridden per attribute. -# Mutability +### Viewability + +Attributes can be marked to allow returning their value in responses. When not allowed and requested using `?fields=`, it results in an HTTP 400 response. + +```c# +public class User : Identifiable +{ + [Attr(~AttrCapabilities.AllowView)] + public string Password { get; set; } +} +``` + +### Mutability Attributes can be marked as mutable, which will allow `PATCH` requests to update them. When immutable, an HTTP 422 response is returned. @@ -53,7 +67,7 @@ public class Person : Identifiable } ``` -# Filter/Sort-ability +### Filter/Sort-ability Attributes can be marked to allow filtering and/or sorting. When not allowed, it results in an HTTP 400 response. @@ -68,7 +82,7 @@ public class Person : Identifiable ## Complex Attributes Models may contain complex attributes. -Serialization of these types is done by Newtonsoft.Json, +Serialization of these types is done by [Newtonsoft.Json](https://www.newtonsoft.com/json), so you should use their APIs to specify serialization formats. You can also use global options to specify `JsonSerializer` configuration. diff --git a/docs/usage/resources/hooks.md b/docs/usage/resources/hooks.md index a0c48cbd17..cd08f38fce 100644 --- a/docs/usage/resources/hooks.md +++ b/docs/usage/resources/hooks.md @@ -10,21 +10,21 @@ By implementing resource hooks on a `ResourceDefintion`, it is possible to in * Transformation of the served data This usage guide covers the following sections -1. [**Semantics: pipelines, actions and hooks**](#semantics-pipelines-actions-and-hooks) +1. [**Semantics: pipelines, actions and hooks**](#1-semantics-pipelines-actions-and-hooks) Understanding the semantics will be helpful in identifying which hooks on `ResourceDefinition` you need to implement for your use-case. -2. [**Basic usage**](#basic-usage) +2. [**Basic usage**](#2-basic-usage) Some examples to get you started. * [**Getting started: most minimal example**](#getting-started-most-minimal-example) * [**Logging**](#logging) * [**Transforming data with OnReturn**](#transforming-data-with-onreturn) * [**Loading database values**](#loading-database-values) -3. [**Advanced usage**](#advanced-usage) +3. [**Advanced usage**](#3-advanced-usage) Complicated examples that show the advanced features of hooks. * [**Simple authorization: explicitly affected resources**](#simple-authorization-explicitly-affected-resources) * [**Advanced authorization: implicitly affected resources**](#advanced-authorization-implicitly-affected-resources) * [**Synchronizing data across microservices**](#synchronizing-data-across-microservices) * [**Hooks for many-to-many join tables**](#hooks-for-many-to-many-join-tables) -5. [**Hook execution overview**](#hook-execution-overview) +5. [**Hook execution overview**](#4-hook-execution-overview) A table overview of all pipelines and involved hooks # 1. Semantics: pipelines, actions and hooks @@ -72,7 +72,7 @@ the **Delete** pipeline also allows for an `implicit update relationship` actio ### Shared actions Note that **some actions are shared across pipelines**. For example, both the **Post** and **Patch** pipeline can perform the `update relationship` action on an (already existing) involved resource. Similarly, the **Get** and **GetSingle** pipelines perform the same `read` action.

-For a complete list of actions associated with each pipeline, see the [overview table](#hook-execution-overview). +For a complete list of actions associated with each pipeline, see the [overview table](#4-hook-execution-overview). ## Hooks For all actions it is possible to implement **at least one hook** to intercept its execution. These hooks can be implemented by overriding the corresponding virtual implementation on `ResourceDefintion`. (Note that the base implementation is a dummy implementation, which is ignored when firing hooks.) @@ -459,7 +459,7 @@ public override void BeforeImplicitUpdateRelationship(IAffectedRelationships` -2. If you are using custom services, you will be responsible for injecting the `IResourceHookExecutor` service into your services and call the appropriate methods. See the [hook execution overview](#hook-execution-overview) to determine which hook should be fired in which scenario. +2. If you are using custom services, you will be responsible for injecting the `IResourceHookExecutor` service into your services and call the appropriate methods. See the [hook execution overview](#4-hook-execution-overview) to determine which hook should be fired in which scenario. If you are required to use the `BeforeImplicitUpdateRelationship` hook (see previous example), there is an additional requirement. For this hook, given a particular relationship, JsonApiDotNetCore needs to be able to resolve the inverse relationship. For example: if `Article` has one author (a `Person`), then it needs to be able to resolve the `RelationshipAttribute` that corresponds to the inverse relationship for the `author` property. There are two approaches : diff --git a/docs/usage/resources/relationships.md b/docs/usage/resources/relationships.md index 4eff452475..7b6813c536 100644 --- a/docs/usage/resources/relationships.md +++ b/docs/usage/resources/relationships.md @@ -40,7 +40,7 @@ public class Person : Identifiable Currently, Entity Framework Core [does not support](https://github.com/aspnet/EntityFrameworkCore/issues/1368) many-to-many relationships without a join entity. For this reason, we have decided to fill this gap by allowing applications to declare a relationship as `HasManyThrough`. -JsonApiDotNetCore will expose this attribute to the client the same way as any other `HasMany` attribute. +JsonApiDotNetCore will expose this relationship to the client the same way as any other `HasMany` attribute. However, under the covers it will use the join type and Entity Framework Core's APIs to get and set the relationship. ```c# @@ -59,7 +59,7 @@ public class Article : Identifiable _since v4.0_ -Your entity may contain a calculated property, whose value depends on a navigation property that is not exposed as a json:api resource. +Your resource may expose a calculated property, whose value depends on a related entity that is not exposed as a json:api resource. So for the calculated property to be evaluated correctly, the related entity must always be retrieved. You can achieve that using `EagerLoad`, for example: ```c# diff --git a/docs/usage/resources/resource-definitions.md b/docs/usage/resources/resource-definitions.md index 16546820aa..586484b813 100644 --- a/docs/usage/resources/resource-definitions.md +++ b/docs/usage/resources/resource-definitions.md @@ -3,15 +3,48 @@ In order to improve the developer experience, we have introduced a type that makes common modifications to the default API behavior easier. `ResourceDefinition` was first introduced in v2.3.4. -## Runtime Attribute Filtering +Resource definitions are resolved from the D/I container, so you can inject dependencies in their constructor. -_since v2.3.4_ +## Customizing query clauses -There are some cases where you want attributes excluded from your resource response. -For example, you may accept some form data that shouldn't be exposed after creation. -This kind of data may get hashed in the database and should never be exposed to the client. +_since v4.0_ -Using the techniques described below, you can achieve the following request/response behavior: +For various reasons (see examples below) you may need to change parts of the query, depending on resource type. +`ResourceDefinition` provides overridable methods that pass you the result of query string parameter parsing. +The value returned by you determines what will be used to execute the query. + +An intermediate format (`QueryExpression` and derived types) is used, which enables us to separate json:api implementation +from Entity Framework Core `IQueryable` execution. + +### Excluding fields + +There are some cases where you want attributes conditionally excluded from your resource response. +For example, you may accept some sensitive data that should only be exposed to administrators after creation. + +Note: to exclude attributes unconditionally, use `Attr[~AttrCapabilities.AllowView]`. + +```c# +public class UserDefinition : ResourceDefinition +{ + public UserDefinition(IResourceGraph resourceGraph) : base(resourceGraph) + { } + + public override SparseFieldSetExpression OnApplySparseFieldSet(SparseFieldSetExpression existingSparseFieldSet) + { + if (IsAdministrator) + { + return existingSparseFieldSet; + } + + var resourceContext = ResourceGraph.GetResourceContext(); + var passwordAttribute = resourceContext.Attributes.Single(a => a.Property.Name == nameof(User.Password)); + + return existingSparseFieldSet.Excluding(passwordAttribute); + } +} +``` + +Using this technique, you can achieve the following request/response behavior: ```http POST /users HTTP/1.1 @@ -21,7 +54,7 @@ Content-Type: application/vnd.api+json "data": { "type": "users", "attributes": { - "account-number": "1234567890", + "password": "secret", "name": "John Doe" } } @@ -44,83 +77,106 @@ Content-Type: application/vnd.api+json } ``` -### Single Attribute +## Default sort order + +You can define the default sort order if no `sort` query string parameter is provided. ```c# -public class UserDefinition : ResourceDefinition +public class AccountDefinition : ResourceDefinition { - public UserDefinition(IResourceGraph resourceGraph) : base(resourceGraph) + public override SortExpression OnApplySort(SortExpression existingSort) { - HideFields(user => user.AccountNumber); + if (existingSort != null) + { + return existingSort; + } + + return CreateSortExpressionFromLambda(new PropertySortOrder + { + (account => account.Name, ListSortDirection.Ascending), + (account => account.ModifiedAt, ListSortDirection.Descending) + }); } } ``` -### Multiple Attributes +## Enforce page size + +You may want to enforce paging on large database tables. ```c# -public class UserDefinition : ResourceDefinition +public class AccessLogDefinition : ResourceDefinition { - public UserDefinition(IResourceGraph resourceGraph) : base(resourceGraph) + public override PaginationExpression OnApplyPagination(PaginationExpression existingPagination) { - HideFields(user => new {user.AccountNumber, user.Password}); + var maxPageSize = new PageSize(10); + + if (existingPagination != null) + { + var pageSize = existingPagination.PageSize?.Value <= maxPageSize.Value ? existingPagination.PageSize : maxPageSize; + return new PaginationExpression(existingPagination.PageNumber, pageSize); + } + + return new PaginationExpression(PageNumber.ValueOne, _maxPageSize); } } ``` -## Default Sort +## Exclude soft-deleted resources -_since v3.0.0_ - -You can define the default sort behavior if no `sort` query is provided. +Soft-deletion sets `IsSoftDeleted` to `true` instead of actually deleting the record, so you may want to always filter them out. ```c# public class AccountDefinition : ResourceDefinition { - public override PropertySortOrder GetDefaultSortOrder() + public override FilterExpression OnApplyFilter(FilterExpression existingFilter) { - return new PropertySortOrder - { - (account => account.LastLoginTime, SortDirection.Descending), - (account => account.UserName, SortDirection.Ascending) - }; + var resourceContext = ResourceGraph.GetResourceContext(); + var isSoftDeletedAttribute = resourceContext.Attributes.Single(a => a.Property.Name == nameof(Account.IsSoftDeleted)); + + var isNotSoftDeleted = new ComparisonExpression(ComparisonOperator.Equals, + new ResourceFieldChainExpression(isSoftDeletedAttribute), new LiteralConstantExpression(bool.FalseString)); + + return existingFilter == null + ? (FilterExpression) isNotSoftDeleted + : new LogicalExpression(LogicalOperator.And, new[] {isNotSoftDeleted, existingFilter}); } } ``` -## Custom Query Filters +## Custom query string parameters _since v3.0.0_ -You can define additional query string parameters and the query that should be used. -If the key is present in a filter request, the supplied query will be used rather than the default behavior. +You can define additional query string parameters with the query expression that should be used. +If the key is present in a query string, the supplied query will be executed before the default behavior. + +Note this directly influences the Entity Framework Core `IQueryable`. As opposed to using `OnApplyFilter`, this enables the full range of EF Core functionality. +But it only works on primary resource endpoints (for example: /articles, but not on /blogs/1/articles or /blogs?include=articles). ```c# public class ItemDefinition : ResourceDefinition { - // handles queries like: ?filter[was-active-on]=2018-10-15T01:25:52Z - public override QueryFilters GetQueryFilters() + protected override QueryStringParameterHandlers OnRegisterQueryableHandlersForQueryStringParameters() { - return new QueryFilters + return new QueryStringParameterHandlers { - { - "was-active-on", (items, filter) => - { - return DateTime.TryParse(filter.Value, out DateTime timeValue) - ? items.Where(item => item.ExpireTime == null || timeValue < item.ExpireTime) - : throw new JsonApiException(new Error(HttpStatusCode.BadRequest) - { - Title = "Invalid filter value", - Detail = $"'{filter.Value}' is not a valid date." - }); - } - } + ["isActive"] = (source, parameterValue) => source + .Include(item => item.Children) + .Where(item => item.LastUpdateTime > DateTime.Now.AddMonths(-1)), + ["isHighRisk"] = FilterByHighRisk }; } + + private static IQueryable FilterByHighRisk(IQueryable source, StringValues parameterValue) + { + bool isFilterOnHighRisk = bool.Parse(parameterValue); + return isFilterOnHighRisk ? source.Where(item => item.RiskLevel >= 5) : source.Where(item => item.RiskLevel < 5); + } } ``` -## Using ResourceDefinitions Prior to v3 +## Using ResourceDefinitions prior to v3 Prior to the introduction of auto-discovery, you needed to register the `ResourceDefinition` on the container yourself: diff --git a/docs/usage/routing.md b/docs/usage/routing.md index 46805f5523..f6ddc72e37 100644 --- a/docs/usage/routing.md +++ b/docs/usage/routing.md @@ -55,5 +55,5 @@ public class MyModelsController : JsonApiController } ``` -See [this](~/usage/resource-graph.html#public-resource-type-name) for +See [this](~/usage/resource-graph.md#public-resource-type-name) for more information on how the resource name is determined. diff --git a/docs/usage/sorting.md b/docs/usage/sorting.md index 1f542715b6..a6fcb4f771 100644 --- a/docs/usage/sorting.md +++ b/docs/usage/sorting.md @@ -1,8 +1,6 @@ # Sorting -Resources can be sorted by one or more attributes. -The default sort order is ascending. -To sort descending, prepend the sort key with a minus (-) sign. +Resources can be sorted by one or more attributes in ascending or descending order. The default is ascending by ID. ## Ascending @@ -12,25 +10,47 @@ GET /api/articles?sort=author HTTP/1.1 ## Descending +To sort descending, prepend the attribute with a minus (-) sign. + ```http GET /api/articles?sort=-author HTTP/1.1 ``` ## Multiple attributes +Multiple attributes are separated by a comma. + ```http GET /api/articles?sort=author,-pageCount HTTP/1.1 ``` -## Limitations +## Count -Sorting currently does **not** work on nested endpoints: +To sort on the number of nested resources, use the `count` function. ```http -GET /api/blogs/1/articles?sort=title HTTP/1.1 +GET /api/blogs?sort=count(articles) HTTP/1.1 ``` +This sorts the list of blogs by their number of articles. + +## Nesting + +Sorting can be used on nested endpoints, such as: + +```http +GET /api/blogs/1/articles?sort=caption HTTP/1.1 +``` + +and on included resources, for example: + +```http +GET /api/blogs/1/articles?include=revisions&sort=caption&sort[revisions]=publishTime HTTP/1.1 +``` + +This sorts the list of blogs by their captions and included revisions by their publication time. + ## Default Sort -See the topic on [Resource Definitions](~/usage/resources/resource-definitions) -for defining the default sort behavior. +See the topic on [Resource Definitions](~/usage/resources/resource-definitions.md) +for overriding the default sort behavior. diff --git a/docs/usage/sparse-field-selection.md b/docs/usage/sparse-field-selection.md deleted file mode 100644 index 4d79d3fc7b..0000000000 --- a/docs/usage/sparse-field-selection.md +++ /dev/null @@ -1,25 +0,0 @@ -# Sparse Field Selection - -As an alternative to returning all attributes from a resource, the `fields` query string parameter can be used to select only a subset. -This can be used on the resource being requested (top-level), as well as on single-depth related resources that are being included. - -Top-level example: -```http -GET /articles?fields=title,body HTTP/1.1 -``` - -Example for an included relationship: -```http -GET /articles?include=author&fields[author]=name HTTP/1.1 -``` - -Example for both top-level and relationship: -```http -GET /articles?fields=title,body&include=author&fields[author]=name HTTP/1.1 -``` - -Field selection currently does **not** work on nested endpoints: - -```http -GET /api/blogs/1/articles?fields=title,body HTTP/1.1 -``` diff --git a/docs/usage/sparse-fieldset-selection.md b/docs/usage/sparse-fieldset-selection.md new file mode 100644 index 0000000000..9e7b654136 --- /dev/null +++ b/docs/usage/sparse-fieldset-selection.md @@ -0,0 +1,33 @@ +# Sparse Fieldset Selection + +As an alternative to returning all attributes from a resource, the `fields` query string parameter can be used to select only a subset. +This can be used on the resource being requested, as well as nested endpoints and/or included resources. + +Top-level example: +```http +GET /articles?fields=title,body HTTP/1.1 +``` + +Nested endpoint example: +```http +GET /api/blogs/1/articles?fields=title,body HTTP/1.1 +``` + +Example for an included HasOne relationship: +```http +GET /articles?include=author&fields[author]=name HTTP/1.1 +``` + +Example for an included HasMany relationship: +```http +GET /articles?include=revisions&fields[revisions]=publishTime HTTP/1.1 +``` + +Example for both top-level and relationship: +```http +GET /articles?include=author&fields=title,body&fields[author]=name HTTP/1.1 +``` + +## Overriding + +As a developer, you can force to include and/or exclude specific fields as [described previously](~/usage/resources/resource-definitions.md). diff --git a/docs/usage/toc.md b/docs/usage/toc.md index f7a5f5ce1c..ff9476d800 100644 --- a/docs/usage/toc.md +++ b/docs/usage/toc.md @@ -8,7 +8,7 @@ # [Filtering](filtering.md) # [Sorting](sorting.md) # [Pagination](pagination.md) -# [Sparse Field Selection](sparse-field-selection.md) +# [Sparse Fieldset Selection](sparse-fieldset-selection.md) # [Including Relationships](including-relationships.md) # [Routing](routing.md) # [Errors](errors.md) @@ -17,7 +17,7 @@ # Extensibility ## [Layer Overview](extensibility/layer-overview.md) ## [Controllers](extensibility/controllers.md) -## [Services](extensibility/services.md) -## [Repositories](extensibility/repositories.md) +## [Resource Services](extensibility/services.md) +## [Resource Repositories](extensibility/repositories.md) ## [Middleware](extensibility/middleware.md) ## [Custom Query Formats](extensibility/custom-query-formats.md) diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/ArticlesController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/ArticlesController.cs index 270aeee9bf..4ae287c670 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/ArticlesController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/ArticlesController.cs @@ -6,7 +6,6 @@ namespace JsonApiDotNetCoreExample.Controllers { - [DisableQuery(StandardQueryStringParameters.Sort | StandardQueryStringParameters.Page)] public sealed class ArticlesController : JsonApiController
{ public ArticlesController( diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/AuthorsController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/AuthorsController.cs new file mode 100644 index 0000000000..273a62f2b7 --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/AuthorsController.cs @@ -0,0 +1,18 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using JsonApiDotNetCoreExample.Models; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExample.Controllers +{ + public sealed class AuthorsController : JsonApiController + { + public AuthorsController( + IJsonApiOptions jsonApiOptions, + ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(jsonApiOptions, loggerFactory, resourceService) + { } + } +} diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/ModelsController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/BlogsController.cs similarity index 72% rename from src/Examples/JsonApiDotNetCoreExample/Controllers/ModelsController.cs rename to src/Examples/JsonApiDotNetCoreExample/Controllers/BlogsController.cs index 2c9ce2898a..e98bdad0f9 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/ModelsController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/BlogsController.cs @@ -6,12 +6,12 @@ namespace JsonApiDotNetCoreExample.Controllers { - public sealed class ModelsController : JsonApiController + public sealed class BlogsController : JsonApiController { - public ModelsController( + public BlogsController( IJsonApiOptions jsonApiOptions, ILoggerFactory loggerFactory, - IResourceService resourceService) + IResourceService resourceService) : base(jsonApiOptions, loggerFactory, resourceService) { } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/CountriesController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/CountriesController.cs new file mode 100644 index 0000000000..5da41f2726 --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/CountriesController.cs @@ -0,0 +1,19 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using JsonApiDotNetCoreExample.Models; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExample.Controllers +{ + [DisableQuery(StandardQueryStringParameters.Sort | StandardQueryStringParameters.Page)] + public sealed class CountriesController : JsonApiController + { + public CountriesController( + IJsonApiOptions jsonApiOptions, + ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(jsonApiOptions, loggerFactory, resourceService) + { } + } +} diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/PassportsController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/PassportsController.cs index 0fe73da0e1..67ec633b6e 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/PassportsController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/PassportsController.cs @@ -28,16 +28,16 @@ public async Task GetAsync(string id) } [HttpPatch("{id}")] - public async Task PatchAsync(string id, [FromBody] Passport entity) + public async Task PatchAsync(string id, [FromBody] Passport resource) { int idValue = HexadecimalObfuscationCodec.Decode(id); - return await base.PatchAsync(idValue, entity); + return await base.PatchAsync(idValue, resource); } [HttpPost] - public override async Task PostAsync([FromBody] Passport entity) + public override async Task PostAsync([FromBody] Passport resource) { - return await base.PostAsync(entity); + return await base.PostAsync(resource); } [HttpDelete("{id}")] diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoCollectionsController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoCollectionsController.cs index ccdcb088ca..0c748d385a 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoCollectionsController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoCollectionsController.cs @@ -27,15 +27,15 @@ public TodoCollectionsController( } [HttpPatch("{id}")] - public override async Task PatchAsync(Guid id, [FromBody] TodoItemCollection entity) + public override async Task PatchAsync(Guid id, [FromBody] TodoItemCollection resource) { - if (entity.Name == "PRE-ATTACH-TEST") + if (resource.Name == "PRE-ATTACH-TEST") { - var targetTodoId = entity.TodoItems.First().Id; + var targetTodoId = resource.TodoItems.First().Id; var todoItemContext = _dbResolver.GetContext().Set(); await todoItemContext.Where(ti => ti.Id == targetTodoId).FirstOrDefaultAsync(); } - return await base.PatchAsync(id, entity); + return await base.PatchAsync(id, resource); } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs index 1b2865c098..14a02fefae 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs @@ -61,8 +61,8 @@ public CustomJsonApiController( [HttpGet] public async Task GetAsync() { - var entities = await _resourceService.GetAsync(); - return Ok(entities); + var resources = await _resourceService.GetAsync(); + return Ok(resources); } [HttpGet("{id}")] @@ -70,8 +70,8 @@ public async Task GetAsync(TId id) { try { - var entity = await _resourceService.GetAsync(id); - return Ok(entity); + var resource = await _resourceService.GetAsync(id); + return Ok(resource); } catch (ResourceNotFoundException) { @@ -84,7 +84,7 @@ public async Task GetRelationshipsAsync(TId id, string relationsh { try { - var relationship = await _resourceService.GetRelationshipsAsync(id, relationshipName); + var relationship = await _resourceService.GetRelationshipAsync(id, relationshipName); return Ok(relationship); } catch (ResourceNotFoundException) @@ -96,34 +96,34 @@ public async Task GetRelationshipsAsync(TId id, string relationsh [HttpGet("{id}/{relationshipName}")] public async Task GetRelationshipAsync(TId id, string relationshipName) { - var relationship = await _resourceService.GetRelationshipAsync(id, relationshipName); + var relationship = await _resourceService.GetSecondaryAsync(id, relationshipName); return Ok(relationship); } [HttpPost] - public async Task PostAsync([FromBody] T entity) + public async Task PostAsync([FromBody] T resource) { - if (entity == null) + if (resource == null) return UnprocessableEntity(); - if (_options.AllowClientGeneratedIds && !string.IsNullOrEmpty(entity.StringId)) + if (_options.AllowClientGeneratedIds && !string.IsNullOrEmpty(resource.StringId)) return Forbidden(); - entity = await _resourceService.CreateAsync(entity); + resource = await _resourceService.CreateAsync(resource); - return Created($"{HttpContext.Request.Path}/{entity.Id}", entity); + return Created($"{HttpContext.Request.Path}/{resource.Id}", resource); } [HttpPatch("{id}")] - public async Task PatchAsync(TId id, [FromBody] T entity) + public async Task PatchAsync(TId id, [FromBody] T resource) { - if (entity == null) + if (resource == null) return UnprocessableEntity(); try { - var updatedEntity = await _resourceService.UpdateAsync(id, entity); - return Ok(updatedEntity); + var updated = await _resourceService.UpdateAsync(id, resource); + return Ok(updated); } catch (ResourceNotFoundException) { @@ -134,7 +134,7 @@ public async Task PatchAsync(TId id, [FromBody] T entity) [HttpPatch("{id}/relationships/{relationshipName}")] public async Task PatchRelationshipsAsync(TId id, string relationshipName, [FromBody] List relationships) { - await _resourceService.UpdateRelationshipsAsync(id, relationshipName, relationships); + await _resourceService.UpdateRelationshipAsync(id, relationshipName, relationships); return Ok(); } diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsTestController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsTestController.cs index 02b0585893..24ed45bc67 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsTestController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsTestController.cs @@ -40,15 +40,15 @@ public TodoItemsTestController( public override async Task GetAsync(int id) => await base.GetAsync(id); [HttpGet("{id}/relationships/{relationshipName}")] - public override async Task GetRelationshipsAsync(int id, string relationshipName) - => await base.GetRelationshipsAsync(id, relationshipName); - - [HttpGet("{id}/{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); + [HttpPost] - public override async Task PostAsync(TodoItem entity) + public override async Task PostAsync(TodoItem resource) { await Task.Yield(); @@ -59,7 +59,7 @@ public override async Task PostAsync(TodoItem entity) } [HttpPatch("{id}")] - public override async Task PatchAsync(int id, [FromBody] TodoItem entity) + public override async Task PatchAsync(int id, [FromBody] TodoItem resource) { await Task.Yield(); @@ -67,9 +67,9 @@ public override async Task PatchAsync(int id, [FromBody] TodoItem } [HttpPatch("{id}/relationships/{relationshipName}")] - public override async Task PatchRelationshipsAsync( + public override async Task PatchRelationshipAsync( int id, string relationshipName, [FromBody] object relationships) - => await base.PatchRelationshipsAsync(id, relationshipName, relationships); + => await base.PatchRelationshipAsync(id, relationshipName, relationships); [HttpDelete("{id}")] public override async Task DeleteAsync(int id) diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/VisasController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/VisasController.cs new file mode 100644 index 0000000000..2063710141 --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/VisasController.cs @@ -0,0 +1,18 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using JsonApiDotNetCoreExample.Models; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExample.Controllers +{ + public sealed class VisasController : JsonApiController + { + public VisasController( + IJsonApiOptions jsonApiOptions, + ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(jsonApiOptions, loggerFactory, resourceService) + { } + } +} diff --git a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs index c299c2bdb7..20ba2d0f88 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs @@ -11,6 +11,7 @@ public sealed class AppDbContext : DbContext public DbSet TodoItems { get; set; } public DbSet Passports { get; set; } + public DbSet Visas { get; set; } public DbSet People { get; set; } public DbSet TodoItemCollections { get; set; } public DbSet KebabCasedModels { get; set; } @@ -20,6 +21,7 @@ public sealed class AppDbContext : DbContext public DbSet PersonRoles { get; set; } public DbSet ArticleTags { get; set; } public DbSet Tags { get; set; } + public DbSet Blogs { get; set; } public AppDbContext(DbContextOptions options, ISystemClock systemClock) : base(options) { @@ -81,6 +83,11 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .WithOne(p => p.OneToOneTodoItem) .HasForeignKey(p => p.OneToOnePersonId); + modelBuilder.Entity() + .HasOne(p => p.Owner) + .WithMany(p => p.TodoCollections) + .OnDelete(DeleteBehavior.Cascade); + modelBuilder.Entity() .HasOne(p => p.OneToOneTodoItem) .WithOne(p => p.OneToOnePerson) diff --git a/src/Examples/JsonApiDotNetCoreExample/Definitions/ArticleDefinition.cs b/src/Examples/JsonApiDotNetCoreExample/Definitions/ArticleDefinition.cs index 94526f898a..3eddbc41e6 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Definitions/ArticleDefinition.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Definitions/ArticleDefinition.cs @@ -14,9 +14,9 @@ public class ArticleDefinition : ResourceDefinition
{ public ArticleDefinition(IResourceGraph resourceGraph) : base(resourceGraph) { } - public override IEnumerable
OnReturn(HashSet
entities, ResourcePipeline pipeline) + public override IEnumerable
OnReturn(HashSet
resources, ResourcePipeline pipeline) { - if (pipeline == ResourcePipeline.GetSingle && entities.Single().Name == "Classified") + if (pipeline == ResourcePipeline.GetSingle && resources.Any(r => r.Caption == "Classified")) { throw new JsonApiException(new Error(HttpStatusCode.Forbidden) { @@ -24,7 +24,7 @@ public override IEnumerable
OnReturn(HashSet
entities, Resourc }); } - return entities.Where(t => t.Name != "This should not be included"); + return resources.Where(t => t.Caption != "This should not be included"); } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Definitions/LockableDefinition.cs b/src/Examples/JsonApiDotNetCoreExample/Definitions/LockableDefinition.cs index 233a4f79bb..a4649807dd 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Definitions/LockableDefinition.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Definitions/LockableDefinition.cs @@ -13,9 +13,9 @@ public abstract class LockableDefinition : ResourceDefinition where T : cl { protected LockableDefinition(IResourceGraph resourceGraph) : base(resourceGraph) { } - protected void DisallowLocked(IEnumerable entities) + protected void DisallowLocked(IEnumerable resources) { - foreach (var e in entities ?? Enumerable.Empty()) + foreach (var e in resources ?? Enumerable.Empty()) { if (e.IsLocked) { diff --git a/src/Examples/JsonApiDotNetCoreExample/Definitions/ModelDefinition.cs b/src/Examples/JsonApiDotNetCoreExample/Definitions/ModelDefinition.cs deleted file mode 100644 index f471347126..0000000000 --- a/src/Examples/JsonApiDotNetCoreExample/Definitions/ModelDefinition.cs +++ /dev/null @@ -1,19 +0,0 @@ -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCoreExample.Models; - -namespace JsonApiDotNetCoreExample.Definitions -{ - public class ModelDefinition : ResourceDefinition - { - public ModelDefinition(IResourceGraph resourceGraph) : base(resourceGraph) - { - // this allows POST / PATCH requests to set the value of a - // property, but we don't include this value in the response - // this might be used if the incoming value gets hashed or - // encrypted prior to being persisted and this value should - // never be sent back to the client - HideFields(model => model.DoNotExpose); - } - } -} diff --git a/src/Examples/JsonApiDotNetCoreExample/Definitions/PassportDefinition.cs b/src/Examples/JsonApiDotNetCoreExample/Definitions/PassportDefinition.cs index cd4a87a62b..dbe26ddcca 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Definitions/PassportDefinition.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Definitions/PassportDefinition.cs @@ -32,11 +32,11 @@ public override void BeforeImplicitUpdateRelationship(IRelationshipsDictionary

().ToList().ForEach(kvp => DoesNotTouchLockedPassports(kvp.Value)); } - private void DoesNotTouchLockedPassports(IEnumerable entities) + private void DoesNotTouchLockedPassports(IEnumerable resources) { - foreach (var entity in entities ?? Enumerable.Empty()) + foreach (var passport in resources ?? Enumerable.Empty()) { - if (entity.IsLocked) + if (passport.IsLocked) { throw new JsonApiException(new Error(HttpStatusCode.Forbidden) { diff --git a/src/Examples/JsonApiDotNetCoreExample/Definitions/PersonDefinition.cs b/src/Examples/JsonApiDotNetCoreExample/Definitions/PersonDefinition.cs index c73224ebb9..9364e71505 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Definitions/PersonDefinition.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Definitions/PersonDefinition.cs @@ -11,15 +11,15 @@ public class PersonDefinition : LockableDefinition, IHasMeta { public PersonDefinition(IResourceGraph resourceGraph) : base(resourceGraph) { } - public override IEnumerable BeforeUpdateRelationship(HashSet ids, IRelationshipsDictionary entitiesByRelationship, ResourcePipeline pipeline) + public override IEnumerable BeforeUpdateRelationship(HashSet ids, IRelationshipsDictionary resourcesByRelationship, ResourcePipeline pipeline) { - BeforeImplicitUpdateRelationship(entitiesByRelationship, pipeline); + BeforeImplicitUpdateRelationship(resourcesByRelationship, pipeline); return ids; } - public override void BeforeImplicitUpdateRelationship(IRelationshipsDictionary entitiesByRelationship, ResourcePipeline pipeline) + public override void BeforeImplicitUpdateRelationship(IRelationshipsDictionary resourcesByRelationship, ResourcePipeline pipeline) { - entitiesByRelationship.GetByRelationship().ToList().ForEach(kvp => DisallowLocked(kvp.Value)); + resourcesByRelationship.GetByRelationship().ToList().ForEach(kvp => DisallowLocked(kvp.Value)); } public Dictionary GetMeta() diff --git a/src/Examples/JsonApiDotNetCoreExample/Definitions/TagDefinition.cs b/src/Examples/JsonApiDotNetCoreExample/Definitions/TagDefinition.cs index d9d7366437..b1cea0ec72 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Definitions/TagDefinition.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Definitions/TagDefinition.cs @@ -11,14 +11,14 @@ public class TagDefinition : ResourceDefinition { public TagDefinition(IResourceGraph resourceGraph) : base(resourceGraph) { } - public override IEnumerable BeforeCreate(IEntityHashSet affected, ResourcePipeline pipeline) + public override IEnumerable BeforeCreate(IResourceHashSet affected, ResourcePipeline pipeline) { return base.BeforeCreate(affected, pipeline); } - public override IEnumerable OnReturn(HashSet entities, ResourcePipeline pipeline) + public override IEnumerable OnReturn(HashSet resources, ResourcePipeline pipeline) { - return entities.Where(t => t.Name != "This should not be included"); + return resources.Where(t => t.Name != "This should not be included"); } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Definitions/UserDefinition.cs b/src/Examples/JsonApiDotNetCoreExample/Definitions/UserDefinition.cs deleted file mode 100644 index 5c2ae3fed0..0000000000 --- a/src/Examples/JsonApiDotNetCoreExample/Definitions/UserDefinition.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System.Linq; -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Internal.Query; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCoreExample.Models; - -namespace JsonApiDotNetCoreExample.Definitions -{ - public class UserDefinition : ResourceDefinition - { - public UserDefinition(IResourceGraph resourceGraph) : base(resourceGraph) - { - HideFields(u => u.Password); - } - - public override QueryFilters GetQueryFilters() - { - return new QueryFilters - { - { "firstCharacter", FirstCharacterFilter } - }; - } - - private IQueryable FirstCharacterFilter(IQueryable users, FilterQuery filterQuery) - { - switch (filterQuery.Operation) - { - // In EF core >= 3.0 we need to explicitly evaluate the query first. This could probably be translated - // into a query by building expression trees. - case "lt": - return users.ToList().Where(u => u.Username.First() < filterQuery.Value[0]).AsQueryable(); - default: - return users.ToList().Where(u => u.Username.First() == filterQuery.Value[0]).AsQueryable(); - } - } - } -} diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Address.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Address.cs new file mode 100644 index 0000000000..5deccaf48d --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Address.cs @@ -0,0 +1,17 @@ +using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; + +namespace JsonApiDotNetCoreExample.Models +{ + public sealed class Address : Identifiable + { + [Attr] + public string Street { get; set; } + + [Attr] + public string ZipCode { get; set; } + + [HasOne] + public Country Country { get; set; } + } +} diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Article.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Article.cs index 13123fcda0..d835965eb1 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Article.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Article.cs @@ -9,11 +9,13 @@ public sealed class Article : Identifiable { [Attr] [IsRequired(AllowEmptyStrings = true)] - public string Name { get; set; } + public string Caption { get; set; } + + [Attr] + public string Url { get; set; } [HasOne] public Author Author { get; set; } - public int AuthorId { get; set; } [NotMapped] [HasManyThrough(nameof(ArticleTags))] @@ -24,5 +26,11 @@ public sealed class Article : Identifiable [HasManyThrough(nameof(IdentifiableArticleTags))] public ICollection IdentifiableTags { get; set; } public ICollection IdentifiableArticleTags { get; set; } + + [HasMany] + public ICollection Revisions { get; set; } + + [HasOne] + public Blog Blog { get; set; } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/ArticleTag.cs b/src/Examples/JsonApiDotNetCoreExample/Models/ArticleTag.cs index f5ae2cc7a5..39d1dca693 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/ArticleTag.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/ArticleTag.cs @@ -1,7 +1,5 @@ -using System; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Models.Annotation; -using JsonApiDotNetCoreExample.Data; namespace JsonApiDotNetCoreExample.Models { @@ -12,11 +10,6 @@ public sealed class ArticleTag public int TagId { get; set; } public Tag Tag { get; set; } - - public ArticleTag(AppDbContext appDbContext) - { - if (appDbContext == null) throw new ArgumentNullException(nameof(appDbContext)); - } } public class IdentifiableArticleTag : Identifiable diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Author.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Author.cs index 88e413d562..a089730f2b 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Author.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Author.cs @@ -1,3 +1,4 @@ +using System; using JsonApiDotNetCore.Models; using System.Collections.Generic; using JsonApiDotNetCore.Models.Annotation; @@ -6,12 +7,23 @@ namespace JsonApiDotNetCoreExample.Models { public sealed class Author : Identifiable { + [Attr] + public string FirstName { get; set; } + [Attr] [IsRequired(AllowEmptyStrings = true)] - public string Name { get; set; } + public string LastName { get; set; } + + [Attr] + public DateTime? DateOfBirth { get; set; } + + [Attr] + public string BusinessEmail { get; set; } + + [HasOne] + public Address LivingAddress { get; set; } [HasMany] public IList

Articles { get; set; } } } - diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Blog.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Blog.cs new file mode 100644 index 0000000000..15a01e6584 --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Blog.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; + +namespace JsonApiDotNetCoreExample.Models +{ + public sealed class Blog : Identifiable + { + [Attr] + public string Title { get; set; } + + [Attr] + public string CompanyName { get; set; } + + [HasMany] + public IList
Articles { get; set; } + + [HasOne] + public Author Owner { get; set; } + } +} diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Country.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Country.cs index 3e81c1a51e..831b464b44 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Country.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Country.cs @@ -1,8 +1,11 @@ +using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; + namespace JsonApiDotNetCoreExample.Models { - public class Country + public class Country : Identifiable { - public int Id { get; set; } + [Attr] public string Name { get; set; } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Model.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Model.cs deleted file mode 100644 index 22da2c19fb..0000000000 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Model.cs +++ /dev/null @@ -1,11 +0,0 @@ -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Models.Annotation; - -namespace JsonApiDotNetCoreExample.Models -{ - public sealed class Model : Identifiable - { - [Attr] - public string DoNotExpose { get; set; } - } -} diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Passport.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Passport.cs index f73bfbd458..195df3528c 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Passport.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Passport.cs @@ -54,11 +54,7 @@ public string BirthCountryName get => BirthCountry.Name; set { - if (BirthCountry == null) - { - BirthCountry = new Country(); - } - + BirthCountry ??= new Country(); BirthCountry.Name = value; } } @@ -66,7 +62,7 @@ public string BirthCountryName [EagerLoad] public Country BirthCountry { get; set; } - [Attr(AttrCapabilities.All & ~AttrCapabilities.AllowMutate)] + [Attr(AttrCapabilities.All & ~AttrCapabilities.AllowChange)] [NotMapped] public string GrantedVisaCountries => GrantedVisas == null || !GrantedVisas.Any() ? null diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs index 8ee4c8aa62..979c169d29 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs @@ -2,7 +2,7 @@ using System.Linq; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Models.Annotation; -using JsonApiDotNetCore.Models.Links; +using JsonApiDotNetCore.Models.JsonApiDocuments; namespace JsonApiDotNetCoreExample.Models { @@ -54,7 +54,7 @@ public string FirstName public ISet AssignedTodoItems { get; set; } [HasMany] - public HashSet todoCollections { get; set; } + public HashSet TodoCollections { get; set; } [HasOne] public PersonRole Role { get; set; } @@ -67,7 +67,7 @@ public string FirstName public TodoItem StakeHolderTodoItem { get; set; } public int? StakeHolderTodoItemId { get; set; } - [HasOne(links: Link.All, canInclude: false)] + [HasOne(links: Links.All, canInclude: false)] public TodoItem UnIncludeableItem { get; set; } [HasOne] diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Revision.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Revision.cs new file mode 100644 index 0000000000..e475632a36 --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Revision.cs @@ -0,0 +1,18 @@ +using System; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; + +namespace JsonApiDotNetCoreExample.Models +{ + public sealed class Revision : Identifiable + { + [Attr] + public DateTime PublishTime { get; set; } + + [HasOne] + public Author Author { get; set; } + + [HasOne] + public Article Article { get; set; } + } +} diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs index 55c3f7c3de..ccf0da0b75 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs @@ -1,8 +1,6 @@ -using System; using System.ComponentModel.DataAnnotations; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Models.Annotation; -using JsonApiDotNetCoreExample.Data; namespace JsonApiDotNetCoreExample.Models { @@ -12,9 +10,14 @@ public class Tag : Identifiable [RegularExpression(@"^\W$")] public string Name { get; set; } - public Tag(AppDbContext appDbContext) - { - if (appDbContext == null) throw new ArgumentNullException(nameof(appDbContext)); - } + [Attr] + public TagColor Color { get; set; } + } + + public enum TagColor + { + Red, + Green, + Blue } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs index c2385fd20f..d19201cc54 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs @@ -39,7 +39,7 @@ public string AlwaysChangingValue [Attr] public DateTime? UpdatedDate { get; set; } - [Attr(AttrCapabilities.All & ~AttrCapabilities.AllowMutate)] + [Attr(AttrCapabilities.All & ~AttrCapabilities.AllowChange)] public string CalculatedValue => "calculated"; [Attr] diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/User.cs b/src/Examples/JsonApiDotNetCoreExample/Models/User.cs index 2d70d12881..79f4c8cc89 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/User.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/User.cs @@ -11,9 +11,9 @@ public class User : Identifiable private readonly ISystemClock _systemClock; private string _password; - [Attr] public string Username { get; set; } + [Attr] public string UserName { get; set; } - [Attr] + [Attr(AttrCapabilities.AllowChange)] public string Password { get => _password; diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Visa.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Visa.cs index 7647647f73..f67112db0a 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Visa.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Visa.cs @@ -1,14 +1,17 @@ using System; +using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Models.Annotation; namespace JsonApiDotNetCoreExample.Models { - public class Visa + public sealed class Visa : Identifiable { - public int Id { get; set; } - + [Attr] public DateTime ExpiresAt { get; set; } + [Attr] + public string CountryName => TargetCountry.Name; + [EagerLoad] public Country TargetCountry { get; set; } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Program.cs b/src/Examples/JsonApiDotNetCoreExample/Program.cs index f17228e167..8a892fcfe5 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Program.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Program.cs @@ -1,5 +1,5 @@ -using Microsoft.AspNetCore; using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Hosting; namespace JsonApiDotNetCoreExample { @@ -7,10 +7,14 @@ public class Program { public static void Main(string[] args) { - CreateWebHostBuilder(args).Build().Run(); + CreateHostBuilder(args).Build().Run(); } - public static IWebHostBuilder CreateWebHostBuilder(string[] args) => - WebHost.CreateDefaultBuilder(args) - .UseStartup(); + + public static IHostBuilder CreateHostBuilder(string[] args) => + Host.CreateDefaultBuilder(args) + .ConfigureWebHostDefaults(webBuilder => + { + webBuilder.UseStartup(); + }); } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs b/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs index 3a35f05165..4edf6a14aa 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs @@ -1,37 +1,38 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Data; using JsonApiDotNetCore.Hooks; -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Query; using JsonApiDotNetCore.Services; using JsonApiDotNetCoreExample.Models; using Microsoft.Extensions.Logging; -using System.Collections.Generic; using System.Threading.Tasks; using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Internal.Queries; using JsonApiDotNetCore.RequestServices; +using JsonApiDotNetCore.RequestServices.Contracts; namespace JsonApiDotNetCoreExample.Services { - public class CustomArticleService : DefaultResourceService
+ public class CustomArticleService : JsonApiResourceService
{ public CustomArticleService( - IEnumerable queryParameters, + IResourceRepository
repository, + IQueryLayerComposer queryLayerComposer, + IPaginationContext paginationContext, IJsonApiOptions options, ILoggerFactory loggerFactory, - IResourceRepository repository, - IResourceContextProvider provider, + ICurrentRequest currentRequest, IResourceChangeTracker
resourceChangeTracker, IResourceFactory resourceFactory, IResourceHookExecutor hookExecutor = null) - : base(queryParameters, options, loggerFactory, repository, provider, resourceChangeTracker, resourceFactory, hookExecutor) + : base(repository, queryLayerComposer, paginationContext, options, loggerFactory, currentRequest, + resourceChangeTracker, resourceFactory, hookExecutor) { } public override async Task
GetAsync(int id) { - var newEntity = await base.GetAsync(id); - newEntity.Name = "None for you Glen Coco"; - return newEntity; + var resource = await base.GetAsync(id); + resource.Caption = "None for you Glen Coco"; + return resource; } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Services/SkipCacheQueryParameterService.cs b/src/Examples/JsonApiDotNetCoreExample/Services/SkipCacheQueryStringParameterReader.cs similarity index 78% rename from src/Examples/JsonApiDotNetCoreExample/Services/SkipCacheQueryParameterService.cs rename to src/Examples/JsonApiDotNetCoreExample/Services/SkipCacheQueryStringParameterReader.cs index e5892ccf3f..85ed70c32e 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Services/SkipCacheQueryParameterService.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Services/SkipCacheQueryStringParameterReader.cs @@ -1,12 +1,12 @@ using System.Linq; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Exceptions; -using JsonApiDotNetCore.Query; +using JsonApiDotNetCore.Internal.QueryStrings; using Microsoft.Extensions.Primitives; namespace JsonApiDotNetCoreExample.Services { - public class SkipCacheQueryParameterService : IQueryParameterService + public class SkipCacheQueryStringParameterReader : IQueryStringParameterReader { private const string _skipCacheParameterName = "skipCache"; @@ -17,12 +17,12 @@ public bool IsEnabled(DisableQueryAttribute disableQueryAttribute) return !disableQueryAttribute.ParameterNames.Contains(_skipCacheParameterName.ToLowerInvariant()); } - public bool CanParse(string parameterName) + public bool CanRead(string parameterName) { return parameterName == _skipCacheParameterName; } - public void Parse(string parameterName, StringValues parameterValue) + public void Read(string parameterName, StringValues parameterValue) { if (!bool.TryParse(parameterValue, out bool skipCache)) { diff --git a/src/Examples/JsonApiDotNetCoreExample/Startups/EmptyStartup.cs b/src/Examples/JsonApiDotNetCoreExample/Startups/EmptyStartup.cs new file mode 100644 index 0000000000..297f1e8bfc --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample/Startups/EmptyStartup.cs @@ -0,0 +1,26 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace JsonApiDotNetCoreExample +{ + /// + /// Empty startup class, required for integration tests. + /// Changes in .NET Core 3 no longer allow Startup class to be defined in test projects. See https://github.com/aspnet/AspNetCore/issues/15373. + /// + public abstract class EmptyStartup + { + protected EmptyStartup(IConfiguration configuration) + { + } + + public virtual void ConfigureServices(IServiceCollection services) + { + } + + public virtual void Configure(IApplicationBuilder app, IWebHostEnvironment environment) + { + } + } +} diff --git a/src/Examples/JsonApiDotNetCoreExample/Startups/KebabCaseStartup.cs b/src/Examples/JsonApiDotNetCoreExample/Startups/KebabCaseStartup.cs deleted file mode 100644 index 816827345c..0000000000 --- a/src/Examples/JsonApiDotNetCoreExample/Startups/KebabCaseStartup.cs +++ /dev/null @@ -1,24 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using Microsoft.AspNetCore.Hosting; -using Newtonsoft.Json.Serialization; - -namespace JsonApiDotNetCoreExample -{ - /// - /// This should be in JsonApiDotNetCoreExampleTests project but changes in .net core 3.0 - /// do no longer allow that. See https://github.com/aspnet/AspNetCore/issues/15373. - /// - public sealed class KebabCaseStartup : TestStartup - { - public KebabCaseStartup(IWebHostEnvironment env) : base(env) - { - } - - protected override void ConfigureJsonApiOptions(JsonApiOptions options) - { - base.ConfigureJsonApiOptions(options); - - ((DefaultContractResolver)options.SerializerSettings.ContractResolver).NamingStrategy = new KebabCaseNamingStrategy(); - } - } -} diff --git a/src/Examples/JsonApiDotNetCoreExample/Startups/MetaStartup.cs b/src/Examples/JsonApiDotNetCoreExample/Startups/MetaStartup.cs deleted file mode 100644 index 026550950c..0000000000 --- a/src/Examples/JsonApiDotNetCoreExample/Startups/MetaStartup.cs +++ /dev/null @@ -1,32 +0,0 @@ -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.DependencyInjection; -using JsonApiDotNetCore.Services; -using System.Collections.Generic; - -namespace JsonApiDotNetCoreExample -{ - /// - /// This should be in JsonApiDotNetCoreExampleTests project but changes in .net core 3.0 - /// do no longer allow that. See https://github.com/aspnet/AspNetCore/issues/15373. - /// - public sealed class MetaStartup : TestStartup - { - public MetaStartup(IWebHostEnvironment env) : base(env) { } - - public override void ConfigureServices(IServiceCollection services) - { - services.AddScoped(); - base.ConfigureServices(services); - } - } - - public sealed class MetaService : IRequestMeta - { - public Dictionary GetMeta() - { - return new Dictionary { - { "request-meta", "request-meta-value" } - }; - } - } -} diff --git a/src/Examples/JsonApiDotNetCoreExample/Startups/NoDefaultPageSizeStartup.cs b/src/Examples/JsonApiDotNetCoreExample/Startups/NoDefaultPageSizeStartup.cs deleted file mode 100644 index 9b10947629..0000000000 --- a/src/Examples/JsonApiDotNetCoreExample/Startups/NoDefaultPageSizeStartup.cs +++ /dev/null @@ -1,23 +0,0 @@ -using Microsoft.AspNetCore.Hosting; -using JsonApiDotNetCore.Configuration; - -namespace JsonApiDotNetCoreExample -{ - /// - /// This should be in JsonApiDotNetCoreExampleTests project but changes in .net core 3.0 - /// do no longer allow that. See https://github.com/aspnet/AspNetCore/issues/15373. - /// - public sealed class NoDefaultPageSizeStartup : TestStartup - { - public NoDefaultPageSizeStartup(IWebHostEnvironment env) : base(env) - { - } - - protected override void ConfigureJsonApiOptions(JsonApiOptions options) - { - base.ConfigureJsonApiOptions(options); - - options.DefaultPageSize = 0; - } - } -} diff --git a/src/Examples/JsonApiDotNetCoreExample/Startups/NoNamespaceStartup.cs b/src/Examples/JsonApiDotNetCoreExample/Startups/NoNamespaceStartup.cs deleted file mode 100644 index a6fade4548..0000000000 --- a/src/Examples/JsonApiDotNetCoreExample/Startups/NoNamespaceStartup.cs +++ /dev/null @@ -1,23 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using Microsoft.AspNetCore.Hosting; - -namespace JsonApiDotNetCoreExample.Startups -{ - /// - /// This should be in JsonApiDotNetCoreExampleTests project but changes in .net core 3.0 - /// do no longer allow that. See https://github.com/aspnet/AspNetCore/issues/15373. - /// - public class NoNamespaceStartup : TestStartup - { - public NoNamespaceStartup(IWebHostEnvironment env) : base(env) - { - } - - protected override void ConfigureJsonApiOptions(JsonApiOptions options) - { - base.ConfigureJsonApiOptions(options); - - options.Namespace = null; - } - } -} diff --git a/src/Examples/JsonApiDotNetCoreExample/Startups/Startup.cs b/src/Examples/JsonApiDotNetCoreExample/Startups/Startup.cs index 292dd9019b..62f2cb65af 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Startups/Startup.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Startups/Startup.cs @@ -7,45 +7,38 @@ using System; using JsonApiDotNetCore; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Query; +using JsonApiDotNetCore.Internal.QueryStrings; using JsonApiDotNetCoreExample.Services; using Microsoft.AspNetCore.Authentication; +using Newtonsoft.Json; using Newtonsoft.Json.Converters; namespace JsonApiDotNetCoreExample { - public class Startup + public class Startup : EmptyStartup { private readonly string _connectionString; - public Startup(IWebHostEnvironment env) + public Startup(IConfiguration configuration) : base(configuration) { - var builder = new ConfigurationBuilder() - .SetBasePath(env.ContentRootPath) - .AddJsonFile("appsettings.json", optional: true, reloadOnChange: false) - .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true) - .AddEnvironmentVariables(); - var configuration = builder.Build(); - string postgresPassword = Environment.GetEnvironmentVariable("PGPASSWORD") ?? "postgres"; _connectionString = configuration["Data:DefaultConnection"].Replace("###", postgresPassword); } - public virtual void ConfigureServices(IServiceCollection services) + public override void ConfigureServices(IServiceCollection services) { ConfigureClock(services); - services.AddScoped(); - services.AddScoped(sp => sp.GetService()); + services.AddScoped(); + services.AddScoped(sp => sp.GetService()); + + services.AddDbContext(options => + { + options.EnableSensitiveDataLogging(); + options.UseNpgsql(_connectionString, innerOptions => innerOptions.SetPostgresVersion(new Version(9, 6))); + }, ServiceLifetime.Transient); - services - .AddDbContext(options => - { - options - .EnableSensitiveDataLogging() - .UseNpgsql(_connectionString, innerOptions => innerOptions.SetPostgresVersion(new Version(9,6))); - }, ServiceLifetime.Transient) - .AddJsonApi(ConfigureJsonApiOptions, discovery => discovery.AddCurrentAssembly()); + services.AddJsonApi(ConfigureJsonApiOptions, discovery => discovery.AddCurrentAssembly()); // once all tests have been moved to WebApplicationFactory format we can get rid of this line below services.AddClientSerialization(); @@ -60,19 +53,20 @@ protected virtual void ConfigureJsonApiOptions(JsonApiOptions options) { options.IncludeExceptionStackTraceInErrors = true; options.Namespace = "api/v1"; - options.DefaultPageSize = 5; - options.IncludeTotalRecordCount = true; - options.LoadDatabaseValues = true; + options.DefaultPageSize = new PageSize(5); + options.IncludeTotalResourceCount = true; options.ValidateModelState = true; - options.EnableResourceHooks = true; + options.SerializerSettings.Formatting = Formatting.Indented; options.SerializerSettings.Converters.Add(new StringEnumConverter()); } - public void Configure( - IApplicationBuilder app, - AppDbContext context) + public override void Configure(IApplicationBuilder app, IWebHostEnvironment environment) { - context.Database.EnsureCreated(); + using (var scope = app.ApplicationServices.CreateScope()) + { + var appDbContext = scope.ServiceProvider.GetRequiredService(); + appDbContext.Database.EnsureCreated(); + } app.UseRouting(); app.UseJsonApi(); diff --git a/src/Examples/JsonApiDotNetCoreExample/Startups/TestStartup.cs b/src/Examples/JsonApiDotNetCoreExample/Startups/TestStartup.cs index 0d976b7ebd..e1af39084c 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Startups/TestStartup.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Startups/TestStartup.cs @@ -1,22 +1,25 @@ using System; using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; namespace JsonApiDotNetCoreExample { public class TestStartup : Startup { - public TestStartup(IWebHostEnvironment env) : base(env) + public TestStartup(IConfiguration configuration) : base(configuration) { } protected override void ConfigureClock(IServiceCollection services) { - services.AddSingleton(); + services.AddSingleton(); } - private class AlwaysChangingSystemClock : ISystemClock + /// + /// Advances the clock one second each time the current time is requested. + /// + private class TickingSystemClock : ISystemClock { private DateTimeOffset _utcNow; @@ -30,12 +33,12 @@ public DateTimeOffset UtcNow } } - public AlwaysChangingSystemClock() + public TickingSystemClock() : this(new DateTimeOffset(new DateTime(2000, 1, 1))) { } - public AlwaysChangingSystemClock(DateTimeOffset utcNow) + public TickingSystemClock(DateTimeOffset utcNow) { _utcNow = utcNow; } diff --git a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs b/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs index 4b6fa24055..8eeae612c7 100644 --- a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs +++ b/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs @@ -21,10 +21,10 @@ public WorkItemService(IConfiguration configuration) _connectionString = configuration["Data:DefaultConnection"].Replace("###", postgresPassword); } - public async Task> GetAsync() + public async Task> GetAsync() { - return await QueryAsync(async connection => - await connection.QueryAsync(@"select * from ""WorkItems""")); + return (await QueryAsync(async connection => + await connection.QueryAsync(@"select * from ""WorkItems"""))).ToList(); } public async Task GetAsync(int id) @@ -35,22 +35,22 @@ public async Task GetAsync(int id) return query.Single(); } - public Task GetRelationshipAsync(int id, string relationshipName) + public Task GetSecondaryAsync(int id, string relationshipName) { throw new NotImplementedException(); } - public Task GetRelationshipsAsync(int id, string relationshipName) + public Task GetRelationshipAsync(int id, string relationshipName) { throw new NotImplementedException(); } - public async Task CreateAsync(WorkItem entity) + 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 = entity.Title, ordinal = entity.DurationInHours, uniqueId = entity.ProjectId, isLocked = entity.IsBlocked }); + var result = await connection.QueryAsync(query, new { description = resource.Title, ordinal = resource.DurationInHours, uniqueId = resource.ProjectId, isLocked = resource.IsBlocked }); return result; })).SingleOrDefault(); } @@ -61,12 +61,12 @@ await QueryAsync(async connection => await connection.QueryAsync(@"delete from ""WorkItems"" where ""Id""=@id", new { id })); } - public Task UpdateAsync(int id, WorkItem entity) + public Task UpdateAsync(int id, WorkItem requestResource) { throw new NotImplementedException(); } - public Task UpdateRelationshipsAsync(int id, string relationshipName, object relationships) + public Task UpdateRelationshipAsync(int id, string relationshipName, object relationships) { throw new NotImplementedException(); } diff --git a/src/Examples/ReportsExample/Services/ReportService.cs b/src/Examples/ReportsExample/Services/ReportService.cs index c502fa9987..61a75d3886 100644 --- a/src/Examples/ReportsExample/Services/ReportService.cs +++ b/src/Examples/ReportsExample/Services/ReportService.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using JsonApiDotNetCore.Services; using Microsoft.Extensions.Logging; @@ -15,11 +16,11 @@ public ReportService(ILoggerFactory loggerFactory) _logger = loggerFactory.CreateLogger(); } - public Task> GetAsync() + public Task> GetAsync() { _logger.LogInformation("GetAsync"); - IEnumerable reports = GetReports(); + IReadOnlyCollection reports = GetReports().ToList(); return Task.FromResult(reports); } diff --git a/src/JsonApiDotNetCore/Builders/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Builders/JsonApiApplicationBuilder.cs index f542ba40e3..68c727ce47 100644 --- a/src/JsonApiDotNetCore/Builders/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Builders/JsonApiApplicationBuilder.cs @@ -5,8 +5,6 @@ using JsonApiDotNetCore.Graph; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Generics; -using JsonApiDotNetCore.Managers; -using JsonApiDotNetCore.Managers.Contracts; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Serialization; @@ -16,12 +14,14 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using JsonApiDotNetCore.Internal.Contracts; +using JsonApiDotNetCore.Internal.Queries; +using JsonApiDotNetCore.Internal.QueryStrings; using JsonApiDotNetCore.Query; using JsonApiDotNetCore.Serialization.Server.Builders; using JsonApiDotNetCore.Serialization.Server; using Microsoft.Extensions.DependencyInjection.Extensions; -using JsonApiDotNetCore.QueryParameterServices.Common; using JsonApiDotNetCore.RequestServices; +using JsonApiDotNetCore.RequestServices.Contracts; namespace JsonApiDotNetCore.Builders { @@ -145,42 +145,43 @@ public void ConfigureServices() _services.AddSingleton(new DbContextOptionsBuilder().Options); } - _services.AddScoped(typeof(IResourceRepository<>), typeof(DefaultResourceRepository<>)); - _services.AddScoped(typeof(IResourceRepository<,>), typeof(DefaultResourceRepository<,>)); + _services.AddScoped(typeof(IResourceRepository<>), typeof(EntityFrameworkCoreRepository<>)); + _services.AddScoped(typeof(IResourceRepository<,>), typeof(EntityFrameworkCoreRepository<,>)); - _services.AddScoped(typeof(IResourceReadRepository<,>), typeof(DefaultResourceRepository<,>)); - _services.AddScoped(typeof(IResourceWriteRepository<,>), typeof(DefaultResourceRepository<,>)); + _services.AddScoped(typeof(IResourceReadRepository<,>), typeof(EntityFrameworkCoreRepository<,>)); + _services.AddScoped(typeof(IResourceWriteRepository<,>), typeof(EntityFrameworkCoreRepository<,>)); - _services.AddScoped(typeof(ICreateService<>), typeof(DefaultResourceService<>)); - _services.AddScoped(typeof(ICreateService<,>), typeof(DefaultResourceService<,>)); + _services.AddScoped(typeof(ICreateService<>), typeof(JsonApiResourceService<>)); + _services.AddScoped(typeof(ICreateService<,>), typeof(JsonApiResourceService<,>)); - _services.AddScoped(typeof(IGetAllService<>), typeof(DefaultResourceService<>)); - _services.AddScoped(typeof(IGetAllService<,>), typeof(DefaultResourceService<,>)); + _services.AddScoped(typeof(IGetAllService<>), typeof(JsonApiResourceService<>)); + _services.AddScoped(typeof(IGetAllService<,>), typeof(JsonApiResourceService<,>)); - _services.AddScoped(typeof(IGetByIdService<>), typeof(DefaultResourceService<>)); - _services.AddScoped(typeof(IGetByIdService<,>), typeof(DefaultResourceService<,>)); + _services.AddScoped(typeof(IGetByIdService<>), typeof(JsonApiResourceService<>)); + _services.AddScoped(typeof(IGetByIdService<,>), typeof(JsonApiResourceService<,>)); - _services.AddScoped(typeof(IGetRelationshipService<,>), typeof(DefaultResourceService<>)); - _services.AddScoped(typeof(IGetRelationshipService<,>), typeof(DefaultResourceService<,>)); + _services.AddScoped(typeof(IGetRelationshipService<>), typeof(JsonApiResourceService<>)); + _services.AddScoped(typeof(IGetRelationshipService<,>), typeof(JsonApiResourceService<,>)); - _services.AddScoped(typeof(IUpdateService<>), typeof(DefaultResourceService<>)); - _services.AddScoped(typeof(IUpdateService<,>), typeof(DefaultResourceService<,>)); + _services.AddScoped(typeof(IGetSecondaryService<>), typeof(JsonApiResourceService<>)); + _services.AddScoped(typeof(IGetSecondaryService<,>), typeof(JsonApiResourceService<,>)); - _services.AddScoped(typeof(IDeleteService<>), typeof(DefaultResourceService<>)); - _services.AddScoped(typeof(IDeleteService<,>), typeof(DefaultResourceService<,>)); + _services.AddScoped(typeof(IUpdateService<>), typeof(JsonApiResourceService<>)); + _services.AddScoped(typeof(IUpdateService<,>), typeof(JsonApiResourceService<,>)); - _services.AddScoped(typeof(IResourceService<>), typeof(DefaultResourceService<>)); - _services.AddScoped(typeof(IResourceService<,>), typeof(DefaultResourceService<,>)); + _services.AddScoped(typeof(IDeleteService<>), typeof(JsonApiResourceService<>)); + _services.AddScoped(typeof(IDeleteService<,>), typeof(JsonApiResourceService<,>)); - _services.AddScoped(typeof(IResourceQueryService<,>), typeof(DefaultResourceService<,>)); - _services.AddScoped(typeof(IResourceCommandService<,>), typeof(DefaultResourceService<,>)); + _services.AddScoped(typeof(IResourceService<>), typeof(JsonApiResourceService<>)); + _services.AddScoped(typeof(IResourceService<,>), typeof(JsonApiResourceService<,>)); + + _services.AddScoped(typeof(IResourceQueryService<,>), typeof(JsonApiResourceService<,>)); + _services.AddScoped(typeof(IResourceCommandService<,>), typeof(JsonApiResourceService<,>)); - _services.AddSingleton(_options); _services.AddSingleton(resourceGraph); _services.AddSingleton(); _services.AddSingleton(resourceGraph); - _services.AddSingleton(); - _services.AddSingleton(); + _services.AddSingleton(); _services.AddScoped(); _services.AddScoped(); @@ -188,39 +189,52 @@ public void ConfigureServices() _services.AddScoped(); _services.AddScoped(); _services.AddScoped(typeof(RepositoryRelationshipUpdateHelper<>)); - _services.AddScoped(); _services.AddScoped(); _services.AddScoped(); _services.AddScoped(); - _services.AddScoped(typeof(IResourceChangeTracker<>), typeof(DefaultResourceChangeTracker<>)); - _services.AddScoped(); - _services.AddScoped(); + _services.AddScoped(typeof(IResourceChangeTracker<>), typeof(ResourceChangeTracker<>)); + _services.AddScoped(); + _services.AddScoped(); + _services.AddScoped(); AddServerSerialization(); - AddQueryParameterServices(); + AddQueryStringParameterServices(); if (_options.EnableResourceHooks) AddResourceHooks(); _services.AddScoped(); } - private void AddQueryParameterServices() + private void AddQueryStringParameterServices() { - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - - _services.AddScoped(sp => sp.GetService()); - _services.AddScoped(sp => sp.GetService()); - _services.AddScoped(sp => sp.GetService()); - _services.AddScoped(sp => sp.GetService()); - _services.AddScoped(sp => sp.GetService()); - _services.AddScoped(sp => sp.GetService()); - _services.AddScoped(sp => sp.GetService()); + _services.AddScoped(); + _services.AddScoped(); + _services.AddScoped(); + _services.AddScoped(); + _services.AddScoped(); + _services.AddScoped(); + _services.AddScoped(); + _services.AddScoped(); + + _services.AddScoped(sp => sp.GetService()); + _services.AddScoped(sp => sp.GetService()); + _services.AddScoped(sp => sp.GetService()); + _services.AddScoped(sp => sp.GetService()); + _services.AddScoped(sp => sp.GetService()); + _services.AddScoped(sp => sp.GetService()); + _services.AddScoped(sp => sp.GetService()); + _services.AddScoped(sp => sp.GetService()); + + _services.AddScoped(sp => sp.GetService()); + _services.AddScoped(sp => sp.GetService()); + _services.AddScoped(sp => sp.GetService()); + _services.AddScoped(sp => sp.GetService()); + _services.AddScoped(sp => sp.GetService()); + _services.AddScoped(sp => sp.GetService()); + + _services.AddScoped(); + _services.AddScoped(); + _services.AddSingleton(); } private void AddResourceHooks() @@ -248,7 +262,7 @@ private void AddServerSerialization() private void RegisterJsonApiStartupServices() { _services.AddSingleton(_options); - _services.TryAddSingleton(); + _services.TryAddSingleton(); _services.TryAddSingleton(); _services.TryAddSingleton(sp => new ServiceDiscoveryFacade(_services, sp.GetRequiredService())); _services.TryAddScoped(); diff --git a/src/JsonApiDotNetCore/Builders/ResourceGraphBuilder.cs b/src/JsonApiDotNetCore/Builders/ResourceGraphBuilder.cs index c7b91130d3..f6781f93ed 100644 --- a/src/JsonApiDotNetCore/Builders/ResourceGraphBuilder.cs +++ b/src/JsonApiDotNetCore/Builders/ResourceGraphBuilder.cs @@ -72,22 +72,22 @@ public IResourceGraphBuilder AddResource(Type resourceType, Type idType = null, return this; } - private ResourceContext CreateResourceContext(string pluralizedTypeName, Type entityType, Type idType) => new ResourceContext + private ResourceContext CreateResourceContext(string pluralizedTypeName, Type resourceType, Type idType) => new ResourceContext { ResourceName = pluralizedTypeName, - ResourceType = entityType, + ResourceType = resourceType, IdentityType = idType, - Attributes = GetAttributes(entityType), - Relationships = GetRelationships(entityType), - EagerLoads = GetEagerLoads(entityType), - ResourceDefinitionType = GetResourceDefinitionType(entityType) + Attributes = GetAttributes(resourceType), + Relationships = GetRelationships(resourceType), + EagerLoads = GetEagerLoads(resourceType), + ResourceDefinitionType = GetResourceDefinitionType(resourceType) }; - protected virtual List GetAttributes(Type entityType) + protected virtual List GetAttributes(Type resourceType) { var attributes = new List(); - foreach (var property in entityType.GetProperties()) + foreach (var property in resourceType.GetProperties()) { var attribute = (AttrAttribute)property.GetCustomAttribute(typeof(AttrAttribute)); @@ -122,10 +122,10 @@ protected virtual List GetAttributes(Type entityType) return attributes; } - protected virtual List GetRelationships(Type entityType) + protected virtual List GetRelationships(Type resourceType) { var attributes = new List(); - var properties = entityType.GetProperties(); + var properties = resourceType.GetProperties(); foreach (var prop in properties) { var attribute = (RelationshipAttribute)prop.GetCustomAttribute(typeof(RelationshipAttribute)); @@ -134,18 +134,18 @@ protected virtual List GetRelationships(Type entityType) attribute.Property = prop; attribute.PublicName ??= FormatPropertyName(prop); attribute.RightType = GetRelationshipType(attribute, prop); - attribute.LeftType = entityType; + attribute.LeftType = resourceType; attributes.Add(attribute); if (attribute is HasManyThroughAttribute hasManyThroughAttribute) { var throughProperty = properties.SingleOrDefault(p => p.Name == hasManyThroughAttribute.ThroughPropertyName); if (throughProperty == null) - throw new JsonApiSetupException($"Invalid {nameof(HasManyThroughAttribute)} on '{entityType}.{attribute.Property.Name}': Resource does not contain a property named '{hasManyThroughAttribute.ThroughPropertyName}'."); + throw new JsonApiSetupException($"Invalid {nameof(HasManyThroughAttribute)} on '{resourceType}.{attribute.Property.Name}': Resource does not contain a property named '{hasManyThroughAttribute.ThroughPropertyName}'."); var throughType = TryGetThroughType(throughProperty); if (throughType == null) - throw new JsonApiSetupException($"Invalid {nameof(HasManyThroughAttribute)} on '{entityType}.{attribute.Property.Name}': Referenced property '{throughProperty.Name}' does not implement 'ICollection'."); + throw new JsonApiSetupException($"Invalid {nameof(HasManyThroughAttribute)} on '{resourceType}.{attribute.Property.Name}': Referenced property '{throughProperty.Name}' does not implement 'ICollection'."); // ICollection hasManyThroughAttribute.ThroughProperty = throughProperty; @@ -156,13 +156,13 @@ protected virtual List GetRelationships(Type entityType) var throughProperties = throughType.GetProperties(); // ArticleTag.Article - hasManyThroughAttribute.LeftProperty = throughProperties.SingleOrDefault(x => x.PropertyType == entityType) - ?? throw new JsonApiSetupException($"{throughType} does not contain a navigation property to type {entityType}"); + hasManyThroughAttribute.LeftProperty = throughProperties.SingleOrDefault(x => x.PropertyType == resourceType) + ?? throw new JsonApiSetupException($"{throughType} does not contain a navigation property to type {resourceType}"); // ArticleTag.ArticleId var leftIdPropertyName = JsonApiOptions.RelatedIdMapper.GetRelatedIdPropertyName(hasManyThroughAttribute.LeftProperty.Name); hasManyThroughAttribute.LeftIdProperty = throughProperties.SingleOrDefault(x => x.Name == leftIdPropertyName) - ?? throw new JsonApiSetupException($"{throughType} does not contain a relationship id property to type {entityType} with name {leftIdPropertyName}"); + ?? throw new JsonApiSetupException($"{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) @@ -199,7 +199,7 @@ private static Type TryGetThroughType(PropertyInfo throughProperty) protected virtual Type GetRelationshipType(RelationshipAttribute relation, PropertyInfo prop) => relation is HasOneAttribute ? prop.PropertyType : prop.PropertyType.GetGenericArguments()[0]; - private List GetEagerLoads(Type entityType, int recursionDepth = 0) + private List GetEagerLoads(Type resourceType, int recursionDepth = 0) { if (recursionDepth >= 500) { @@ -207,7 +207,7 @@ private List GetEagerLoads(Type entityType, int recursionDep } var attributes = new List(); - var properties = entityType.GetProperties(); + var properties = resourceType.GetProperties(); foreach (var property in properties) { @@ -232,7 +232,7 @@ private static Type TypeOrElementType(Type type) return interfaces.Length == 1 ? interfaces.Single().GenericTypeArguments[0] : type; } - private Type GetResourceDefinitionType(Type entityType) => typeof(ResourceDefinition<>).MakeGenericType(entityType); + private Type GetResourceDefinitionType(Type resourceType) => typeof(ResourceDefinition<>).MakeGenericType(resourceType); private string FormatResourceName(Type resourceType) { diff --git a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs index 1a38100276..bdfb67d180 100644 --- a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs @@ -1,51 +1,169 @@ using System; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; using JsonApiDotNetCore.Models.JsonApiDocuments; using Newtonsoft.Json; using Newtonsoft.Json.Serialization; namespace JsonApiDotNetCore.Configuration { - public interface IJsonApiOptions : ILinksConfiguration + public interface IJsonApiOptions { + /// + /// The URL prefix to use for exposed endpoints. + /// + /// + /// options.Namespace = "api/v1"; + /// + string Namespace { get; } + + /// + /// Specifies the default query string capabilities that can be used on exposed json:api attributes. + /// Defaults to . + /// + AttrCapabilities DefaultAttrCapabilities { get; } + /// /// Whether or not stack traces should be serialized in objects. + /// False by default. /// - bool IncludeExceptionStackTraceInErrors { get; set; } + bool IncludeExceptionStackTraceInErrors { get; } /// - /// Whether or not database values should be included by default - /// for resource hooks. Ignored if EnableResourceHooks is set false. - /// - /// Defaults to . + /// Use relative links for all resources. /// - bool LoadDatabaseValues { get; set; } + /// + /// + /// options.UseRelativeLinks = true; + /// + /// + /// { + /// "type": "articles", + /// "id": "4309", + /// "relationships": { + /// "author": { + /// "links": { + /// "self": "/api/v1/articles/4309/relationships/author", + /// "related": "/api/v1/articles/4309/author" + /// } + /// } + /// } + /// } + /// + /// + bool UseRelativeLinks { get; } + /// - /// Whether or not the total-record count should be included in all document - /// level meta objects. - /// Defaults to false. + /// Configures globally which links to show in the + /// object for a requested resource. Setting can be overridden per resource by + /// adding a to the class definition of that resource. + /// + Links TopLevelLinks { get; } + + /// + /// Configures globally which links to show in the + /// object for a requested resource. Setting can be overridden per resource by + /// adding a to the class definition of that resource. + /// + Links ResourceLinks { get; } + + /// + /// Configures globally which links to show in the + /// object for a requested resource. Setting can be overridden per resource by + /// adding a to the class definition of that resource. + /// This option can also be specified per relationship by using the associated links argument + /// in the constructor of . /// /// - /// options.IncludeTotalRecordCount = true; + /// + /// options.RelationshipLinks = Links.None; + /// + /// + /// { + /// "type": "articles", + /// "id": "4309", + /// "relationships": { + /// "author": { "data": { "type": "people", "id": "1234" } + /// } + /// } + /// } + /// /// - bool IncludeTotalRecordCount { get; set; } - int DefaultPageSize { get; } - int? MaximumPageSize { get; } - int? MaximumPageNumber { get; } + Links RelationshipLinks { get; } + + /// + /// Whether or not the total resource count should be included in all document-level meta objects. + /// False by default. + /// + bool IncludeTotalResourceCount { get; } + + /// + /// The page size (10 by default) that is used when not specified in query string. Set to null to not use paging by default. + /// + PageSize DefaultPageSize { get; } + + /// + /// The maximum page size that can be used, or null for unconstrained (default). + /// + PageSize MaximumPageSize { get; } + + /// + /// The maximum page number that can be used, or null for unconstrained (default). + /// + PageNumber MaximumPageNumber { get; } + + /// + /// Whether or not to enable ASP.NET Core model state validation. + /// False by default. + /// bool ValidateModelState { get; } + + /// + /// Whether or not clients can provide IDs when creating resources. When not allowed, a 403 Forbidden response is returned + /// if a client attempts to create a resource with a defined ID. + /// False by default. + /// bool AllowClientGeneratedIds { get; } - bool AllowCustomQueryStringParameters { get; set; } - string Namespace { get; set; } + + /// + /// Whether or not resource hooks are enabled. + /// This is currently an experimental feature and subject to change in future versions. + /// Defaults to False. + /// + public bool EnableResourceHooks { get; } + + /// + /// Whether or not database values should be included by default for resource hooks. + /// Ignored if EnableResourceHooks is set to false. + /// False by default. + /// + bool LoadDatabaseValues { get; } + + /// + /// Whether or not to produce an error on unknown query string parameters. + /// False by default. + /// + bool AllowUnknownQueryStringParameters { get; } + + /// + /// Determines whether legacy filter notation in query strings, such as =eq:, =like:, and =in: is enabled. + /// False by default. + /// + bool EnableLegacyFilterNotation { get; } /// /// Determines whether the serialization setting can be overridden by using a query string parameter. + /// False by default. /// - bool AllowQueryStringOverrideForSerializerNullValueHandling { get; set; } + bool AllowQueryStringOverrideForSerializerNullValueHandling { get; } /// /// Determines whether the serialization setting can be overridden by using a query string parameter. + /// False by default. /// - bool AllowQueryStringOverrideForSerializerDefaultValueHandling { get; set; } + bool AllowQueryStringOverrideForSerializerDefaultValueHandling { get; } + + // TODO: Add MaximumIncludeDepth /// /// Specifies the settings that are used by the . @@ -62,12 +180,6 @@ public interface IJsonApiOptions : ILinksConfiguration /// JsonSerializerSettings SerializerSettings { get; } - internal DefaultContractResolver SerializerContractResolver => (DefaultContractResolver)SerializerSettings.ContractResolver; - - /// - /// Specifies the default query string capabilities that can be used on exposed json:api attributes. - /// Defaults to . - /// - AttrCapabilities DefaultAttrCapabilities { get; } + internal DefaultContractResolver SerializerContractResolver => (DefaultContractResolver) SerializerSettings.ContractResolver; } } diff --git a/src/JsonApiDotNetCore/Configuration/ILinksConfiguration.cs b/src/JsonApiDotNetCore/Configuration/ILinksConfiguration.cs deleted file mode 100644 index 5e665c47a7..0000000000 --- a/src/JsonApiDotNetCore/Configuration/ILinksConfiguration.cs +++ /dev/null @@ -1,72 +0,0 @@ -using JsonApiDotNetCore.Models.Annotation; -using JsonApiDotNetCore.Models.Links; - -namespace JsonApiDotNetCore.Configuration -{ - /// - /// Options to configure links at a global level. - /// - public interface ILinksConfiguration - { - /// - /// Use relative links for all resources. - /// - /// - /// - /// options.RelativeLinks = true; - /// - /// - /// { - /// "type": "articles", - /// "id": "4309", - /// "relationships": { - /// "author": { - /// "links": { - /// "self": "/api/v1/articles/4309/relationships/author", - /// "related": "/api/v1/articles/4309/author" - /// } - /// } - /// } - /// } - /// - /// - bool RelativeLinks { get; } - /// - /// Configures globally which links to show in the - /// object for a requested resource. Setting can be overriden per resource by - /// adding a to the class definition of that resource. - /// - Link TopLevelLinks { get; } - - /// - /// Configures globally which links to show in the - /// object for a requested resource. Setting can be overriden per resource by - /// adding a to the class definition of that resource. - /// - Link ResourceLinks { get; } - /// - /// Configures globally which links to show in the - /// object for a requested resource. Setting can be overriden per resource by - /// adding a to the class definition of that resource. - /// Option can also be specified per relationship by using the associated links argument - /// in the constructor of . - /// - /// - /// - /// options.DefaultRelationshipLinks = Link.None; - /// - /// - /// { - /// "type": "articles", - /// "id": "4309", - /// "relationships": { - /// "author": { "data": { "type": "people", "id": "1234" } - /// } - /// } - /// } - /// - /// - Link RelationshipLinks { get; } - - } -} \ No newline at end of file diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs index 6f0c005fc7..da704d9fab 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs @@ -1,6 +1,6 @@ using JsonApiDotNetCore.Graph; using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Models.Links; +using JsonApiDotNetCore.Models.JsonApiDocuments; using Newtonsoft.Json; using Newtonsoft.Json.Serialization; @@ -12,120 +12,61 @@ namespace JsonApiDotNetCore.Configuration public class JsonApiOptions : IJsonApiOptions { /// - public bool RelativeLinks { get; set; } = false; + public string Namespace { get; set; } /// - public Link TopLevelLinks { get; set; } = Link.All; + public AttrCapabilities DefaultAttrCapabilities { get; set; } = AttrCapabilities.All; /// - public Link ResourceLinks { get; set; } = Link.All; + public bool IncludeExceptionStackTraceInErrors { get; set; } /// - public Link RelationshipLinks { get; set; } = Link.All; + public bool UseRelativeLinks { get; set; } - /// - /// Provides an interface for formatting relationship id properties given the navigation property name - /// - public static IRelatedIdMapper RelatedIdMapper { get; set; } = new DefaultRelatedIdMapper(); + /// + public Links TopLevelLinks { get; set; } = Links.All; /// - public bool IncludeExceptionStackTraceInErrors { get; set; } = false; + public Links ResourceLinks { get; set; } = Links.All; - /// - /// Whether or not resource hooks are enabled. - /// This is currently an experimental feature and defaults to . - /// - public bool EnableResourceHooks { get; set; } = false; + /// + public Links RelationshipLinks { get; set; } = Links.All; - /// - /// Whether or not database values should be included by default - /// for resource hooks. Ignored if EnableResourceHooks is set false. - /// - /// Defaults to . - /// - public bool LoadDatabaseValues { get; set; } + /// + public bool IncludeTotalResourceCount { get; set; } - /// - /// The base URL Namespace - /// - /// - /// options.Namespace = "api/v1"; - /// - public string Namespace { get; set; } + /// + public PageSize DefaultPageSize { get; set; } = new PageSize(10); /// - public bool AllowQueryStringOverrideForSerializerNullValueHandling { get; set; } + public PageSize MaximumPageSize { get; set; } /// - public bool AllowQueryStringOverrideForSerializerDefaultValueHandling { get; set; } + public PageNumber MaximumPageNumber { get; set; } /// - public AttrCapabilities DefaultAttrCapabilities { get; set; } = AttrCapabilities.All; + public bool ValidateModelState { get; set; } - /// - /// The default page size for all resources. The value zero means: no paging. - /// - /// - /// options.DefaultPageSize = 10; - /// - public int DefaultPageSize { get; set; } = 10; + /// + public bool AllowClientGeneratedIds { get; set; } - /// - /// Optional. When set, limits the maximum page size for all resources. - /// - /// - /// options.MaximumPageSize = 50; - /// - public int? MaximumPageSize { get; set; } + /// + public bool EnableResourceHooks { get; set; } - /// - /// Optional. When set, limits the maximum page number for all resources. - /// - /// - /// options.MaximumPageNumber = 100; - /// - public int? MaximumPageNumber { get; set; } + /// + public bool LoadDatabaseValues { get; set; } - /// - /// Whether or not the total-record count should be included in all document - /// level meta objects. - /// Defaults to false. - /// - /// - /// options.IncludeTotalRecordCount = true; - /// - public bool IncludeTotalRecordCount { get; set; } + /// + public bool AllowUnknownQueryStringParameters { get; set; } - /// - /// Whether or not clients can provide ids when creating resources. - /// Defaults to false. When disabled the application will respond - /// with a 403 Forbidden response if a client attempts to create a - /// resource with a defined id. - /// - /// - /// options.AllowClientGeneratedIds = true; - /// - public bool AllowClientGeneratedIds { get; set; } + /// + public bool EnableLegacyFilterNotation { get; set; } - /// - /// Whether or not to allow all custom query string parameters. - /// - /// - /// - /// options.AllowCustomQueryStringParameters = true; - /// - /// - public bool AllowCustomQueryStringParameters { get; set; } + /// + public bool AllowQueryStringOverrideForSerializerNullValueHandling { get; set; } - /// - /// Whether or not to validate model state. - /// - /// - /// - /// options.ValidateModelState = true; - /// - /// - public bool ValidateModelState { get; set; } + /// + public bool AllowQueryStringOverrideForSerializerDefaultValueHandling { get; set; } /// public JsonSerializerSettings SerializerSettings { get; } = new JsonSerializerSettings @@ -135,5 +76,14 @@ public class JsonApiOptions : IJsonApiOptions NamingStrategy = new CamelCaseNamingStrategy() } }; + + /// + /// 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/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index 8e71f596b3..b47e819d91 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs @@ -9,13 +9,13 @@ namespace JsonApiDotNetCore.Controllers { - public abstract class BaseJsonApiController : JsonApiControllerMixin where T : class, IIdentifiable + public abstract class BaseJsonApiController : CoreJsonApiController where T : class, IIdentifiable { private readonly IJsonApiOptions _jsonApiOptions; private readonly IGetAllService _getAll; private readonly IGetByIdService _getById; + private readonly IGetSecondaryService _getSecondary; private readonly IGetRelationshipService _getRelationship; - private readonly IGetRelationshipsService _getRelationships; private readonly ICreateService _create; private readonly IUpdateService _update; private readonly IUpdateRelationshipService _updateRelationships; @@ -44,8 +44,8 @@ protected BaseJsonApiController( ILoggerFactory loggerFactory, IGetAllService getAll = null, IGetByIdService getById = null, + IGetSecondaryService getSecondary = null, IGetRelationshipService getRelationship = null, - IGetRelationshipsService getRelationships = null, ICreateService create = null, IUpdateService update = null, IUpdateRelationshipService updateRelationships = null, @@ -55,8 +55,8 @@ protected BaseJsonApiController( _logger = loggerFactory.CreateLogger>(); _getAll = getAll; _getById = getById; + _getSecondary = getSecondary; _getRelationship = getRelationship; - _getRelationships = getRelationships; _create = create; _update = update; _updateRelationships = updateRelationships; @@ -68,8 +68,8 @@ public virtual async Task GetAsync() _logger.LogTrace($"Entering {nameof(GetAsync)}()."); if (_getAll == null) throw new RequestMethodNotAllowedException(HttpMethod.Get); - var entities = await _getAll.GetAsync(); - return Ok(entities); + var resources = await _getAll.GetAsync(); + return Ok(resources); } public virtual async Task GetAsync(TId id) @@ -77,40 +77,40 @@ public virtual async Task GetAsync(TId id) _logger.LogTrace($"Entering {nameof(GetAsync)}('{id}')."); if (_getById == null) throw new RequestMethodNotAllowedException(HttpMethod.Get); - var entity = await _getById.GetAsync(id); - return Ok(entity); + var resource = await _getById.GetAsync(id); + return Ok(resource); } - public virtual async Task GetRelationshipsAsync(TId id, string relationshipName) + public virtual async Task GetRelationshipAsync(TId id, string relationshipName) { - _logger.LogTrace($"Entering {nameof(GetRelationshipsAsync)}('{id}', '{relationshipName}')."); + _logger.LogTrace($"Entering {nameof(GetRelationshipAsync)}('{id}', '{relationshipName}')."); - if (_getRelationships == null) throw new RequestMethodNotAllowedException(HttpMethod.Get); - var relationship = await _getRelationships.GetRelationshipsAsync(id, relationshipName); + if (_getRelationship == null) throw new RequestMethodNotAllowedException(HttpMethod.Get); + var relationship = await _getRelationship.GetRelationshipAsync(id, relationshipName); return Ok(relationship); } - public virtual async Task GetRelationshipAsync(TId id, string relationshipName) + public virtual async Task GetSecondaryAsync(TId id, string relationshipName) { - _logger.LogTrace($"Entering {nameof(GetRelationshipAsync)}('{id}', '{relationshipName}')."); + _logger.LogTrace($"Entering {nameof(GetSecondaryAsync)}('{id}', '{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); } - public virtual async Task PostAsync([FromBody] T entity) + public virtual async Task PostAsync([FromBody] T resource) { - _logger.LogTrace($"Entering {nameof(PostAsync)}({(entity == null ? "null" : "object")})."); + _logger.LogTrace($"Entering {nameof(PostAsync)}({(resource == null ? "null" : "object")})."); if (_create == null) throw new RequestMethodNotAllowedException(HttpMethod.Post); - if (entity == null) + if (resource == null) throw new InvalidRequestBodyException(null, null, null); - if (!_jsonApiOptions.AllowClientGeneratedIds && !string.IsNullOrEmpty(entity.StringId)) + if (!_jsonApiOptions.AllowClientGeneratedIds && !string.IsNullOrEmpty(resource.StringId)) throw new ResourceIdInPostRequestNotAllowedException(); if (_jsonApiOptions.ValidateModelState && !ModelState.IsValid) @@ -119,17 +119,17 @@ public virtual async Task PostAsync([FromBody] T entity) throw new InvalidModelStateException(ModelState, typeof(T), _jsonApiOptions.IncludeExceptionStackTraceInErrors, namingStrategy); } - entity = await _create.CreateAsync(entity); + resource = await _create.CreateAsync(resource); - return Created($"{HttpContext.Request.Path}/{entity.StringId}", entity); + return Created($"{HttpContext.Request.Path}/{resource.StringId}", resource); } - public virtual async Task PatchAsync(TId id, [FromBody] T entity) + public virtual async Task PatchAsync(TId id, [FromBody] T resource) { - _logger.LogTrace($"Entering {nameof(PatchAsync)}('{id}', {(entity == null ? "null" : "object")})."); + _logger.LogTrace($"Entering {nameof(PatchAsync)}('{id}', {(resource == null ? "null" : "object")})."); if (_update == null) throw new RequestMethodNotAllowedException(HttpMethod.Patch); - if (entity == null) + if (resource == null) throw new InvalidRequestBodyException(null, null, null); if (_jsonApiOptions.ValidateModelState && !ModelState.IsValid) @@ -138,16 +138,16 @@ public virtual async Task PatchAsync(TId id, [FromBody] T entity) throw new InvalidModelStateException(ModelState, typeof(T), _jsonApiOptions.IncludeExceptionStackTraceInErrors, namingStrategy); } - var updatedEntity = await _update.UpdateAsync(id, entity); - return updatedEntity == null ? Ok(null) : Ok(updatedEntity); + var updated = await _update.UpdateAsync(id, resource); + return updated == null ? Ok(null) : Ok(updated); } - public virtual async Task PatchRelationshipsAsync(TId id, string relationshipName, [FromBody] object relationships) + public virtual async Task PatchRelationshipAsync(TId id, string relationshipName, [FromBody] object relationships) { - _logger.LogTrace($"Entering {nameof(PatchRelationshipsAsync)}('{id}', '{relationshipName}', {(relationships == null ? "null" : "object")})."); + _logger.LogTrace($"Entering {nameof(PatchRelationshipAsync)}('{id}', '{relationshipName}', {(relationships == null ? "null" : "object")})."); if (_updateRelationships == null) throw new RequestMethodNotAllowedException(HttpMethod.Patch); - await _updateRelationships.UpdateRelationshipsAsync(id, relationshipName, relationships); + await _updateRelationships.UpdateRelationshipAsync(id, relationshipName, relationships); return Ok(); } @@ -184,13 +184,13 @@ protected BaseJsonApiController( ILoggerFactory loggerFactory, IGetAllService getAll = null, IGetByIdService getById = null, + IGetSecondaryService getSecondary = null, IGetRelationshipService getRelationship = null, - IGetRelationshipsService getRelationships = null, ICreateService create = null, IUpdateService update = null, IUpdateRelationshipService updateRelationships = null, IDeleteService delete = null) - : base(jsonApiOptions, loggerFactory, getAll, getById, getRelationship, getRelationships, create, update, + : base(jsonApiOptions, loggerFactory, getAll, getById, getSecondary, getRelationship, create, update, updateRelationships, delete) { } } diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiControllerMixin.cs b/src/JsonApiDotNetCore/Controllers/CoreJsonApiController.cs similarity index 83% rename from src/JsonApiDotNetCore/Controllers/JsonApiControllerMixin.cs rename to src/JsonApiDotNetCore/Controllers/CoreJsonApiController.cs index 6b10f89944..aaf2cb6795 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiControllerMixin.cs +++ b/src/JsonApiDotNetCore/Controllers/CoreJsonApiController.cs @@ -5,8 +5,8 @@ namespace JsonApiDotNetCore.Controllers { - [ServiceFilter(typeof(IQueryParameterActionFilter))] - public abstract class JsonApiControllerMixin : ControllerBase + [ServiceFilter(typeof(IQueryStringActionFilter))] + public abstract class CoreJsonApiController : ControllerBase { protected IActionResult Error(Error error) { diff --git a/src/JsonApiDotNetCore/Controllers/DisableQueryAttribute.cs b/src/JsonApiDotNetCore/Controllers/DisableQueryAttribute.cs index e94cce4264..fa77d78a73 100644 --- a/src/JsonApiDotNetCore/Controllers/DisableQueryAttribute.cs +++ b/src/JsonApiDotNetCore/Controllers/DisableQueryAttribute.cs @@ -18,9 +18,16 @@ public sealed class DisableQueryAttribute : Attribute /// public DisableQueryAttribute(StandardQueryStringParameters parameters) { - _parameterNames = parameters != StandardQueryStringParameters.None - ? ParseList(parameters.ToString()) - : new List(); + _parameterNames = new List(); + + foreach (StandardQueryStringParameters value in Enum.GetValues(typeof(StandardQueryStringParameters))) + { + if (value != StandardQueryStringParameters.None && value != StandardQueryStringParameters.All && + parameters.HasFlag(value)) + { + _parameterNames.Add(value.ToString().ToLowerInvariant()); + } + } } /// @@ -30,12 +37,7 @@ public DisableQueryAttribute(StandardQueryStringParameters parameters) /// public DisableQueryAttribute(string parameterNames) { - _parameterNames = ParseList(parameterNames); - } - - private static List ParseList(string parameterNames) - { - return parameterNames.Split(",").Select(x => x.Trim().ToLowerInvariant()).ToList(); + _parameterNames = parameterNames.Split(",").Select(x => x.Trim().ToLowerInvariant()).ToList(); } public bool ContainsParameter(StandardQueryStringParameters parameter) diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs index 868355062c..c02d019585 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs @@ -27,17 +27,17 @@ protected JsonApiCommandController( { } [HttpPost] - public override async Task PostAsync([FromBody] T entity) - => await base.PostAsync(entity); + public override async Task PostAsync([FromBody] T resource) + => await base.PostAsync(resource); [HttpPatch("{id}")] - public override async Task PatchAsync(TId id, [FromBody] T entity) - => await base.PatchAsync(id, entity); + public override async Task PatchAsync(TId id, [FromBody] T resource) + => await base.PatchAsync(id, resource); [HttpPatch("{id}/relationships/{relationshipName}")] - public override async Task PatchRelationshipsAsync( + public override async Task PatchRelationshipAsync( TId id, string relationshipName, [FromBody] object relationships) - => await base.PatchRelationshipsAsync(id, relationshipName, relationships); + => await base.PatchRelationshipAsync(id, relationshipName, relationships); [HttpDelete("{id}")] public override async Task DeleteAsync(TId id) => await base.DeleteAsync(id); diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs index 73877ca996..25725ec5ca 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs @@ -21,13 +21,13 @@ public JsonApiController( ILoggerFactory loggerFactory, IGetAllService getAll = null, IGetByIdService getById = null, + IGetSecondaryService getSecondary = null, IGetRelationshipService getRelationship = null, - IGetRelationshipsService getRelationships = null, ICreateService create = null, IUpdateService update = null, IUpdateRelationshipService updateRelationships = null, IDeleteService delete = null) - : base(jsonApiOptions, loggerFactory, getAll, getById, getRelationship, getRelationships, create, update, + : base(jsonApiOptions, loggerFactory, getAll, getById, getSecondary, getRelationship, create, update, updateRelationships, delete) { } @@ -38,27 +38,27 @@ public JsonApiController( public override async Task GetAsync(TId id) => await base.GetAsync(id); [HttpGet("{id}/relationships/{relationshipName}")] - public override async Task GetRelationshipsAsync(TId id, string relationshipName) - => await base.GetRelationshipsAsync(id, relationshipName); - - [HttpGet("{id}/{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); + [HttpPost] - public override async Task PostAsync([FromBody] T entity) - => await base.PostAsync(entity); + public override async Task PostAsync([FromBody] T resource) + => await base.PostAsync(resource); [HttpPatch("{id}")] - public override async Task PatchAsync(TId id, [FromBody] T entity) + public override async Task PatchAsync(TId id, [FromBody] T resource) { - return await base.PatchAsync(id, entity); + return await base.PatchAsync(id, resource); } [HttpPatch("{id}/relationships/{relationshipName}")] - public override async Task PatchRelationshipsAsync( + public override async Task PatchRelationshipAsync( TId id, string relationshipName, [FromBody] object relationships) - => await base.PatchRelationshipsAsync(id, relationshipName, relationships); + => await base.PatchRelationshipAsync(id, relationshipName, relationships); [HttpDelete("{id}")] public override async Task DeleteAsync(TId id) => await base.DeleteAsync(id); @@ -78,13 +78,13 @@ public JsonApiController( ILoggerFactory loggerFactory, IGetAllService getAll = null, IGetByIdService getById = null, + IGetSecondaryService getSecondary = null, IGetRelationshipService getRelationship = null, - IGetRelationshipsService getRelationships = null, ICreateService create = null, IUpdateService update = null, IUpdateRelationshipService updateRelationships = null, IDeleteService delete = null) - : base(jsonApiOptions, loggerFactory, getAll, getById, getRelationship, getRelationships, create, update, + : base(jsonApiOptions, loggerFactory, getAll, getById, getSecondary, getRelationship, create, update, updateRelationships, delete) { } } diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiQueryController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiQueryController.cs index e7a357caf3..9e4958cf3b 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiQueryController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiQueryController.cs @@ -33,11 +33,11 @@ protected JsonApiQueryController( public override async Task GetAsync(TId id) => await base.GetAsync(id); [HttpGet("{id}/relationships/{relationshipName}")] - public override async Task GetRelationshipsAsync(TId id, string relationshipName) - => await base.GetRelationshipsAsync(id, relationshipName); - - [HttpGet("{id}/{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); } } diff --git a/src/JsonApiDotNetCore/Data/DefaultResourceRepository.cs b/src/JsonApiDotNetCore/Data/EntityFrameworkCoreRepository.cs similarity index 56% rename from src/JsonApiDotNetCore/Data/DefaultResourceRepository.cs rename to src/JsonApiDotNetCore/Data/EntityFrameworkCoreRepository.cs index 967139ca53..e1633ffe4f 100644 --- a/src/JsonApiDotNetCore/Data/DefaultResourceRepository.cs +++ b/src/JsonApiDotNetCore/Data/EntityFrameworkCoreRepository.cs @@ -7,132 +7,132 @@ using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Internal.Generics; -using JsonApiDotNetCore.Internal.Query; +using JsonApiDotNetCore.Internal.Queries; +using JsonApiDotNetCore.Internal.Queries.Expressions; +using JsonApiDotNetCore.Internal.Queries.QueryableBuilding; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Models.Annotation; using JsonApiDotNetCore.Serialization; using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Query.Internal; using Microsoft.Extensions.Logging; namespace JsonApiDotNetCore.Data { /// - /// Provides a default repository implementation and is responsible for - /// abstracting any EF Core APIs away from the service layer. + /// Provides a repository implementation that uses Entity Framework Core. /// - public class DefaultResourceRepository : IResourceRepository + public class EntityFrameworkCoreRepository : IResourceRepository where TResource : class, IIdentifiable { private readonly ITargetedFields _targetedFields; - private readonly DbContext _context; - private readonly DbSet _dbSet; + private readonly DbContext _dbContext; private readonly IResourceGraph _resourceGraph; private readonly IGenericServiceFactory _genericServiceFactory; private readonly IResourceFactory _resourceFactory; - private readonly ILogger> _logger; + private readonly IEnumerable _constraintProviders; + private readonly ILogger> _logger; - public DefaultResourceRepository( + public EntityFrameworkCoreRepository( ITargetedFields targetedFields, IDbContextResolver contextResolver, IResourceGraph resourceGraph, IGenericServiceFactory genericServiceFactory, IResourceFactory resourceFactory, + IEnumerable constraintProviders, ILoggerFactory loggerFactory) { _targetedFields = targetedFields; _resourceGraph = resourceGraph; _genericServiceFactory = genericServiceFactory; _resourceFactory = resourceFactory; - _context = contextResolver.GetContext(); - _dbSet = _context.Set(); - _logger = loggerFactory.CreateLogger>(); + _constraintProviders = constraintProviders; + _dbContext = contextResolver.GetContext(); + _logger = loggerFactory.CreateLogger>(); } /// - public virtual IQueryable Get() + public virtual async Task> GetAsync(QueryLayer layer) { - _logger.LogTrace($"Entering {nameof(Get)}()."); + _logger.LogTrace($"Entering {nameof(GetAsync)}('{layer}')."); - var resourceContext = _resourceGraph.GetResourceContext(); - return EagerLoad(_dbSet, resourceContext.EagerLoads); - } - - /// - public virtual IQueryable Get(TId id) - { - _logger.LogTrace($"Entering {nameof(Get)}('{id}')."); - - return Get().Where(e => e.Id.Equals(id)); - } - - /// - public virtual IQueryable Select(IQueryable entities, IEnumerable propertyNames = null) - { - _logger.LogTrace($"Entering {nameof(Select)}({nameof(entities)}, {nameof(propertyNames)})."); + if (layer == null) + { + throw new ArgumentNullException(nameof(layer)); + } - return entities.Select(propertyNames, _resourceFactory); + IQueryable query = ApplyQueryLayer(layer); + return await query.ToListAsync(); } /// - public virtual IQueryable Filter(IQueryable entities, FilterQueryContext filterQueryContext) + public virtual async Task CountAsync(FilterExpression topFilter) { - _logger.LogTrace($"Entering {nameof(Filter)}({nameof(entities)}, {nameof(filterQueryContext)})."); + _logger.LogTrace($"Entering {nameof(CountAsync)}('{topFilter}')."); - if (filterQueryContext.IsCustom) + var resourceContext = _resourceGraph.GetResourceContext(); + var layer = new QueryLayer(resourceContext) { - var query = (Func, FilterQuery, IQueryable>)filterQueryContext.CustomQuery; - return query(entities, filterQueryContext.Query); - } - return entities.Filter(filterQueryContext); + Filter = topFilter + }; + + IQueryable query = ApplyQueryLayer(layer); + return await query.CountAsync(); } - /// - public virtual IQueryable Sort(IQueryable entities, IReadOnlyCollection sortQueryContexts) + protected virtual IQueryable ApplyQueryLayer(QueryLayer layer) { - _logger.LogTrace($"Entering {nameof(Sort)}({nameof(entities)}, {nameof(sortQueryContexts)})."); + IQueryable source = GetAll(); - if (!sortQueryContexts.Any()) - { - return entities; - } - - var primarySort = sortQueryContexts.First(); - var entitiesSorted = entities.Sort(primarySort); + var queryableHandlers = _constraintProviders + .SelectMany(p => p.GetConstraints()) + .Where(expressionInScope => expressionInScope.Scope == null) + .Select(expressionInScope => expressionInScope.Expression) + .OfType() + .ToList(); - foreach (var secondarySort in sortQueryContexts.Skip(1)) + foreach (var queryableHandler in queryableHandlers) { - entitiesSorted = entitiesSorted.Sort(secondarySort); + source = queryableHandler.Apply(source); } - return entitiesSorted; + var nameFactory = new LambdaParameterNameFactory(); + var builder = new QueryableBuilder(source.Expression, source.ElementType, typeof(Queryable), nameFactory, _resourceFactory, _resourceGraph, _dbContext.Model); + + var expression = builder.ApplyQuery(layer); + return source.Provider.CreateQuery(expression); + } + + protected virtual IQueryable GetAll() + { + return _dbContext.Set(); } /// - public virtual async Task CreateAsync(TResource entity) + public virtual async Task CreateAsync(TResource resource) { - _logger.LogTrace($"Entering {nameof(CreateAsync)}({(entity == null ? "null" : "object")})."); + _logger.LogTrace($"Entering {nameof(CreateAsync)}({(resource == null ? "null" : "object")})."); foreach (var relationshipAttr in _targetedFields.Relationships) { - object trackedRelationshipValue = GetTrackedRelationshipValue(relationshipAttr, entity, out bool relationshipWasAlreadyTracked); + 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 - // entity 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 entity. + // 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(entity, trackedRelationshipValue, _resourceFactory); + relationshipAttr.SetValue(resource, trackedRelationshipValue, _resourceFactory); } - _dbSet.Add(entity); - await _context.SaveChangesAsync(); + + _dbContext.Set().Add(resource); + await _dbContext.SaveChangesAsync(); - FlushFromCache(entity); + 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(entity); + DetachRelationships(resource); } /// @@ -152,7 +152,7 @@ private void LoadInverseRelationships(object trackedRelationshipValue, Relations if (relationshipAttr.InverseNavigation == null || trackedRelationshipValue == null) return; if (relationshipAttr is HasOneAttribute hasOneAttr) { - var relationEntry = _context.Entry((IIdentifiable)trackedRelationshipValue); + var relationEntry = _dbContext.Entry((IIdentifiable)trackedRelationshipValue); if (IsHasOneRelationship(hasOneAttr.InverseNavigation, trackedRelationshipValue.GetType())) relationEntry.Reference(hasOneAttr.InverseNavigation).Load(); else @@ -161,7 +161,7 @@ private void LoadInverseRelationships(object trackedRelationshipValue, Relations else if (relationshipAttr is HasManyAttribute hasManyAttr && !(relationshipAttr is HasManyThroughAttribute)) { foreach (IIdentifiable relationshipValue in (IEnumerable)trackedRelationshipValue) - _context.Entry(relationshipValue).Reference(hasManyAttr.InverseNavigation).Load(); + _dbContext.Entry(relationshipValue).Reference(hasManyAttr.InverseNavigation).Load(); } } @@ -180,77 +180,77 @@ private bool IsHasOneRelationship(string internalRelationshipName, Type type) return !type.GetProperty(internalRelationshipName).PropertyType.IsOrImplementsInterface(typeof(IEnumerable)); } - private void DetachRelationships(TResource entity) + private void DetachRelationships(TResource resource) { foreach (var relationship in _targetedFields.Relationships) { - var value = relationship.GetValue(entity); + var value = relationship.GetValue(resource); if (value == null) continue; if (value is IEnumerable collection) { foreach (IIdentifiable single in collection) - _context.Entry(single).State = EntityState.Detached; + _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(entity, null, _resourceFactory); + relationship.SetValue(resource, null, _resourceFactory); } else { - _context.Entry(value).State = EntityState.Detached; + _dbContext.Entry(value).State = EntityState.Detached; } } } /// - public virtual async Task UpdateAsync(TResource requestEntity, TResource databaseEntity) + public virtual async Task UpdateAsync(TResource requestResource, TResource databaseResource) { - _logger.LogTrace($"Entering {nameof(UpdateAsync)}({(requestEntity == null ? "null" : "object")}, {(databaseEntity == null ? "null" : "object")})."); + _logger.LogTrace($"Entering {nameof(UpdateAsync)}({(requestResource == null ? "null" : "object")}, {(databaseResource == null ? "null" : "object")})."); foreach (var attribute in _targetedFields.Attributes) - attribute.SetValue(databaseEntity, attribute.GetValue(requestEntity)); + attribute.SetValue(databaseResource, attribute.GetValue(requestResource)); foreach (var relationshipAttr in _targetedFields.Relationships) { // loads databasePerson.todoItems - LoadCurrentRelationships(databaseEntity, relationshipAttr); + 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, requestEntity, out _); + 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 entity - //AssignRelationshipValue(databaseEntity, trackedRelationshipValue, relationshipAttr); - relationshipAttr.SetValue(databaseEntity, trackedRelationshipValue, _resourceFactory); + // assigns the updated relationship to the database resource + //AssignRelationshipValue(databaseResource, trackedRelationshipValue, relationshipAttr); + relationshipAttr.SetValue(databaseResource, trackedRelationshipValue, _resourceFactory); } - await _context.SaveChangesAsync(); + await _dbContext.SaveChangesAsync(); } /// /// Responsible for getting the relationship value for a given relationship - /// attribute of a given entity. It ensures that the relationship value + /// 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 entity, out bool wasAlreadyAttached) + private object GetTrackedRelationshipValue(RelationshipAttribute relationshipAttr, TResource resource, out bool wasAlreadyAttached) { wasAlreadyAttached = false; if (relationshipAttr is HasOneAttribute hasOneAttr) { - var relationshipValue = (IIdentifiable)hasOneAttr.GetValue(entity); + var relationshipValue = (IIdentifiable)hasOneAttr.GetValue(resource); if (relationshipValue == null) return null; return GetTrackedHasOneRelationshipValue(relationshipValue, ref wasAlreadyAttached); } - IEnumerable relationshipValueList = (IEnumerable)relationshipAttr.GetValue(entity); + IEnumerable relationshipValueList = (IEnumerable)relationshipAttr.GetValue(resource); if (relationshipValueList == null) return null; @@ -295,7 +295,7 @@ public async Task UpdateRelationshipsAsync(object parent, RelationshipAttribute var helper = _genericServiceFactory.Get(typeof(RepositoryRelationshipUpdateHelper<>), typeToUpdate); await helper.UpdateRelationshipAsync((IIdentifiable)parent, relationship, relationshipIds); - await _context.SaveChangesAsync(); + await _dbContext.SaveChangesAsync(); } /// @@ -303,120 +303,37 @@ public virtual async Task DeleteAsync(TId id) { _logger.LogTrace($"Entering {nameof(DeleteAsync)}('{id}')."); - var entity = await Get(id).FirstOrDefaultAsync(); - if (entity == null) return false; - _dbSet.Remove(entity); - await _context.SaveChangesAsync(); - return true; - } - - public virtual void FlushFromCache(TResource entity) - { - _logger.LogTrace($"Entering {nameof(FlushFromCache)}({nameof(entity)})."); - - _context.Entry(entity).State = EntityState.Detached; - } - - private IQueryable EagerLoad(IQueryable entities, IEnumerable attributes, string chainPrefix = null) - { - foreach (var attribute in attributes) - { - string path = chainPrefix != null ? chainPrefix + "." + attribute.Property.Name : attribute.Property.Name; - entities = entities.Include(path); - - entities = EagerLoad(entities, attribute.Children, path); - } - - return entities; - } - - public virtual IQueryable Include(IQueryable entities, IEnumerable inclusionChain = null) - { - _logger.LogTrace($"Entering {nameof(Include)}({nameof(entities)}, {nameof(inclusionChain)})."); + var resourceToDelete = _resourceFactory.CreateInstance(); + resourceToDelete.Id = id; - if (inclusionChain == null || !inclusionChain.Any()) + var resourceFromCache = _dbContext.GetTrackedEntity(resourceToDelete); + if (resourceFromCache != null) { - return entities; + resourceToDelete = resourceFromCache; } - - string internalRelationshipPath = null; - foreach (var relationship in inclusionChain) + else { - internalRelationshipPath = internalRelationshipPath == null - ? relationship.RelationshipPath - : $"{internalRelationshipPath}.{relationship.RelationshipPath}"; - - var resourceContext = _resourceGraph.GetResourceContext(relationship.RightType); - entities = EagerLoad(entities, resourceContext.EagerLoads, internalRelationshipPath); + _dbContext.Attach(resourceToDelete); } - return entities.Include(internalRelationshipPath); - } - - /// - public virtual async Task> PageAsync(IQueryable entities, int pageSize, int pageNumber) - { - _logger.LogTrace($"Entering {nameof(PageAsync)}({nameof(entities)}, {pageSize}, {pageNumber})."); - - // the IQueryable returned from the hook executor is sometimes consumed here. - // In this case, it does not support .ToListAsync(), so we use the method below. - if (pageNumber >= 0) - { - entities = entities.PageForward(pageSize, pageNumber); - return entities is IAsyncQueryProvider ? await entities.ToListAsync() : entities.ToList(); - } + _dbContext.Remove(resourceToDelete); - if (entities is IAsyncEnumerable) + try { - // since EntityFramework does not support IQueryable.Reverse(), we need to know the number of queried entities - var totalCount = await entities.CountAsync(); - - int virtualFirstIndex = totalCount - pageSize * Math.Abs(pageNumber); - int numberOfElementsInPage = Math.Min(pageSize, virtualFirstIndex + pageSize); - - var result = await ToListAsync(entities.Skip(virtualFirstIndex).Take(numberOfElementsInPage)); - return result.Reverse(); + await _dbContext.SaveChangesAsync(); + return true; } - else + catch (DbUpdateConcurrencyException) { - int firstIndex = pageSize * (Math.Abs(pageNumber) - 1); - int numberOfElementsInPage = Math.Min(pageSize, firstIndex + pageSize); - return entities.Reverse().Skip(firstIndex).Take(numberOfElementsInPage); - } - } - - /// - public async Task CountAsync(IQueryable entities) - { - _logger.LogTrace($"Entering {nameof(CountAsync)}({nameof(entities)})."); - - if (entities is IAsyncEnumerable) - { - return await entities.CountAsync(); + return false; } - return entities.Count(); } - /// - public virtual async Task FirstOrDefaultAsync(IQueryable entities) + public virtual void FlushFromCache(TResource resource) { - _logger.LogTrace($"Entering {nameof(FirstOrDefaultAsync)}({nameof(entities)})."); + _logger.LogTrace($"Entering {nameof(FlushFromCache)}({nameof(resource)})."); - return (entities is IAsyncEnumerable) - ? await entities.FirstOrDefaultAsync() - : entities.FirstOrDefault(); - } - - /// - public async Task> ToListAsync(IQueryable entities) - { - _logger.LogTrace($"Entering {nameof(ToListAsync)}({nameof(entities)})."); - - if (entities is IAsyncEnumerable) - { - return await entities.ToListAsync(); - } - return entities.ToList(); + _dbContext.Entry(resource).State = EntityState.Detached; } /// @@ -432,20 +349,20 @@ public async Task> ToListAsync(IQueryable en /// 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 oldEntity, RelationshipAttribute relationshipAttribute) + protected void LoadCurrentRelationships(TResource oldResource, RelationshipAttribute relationshipAttribute) { if (relationshipAttribute is HasManyThroughAttribute throughAttribute) { - _context.Entry(oldEntity).Collection(throughAttribute.ThroughProperty.Name).Load(); + _dbContext.Entry(oldResource).Collection(throughAttribute.ThroughProperty.Name).Load(); } else if (relationshipAttribute is HasManyAttribute hasManyAttribute) { - _context.Entry(oldEntity).Collection(hasManyAttribute.Property.Name).Load(); + _dbContext.Entry(oldResource).Collection(hasManyAttribute.Property.Name).Load(); } } /// - /// Given a IIdentifiable relationship value, verify if an entity of the underlying + /// 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. /// @@ -453,7 +370,7 @@ protected void LoadCurrentRelationships(TResource oldEntity, RelationshipAttribu /// private IIdentifiable AttachOrGetTracked(IIdentifiable relationshipValue) { - var trackedEntity = _context.GetTrackedEntity(relationshipValue); + var trackedEntity = _dbContext.GetTrackedEntity(relationshipValue); if (trackedEntity != null) { @@ -467,24 +384,27 @@ private IIdentifiable AttachOrGetTracked(IIdentifiable relationshipValue) // 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 entity were updated. - _context.Entry(relationshipValue).State = EntityState.Unchanged; + // this resource were updated. + _dbContext.Entry(relationshipValue).State = EntityState.Unchanged; return null; } } - /// - public class DefaultResourceRepository : DefaultResourceRepository, IResourceRepository + /// + /// Provides a repository implementation that uses Entity Framework Core. + /// + public class EntityFrameworkCoreRepository : EntityFrameworkCoreRepository, IResourceRepository where TResource : class, IIdentifiable { - public DefaultResourceRepository( + public EntityFrameworkCoreRepository( ITargetedFields targetedFields, IDbContextResolver contextResolver, - IResourceGraph resourceGraph, + IResourceGraph resourceGraph, IGenericServiceFactory genericServiceFactory, IResourceFactory resourceFactory, + IEnumerable constraintProviders, ILoggerFactory loggerFactory) - : base(targetedFields, contextResolver, resourceGraph, genericServiceFactory, resourceFactory, loggerFactory) + : base(targetedFields, contextResolver, resourceGraph, genericServiceFactory, resourceFactory, constraintProviders, loggerFactory) { } } } diff --git a/src/JsonApiDotNetCore/Data/IResourceReadRepository.cs b/src/JsonApiDotNetCore/Data/IResourceReadRepository.cs index 8cfeb0e3eb..57d41ee741 100644 --- a/src/JsonApiDotNetCore/Data/IResourceReadRepository.cs +++ b/src/JsonApiDotNetCore/Data/IResourceReadRepository.cs @@ -1,9 +1,8 @@ using System.Collections.Generic; -using System.Linq; using System.Threading.Tasks; -using JsonApiDotNetCore.Internal.Query; +using JsonApiDotNetCore.Internal.Queries; +using JsonApiDotNetCore.Internal.Queries.Expressions; using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Models.Annotation; namespace JsonApiDotNetCore.Data { @@ -16,50 +15,13 @@ public interface IResourceReadRepository where TResource : class, IIdentifiable { /// - /// The base GET query. This is a good place to apply rules that should affect all reads, - /// such as authorization of resources. + /// Executes a read query using the specified constraints and returns the list of matching resources. /// - IQueryable Get(); - /// - /// Get the entity by id - /// - IQueryable Get(TId id); - /// - /// Apply fields to the provided queryable - /// - IQueryable Select(IQueryable entities, IEnumerable propertyNames); - /// - /// Include a relationship in the query - /// - /// - /// - /// _todoItemsRepository.GetAndIncludeAsync(1, "achievedDate"); - /// - /// - IQueryable Include(IQueryable entities, IEnumerable inclusionChain); - /// - /// Apply a filter to the provided queryable - /// - IQueryable Filter(IQueryable entities, FilterQueryContext filterQuery); - /// - /// Apply a sort to the provided queryable - /// - IQueryable Sort(IQueryable entities, IReadOnlyCollection sortQueryContexts); - /// - /// Paginate the provided queryable - /// - Task> PageAsync(IQueryable entities, int pageSize, int pageNumber); - /// - /// Count the total number of records - /// - Task CountAsync(IQueryable entities); - /// - /// Get the first element in the collection, return the default value if collection is empty - /// - Task FirstOrDefaultAsync(IQueryable entities); + Task> GetAsync(QueryLayer layer); + /// - /// Convert the collection to a materialized list + /// Executes a read query using the specified top-level filter and returns the top-level count of matching resources. /// - Task> ToListAsync(IQueryable entities); + Task CountAsync(FilterExpression topFilter); } } diff --git a/src/JsonApiDotNetCore/Data/IResourceWriteRepository.cs b/src/JsonApiDotNetCore/Data/IResourceWriteRepository.cs index f1fe5e9672..96549537b6 100644 --- a/src/JsonApiDotNetCore/Data/IResourceWriteRepository.cs +++ b/src/JsonApiDotNetCore/Data/IResourceWriteRepository.cs @@ -5,22 +5,22 @@ namespace JsonApiDotNetCore.Data { - public interface IResourceWriteRepository + public interface IResourceWriteRepository : IResourceWriteRepository where TResource : class, IIdentifiable { } - public interface IResourceWriteRepository + public interface IResourceWriteRepository where TResource : class, IIdentifiable { - Task CreateAsync(TResource entity); + Task CreateAsync(TResource resource); - Task UpdateAsync(TResource requestEntity, TResource databaseEntity); + Task UpdateAsync(TResource requestResource, TResource databaseResource); Task UpdateRelationshipsAsync(object parent, RelationshipAttribute relationship, IEnumerable relationshipIds); Task DeleteAsync(TId id); - void FlushFromCache(TResource entity); + void FlushFromCache(TResource resource); } } diff --git a/src/JsonApiDotNetCore/Exceptions/InvalidQueryException.cs b/src/JsonApiDotNetCore/Exceptions/InvalidQueryException.cs new file mode 100644 index 0000000000..687f6c4306 --- /dev/null +++ b/src/JsonApiDotNetCore/Exceptions/InvalidQueryException.cs @@ -0,0 +1,22 @@ +using System; +using System.Net; +using JsonApiDotNetCore.Internal.Queries; +using JsonApiDotNetCore.Models.JsonApiDocuments; + +namespace JsonApiDotNetCore.Exceptions +{ + /// + /// The error that is thrown when translating a to Entity Framework Core fails. + /// + public sealed class InvalidQueryException : JsonApiException + { + public InvalidQueryException(string reason, Exception exception) + : base(new Error(HttpStatusCode.BadRequest) + { + Title = reason, + Detail = exception.Message + }, exception) + { + } + } +} diff --git a/src/JsonApiDotNetCore/Exceptions/InvalidQueryStringParameterException.cs b/src/JsonApiDotNetCore/Exceptions/InvalidQueryStringParameterException.cs index df94a4eb61..104db23a4a 100644 --- a/src/JsonApiDotNetCore/Exceptions/InvalidQueryStringParameterException.cs +++ b/src/JsonApiDotNetCore/Exceptions/InvalidQueryStringParameterException.cs @@ -1,3 +1,4 @@ +using System; using System.Net; using JsonApiDotNetCore.Models.JsonApiDocuments; @@ -12,6 +13,12 @@ public sealed class InvalidQueryStringParameterException : JsonApiException public InvalidQueryStringParameterException(string queryParameterName, string genericMessage, string specificMessage) + : this(queryParameterName, genericMessage, specificMessage, null) + { + } + + public InvalidQueryStringParameterException(string queryParameterName, string genericMessage, + string specificMessage, Exception innerException) : base(new Error(HttpStatusCode.BadRequest) { Title = genericMessage, @@ -20,7 +27,7 @@ public InvalidQueryStringParameterException(string queryParameterName, string ge { Parameter = queryParameterName } - }) + }, innerException) { QueryParameterName = queryParameterName; } diff --git a/src/JsonApiDotNetCore/Extensions/DbContextExtensions.cs b/src/JsonApiDotNetCore/Extensions/DbContextExtensions.cs index c97e039ff4..7e55386b88 100644 --- a/src/JsonApiDotNetCore/Extensions/DbContextExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/DbContextExtensions.cs @@ -15,19 +15,19 @@ public static class DbContextExtensions /// Determines whether or not EF is already tracking an entity of the same Type and Id /// and returns that entity. /// - internal static IIdentifiable GetTrackedEntity(this DbContext context, IIdentifiable entity) + internal static TEntity GetTrackedEntity(this DbContext context, TEntity entity) + where TEntity : IIdentifiable { if (entity == null) throw new ArgumentNullException(nameof(entity)); - var trackedEntries = context.ChangeTracker + var entityEntry = context.ChangeTracker .Entries() .FirstOrDefault(entry => - entry.Entity.GetType() == entity.GetType() - && ((IIdentifiable)entry.Entity).StringId == entity.StringId - ); + entry.Entity.GetType() == entity.GetType() && + ((IIdentifiable) entry.Entity).StringId == entity.StringId); - return (IIdentifiable)trackedEntries?.Entity; + return (TEntity) entityEntry?.Entity; } /// diff --git a/src/JsonApiDotNetCore/Extensions/EnumerableExtensions.cs b/src/JsonApiDotNetCore/Extensions/EnumerableExtensions.cs deleted file mode 100644 index 9e4bc7a8b6..0000000000 --- a/src/JsonApiDotNetCore/Extensions/EnumerableExtensions.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using JsonApiDotNetCore.Query; - -namespace JsonApiDotNetCore.Extensions -{ - public static class EnumerableExtensions - { - /// - /// gets the first element of type if it exists and casts the result to that. - /// Returns null otherwise. - /// - public static TImplementedService FirstOrDefault(this IEnumerable data) where TImplementedService : class, IQueryParameterService - { - return data.FirstOrDefault(qp => qp is TImplementedService) as TImplementedService; - } - } -} diff --git a/src/JsonApiDotNetCore/Extensions/QueryableExtensions.cs b/src/JsonApiDotNetCore/Extensions/QueryableExtensions.cs deleted file mode 100644 index 01ed0e30e2..0000000000 --- a/src/JsonApiDotNetCore/Extensions/QueryableExtensions.cs +++ /dev/null @@ -1,448 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using System.Linq.Expressions; -using System.Reflection; -using JsonApiDotNetCore.Exceptions; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Internal.Query; -using JsonApiDotNetCore.Models; - -namespace JsonApiDotNetCore.Extensions -{ - public static class QueryableExtensions - { - private static MethodInfo _containsMethod; - private static MethodInfo ContainsMethod - { - get - { - if (_containsMethod == null) - { - _containsMethod = typeof(Enumerable) - .GetMethods(BindingFlags.Static | BindingFlags.Public) - .First(m => m.Name == nameof(Enumerable.Contains) && m.GetParameters().Length == 2); - } - return _containsMethod; - } - } - - public static IQueryable PageForward(this IQueryable source, int pageSize, int pageNumber) - { - if (pageSize > 0) - { - if (pageNumber == 0) - pageNumber = 1; - - if (pageNumber > 0) - return source - .Skip((pageNumber - 1) * pageSize) - .Take(pageSize); - } - - return source; - } - - public static void ForEach(this IEnumerable enumeration, Action action) - { - foreach (T item in enumeration) - { - action(item); - } - } - - public static IQueryable Filter(this IQueryable source, FilterQueryContext filterQuery) - { - if (filterQuery == null) - return source; - - if (filterQuery.Operation == FilterOperation.@in || filterQuery.Operation == FilterOperation.nin) - return CallGenericWhereContainsMethod(source, filterQuery); - - return CallGenericWhereMethod(source, filterQuery); - } - - public static IQueryable Select(this IQueryable source, IEnumerable columns, IResourceFactory resourceFactory) - { - return columns == null || !columns.Any() ? source : CallGenericSelectMethod(source, columns, resourceFactory); - } - - public static IOrderedQueryable Sort(this IQueryable source, SortQueryContext sortQuery) - { - return sortQuery.Direction == SortDirection.Descending - ? source.OrderByDescending(sortQuery.GetPropertyPath()) - : source.OrderBy(sortQuery.GetPropertyPath()); - } - - public static IOrderedQueryable Sort(this IOrderedQueryable source, SortQueryContext sortQuery) - { - return sortQuery.Direction == SortDirection.Descending - ? source.ThenByDescending(sortQuery.GetPropertyPath()) - : source.ThenBy(sortQuery.GetPropertyPath()); - } - - public static IOrderedQueryable OrderBy(this IQueryable source, string propertyName) - => CallGenericOrderMethod(source, propertyName, "OrderBy"); - - public static IOrderedQueryable OrderByDescending(this IQueryable source, string propertyName) - => CallGenericOrderMethod(source, propertyName, "OrderByDescending"); - - public static IOrderedQueryable ThenBy(this IOrderedQueryable source, string propertyName) - => CallGenericOrderMethod(source, propertyName, "ThenBy"); - - public static IOrderedQueryable ThenByDescending(this IOrderedQueryable source, string propertyName) - => CallGenericOrderMethod(source, propertyName, "ThenByDescending"); - - private static IOrderedQueryable CallGenericOrderMethod(IQueryable source, string propertyName, string method) - { - // {x} - var parameter = Expression.Parameter(typeof(TSource), "x"); - MemberExpression member; - - var values = propertyName.Split('.'); - if (values.Length > 1) - { - var relation = Expression.PropertyOrField(parameter, values[0]); - // {x.relationship.propertyName} - member = Expression.Property(relation, values[1]); - } - else - { - // {x.propertyName} - member = Expression.Property(parameter, values[0]); - } - // {x=>x.propertyName} or {x=>x.relationship.propertyName} - var lambda = Expression.Lambda(member, parameter); - - // REFLECTION: source.OrderBy(x => x.Property) - var orderByMethod = typeof(Queryable).GetMethods().First(x => x.Name == method && x.GetParameters().Length == 2); - var orderByGeneric = orderByMethod.MakeGenericMethod(typeof(TSource), member.Type); - var result = orderByGeneric.Invoke(null, new object[] { source, lambda }); - - return (IOrderedQueryable)result; - } - - private static Expression GetFilterExpressionLambda(Expression left, Expression right, FilterOperation operation) - { - Expression body; - switch (operation) - { - case FilterOperation.eq: - // {model.Id == 1} - body = Expression.Equal(left, right); - break; - case FilterOperation.lt: - // {model.Id < 1} - body = Expression.LessThan(left, right); - break; - case FilterOperation.gt: - // {model.Id > 1} - body = Expression.GreaterThan(left, right); - break; - case FilterOperation.le: - // {model.Id <= 1} - body = Expression.LessThanOrEqual(left, right); - break; - case FilterOperation.ge: - // {model.Id >= 1} - body = Expression.GreaterThanOrEqual(left, right); - break; - case FilterOperation.like: - body = Expression.Call(left, "Contains", null, right); - break; - // {model.Id != 1} - case FilterOperation.ne: - body = Expression.NotEqual(left, right); - break; - case FilterOperation.isnotnull: - // {model.Id != null} - if (left.Type.IsValueType && - !(left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(Nullable<>))) - { - var nullableType = typeof(Nullable<>).MakeGenericType(left.Type); - body = Expression.NotEqual(Expression.Convert(left, nullableType), right); - } - else - { - body = Expression.NotEqual(left, right); - } - break; - case FilterOperation.isnull: - // {model.Id == null} - if (left.Type.IsValueType && - !(left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(Nullable<>))) - { - var nullableType = typeof(Nullable<>).MakeGenericType(left.Type); - body = Expression.Equal(Expression.Convert(left, nullableType), right); - } - else - { - body = Expression.Equal(left, right); - } - break; - default: - throw new NotSupportedException($"Filter operation '{operation}' is not supported."); - } - - return body; - } - - private static IQueryable CallGenericWhereContainsMethod(IQueryable source, FilterQueryContext filter) - { - var concreteType = typeof(TSource); - var property = concreteType.GetProperty(filter.Attribute.Property.Name); - - var propertyValues = filter.Value.Split(QueryConstants.COMMA); - ParameterExpression entity = Expression.Parameter(concreteType, "entity"); - MemberExpression member; - if (filter.IsAttributeOfRelationship) - { - var relation = Expression.PropertyOrField(entity, filter.Relationship.Property.Name); - member = Expression.Property(relation, filter.Attribute.Property.Name); - } - else - member = Expression.Property(entity, filter.Attribute.Property.Name); - - var method = ContainsMethod.MakeGenericMethod(member.Type); - var list = TypeHelper.CreateListFor(member.Type); - - foreach (var value in propertyValues) - { - object targetType; - try - { - targetType = TypeHelper.ConvertType(value, member.Type); - } - catch (FormatException) - { - throw new InvalidQueryStringParameterException("filter", - "Mismatch between query string parameter value and resource attribute type.", - $"Failed to convert '{value}' in set '{filter.Value}' to '{property.PropertyType.Name}' for filtering on '{filter.Query.Attribute}' attribute."); - } - - list.Add(targetType); - } - - if (filter.Operation == FilterOperation.@in) - { - // Where(i => arr.Contains(i.column)) - var contains = Expression.Call(method, new Expression[] { Expression.Constant(list), member }); - var lambda = Expression.Lambda>(contains, entity); - - return source.Where(lambda); - } - else - { - // Where(i => !arr.Contains(i.column)) - var notContains = Expression.Not(Expression.Call(method, new Expression[] { Expression.Constant(list), member })); - var lambda = Expression.Lambda>(notContains, entity); - - return source.Where(lambda); - } - } - - /// - /// This calls a generic where method.. more explaining to follow - /// - /// - /// - /// - /// - /// - private static IQueryable CallGenericWhereMethod(IQueryable source, FilterQueryContext filter) - { - var op = filter.Operation; - var concreteType = typeof(TSource); - PropertyInfo property; - MemberExpression left; - - // {model} - var parameter = Expression.Parameter(concreteType, "model"); - // Is relationship attribute - if (filter.IsAttributeOfRelationship) - { - var relationProperty = concreteType.GetProperty(filter.Relationship.Property.Name); - if (relationProperty == null) - throw new ArgumentException($"'{filter.Relationship.Property.Name}' is not a valid relationship of '{concreteType}'"); - - var relatedType = filter.Relationship.RightType; - property = relatedType.GetProperty(filter.Attribute.Property.Name); - if (property == null) - throw new ArgumentException($"'{filter.Attribute.Property.Name}' is not a valid attribute of '{filter.Relationship.Property.Name}'"); - - var leftRelationship = Expression.PropertyOrField(parameter, filter.Relationship.Property.Name); - // {model.Relationship} - left = Expression.PropertyOrField(leftRelationship, property.Name); - } - // Is standalone attribute - else - { - property = concreteType.GetProperty(filter.Attribute.Property.Name); - if (property == null) - throw new ArgumentException($"'{filter.Attribute.Property.Name}' is not a valid property of '{concreteType}'"); - - // {model.Id} - left = Expression.PropertyOrField(parameter, property.Name); - } - - Expression right; - if (op == FilterOperation.isnotnull || op == FilterOperation.isnull) - right = Expression.Constant(null); - else - { - // convert the incoming value to the target value type - // "1" -> 1 - object convertedValue; - try - { - convertedValue = TypeHelper.ConvertType(filter.Value, property.PropertyType); - } - catch (FormatException) - { - throw new InvalidQueryStringParameterException("filter", - "Mismatch between query string parameter value and resource attribute type.", - $"Failed to convert '{filter.Value}' to '{property.PropertyType.Name}' for filtering on '{filter.Query.Attribute}' attribute."); - } - - right = CreateTupleAccessForConstantExpression(convertedValue, property.PropertyType); - } - - var body = GetFilterExpressionLambda(left, right, filter.Operation); - var lambda = Expression.Lambda>(body, parameter); - - return source.Where(lambda); - } - - private static Expression CreateTupleAccessForConstantExpression(object value, Type type) - { - // To enable efficient query plan caching, inline constants (that vary per request) should be converted into query parameters. - // https://stackoverflow.com/questions/54075758/building-a-parameterized-entityframework-core-expression - - // This method can be used to change a query like: - // SELECT ... FROM ... WHERE x."Age" = 3 - // into: - // SELECT ... FROM ... WHERE x."Age" = @p0 - - // The code below builds the next expression for a type T that is unknown at compile time: - // Expression.Property(Expression.Constant(Tuple.Create(value)), "Item1") - // Which represents the next C# code: - // Tuple.Create(value).Item1; - - MethodInfo tupleCreateMethod = typeof(Tuple).GetMethods() - .Single(m => m.Name == "Create" && m.IsGenericMethod && m.GetGenericArguments().Length == 1); - MethodInfo constructedTupleCreateMethod = tupleCreateMethod.MakeGenericMethod(type); - - ConstantExpression constantExpression = Expression.Constant(value, type); - - MethodCallExpression tupleCreateCall = Expression.Call(constructedTupleCreateMethod, constantExpression); - return Expression.Property(tupleCreateCall, "Item1"); - } - - private static IQueryable CallGenericSelectMethod(IQueryable source, IEnumerable columns, IResourceFactory resourceFactory) - { - var sourceType = typeof(TSource); - var parameter = Expression.Parameter(source.ElementType, "x"); - var sourceProperties = new HashSet(); - - // Store all property names to it's own related property (name as key) - var nestedTypesAndProperties = new Dictionary>(); - foreach (var column in columns) - { - var props = column.Split('.'); - if (props.Length > 1) // Nested property - { - if (nestedTypesAndProperties.TryGetValue(props[0], out var properties) == false) - nestedTypesAndProperties.Add(props[0], new HashSet { nameof(Identifiable.Id), props[1] }); - else - properties.Add(props[1]); - } - else - { - sourceProperties.Add(props[0]); - } - } - - // Bind attributes on TSource - var sourceBindings = sourceProperties.Select(prop => Expression.Bind(sourceType.GetProperty(prop), Expression.PropertyOrField(parameter, prop))).ToList(); - - // Bind attributes on nested types - var nestedBindings = new List(); - foreach (var item in nestedTypesAndProperties) - { - var nestedProperty = sourceType.GetProperty(item.Key); - var nestedPropertyType = nestedProperty.PropertyType; - // [HasMany] attribute - Expression bindExpression; - if (nestedPropertyType != typeof(string) && typeof(IEnumerable).IsAssignableFrom(nestedPropertyType)) - { - var collectionElementType = nestedPropertyType.GetGenericArguments().Single(); - // {y} - var nestedParameter = Expression.Parameter(collectionElementType, "y"); - nestedBindings = item.Value.Select(prop => Expression.Bind( - collectionElementType.GetProperty(prop), Expression.PropertyOrField(nestedParameter, prop))).ToList(); - - // { new Item() } - var newNestedExp = resourceFactory.CreateNewExpression(collectionElementType); - var initNestedExp = Expression.MemberInit(newNestedExp, nestedBindings); - // { y => new Item() {Id = y.Id, Name = y.Name}} - var body = Expression.Lambda(initNestedExp, nestedParameter); - // { x.Items } - Expression propertyExpression = Expression.Property(parameter, nestedProperty.Name); - // { x.Items.Select(y => new Item() {Id = y.Id, Name = y.Name}) } - Expression selectMethod = Expression.Call( - typeof(Enumerable), - "Select", - new[] { collectionElementType, collectionElementType }, - propertyExpression, body); - - var enumerableOfElementType = typeof(IEnumerable<>).MakeGenericType(collectionElementType); - var typedCollection = nestedPropertyType.ToConcreteCollectionType(); - var typedCollectionConstructor = typedCollection.GetConstructor(new[] {enumerableOfElementType}); - - // { new HashSet(x.Items.Select(y => new Item() {Id = y.Id, Name = y.Name})) } - bindExpression = Expression.New(typedCollectionConstructor, selectMethod); - } - // [HasOne] attribute - else - { - // {x.Owner} - var srcBody = Expression.PropertyOrField(parameter, item.Key); - foreach (var nested in item.Value) - { - // {x.Owner.Name} - var nestedBody = Expression.PropertyOrField(srcBody, nested); - var propInfo = nestedPropertyType.GetProperty(nested); - nestedBindings.Add(Expression.Bind(propInfo, nestedBody)); - } - // { new Owner() } - var newExp = resourceFactory.CreateNewExpression(nestedPropertyType); - // { new Owner() { Id = x.Owner.Id, Name = x.Owner.Name }} - var newInit = Expression.MemberInit(newExp, nestedBindings); - - // Handle nullable relationships - // { Owner = x.Owner == null ? null : new Owner() {...} } - bindExpression = Expression.Condition( - Expression.Equal(srcBody, Expression.Constant(null)), - Expression.Convert(Expression.Constant(null), nestedPropertyType), - newInit - ); - } - - sourceBindings.Add(Expression.Bind(nestedProperty, bindExpression)); - nestedBindings.Clear(); - } - - var newExpression = resourceFactory.CreateNewExpression(sourceType); - var sourceInit = Expression.MemberInit(newExpression, sourceBindings); - var finalBody = Expression.Lambda(sourceInit, parameter); - - return source.Provider.CreateQuery(Expression.Call( - typeof(Queryable), - "Select", - new[] { source.ElementType, typeof(TSource) }, - source.Expression, - Expression.Quote(finalBody))); - } - } -} diff --git a/src/JsonApiDotNetCore/Formatters/JsonApiReader.cs b/src/JsonApiDotNetCore/Formatters/JsonApiReader.cs index 3cab898881..d6b7621755 100644 --- a/src/JsonApiDotNetCore/Formatters/JsonApiReader.cs +++ b/src/JsonApiDotNetCore/Formatters/JsonApiReader.cs @@ -3,8 +3,8 @@ using System.IO; using System.Threading.Tasks; using JsonApiDotNetCore.Exceptions; -using JsonApiDotNetCore.Managers.Contracts; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.RequestServices.Contracts; using JsonApiDotNetCore.Serialization.Server; using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Mvc.Formatters; @@ -67,9 +67,9 @@ public async Task ReadAsync(InputFormatterContext context) throw new InvalidRequestBodyException("Payload must include id attribute.", null, body); } - if (!_currentRequest.IsRelationshipPath && TryGetId(model, out var bodyId) && bodyId != _currentRequest.BaseId) + if (_currentRequest.Kind == EndpointKind.Primary && TryGetId(model, out var bodyId) && bodyId != _currentRequest.PrimaryId) { - throw new ResourceIdMismatchException(bodyId, _currentRequest.BaseId, context.HttpContext.Request.GetDisplayUrl()); + throw new ResourceIdMismatchException(bodyId, _currentRequest.PrimaryId, context.HttpContext.Request.GetDisplayUrl()); } } diff --git a/src/JsonApiDotNetCore/Graph/ResourceIdMapper.cs b/src/JsonApiDotNetCore/Graph/ResourceIdMapper.cs index 4cafa76366..bf70a7a163 100644 --- a/src/JsonApiDotNetCore/Graph/ResourceIdMapper.cs +++ b/src/JsonApiDotNetCore/Graph/ResourceIdMapper.cs @@ -11,7 +11,7 @@ public interface IRelatedIdMapper /// /// /// - /// DefaultRelatedIdMapper.GetRelatedIdPropertyName("Article"); + /// RelatedIdMapper.GetRelatedIdPropertyName("Article"); /// // "ArticleId" /// /// @@ -19,7 +19,7 @@ public interface IRelatedIdMapper } /// - public sealed class DefaultRelatedIdMapper : IRelatedIdMapper + public sealed class RelatedIdMapper : IRelatedIdMapper { /// public string GetRelatedIdPropertyName(string propertyName) => propertyName + "Id"; diff --git a/src/JsonApiDotNetCore/Graph/ServiceDiscoveryFacade.cs b/src/JsonApiDotNetCore/Graph/ServiceDiscoveryFacade.cs index 7d3488f984..cbe795cdce 100644 --- a/src/JsonApiDotNetCore/Graph/ServiceDiscoveryFacade.cs +++ b/src/JsonApiDotNetCore/Graph/ServiceDiscoveryFacade.cs @@ -27,10 +27,10 @@ public class ServiceDiscoveryFacade : IServiceDiscoveryFacade typeof(IGetAllService<,>), typeof(IGetByIdService<>), typeof(IGetByIdService<,>), + typeof(IGetSecondaryService<>), + typeof(IGetSecondaryService<,>), typeof(IGetRelationshipService<>), typeof(IGetRelationshipService<,>), - typeof(IGetRelationshipsService<>), - typeof(IGetRelationshipsService<,>), typeof(IUpdateService<>), typeof(IUpdateService<,>), typeof(IDeleteService<>), diff --git a/src/JsonApiDotNetCore/Hooks/Discovery/IHooksDiscovery.cs b/src/JsonApiDotNetCore/Hooks/Discovery/IHooksDiscovery.cs index 9db0d2a0ca..e15c8d1404 100644 --- a/src/JsonApiDotNetCore/Hooks/Discovery/IHooksDiscovery.cs +++ b/src/JsonApiDotNetCore/Hooks/Discovery/IHooksDiscovery.cs @@ -1,19 +1,16 @@ -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models; namespace JsonApiDotNetCore.Hooks { - /// /// A singleton service for a particular TResource that stores a field of /// enums that represents which resource hooks have been implemented for that - /// particular entity. + /// particular resource. /// public interface IHooksDiscovery : IHooksDiscovery where TResource : class, IIdentifiable { - } - public interface IHooksDiscovery { /// @@ -24,5 +21,4 @@ public interface IHooksDiscovery ResourceHook[] DatabaseValuesEnabledHooks { get; } ResourceHook[] DatabaseValuesDisabledHooks { get; } } - } diff --git a/src/JsonApiDotNetCore/Hooks/Execution/DiffableEntityHashSet.cs b/src/JsonApiDotNetCore/Hooks/Execution/DiffableResourceHashSet.cs similarity index 68% rename from src/JsonApiDotNetCore/Hooks/Execution/DiffableEntityHashSet.cs rename to src/JsonApiDotNetCore/Hooks/Execution/DiffableResourceHashSet.cs index c42537a884..e64065a357 100644 --- a/src/JsonApiDotNetCore/Hooks/Execution/DiffableEntityHashSet.cs +++ b/src/JsonApiDotNetCore/Hooks/Execution/DiffableResourceHashSet.cs @@ -19,30 +19,30 @@ namespace JsonApiDotNetCore.Hooks /// Also contains information about updated relationships through /// implementation of IRelationshipsDictionary> /// - public interface IDiffableEntityHashSet : IEntityHashSet where TResource : class, IIdentifiable + public interface IDiffableResourceHashSet : IResourceHashSet where TResource : class, IIdentifiable { /// - /// Iterates over diffs, which is the affected entity from the request + /// Iterates over diffs, which is the affected resource from the request /// with their associated current value from the database. /// - IEnumerable> GetDiffs(); + IEnumerable> GetDiffs(); } /// - public sealed class DiffableEntityHashSet : EntityHashSet, IDiffableEntityHashSet where TResource : class, IIdentifiable + public sealed class DiffableResourceHashSet : ResourceHashSet, IDiffableResourceHashSet where TResource : class, IIdentifiable { private readonly HashSet _databaseValues; private readonly bool _databaseValuesLoaded; private readonly Dictionary> _updatedAttributes; - public DiffableEntityHashSet(HashSet requestEntities, - HashSet databaseEntities, + public DiffableResourceHashSet(HashSet requestResources, + HashSet databaseResources, Dictionary> relationships, Dictionary> updatedAttributes) - : base(requestEntities, relationships) + : base(requestResources, relationships) { - _databaseValues = databaseEntities; + _databaseValues = databaseResources; _databaseValuesLoaded |= _databaseValues != null; _updatedAttributes = updatedAttributes; } @@ -50,24 +50,24 @@ public DiffableEntityHashSet(HashSet requestEntities, /// /// Used internally by the ResourceHookExecutor to make live a bit easier with generics /// - internal DiffableEntityHashSet(IEnumerable requestEntities, - IEnumerable databaseEntities, + internal DiffableResourceHashSet(IEnumerable requestResources, + IEnumerable databaseResources, Dictionary relationships, ITargetedFields targetedFields) - : this((HashSet)requestEntities, (HashSet)databaseEntities, TypeHelper.ConvertRelationshipDictionary(relationships), - TypeHelper.ConvertAttributeDictionary(targetedFields.Attributes, (HashSet)requestEntities)) + : this((HashSet)requestResources, (HashSet)databaseResources, TypeHelper.ConvertRelationshipDictionary(relationships), + TypeHelper.ConvertAttributeDictionary(targetedFields.Attributes, (HashSet)requestResources)) { } /// - public IEnumerable> GetDiffs() + public IEnumerable> GetDiffs() { if (!_databaseValuesLoaded) ThrowNoDbValuesError(); - foreach (var entity in this) + foreach (var resource in this) { - TResource currentValueInDatabase = _databaseValues.Single(e => entity.StringId == e.StringId); - yield return new EntityDiffPair(entity, currentValueInDatabase); + TResource currentValueInDatabase = _databaseValues.Single(e => resource.StringId == e.StringId); + yield return new ResourceDiffPair(resource, currentValueInDatabase); } } @@ -86,9 +86,9 @@ public IEnumerable> GetDiffs() // the navigation action references a relationship. Redirect the call to the relationship dictionary. return base.GetAffected(navigationAction); } - else if (_updatedAttributes.TryGetValue(propertyInfo, out HashSet entities)) + else if (_updatedAttributes.TryGetValue(propertyInfo, out HashSet resources)) { - return entities; + return resources; } return new HashSet(); } @@ -100,21 +100,21 @@ private void ThrowNoDbValuesError() } /// - /// A wrapper that contains an entity that is affected by the request, + /// A wrapper that contains a resource that is affected by the request, /// matched to its current database value /// - public sealed class EntityDiffPair where TResource : class, IIdentifiable + public sealed class ResourceDiffPair where TResource : class, IIdentifiable { - public EntityDiffPair(TResource entity, TResource databaseValue) + public ResourceDiffPair(TResource resource, TResource databaseValue) { - Entity = entity; + Resource = resource; DatabaseValue = databaseValue; } /// /// The resource from the request matching the resource from the database. /// - public TResource Entity { get; } + public TResource Resource { get; } /// /// The resource from the database matching the resource from the request. /// diff --git a/src/JsonApiDotNetCore/Hooks/Execution/HookExecutorHelper.cs b/src/JsonApiDotNetCore/Hooks/Execution/HookExecutorHelper.cs index 1173bf4bad..2977742e72 100644 --- a/src/JsonApiDotNetCore/Hooks/Execution/HookExecutorHelper.cs +++ b/src/JsonApiDotNetCore/Hooks/Execution/HookExecutorHelper.cs @@ -11,6 +11,9 @@ using RightType = System.Type; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Internal.Contracts; +using JsonApiDotNetCore.Internal.Queries; +using JsonApiDotNetCore.Internal.Queries.Expressions; using JsonApiDotNetCore.Models.Annotation; namespace JsonApiDotNetCore.Hooks @@ -21,31 +24,32 @@ internal sealed class HookExecutorHelper : IHookExecutorHelper private readonly IdentifiableComparer _comparer = IdentifiableComparer.Instance; private readonly IJsonApiOptions _options; private readonly IGenericServiceFactory _genericProcessorFactory; + private readonly IResourceContextProvider _resourceContextProvider; private readonly Dictionary _hookContainers; private readonly Dictionary _hookDiscoveries; - private readonly List _targetedHooksForRelatedEntities; + private readonly List _targetedHooksForRelatedResources; - public HookExecutorHelper(IGenericServiceFactory genericProcessorFactory, - IJsonApiOptions options) + public HookExecutorHelper(IGenericServiceFactory genericProcessorFactory, IResourceContextProvider resourceContextProvider, IJsonApiOptions options) { _options = options; _genericProcessorFactory = genericProcessorFactory; + _resourceContextProvider = resourceContextProvider; _hookContainers = new Dictionary(); _hookDiscoveries = new Dictionary(); - _targetedHooksForRelatedEntities = new List(); + _targetedHooksForRelatedResources = new List(); } /// - public IResourceHookContainer GetResourceHookContainer(RightType rightType, ResourceHook hook = ResourceHook.None) + public IResourceHookContainer GetResourceHookContainer(RightType targetResource, ResourceHook hook = ResourceHook.None) { // checking the cache if we have a reference for the requested container, // regardless of the hook we will use it for. If the value is null, // it means there was no implementation IResourceHookContainer at all, // so we need not even bother. - if (!_hookContainers.TryGetValue(rightType, out IResourceHookContainer container)) + if (!_hookContainers.TryGetValue(targetResource, out IResourceHookContainer container)) { - container = (_genericProcessorFactory.Get(typeof(ResourceDefinition<>), rightType)); - _hookContainers[rightType] = container; + container = (_genericProcessorFactory.Get(typeof(ResourceDefinition<>), targetResource)); + _hookContainers[targetResource] = container; } if (container == null) return null; @@ -55,7 +59,7 @@ public IResourceHookContainer GetResourceHookContainer(RightType rightType, Reso if (hook == ResourceHook.None) { CheckForTargetHookExistence(); - targetHooks = _targetedHooksForRelatedEntities; + targetHooks = _targetedHooksForRelatedResources; } else { @@ -64,7 +68,7 @@ public IResourceHookContainer GetResourceHookContainer(RightType rightType, Reso foreach (ResourceHook targetHook in targetHooks) { - if (ShouldExecuteHook(rightType, targetHook)) return container; + if (ShouldExecuteHook(targetResource, targetHook)) return container; } return null; } @@ -75,30 +79,30 @@ public IResourceHookContainer GetResourceHookContainer(Res return (IResourceHookContainer)GetResourceHookContainer(typeof(TResource), hook); } - public IEnumerable LoadDbValues(LeftType entityTypeForRepository, IEnumerable entities, ResourceHook hook, params RelationshipAttribute[] relationshipsToNextLayer) + public IEnumerable LoadDbValues(LeftType resourceTypeForRepository, IEnumerable resources, ResourceHook hook, params RelationshipAttribute[] relationshipsToNextLayer) { - var idType = TypeHelper.GetIdType(entityTypeForRepository); + var idType = TypeHelper.GetIdType(resourceTypeForRepository); var parameterizedGetWhere = GetType() .GetMethod(nameof(GetWhereAndInclude), BindingFlags.NonPublic | BindingFlags.Instance) - .MakeGenericMethod(entityTypeForRepository, idType); - var cast = ((IEnumerable)entities).Cast(); + .MakeGenericMethod(resourceTypeForRepository, idType); + var cast = ((IEnumerable)resources).Cast(); var ids = cast.Select(TypeHelper.GetResourceTypedId).CopyToList(idType); var values = (IEnumerable)parameterizedGetWhere.Invoke(this, new object[] { ids, relationshipsToNextLayer }); if (values == null) return null; - return (IEnumerable)Activator.CreateInstance(typeof(HashSet<>).MakeGenericType(entityTypeForRepository), values.CopyToList(entityTypeForRepository)); + return (IEnumerable)Activator.CreateInstance(typeof(HashSet<>).MakeGenericType(resourceTypeForRepository), values.CopyToList(resourceTypeForRepository)); } - public HashSet LoadDbValues(IEnumerable entities, ResourceHook hook, params RelationshipAttribute[] relationships) where TResource : class, IIdentifiable + public HashSet LoadDbValues(IEnumerable resources, ResourceHook hook, params RelationshipAttribute[] relationships) where TResource : class, IIdentifiable { - var entityType = typeof(TResource); - var dbValues = LoadDbValues(entityType, entities, hook, relationships)?.Cast(); + var resourceType = typeof(TResource); + var dbValues = LoadDbValues(resourceType, resources, hook, relationships)?.Cast(); if (dbValues == null) return null; return new HashSet(dbValues); } - public bool ShouldLoadDbValues(Type entityType, ResourceHook hook) + public bool ShouldLoadDbValues(Type resourceType, ResourceHook hook) { - var discovery = GetHookDiscovery(entityType); + var discovery = GetHookDiscovery(resourceType); if (discovery.DatabaseValuesDisabledHooks.Contains(hook)) return false; if (discovery.DatabaseValuesEnabledHooks.Contains(hook)) @@ -106,38 +110,48 @@ public bool ShouldLoadDbValues(Type entityType, ResourceHook hook) return _options.LoadDatabaseValues; } - private bool ShouldExecuteHook(RightType entityType, ResourceHook hook) + private bool ShouldExecuteHook(RightType resourceType, ResourceHook hook) { - var discovery = GetHookDiscovery(entityType); + var discovery = GetHookDiscovery(resourceType); return discovery.ImplementedHooks.Contains(hook); } private void CheckForTargetHookExistence() { - if (!_targetedHooksForRelatedEntities.Any()) + if (!_targetedHooksForRelatedResources.Any()) throw new InvalidOperationException("Something is not right in the breadth first traversal of resource hook: " + "trying to get meta information when no allowed hooks are set"); } - private IHooksDiscovery GetHookDiscovery(Type entityType) + private IHooksDiscovery GetHookDiscovery(Type resourceType) { - if (!_hookDiscoveries.TryGetValue(entityType, out IHooksDiscovery discovery)) + if (!_hookDiscoveries.TryGetValue(resourceType, out IHooksDiscovery discovery)) { - discovery = _genericProcessorFactory.Get(typeof(IHooksDiscovery<>), entityType); - _hookDiscoveries[entityType] = discovery; + discovery = _genericProcessorFactory.Get(typeof(IHooksDiscovery<>), resourceType); + _hookDiscoveries[resourceType] = discovery; } return discovery; } private IEnumerable GetWhereAndInclude(IEnumerable ids, RelationshipAttribute[] relationshipsToNextLayer) where TResource : class, IIdentifiable { - var repo = GetRepository(); - var query = repo.Get().Where(e => ids.Contains(e.Id)); - foreach (var inclusionChainElement in relationshipsToNextLayer) + var resourceContext = _resourceContextProvider.GetResourceContext(); + var idAttribute = resourceContext.Attributes.Single(attr => attr.Property.Name == nameof(Identifiable.Id)); + + var queryLayer = new QueryLayer(resourceContext) + { + Filter = new EqualsAnyOfExpression(new ResourceFieldChainExpression(idAttribute), + ids.Select(id => new LiteralConstantExpression(id.ToString())).ToList()) + }; + + var chains = relationshipsToNextLayer.Select(relationship => new ResourceFieldChainExpression(relationship)).ToList(); + if (chains.Any()) { - query = repo.Include(query, new[] { inclusionChainElement }); + queryLayer.Include = new IncludeExpression(chains); } - return query.ToList(); + + var repository = GetRepository(); + return repository.GetAsync(queryLayer).Result; } private IResourceReadRepository GetRepository() where TResource : class, IIdentifiable @@ -146,11 +160,11 @@ private IResourceReadRepository GetRepository() } public Dictionary LoadImplicitlyAffected( - Dictionary leftEntitiesByRelation, - IEnumerable existingRightEntities = null) + Dictionary leftResourcesByRelation, + IEnumerable existingRightResources = null) { var implicitlyAffected = new Dictionary(); - foreach (var kvp in leftEntitiesByRelation) + foreach (var kvp in leftResourcesByRelation) { if (IsHasManyThrough(kvp, out var lefts, out var relationship)) continue; @@ -159,31 +173,31 @@ public Dictionary LoadImplicitlyAffected( foreach (IIdentifiable ip in includedLefts) { - IList dbRightEntityList = TypeHelper.CreateListFor(relationship.RightType); + IList dbRightResourceList = TypeHelper.CreateListFor(relationship.RightType); var relationshipValue = relationship.GetValue(ip); if (!(relationshipValue is IEnumerable)) { - if (relationshipValue != null) dbRightEntityList.Add(relationshipValue); + if (relationshipValue != null) dbRightResourceList.Add(relationshipValue); } else { foreach (var item in (IEnumerable) relationshipValue) { - dbRightEntityList.Add(item); + dbRightResourceList.Add(item); } } - var dbRightEntityListCast = dbRightEntityList.Cast().ToList(); - if (existingRightEntities != null) dbRightEntityListCast = dbRightEntityListCast.Except(existingRightEntities.Cast(), _comparer).ToList(); + var dbRightResourceListCast = dbRightResourceList.Cast().ToList(); + if (existingRightResources != null) dbRightResourceListCast = dbRightResourceListCast.Except(existingRightResources.Cast(), _comparer).ToList(); - if (dbRightEntityListCast.Any()) + if (dbRightResourceListCast.Any()) { if (!implicitlyAffected.TryGetValue(relationship, out IEnumerable affected)) { affected = TypeHelper.CreateListFor(relationship.RightType); implicitlyAffected[relationship] = affected; } - ((IList)affected).AddRange(dbRightEntityListCast); + ((IList)affected).AddRange(dbRightResourceListCast); } } } @@ -192,11 +206,11 @@ public Dictionary LoadImplicitlyAffected( } private bool IsHasManyThrough(KeyValuePair kvp, - out IEnumerable entities, + out IEnumerable resources, out RelationshipAttribute attr) { attr = kvp.Key; - entities = (kvp.Value); + resources = (kvp.Value); return (kvp.Key is HasManyThroughAttribute); } } diff --git a/src/JsonApiDotNetCore/Hooks/Execution/IHookExecutorHelper.cs b/src/JsonApiDotNetCore/Hooks/Execution/IHookExecutorHelper.cs index 379b2c0d0a..9b37428e8f 100644 --- a/src/JsonApiDotNetCore/Hooks/Execution/IHookExecutorHelper.cs +++ b/src/JsonApiDotNetCore/Hooks/Execution/IHookExecutorHelper.cs @@ -22,7 +22,7 @@ internal interface IHookExecutorHelper /// Also caches the retrieves containers so we don't need to reflectively /// instantiate them multiple times. /// - IResourceHookContainer GetResourceHookContainer(Type targetEntity, ResourceHook hook = ResourceHook.None); + IResourceHookContainer GetResourceHookContainer(Type targetResource, ResourceHook hook = ResourceHook.None); /// /// For a particular ResourceHook and for a given model type, checks if @@ -35,28 +35,28 @@ internal interface IHookExecutorHelper IResourceHookContainer GetResourceHookContainer(ResourceHook hook = ResourceHook.None) where TResource : class, IIdentifiable; /// - /// Load the implicitly affected entities from the database for a given set of target target entities and involved relationships + /// Load the implicitly affected resources from the database for a given set of target target resources and involved relationships /// - /// The implicitly affected entities by relationship - Dictionary LoadImplicitlyAffected(Dictionary leftEntities, IEnumerable existingRightEntities = null); + /// The implicitly affected resources by relationship + Dictionary LoadImplicitlyAffected(Dictionary leftResourcesByRelation, IEnumerable existingRightResources = null); /// - /// For a set of entities, loads current values from the database + /// For a set of resources, loads current values from the database /// - /// type of the entities to be loaded - /// The set of entities to load the db values for + /// type of the resources to be loaded + /// The set of resources to load the db values for /// The hook in which the db values will be displayed. - /// Relationships that need to be included on entities. - IEnumerable LoadDbValues(Type repositoryEntityType, IEnumerable entities, ResourceHook hook, params RelationshipAttribute[] relationships); + /// Relationships that need to be included on resources. + IEnumerable LoadDbValues(Type resourceTypeForRepository, IEnumerable resources, ResourceHook hook, params RelationshipAttribute[] relationships); /// /// Checks if the display database values option is allowed for the targeted hook, and for - /// a given resource of type checks if this hook is implemented and if the + /// a given resource of type checks if this hook is implemented and if the /// database values option is enabled. /// /// true, if should load db values, false otherwise. - /// Container entity type. + /// Container resource type. /// Hook. - bool ShouldLoadDbValues(Type entityType, ResourceHook hook); + bool ShouldLoadDbValues(Type resourceType, ResourceHook hook); } } diff --git a/src/JsonApiDotNetCore/Hooks/Execution/RelationshipsDictionary.cs b/src/JsonApiDotNetCore/Hooks/Execution/RelationshipsDictionary.cs index 35317a868f..3f9ec5e7ad 100644 --- a/src/JsonApiDotNetCore/Hooks/Execution/RelationshipsDictionary.cs +++ b/src/JsonApiDotNetCore/Hooks/Execution/RelationshipsDictionary.cs @@ -27,7 +27,7 @@ public interface IByAffectedRelationships : } /// - /// A helper class that provides insights in which relationships have been updated for which entities. + /// A helper class that provides insights in which relationships have been updated for which resources. /// public interface IRelationshipsDictionary : IRelationshipGetters, @@ -36,20 +36,20 @@ public interface IRelationshipsDictionary : { } /// - /// A helper class that provides insights in which relationships have been updated for which entities. + /// A helper class that provides insights in which relationships have been updated for which resources. /// public interface IRelationshipGetters where TLeftResource : class, IIdentifiable { /// - /// Gets a dictionary of all entities that have an affected relationship to type + /// Gets a dictionary of all resources that have an affected relationship to type /// Dictionary> GetByRelationship() where TRightResource : class, IIdentifiable; /// - /// Gets a dictionary of all entities that have an affected relationship to type + /// Gets a dictionary of all resources that have an affected relationship to type /// Dictionary> GetByRelationship(Type relatedResourceType); /// - /// Gets a collection of all the entities for the property within + /// Gets a collection of all the resources for the property within /// has been affected by the request /// /// diff --git a/src/JsonApiDotNetCore/Hooks/Execution/EntityHashSet.cs b/src/JsonApiDotNetCore/Hooks/Execution/ResourceHashSet.cs similarity index 79% rename from src/JsonApiDotNetCore/Hooks/Execution/EntityHashSet.cs rename to src/JsonApiDotNetCore/Hooks/Execution/ResourceHashSet.cs index a5c6d039a5..70942284e1 100644 --- a/src/JsonApiDotNetCore/Hooks/Execution/EntityHashSet.cs +++ b/src/JsonApiDotNetCore/Hooks/Execution/ResourceHashSet.cs @@ -14,7 +14,7 @@ namespace JsonApiDotNetCore.Hooks /// Also contains information about updated relationships through /// implementation of IAffectedRelationshipsDictionary> /// - public interface IEntityHashSet : IByAffectedRelationships, IReadOnlyCollection where TResource : class, IIdentifiable { } + public interface IResourceHashSet : IByAffectedRelationships, IReadOnlyCollection where TResource : class, IIdentifiable { } /// /// Implementation of IResourceHashSet{TResource}. @@ -24,15 +24,15 @@ public interface IEntityHashSet : IByAffectedRelationships /// Also contains information about updated relationships through /// implementation of IRelationshipsDictionary> /// - public class EntityHashSet : HashSet, IEntityHashSet where TResource : class, IIdentifiable + public class ResourceHashSet : HashSet, IResourceHashSet where TResource : class, IIdentifiable { /// public Dictionary> AffectedRelationships => _relationships; private readonly RelationshipsDictionary _relationships; - public EntityHashSet(HashSet entities, - Dictionary> relationships) : base(entities) + public ResourceHashSet(HashSet resources, + Dictionary> relationships) : base(resources) { _relationships = new RelationshipsDictionary(relationships); } @@ -40,9 +40,9 @@ public EntityHashSet(HashSet entities, /// /// Used internally by the ResourceHookExecutor to make live a bit easier with generics /// - internal EntityHashSet(IEnumerable entities, + internal ResourceHashSet(IEnumerable resources, Dictionary relationships) - : this((HashSet)entities, TypeHelper.ConvertRelationshipDictionary(relationships)) { } + : this((HashSet)resources, TypeHelper.ConvertRelationshipDictionary(relationships)) { } /// diff --git a/src/JsonApiDotNetCore/Hooks/Execution/ResourcePipelineEnum.cs b/src/JsonApiDotNetCore/Hooks/Execution/ResourcePipelineEnum.cs index 3f952c8f52..1c86237b59 100644 --- a/src/JsonApiDotNetCore/Hooks/Execution/ResourcePipelineEnum.cs +++ b/src/JsonApiDotNetCore/Hooks/Execution/ResourcePipelineEnum.cs @@ -1,8 +1,10 @@ -namespace JsonApiDotNetCore.Hooks +using JsonApiDotNetCore.Services; + +namespace JsonApiDotNetCore.Hooks { /// /// An enum that represents the initiator of a resource hook. Eg, when BeforeCreate() - /// is called from EntityResourceService.GetAsync(TId id), it will be called + /// is called from , it will be called /// with parameter pipeline = ResourceAction.GetSingle. /// public enum ResourcePipeline @@ -16,4 +18,4 @@ public enum ResourcePipeline PatchRelationship, Delete } -} \ No newline at end of file +} diff --git a/src/JsonApiDotNetCore/Hooks/IResourceHookContainer.cs b/src/JsonApiDotNetCore/Hooks/IResourceHookContainer.cs index 0c712f516f..8f7cc1636f 100644 --- a/src/JsonApiDotNetCore/Hooks/IResourceHookContainer.cs +++ b/src/JsonApiDotNetCore/Hooks/IResourceHookContainer.cs @@ -23,22 +23,22 @@ public interface IResourceHookContainer public interface IReadHookContainer where TResource : class, IIdentifiable { /// - /// Implement this hook to run custom logic in the - /// layer just before reading entities of type . + /// Implement this hook to run custom logic in the + /// layer just before reading resources of type . /// /// An enum indicating from where the hook was triggered. - /// Indicates whether the to be queried entities are the main request entities or if they were included - /// The string id of the requested entity, in the case of + /// Indicates whether the to be queried resources are the primary request resources or if they were included + /// The string id of the requested resource, in the case of void BeforeRead(ResourcePipeline pipeline, bool isIncluded = false, string stringId = null); /// - /// Implement this hook to run custom logic in the - /// layer just after reading entities of type . + /// Implement this hook to run custom logic in the + /// layer just after reading resources of type . /// - /// The unique set of affected entities. + /// The unique set of affected resources. /// An enum indicating from where the hook was triggered. - /// A boolean to indicate whether the entities in this hook execution are the main entities of the request, + /// A boolean to indicate whether the resources in this hook execution are the primary resources of the request, /// or if they were included as a relationship - void AfterRead(HashSet entities, ResourcePipeline pipeline, bool isIncluded = false); + void AfterRead(HashSet resources, ResourcePipeline pipeline, bool isIncluded = false); } /// @@ -47,40 +47,40 @@ public interface IReadHookContainer where TResource : class, IIdentif public interface ICreateHookContainer where TResource : class, IIdentifiable { /// - /// Implement this hook to run custom logic in the - /// layer just before creation of entities of type . + /// Implement this hook to run custom logic in the + /// layer just before creation of resources of type . /// - /// For the pipeline, + /// For the pipeline, /// will typically contain one entry. /// /// The returned may be a subset - /// of , in which case the operation of the - /// pipeline will not be executed for the omitted entities. The returned - /// set may also contain custom changes of the properties on the entities. + /// of , in which case the operation of the + /// pipeline will not be executed for the omitted resources. The returned + /// set may also contain custom changes of the properties on the resources. /// - /// If new relationships are to be created with the to-be-created entities, + /// If new relationships are to be created with the to-be-created resources, /// this will be reflected by the corresponding NavigationProperty being set. /// For each of these relationships, the /// hook is fired after the execution of this hook. /// - /// The transformed entity set - /// The unique set of affected entities. + /// The transformed resource set + /// The unique set of affected resources. /// An enum indicating from where the hook was triggered. - IEnumerable BeforeCreate(IEntityHashSet entities, ResourcePipeline pipeline); + IEnumerable BeforeCreate(IResourceHashSet resources, ResourcePipeline pipeline); /// - /// Implement this hook to run custom logic in the - /// layer just after creation of entities of type . + /// Implement this hook to run custom logic in the + /// layer just after creation of resources of type . /// - /// If relationships were created with the created entities, this will + /// If relationships were created with the created resources, this will /// be reflected by the corresponding NavigationProperty being set. /// For each of these relationships, the /// hook is fired after the execution of this hook. /// - /// The transformed entity set - /// The unique set of affected entities. + /// The transformed resource set + /// The unique set of affected resources. /// An enum indicating from where the hook was triggered. - void AfterCreate(HashSet entities, ResourcePipeline pipeline); + void AfterCreate(HashSet resources, ResourcePipeline pipeline); } /// @@ -89,19 +89,19 @@ public interface ICreateHookContainer where TResource : class, IIdent public interface IUpdateHookContainer where TResource : class, IIdentifiable { /// - /// Implement this hook to run custom logic in the - /// layer just before updating entities of type . + /// Implement this hook to run custom logic in the + /// layer just before updating resources of type . /// /// For the pipeline, the - /// will typically contain one entity. + /// will typically contain one resource. /// /// The returned may be a subset - /// of the property in parameter , + /// of the property in parameter , /// in which case the operation of the pipeline will not be executed - /// for the omitted entities. The returned set may also contain custom - /// changes of the properties on the entities. + /// for the omitted resources. The returned set may also contain custom + /// changes of the properties on the resources. /// - /// If new relationships are to be created with the to-be-updated entities, + /// If new relationships are to be created with the to-be-updated resources, /// this will be reflected by the corresponding NavigationProperty being set. /// For each of these relationships, the /// hook is fired after the execution of this hook. @@ -111,16 +111,16 @@ public interface IUpdateHookContainer where TResource : class, IIdent /// affected, the /// hook is fired for these. /// - /// The transformed entity set - /// The affected entities. + /// The transformed resource set + /// The affected resources. /// An enum indicating from where the hook was triggered. - IEnumerable BeforeUpdate(IDiffableEntityHashSet entities, ResourcePipeline pipeline); + IEnumerable BeforeUpdate(IDiffableResourceHashSet resources, ResourcePipeline pipeline); /// - /// Implement this hook to run custom logic in the - /// layer just before updating relationships to entities of type . + /// Implement this hook to run custom logic in the + /// layer just before updating relationships to resources of type . /// - /// This hook is fired when a relationship is created to entities of type + /// This hook is fired when a relationship is created to resources of type /// from a dependent pipeline ( /// or ). For example, If an Article was created /// and its author relationship was set to an existing Person, this hook will be fired @@ -128,55 +128,55 @@ public interface IUpdateHookContainer where TResource : class, IIdent /// /// The returned may be a subset /// of , in which case the operation of the - /// pipeline will not be executed for any entity whose id was omitted + /// pipeline will not be executed for any resource whose id was omitted /// /// /// The transformed set of ids /// The unique set of ids /// An enum indicating from where the hook was triggered. - /// A helper that groups the entities by the affected relationship - IEnumerable BeforeUpdateRelationship(HashSet ids, IRelationshipsDictionary entitiesByRelationship, ResourcePipeline pipeline); + /// A helper that groups the resources by the affected relationship + IEnumerable BeforeUpdateRelationship(HashSet ids, IRelationshipsDictionary resourcesByRelationship, ResourcePipeline pipeline); /// - /// Implement this hook to run custom logic in the - /// layer just after updating entities of type . + /// Implement this hook to run custom logic in the + /// layer just after updating resources of type . /// - /// If relationships were updated with the updated entities, this will + /// If relationships were updated with the updated resources, this will /// be reflected by the corresponding NavigationProperty being set. /// For each of these relationships, the /// hook is fired after the execution of this hook. /// - /// The unique set of affected entities. + /// The unique set of affected resources. /// An enum indicating from where the hook was triggered. - void AfterUpdate(HashSet entities, ResourcePipeline pipeline); + void AfterUpdate(HashSet resources, ResourcePipeline pipeline); /// - /// Implement this hook to run custom logic in the layer + /// Implement this hook to run custom logic in the layer /// just after a relationship was updated. /// - /// Relationship helper. + /// Relationship helper. /// An enum indicating from where the hook was triggered. - void AfterUpdateRelationship(IRelationshipsDictionary entitiesByRelationship, ResourcePipeline pipeline); + void AfterUpdateRelationship(IRelationshipsDictionary resourcesByRelationship, ResourcePipeline pipeline); /// - /// Implement this hook to run custom logic in the - /// layer just before implicitly updating relationships to entities of type . + /// Implement this hook to run custom logic in the + /// layer just before implicitly updating relationships to resources of type . /// - /// This hook is fired when a relationship to entities of type + /// This hook is fired when a relationship to resources of type /// is implicitly affected from a dependent pipeline ( /// or ). For example, if an Article was updated /// by setting its author relationship (one-to-one) to an existing Person, /// and by this the relationship to a different Person was implicitly removed, /// this hook will be fired for the latter Person. /// - /// See for information about + /// See for information about /// when this hook is fired. /// /// /// The transformed set of ids - /// A helper that groups the entities by the affected relationship + /// A helper that groups the resources by the affected relationship /// An enum indicating from where the hook was triggered. - void BeforeImplicitUpdateRelationship(IRelationshipsDictionary entitiesByRelationship, ResourcePipeline pipeline); + void BeforeImplicitUpdateRelationship(IRelationshipsDictionary resourcesByRelationship, ResourcePipeline pipeline); } /// @@ -185,34 +185,34 @@ public interface IUpdateHookContainer where TResource : class, IIdent public interface IDeleteHookContainer where TResource : class, IIdentifiable { /// - /// Implement this hook to run custom logic in the - /// layer just before deleting entities of type . + /// Implement this hook to run custom logic in the + /// layer just before deleting resources of type . /// /// For the pipeline, - /// will typically contain one entity. + /// will typically contain one resource. /// /// The returned may be a subset - /// of , in which case the operation of the - /// pipeline will not be executed for the omitted entities. + /// of , in which case the operation of the + /// pipeline will not be executed for the omitted resources. /// - /// If by the deletion of these entities any other entities are affected + /// If by the deletion of these resources any other resources are affected /// implicitly by the removal of their relationships (eg /// in the case of an one-to-one relationship), the - /// hook is fired for these entities. + /// hook is fired for these resources. /// - /// The transformed entity set - /// The unique set of affected entities. + /// The transformed resource set + /// The unique set of affected resources. /// An enum indicating from where the hook was triggered. - IEnumerable BeforeDelete(IEntityHashSet entities, ResourcePipeline pipeline); + IEnumerable BeforeDelete(IResourceHashSet resources, ResourcePipeline pipeline); /// - /// Implement this hook to run custom logic in the - /// layer just after deletion of entities of type . + /// Implement this hook to run custom logic in the + /// layer just after deletion of resources of type . /// - /// The unique set of affected entities. + /// The unique set of affected resources. /// An enum indicating from where the hook was triggered. /// If set to true the deletion succeeded in the repository layer. - void AfterDelete(HashSet entities, ResourcePipeline pipeline, bool succeeded); + void AfterDelete(HashSet resources, ResourcePipeline pipeline, bool succeeded); } /// @@ -222,17 +222,17 @@ public interface IOnReturnHookContainer where TResource : class, IIde { /// /// Implement this hook to transform the result data just before returning - /// the entities of type from the - /// layer + /// the resources of type from the + /// layer /// /// The returned may be a subset - /// of and may contain changes in properties - /// of the encapsulated entities. + /// of and may contain changes in properties + /// of the encapsulated resources. /// /// - /// The transformed entity set - /// The unique set of affected entities + /// The transformed resource set + /// The unique set of affected resources /// An enum indicating from where the hook was triggered. - IEnumerable OnReturn(HashSet entities, ResourcePipeline pipeline); + IEnumerable OnReturn(HashSet resources, ResourcePipeline pipeline); } } diff --git a/src/JsonApiDotNetCore/Hooks/IResourceHookExecutor.cs b/src/JsonApiDotNetCore/Hooks/IResourceHookExecutor.cs index 399f98bc69..c905395286 100644 --- a/src/JsonApiDotNetCore/Hooks/IResourceHookExecutor.cs +++ b/src/JsonApiDotNetCore/Hooks/IResourceHookExecutor.cs @@ -10,7 +10,7 @@ namespace JsonApiDotNetCore.Hooks /// , and /// for more information. /// - /// Uses for traversal of nested entity data structures. + /// Uses for traversal of nested resource data structures. /// Uses for retrieving meta data about hooks, /// fetching database values and performing other recurring internal operations. /// @@ -20,65 +20,65 @@ public interface ICreateHookExecutor { /// /// Executes the Before Cycle by firing the appropriate hooks if they are implemented. - /// The returned set will be used in the actual operation in . + /// The returned set will be used in the actual operation in . /// /// Fires the - /// hook where T = for values in parameter . + /// hook where T = for values in parameter . /// /// Fires the - /// hook for any related (nested) entity for values within parameter + /// hook for any secondary (nested) resource for values within parameter /// /// The transformed set - /// Target entities for the Before cycle. + /// Target resources for the Before cycle. /// An enum indicating from where the hook was triggered. - /// The type of the root entities - IEnumerable BeforeCreate(IEnumerable entities, ResourcePipeline pipeline) where TResource : class, IIdentifiable; + /// The type of the root resources + IEnumerable BeforeCreate(IEnumerable resources, ResourcePipeline pipeline) where TResource : class, IIdentifiable; /// /// Executes the After Cycle by firing the appropriate hooks if they are implemented. /// /// Fires the - /// hook where T = for values in parameter . + /// hook where T = for values in parameter . /// /// Fires the - /// hook for any related (nested) entity for values within parameter + /// hook for any secondary (nested) resource for values within parameter /// - /// Target entities for the Before cycle. + /// Target resources for the Before cycle. /// An enum indicating from where the hook was triggered. - /// The type of the root entities - void AfterCreate(IEnumerable entities, ResourcePipeline pipeline) where TResource : class, IIdentifiable; + /// The type of the root resources + void AfterCreate(IEnumerable resources, ResourcePipeline pipeline) where TResource : class, IIdentifiable; } public interface IDeleteHookExecutor { /// /// Executes the Before Cycle by firing the appropriate hooks if they are implemented. - /// The returned set will be used in the actual operation in . + /// The returned set will be used in the actual operation in . /// /// Fires the - /// hook where T = for values in parameter . + /// hook where T = for values in parameter . /// /// Fires the - /// hook for any entities that are indirectly (implicitly) affected by this operation. - /// Eg: when deleting an entity that has relationships set to other entities, - /// these other entities are implicitly affected by the delete operation. + /// hook for any resources that are indirectly (implicitly) affected by this operation. + /// Eg: when deleting a resource that has relationships set to other resources, + /// these other resources are implicitly affected by the delete operation. /// /// The transformed set - /// Target entities for the Before cycle. + /// Target resources for the Before cycle. /// An enum indicating from where the hook was triggered. - /// The type of the root entities - IEnumerable BeforeDelete(IEnumerable entities, ResourcePipeline pipeline) where TResource : class, IIdentifiable; + /// The type of the root resources + IEnumerable BeforeDelete(IEnumerable resources, ResourcePipeline pipeline) where TResource : class, IIdentifiable; /// /// Executes the After Cycle by firing the appropriate hooks if they are implemented. /// /// Fires the - /// hook where T = for values in parameter . + /// hook where T = for values in parameter . /// - /// Target entities for the Before cycle. + /// Target resources for the Before cycle. /// An enum indicating from where the hook was triggered. /// If set to true the deletion succeeded. - /// The type of the root entities - void AfterDelete(IEnumerable entities, ResourcePipeline pipeline, bool succeeded) where TResource : class, IIdentifiable; + /// The type of the root resources + void AfterDelete(IEnumerable resources, ResourcePipeline pipeline, bool succeeded) where TResource : class, IIdentifiable; } /// @@ -91,23 +91,23 @@ public interface IReadHookExecutor /// /// Fires the /// hook where T = for the requested - /// entities as well as any related relationship. + /// resources as well as any related relationship. /// /// An enum indicating from where the hook was triggered. - /// StringId of the requested entity in the case of - /// . - /// The type of the request entity + /// StringId of the requested resource in the case of + /// . + /// The type of the request resource void BeforeRead(ResourcePipeline pipeline, string stringId = null) where TResource : class, IIdentifiable; /// /// Executes the After Cycle by firing the appropriate hooks if they are implemented. /// /// Fires the for every unique - /// entity type occuring in parameter . + /// resource type occuring in parameter . /// - /// Target entities for the Before cycle. + /// Target resources for the Before cycle. /// An enum indicating from where the hook was triggered. - /// The type of the root entities - void AfterRead(IEnumerable entities, ResourcePipeline pipeline) where TResource : class, IIdentifiable; + /// The type of the root resources + void AfterRead(IEnumerable resources, ResourcePipeline pipeline) where TResource : class, IIdentifiable; } /// @@ -117,38 +117,38 @@ public interface IUpdateHookExecutor { /// /// Executes the Before Cycle by firing the appropriate hooks if they are implemented. - /// The returned set will be used in the actual operation in . + /// The returned set will be used in the actual operation in . /// - /// Fires the - /// hook where T = for values in parameter . + /// Fires the + /// hook where T = for values in parameter . /// /// Fires the - /// hook for any related (nested) entity for values within parameter + /// hook for any secondary (nested) resource for values within parameter /// /// Fires the - /// hook for any entities that are indirectly (implicitly) affected by this operation. - /// Eg: when updating a one-to-one relationship of an entity which already + /// hook for any resources that are indirectly (implicitly) affected by this operation. + /// Eg: when updating a one-to-one relationship of a resource which already /// had this relationship populated, then this update will indirectly affect /// the existing relationship value. /// /// The transformed set - /// Target entities for the Before cycle. + /// Target resources for the Before cycle. /// An enum indicating from where the hook was triggered. - /// The type of the root entities - IEnumerable BeforeUpdate(IEnumerable entities, ResourcePipeline pipeline) where TResource : class, IIdentifiable; + /// The type of the root resources + IEnumerable BeforeUpdate(IEnumerable resources, ResourcePipeline pipeline) where TResource : class, IIdentifiable; /// /// Executes the After Cycle by firing the appropriate hooks if they are implemented. /// /// Fires the - /// hook where T = for values in parameter . + /// hook where T = for values in parameter . /// /// Fires the - /// hook for any related (nested) entity for values within parameter + /// hook for any secondary (nested) resource for values within parameter /// - /// Target entities for the Before cycle. + /// Target resources for the Before cycle. /// An enum indicating from where the hook was triggered. - /// The type of the root entities - void AfterUpdate(IEnumerable entities, ResourcePipeline pipeline) where TResource : class, IIdentifiable; + /// The type of the root resources + void AfterUpdate(IEnumerable resources, ResourcePipeline pipeline) where TResource : class, IIdentifiable; } /// @@ -160,12 +160,12 @@ public interface IOnReturnHookExecutor /// Executes the On Cycle by firing the appropriate hooks if they are implemented. /// /// Fires the for every unique - /// entity type occuring in parameter . + /// resource type occuring in parameter . /// /// The transformed set - /// Target entities for the Before cycle. + /// Target resources for the Before cycle. /// An enum indicating from where the hook was triggered. - /// The type of the root entities - IEnumerable OnReturn(IEnumerable entities, ResourcePipeline pipeline) where TResource : class, IIdentifiable; + /// The type of the root resources + IEnumerable OnReturn(IEnumerable resources, ResourcePipeline pipeline) where TResource : class, IIdentifiable; } } diff --git a/src/JsonApiDotNetCore/Hooks/ResourceHookExecutor.cs b/src/JsonApiDotNetCore/Hooks/ResourceHookExecutor.cs index e03c992bf0..a1ea87b999 100644 --- a/src/JsonApiDotNetCore/Hooks/ResourceHookExecutor.cs +++ b/src/JsonApiDotNetCore/Hooks/ResourceHookExecutor.cs @@ -9,9 +9,10 @@ using RightType = System.Type; using JsonApiDotNetCore.Extensions; using JsonApiDotNetCore.Internal.Contracts; +using JsonApiDotNetCore.Internal.Queries; +using JsonApiDotNetCore.Internal.Queries.Expressions; using JsonApiDotNetCore.Models.Annotation; using JsonApiDotNetCore.Serialization; -using JsonApiDotNetCore.Query; namespace JsonApiDotNetCore.Hooks { @@ -20,7 +21,7 @@ internal sealed class ResourceHookExecutor : IResourceHookExecutor { private readonly IHookExecutorHelper _executorHelper; private readonly ITraversalHelper _traversalHelper; - private readonly IIncludeService _includeService; + private readonly IEnumerable _constraintProviders; private readonly ITargetedFields _targetedFields; private readonly IResourceGraph _resourceGraph; private readonly IResourceFactory _resourceFactory; @@ -29,14 +30,14 @@ public ResourceHookExecutor( IHookExecutorHelper executorHelper, ITraversalHelper traversalHelper, ITargetedFields targetedFields, - IIncludeService includedRelationships, + IEnumerable constraintProviders, IResourceGraph resourceGraph, IResourceFactory resourceFactory) { _executorHelper = executorHelper; _traversalHelper = traversalHelper; _targetedFields = targetedFields; - _includeService = includedRelationships; + _constraintProviders = constraintProviders; _resourceGraph = resourceGraph; _resourceFactory = resourceFactory; } @@ -47,53 +48,62 @@ public void BeforeRead(ResourcePipeline pipeline, string stringId = n var hookContainer = _executorHelper.GetResourceHookContainer(ResourceHook.BeforeRead); hookContainer?.BeforeRead(pipeline, false, stringId); var calledContainers = new List { typeof(TResource) }; - foreach (var chain in _includeService.Get()) - RecursiveBeforeRead(chain, pipeline, calledContainers); + + var includes = _constraintProviders + .SelectMany(p => p.GetConstraints()) + .Select(expressionInScope => expressionInScope.Expression) + .OfType() + .ToArray(); + + foreach (var chain in includes.SelectMany(include => include.Chains)) + { + RecursiveBeforeRead(chain.Fields.Cast().ToList(), pipeline, calledContainers); + } } /// - public IEnumerable BeforeUpdate(IEnumerable entities, ResourcePipeline pipeline) where TResource : class, IIdentifiable + public IEnumerable BeforeUpdate(IEnumerable resources, ResourcePipeline pipeline) where TResource : class, IIdentifiable { - if (GetHook(ResourceHook.BeforeUpdate, entities, out var container, out var node)) + if (GetHook(ResourceHook.BeforeUpdate, resources, out var container, out var node)) { var relationships = node.RelationshipsToNextLayer.Select(p => p.Attribute).ToArray(); - var dbValues = LoadDbValues(typeof(TResource), (IEnumerable)node.UniqueEntities, ResourceHook.BeforeUpdate, relationships); - var diff = new DiffableEntityHashSet(node.UniqueEntities, dbValues, node.LeftsToNextLayer(), _targetedFields); + var dbValues = LoadDbValues(typeof(TResource), (IEnumerable)node.UniqueResources, ResourceHook.BeforeUpdate, relationships); + var diff = new DiffableResourceHashSet(node.UniqueResources, dbValues, node.LeftsToNextLayer(), _targetedFields); IEnumerable updated = container.BeforeUpdate(diff, pipeline); node.UpdateUnique(updated); - node.Reassign(_resourceFactory, entities); + node.Reassign(_resourceFactory, resources); } FireNestedBeforeUpdateHooks(pipeline, _traversalHelper.CreateNextLayer(node)); - return entities; + return resources; } /// - public IEnumerable BeforeCreate(IEnumerable entities, ResourcePipeline pipeline) where TResource : class, IIdentifiable + public IEnumerable BeforeCreate(IEnumerable resources, ResourcePipeline pipeline) where TResource : class, IIdentifiable { - if (GetHook(ResourceHook.BeforeCreate, entities, out var container, out var node)) + if (GetHook(ResourceHook.BeforeCreate, resources, out var container, out var node)) { - var affected = new EntityHashSet((HashSet)node.UniqueEntities, node.LeftsToNextLayer()); + var affected = new ResourceHashSet((HashSet)node.UniqueResources, node.LeftsToNextLayer()); IEnumerable updated = container.BeforeCreate(affected, pipeline); node.UpdateUnique(updated); - node.Reassign(_resourceFactory, entities); + node.Reassign(_resourceFactory, resources); } FireNestedBeforeUpdateHooks(pipeline, _traversalHelper.CreateNextLayer(node)); - return entities; + return resources; } /// - public IEnumerable BeforeDelete(IEnumerable entities, ResourcePipeline pipeline) where TResource : class, IIdentifiable + public IEnumerable BeforeDelete(IEnumerable resources, ResourcePipeline pipeline) where TResource : class, IIdentifiable { - if (GetHook(ResourceHook.BeforeDelete, entities, out var container, out var node)) + if (GetHook(ResourceHook.BeforeDelete, resources, out var container, out var node)) { var relationships = node.RelationshipsToNextLayer.Select(p => p.Attribute).ToArray(); - var targetEntities = LoadDbValues(typeof(TResource), (IEnumerable)node.UniqueEntities, ResourceHook.BeforeDelete, relationships) ?? node.UniqueEntities; - var affected = new EntityHashSet(targetEntities, node.LeftsToNextLayer()); + var targetResources = LoadDbValues(typeof(TResource), (IEnumerable)node.UniqueResources, ResourceHook.BeforeDelete, relationships) ?? node.UniqueResources; + var affected = new ResourceHashSet(targetResources, node.LeftsToNextLayer()); IEnumerable updated = container.BeforeDelete(affected, pipeline); node.UpdateUnique(updated); - node.Reassign(_resourceFactory, entities); + node.Reassign(_resourceFactory, resources); } // If we're deleting an article, we're implicitly affected any owners related to it. @@ -106,49 +116,49 @@ public IEnumerable BeforeDelete(IEnumerable ent var implicitTargets = entry.Value; FireForAffectedImplicits(rightType, implicitTargets, pipeline); } - return entities; + return resources; } /// - public IEnumerable OnReturn(IEnumerable entities, ResourcePipeline pipeline) where TResource : class, IIdentifiable + public IEnumerable OnReturn(IEnumerable resources, ResourcePipeline pipeline) where TResource : class, IIdentifiable { - if (GetHook(ResourceHook.OnReturn, entities, out var container, out var node) && pipeline != ResourcePipeline.GetRelationship) + if (GetHook(ResourceHook.OnReturn, resources, out var container, out var node) && pipeline != ResourcePipeline.GetRelationship) { - IEnumerable updated = container.OnReturn((HashSet)node.UniqueEntities, pipeline); + IEnumerable updated = container.OnReturn((HashSet)node.UniqueResources, pipeline); ValidateHookResponse(updated); node.UpdateUnique(updated); - node.Reassign(_resourceFactory, entities); + node.Reassign(_resourceFactory, resources); } Traverse(_traversalHelper.CreateNextLayer(node), ResourceHook.OnReturn, (nextContainer, nextNode) => { - var filteredUniqueSet = CallHook(nextContainer, ResourceHook.OnReturn, new object[] { nextNode.UniqueEntities, pipeline }); + var filteredUniqueSet = CallHook(nextContainer, ResourceHook.OnReturn, new object[] { nextNode.UniqueResources, pipeline }); nextNode.UpdateUnique(filteredUniqueSet); nextNode.Reassign(_resourceFactory); }); - return entities; + return resources; } /// - public void AfterRead(IEnumerable entities, ResourcePipeline pipeline) where TResource : class, IIdentifiable + public void AfterRead(IEnumerable resources, ResourcePipeline pipeline) where TResource : class, IIdentifiable { - if (GetHook(ResourceHook.AfterRead, entities, out var container, out var node)) + if (GetHook(ResourceHook.AfterRead, resources, out var container, out var node)) { - container.AfterRead((HashSet)node.UniqueEntities, pipeline); + container.AfterRead((HashSet)node.UniqueResources, pipeline); } Traverse(_traversalHelper.CreateNextLayer(node), ResourceHook.AfterRead, (nextContainer, nextNode) => { - CallHook(nextContainer, ResourceHook.AfterRead, new object[] { nextNode.UniqueEntities, pipeline, true }); + CallHook(nextContainer, ResourceHook.AfterRead, new object[] { nextNode.UniqueResources, pipeline, true }); }); } /// - public void AfterCreate(IEnumerable entities, ResourcePipeline pipeline) where TResource : class, IIdentifiable + public void AfterCreate(IEnumerable resources, ResourcePipeline pipeline) where TResource : class, IIdentifiable { - if (GetHook(ResourceHook.AfterCreate, entities, out var container, out var node)) + if (GetHook(ResourceHook.AfterCreate, resources, out var container, out var node)) { - container.AfterCreate((HashSet)node.UniqueEntities, pipeline); + container.AfterCreate((HashSet)node.UniqueResources, pipeline); } Traverse(_traversalHelper.CreateNextLayer(node), @@ -157,11 +167,11 @@ public void AfterCreate(IEnumerable entities, ResourcePipe } /// - public void AfterUpdate(IEnumerable entities, ResourcePipeline pipeline) where TResource : class, IIdentifiable + public void AfterUpdate(IEnumerable resources, ResourcePipeline pipeline) where TResource : class, IIdentifiable { - if (GetHook(ResourceHook.AfterUpdate, entities, out var container, out var node)) + if (GetHook(ResourceHook.AfterUpdate, resources, out var container, out var node)) { - container.AfterUpdate((HashSet)node.UniqueEntities, pipeline); + container.AfterUpdate((HashSet)node.UniqueResources, pipeline); } Traverse(_traversalHelper.CreateNextLayer(node), @@ -170,11 +180,11 @@ public void AfterUpdate(IEnumerable entities, ResourcePipe } /// - public void AfterDelete(IEnumerable entities, ResourcePipeline pipeline, bool succeeded) where TResource : class, IIdentifiable + public void AfterDelete(IEnumerable resources, ResourcePipeline pipeline, bool succeeded) where TResource : class, IIdentifiable { - if (GetHook(ResourceHook.AfterDelete, entities, out var container, out var node)) + if (GetHook(ResourceHook.AfterDelete, resources, out var container, out var node)) { - container.AfterDelete((HashSet)node.UniqueEntities, pipeline, succeeded); + container.AfterDelete((HashSet)node.UniqueResources, pipeline, succeeded); } } @@ -183,14 +193,14 @@ public void AfterDelete(IEnumerable entities, ResourcePipe /// , gets the hook container if the target /// hook was implemented and should be executed. /// - /// Along the way, creates a traversable node from the root entity set. + /// Along the way, creates a traversable node from the root resource set. /// /// true, if hook was implemented, false otherwise. - private bool GetHook(ResourceHook target, IEnumerable entities, + private bool GetHook(ResourceHook target, IEnumerable resources, out IResourceHookContainer container, out RootNode node) where TResource : class, IIdentifiable { - node = _traversalHelper.CreateRootNode(entities); + node = _traversalHelper.CreateRootNode(resources); container = _executorHelper.GetResourceHookContainer(target); return container != null; } @@ -198,13 +208,13 @@ private bool GetHook(ResourceHook target, IEnumerable enti /// /// Traverses the nodes in a . /// - private void Traverse(NodeLayer currentLayer, ResourceHook target, Action action) + private void Traverse(NodeLayer currentLayer, ResourceHook target, Action action) { - if (!currentLayer.AnyEntities()) return; - foreach (INode node in currentLayer) + if (!currentLayer.AnyResources()) return; + foreach (IResourceNode node in currentLayer) { - var entityType = node.ResourceType; - var hookContainer = _executorHelper.GetResourceHookContainer(entityType, target); + var resourceType = node.ResourceType; + var hookContainer = _executorHelper.GetResourceHookContainer(resourceType, target); if (hookContainer == null) continue; action(hookContainer, node); } @@ -233,7 +243,7 @@ private void RecursiveBeforeRead(List relationshipChain, } /// - /// Fires the nested before hooks for entities in the current + /// Fires the nested before hooks for resources in the current /// /// /// For example: consider the case when the owner of article1 (one-to-one) @@ -244,35 +254,35 @@ private void RecursiveBeforeRead(List relationshipChain, /// owner2, and lastly the BeforeImplicitUpdateRelationship for article2. private void FireNestedBeforeUpdateHooks(ResourcePipeline pipeline, NodeLayer layer) { - foreach (INode node in layer) + foreach (IResourceNode node in layer) { var nestedHookContainer = _executorHelper.GetResourceHookContainer(node.ResourceType, ResourceHook.BeforeUpdateRelationship); - IEnumerable uniqueEntities = node.UniqueEntities; - RightType entityType = node.ResourceType; - Dictionary currentEntitiesGrouped; - Dictionary currentEntitiesGroupedInverse; + IEnumerable uniqueResources = node.UniqueResources; + RightType resourceType = node.ResourceType; + Dictionary currentResourcesGrouped; + Dictionary currentResourcesGroupedInverse; // fire the BeforeUpdateRelationship hook for owner_new if (nestedHookContainer != null) { - if (uniqueEntities.Cast().Any()) + if (uniqueResources.Cast().Any()) { var relationships = node.RelationshipsToNextLayer.Select(p => p.Attribute).ToArray(); - var dbValues = LoadDbValues(entityType, uniqueEntities, ResourceHook.BeforeUpdateRelationship, relationships); + var dbValues = LoadDbValues(resourceType, uniqueResources, ResourceHook.BeforeUpdateRelationship, relationships); - // these are the entities of the current node grouped by + // these are the resources of the current node grouped by // RelationshipAttributes that occured in the previous layer // so it looks like { HasOneAttribute:owner => owner_new }. // Note that in the BeforeUpdateRelationship hook of Person, // we want want inverse relationship attribute: // we now have the one pointing from article -> person, ] // but we require the the one that points from person -> article - currentEntitiesGrouped = node.RelationshipsFromPreviousLayer.GetRightEntities(); - currentEntitiesGroupedInverse = ReplaceKeysWithInverseRelationships(currentEntitiesGrouped); + currentResourcesGrouped = node.RelationshipsFromPreviousLayer.GetRightResources(); + currentResourcesGroupedInverse = ReplaceKeysWithInverseRelationships(currentResourcesGrouped); - var resourcesByRelationship = CreateRelationshipHelper(entityType, currentEntitiesGroupedInverse, dbValues); - var allowedIds = CallHook(nestedHookContainer, ResourceHook.BeforeUpdateRelationship, new object[] { GetIds(uniqueEntities), resourcesByRelationship, pipeline }).Cast(); - var updated = GetAllowedEntities(uniqueEntities, allowedIds); + var resourcesByRelationship = CreateRelationshipHelper(resourceType, currentResourcesGroupedInverse, dbValues); + var allowedIds = CallHook(nestedHookContainer, ResourceHook.BeforeUpdateRelationship, new object[] { GetIds(uniqueResources), resourcesByRelationship, pipeline }).Cast(); + var updated = GetAllowedResources(uniqueResources, allowedIds); node.UpdateUnique(updated); node.Reassign(_resourceFactory); } @@ -280,70 +290,70 @@ private void FireNestedBeforeUpdateHooks(ResourcePipeline pipeline, NodeLayer la // Fire the BeforeImplicitUpdateRelationship hook for owner_old. // Note: if the pipeline is Post it means we just created article1, - // which means we are sure that it isn't related to any other entities yet. + // which means we are sure that it isn't related to any other resources yet. if (pipeline != ResourcePipeline.Post) { // To fire a hook for owner_old, we need to first get a reference to it. // For this, we need to query the database for the HasOneAttribute:owner // relationship of article1, which is referred to as the // left side of the HasOneAttribute:owner relationship. - var leftEntities = node.RelationshipsFromPreviousLayer.GetLeftEntities(); - if (leftEntities.Any()) + var leftResources = node.RelationshipsFromPreviousLayer.GetLeftResources(); + if (leftResources.Any()) { - // owner_old is loaded, which is an "implicitly affected entity" - FireForAffectedImplicits(entityType, leftEntities, pipeline, uniqueEntities); + // owner_old is loaded, which is an "implicitly affected resource" + FireForAffectedImplicits(resourceType, leftResources, pipeline, uniqueResources); } } // Fire the BeforeImplicitUpdateRelationship hook for article2 // For this, we need to query the database for the current owner // relationship value of owner_new. - currentEntitiesGrouped = node.RelationshipsFromPreviousLayer.GetRightEntities(); - if (currentEntitiesGrouped.Any()) + currentResourcesGrouped = node.RelationshipsFromPreviousLayer.GetRightResources(); + if (currentResourcesGrouped.Any()) { - // rightEntities is grouped by relationships from previous + // rightResources is grouped by relationships from previous // layer, ie { HasOneAttribute:owner => owner_new }. But // to load article2 onto owner_new, we need to have the // RelationshipAttribute from owner to article, which is the // inverse of HasOneAttribute:owner - currentEntitiesGroupedInverse = ReplaceKeysWithInverseRelationships(currentEntitiesGrouped); + currentResourcesGroupedInverse = ReplaceKeysWithInverseRelationships(currentResourcesGrouped); // Note that currently in the JADNC implementation of hooks, // the root layer is ALWAYS homogenous, so we safely assume // that for every relationship to the previous layer, the // left type is the same. - LeftType leftType = currentEntitiesGrouped.First().Key.LeftType; - FireForAffectedImplicits(leftType, currentEntitiesGroupedInverse, pipeline); + LeftType leftType = currentResourcesGrouped.First().Key.LeftType; + FireForAffectedImplicits(leftType, currentResourcesGroupedInverse, pipeline); } } } /// - /// replaces the keys of the dictionary + /// replaces the keys of the dictionary /// with its inverse relationship attribute. /// - /// Entities grouped by relationship attribute - private Dictionary ReplaceKeysWithInverseRelationships(Dictionary entitiesByRelationship) + /// Resources grouped by relationship attribute + private Dictionary ReplaceKeysWithInverseRelationships(Dictionary resourcesByRelationship) { // when Article has one Owner (HasOneAttribute:owner) is set, there is no guarantee // 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 entities resolved through inverse relationships. - var inversableRelationshipAttributes = entitiesByRelationship.Where(kvp => kvp.Key.InverseNavigation != null); + // currently cannot fire hooks for resources resolved through inverse relationships. + var inversableRelationshipAttributes = resourcesByRelationship.Where(kvp => kvp.Key.InverseNavigation != null); return inversableRelationshipAttributes.ToDictionary(kvp => _resourceGraph.GetInverse(kvp.Key), kvp => kvp.Value); } /// - /// Given a source of entities, gets the implicitly affected entities + /// Given a source of resources, gets the implicitly affected resources /// from the database and calls the BeforeImplicitUpdateRelationship hook. /// - private void FireForAffectedImplicits(Type entityTypeToInclude, Dictionary implicitsTarget, ResourcePipeline pipeline, IEnumerable existingImplicitEntities = null) + private void FireForAffectedImplicits(Type resourceTypeToInclude, Dictionary implicitsTarget, ResourcePipeline pipeline, IEnumerable existingImplicitResources = null) { - var container = _executorHelper.GetResourceHookContainer(entityTypeToInclude, ResourceHook.BeforeImplicitUpdateRelationship); + var container = _executorHelper.GetResourceHookContainer(resourceTypeToInclude, ResourceHook.BeforeImplicitUpdateRelationship); if (container == null) return; - var implicitAffected = _executorHelper.LoadImplicitlyAffected(implicitsTarget, existingImplicitEntities); + var implicitAffected = _executorHelper.LoadImplicitlyAffected(implicitsTarget, existingImplicitResources); if (!implicitAffected.Any()) return; var inverse = implicitAffected.ToDictionary(kvp => _resourceGraph.GetInverse(kvp.Key), kvp => kvp.Value); - var resourcesByRelationship = CreateRelationshipHelper(entityTypeToInclude, inverse); + var resourcesByRelationship = CreateRelationshipHelper(resourceTypeToInclude, inverse); CallHook(container, ResourceHook.BeforeImplicitUpdateRelationship, new object[] { resourcesByRelationship, pipeline, }); } @@ -390,80 +400,80 @@ private object ThrowJsonApiExceptionOnError(Func action) } /// - /// Helper method to instantiate AffectedRelationships for a given + /// Helper method to instantiate AffectedRelationships for a given /// If are included, the values of the entries in need to be replaced with these values. /// /// The relationship helper. - private IRelationshipsDictionary CreateRelationshipHelper(RightType entityType, Dictionary prevLayerRelationships, IEnumerable dbValues = null) + private IRelationshipsDictionary CreateRelationshipHelper(RightType resourceType, Dictionary prevLayerRelationships, IEnumerable dbValues = null) { if (dbValues != null) prevLayerRelationships = ReplaceWithDbValues(prevLayerRelationships, dbValues.Cast()); - return (IRelationshipsDictionary)TypeHelper.CreateInstanceOfOpenType(typeof(RelationshipsDictionary<>), entityType, true, prevLayerRelationships); + return (IRelationshipsDictionary)TypeHelper.CreateInstanceOfOpenType(typeof(RelationshipsDictionary<>), resourceType, true, prevLayerRelationships); } /// - /// Replaces the entities in the values of the prevLayerRelationships dictionary - /// with the corresponding entities loaded from the db. + /// Replaces the resources in the values of the prevLayerRelationships dictionary + /// with the corresponding resources loaded from the db. /// private Dictionary ReplaceWithDbValues(Dictionary prevLayerRelationships, IEnumerable dbValues) { foreach (var key in prevLayerRelationships.Keys.ToList()) { - var replaced = prevLayerRelationships[key].Cast().Select(entity => dbValues.Single(dbEntity => dbEntity.StringId == entity.StringId)).CopyToList(key.LeftType); + var replaced = prevLayerRelationships[key].Cast().Select(resource => dbValues.Single(dbResource => dbResource.StringId == resource.StringId)).CopyToList(key.LeftType); prevLayerRelationships[key] = TypeHelper.CreateHashSetFor(key.LeftType, replaced); } return prevLayerRelationships; } /// - /// Filter the source set by removing the entities with id that are not + /// Filter the source set by removing the resources with id that are not /// in . /// - private HashSet GetAllowedEntities(IEnumerable source, IEnumerable allowedIds) + private HashSet GetAllowedResources(IEnumerable source, IEnumerable allowedIds) { return new HashSet(source.Cast().Where(ue => allowedIds.Contains(ue.StringId))); } /// - /// given the set of , it will load all the - /// values from the database of these entities. + /// given the set of , it will load all the + /// values from the database of these resources. /// /// The db values. - /// type of the entities to be loaded - /// The set of entities to load the db values for + /// type of the resources to be loaded + /// The set of resources to load the db values for /// The hook in which the db values will be displayed. - /// Relationships from to the next layer: - /// this indicates which relationships will be included on . - private IEnumerable LoadDbValues(Type entityType, IEnumerable uniqueEntities, ResourceHook targetHook, RelationshipAttribute[] relationshipsToNextLayer) + /// Relationships from to the next layer: + /// this indicates which relationships will be included on . + private IEnumerable LoadDbValues(Type resourceType, IEnumerable uniqueResources, ResourceHook targetHook, RelationshipAttribute[] relationshipsToNextLayer) { // We only need to load database values if the target hook of this hook execution // cycle is compatible with displaying database values and has this option enabled. - if (!_executorHelper.ShouldLoadDbValues(entityType, targetHook)) return null; - return _executorHelper.LoadDbValues(entityType, uniqueEntities, targetHook, relationshipsToNextLayer); + if (!_executorHelper.ShouldLoadDbValues(resourceType, targetHook)) return null; + return _executorHelper.LoadDbValues(resourceType, uniqueResources, targetHook, relationshipsToNextLayer); } /// /// Fires the AfterUpdateRelationship hook /// - private void FireAfterUpdateRelationship(IResourceHookContainer container, INode node, ResourcePipeline pipeline) + private void FireAfterUpdateRelationship(IResourceHookContainer container, IResourceNode node, ResourcePipeline pipeline) { - Dictionary currentEntitiesGrouped = node.RelationshipsFromPreviousLayer.GetRightEntities(); - // the relationships attributes in currenEntitiesGrouped will be pointing from a + Dictionary currentResourcesGrouped = node.RelationshipsFromPreviousLayer.GetRightResources(); + // the relationships attributes in currentResourcesGrouped will be pointing from a // resource in the previouslayer to a resource in the current (nested) layer. // For the nested hook we need to replace these attributes with their inverse. // See the FireNestedBeforeUpdateHooks method for a more detailed example. - var resourcesByRelationship = CreateRelationshipHelper(node.ResourceType, ReplaceKeysWithInverseRelationships(currentEntitiesGrouped)); + var resourcesByRelationship = CreateRelationshipHelper(node.ResourceType, ReplaceKeysWithInverseRelationships(currentResourcesGrouped)); CallHook(container, ResourceHook.AfterUpdateRelationship, new object[] { resourcesByRelationship, pipeline }); } /// - /// Returns a list of StringIds from a list of IIdentifiable entities (). + /// Returns a list of StringIds from a list of IIdentifiable resources (). /// /// The ids. - /// IIdentifiable entities. - private HashSet GetIds(IEnumerable entities) + /// IIdentifiable resources. + private HashSet GetIds(IEnumerable resources) { - return new HashSet(entities.Cast().Select(e => e.StringId)); + return new HashSet(resources.Cast().Select(e => e.StringId)); } } } diff --git a/src/JsonApiDotNetCore/Hooks/Traversal/ChildNode.cs b/src/JsonApiDotNetCore/Hooks/Traversal/ChildNode.cs index 3e971e3fb9..9e6e45b297 100644 --- a/src/JsonApiDotNetCore/Hooks/Traversal/ChildNode.cs +++ b/src/JsonApiDotNetCore/Hooks/Traversal/ChildNode.cs @@ -12,7 +12,7 @@ namespace JsonApiDotNetCore.Hooks /// Child node in the tree /// /// - internal sealed class ChildNode : INode where TResource : class, IIdentifiable + internal sealed class ChildNode : IResourceNode where TResource : class, IIdentifiable { private readonly IdentifiableComparer _comparer = IdentifiableComparer.Instance; /// @@ -20,11 +20,11 @@ internal sealed class ChildNode : INode where TResource : class, IIde /// public RelationshipProxy[] RelationshipsToNextLayer { get; } /// - public IEnumerable UniqueEntities + public IEnumerable UniqueResources { get { - return new HashSet(_relationshipsFromPreviousLayer.SelectMany(rfpl => rfpl.RightEntities)); + return new HashSet(_relationshipsFromPreviousLayer.SelectMany(rfpl => rfpl.RightResources)); } } @@ -46,7 +46,7 @@ public void UpdateUnique(IEnumerable updated) List cast = updated.Cast().ToList(); foreach (var group in _relationshipsFromPreviousLayer) { - group.RightEntities = new HashSet(group.RightEntities.Intersect(cast, _comparer).Cast()); + group.RightResources = new HashSet(group.RightResources.Intersect(cast, _comparer).Cast()); } } @@ -55,13 +55,13 @@ public void UpdateUnique(IEnumerable updated) /// public void Reassign(IResourceFactory resourceFactory, IEnumerable updated = null) { - var unique = (HashSet)UniqueEntities; + var unique = (HashSet)UniqueResources; foreach (var group in _relationshipsFromPreviousLayer) { var proxy = group.Proxy; - var leftEntities = group.LeftEntities; + var leftResources = group.LeftResources; - foreach (IIdentifiable left in leftEntities) + foreach (IIdentifiable left in leftResources) { var currentValue = proxy.GetValue(left); diff --git a/src/JsonApiDotNetCore/Hooks/Traversal/IEntityNode.cs b/src/JsonApiDotNetCore/Hooks/Traversal/IResourceNode.cs similarity index 81% rename from src/JsonApiDotNetCore/Hooks/Traversal/IEntityNode.cs rename to src/JsonApiDotNetCore/Hooks/Traversal/IResourceNode.cs index 71d305227c..73a2a16f5d 100644 --- a/src/JsonApiDotNetCore/Hooks/Traversal/IEntityNode.cs +++ b/src/JsonApiDotNetCore/Hooks/Traversal/IResourceNode.cs @@ -7,16 +7,16 @@ namespace JsonApiDotNetCore.Hooks /// /// This is the interface that nodes need to inherit from /// - internal interface INode + internal interface IResourceNode { /// - /// Each node represents the entities of a given type throughout a particular layer. + /// Each node represents the resources of a given type throughout a particular layer. /// RightType ResourceType { get; } /// - /// The unique set of entities in this node. Note that these are all of the same type. + /// The unique set of resources in this node. Note that these are all of the same type. /// - IEnumerable UniqueEntities { get; } + IEnumerable UniqueResources { get; } /// /// Relationships to the next layer /// @@ -33,7 +33,7 @@ internal interface INode /// void Reassign(IResourceFactory resourceFactory, IEnumerable source = null); /// - /// A helper method to internally update the unique set of entities as a result of + /// A helper method to internally update the unique set of resources as a result of /// a filter action in a hook. /// /// Updated. diff --git a/src/JsonApiDotNetCore/Hooks/Traversal/ITraversalHelper.cs b/src/JsonApiDotNetCore/Hooks/Traversal/ITraversalHelper.cs index 2a93cbb4b0..017d3c78c1 100644 --- a/src/JsonApiDotNetCore/Hooks/Traversal/ITraversalHelper.cs +++ b/src/JsonApiDotNetCore/Hooks/Traversal/ITraversalHelper.cs @@ -10,21 +10,21 @@ internal interface ITraversalHelper /// /// /// - NodeLayer CreateNextLayer(INode node); + NodeLayer CreateNextLayer(IResourceNode node); /// /// Creates the next layer based on the nodes provided /// /// /// - NodeLayer CreateNextLayer(IEnumerable nodes); + NodeLayer CreateNextLayer(IEnumerable nodes); /// /// Creates a root node for breadth-first-traversal (BFS). Note that typically, in /// JADNC, the root layer will be homogeneous. Also, because it is the first layer, /// there can be no relationships to previous layers, only to next layers. /// /// The root node. - /// Root entities. + /// Root resources. /// The 1st type parameter. - RootNode CreateRootNode(IEnumerable rootEntities) where TResource : class, IIdentifiable; + RootNode CreateRootNode(IEnumerable rootResources) where TResource : class, IIdentifiable; } } diff --git a/src/JsonApiDotNetCore/Hooks/Traversal/RelationshipGroup.cs b/src/JsonApiDotNetCore/Hooks/Traversal/RelationshipGroup.cs index c3febd7a3b..c6771c1ad6 100644 --- a/src/JsonApiDotNetCore/Hooks/Traversal/RelationshipGroup.cs +++ b/src/JsonApiDotNetCore/Hooks/Traversal/RelationshipGroup.cs @@ -6,19 +6,19 @@ namespace JsonApiDotNetCore.Hooks internal interface IRelationshipGroup { RelationshipProxy Proxy { get; } - HashSet LeftEntities { get; } + HashSet LeftResources { get; } } internal sealed class RelationshipGroup : IRelationshipGroup where TRight : class, IIdentifiable { public RelationshipProxy Proxy { get; } - public HashSet LeftEntities { get; } - public HashSet RightEntities { get; internal set; } - public RelationshipGroup(RelationshipProxy proxy, HashSet leftEntities, HashSet rightEntities) + public HashSet LeftResources { get; } + public HashSet RightResources { get; internal set; } + public RelationshipGroup(RelationshipProxy proxy, HashSet leftResources, HashSet rightResources) { Proxy = proxy; - LeftEntities = leftEntities; - RightEntities = rightEntities; + LeftResources = leftResources; + RightResources = rightResources; } } } diff --git a/src/JsonApiDotNetCore/Hooks/Traversal/RelationshipProxy.cs b/src/JsonApiDotNetCore/Hooks/Traversal/RelationshipProxy.cs index 17d4f83415..62bacec551 100644 --- a/src/JsonApiDotNetCore/Hooks/Traversal/RelationshipProxy.cs +++ b/src/JsonApiDotNetCore/Hooks/Traversal/RelationshipProxy.cs @@ -14,20 +14,20 @@ namespace JsonApiDotNetCore.Hooks /// A wrapper for RelationshipAttribute with an abstraction layer that works on the /// getters and setters of relationships. These are different in the case of /// HasMany vs HasManyThrough, and HasManyThrough. - /// It also depends on if the join table entity - /// (eg ArticleTags) is identifiable (in which case we will traverse through + /// It also depends on if the through type (eg ArticleTags) + /// is identifiable (in which case we will traverse through /// it and fire hooks for it, if defined) or not (in which case we skip /// ArticleTags and go directly to Tags. /// internal sealed class RelationshipProxy { - private readonly bool _skipJoinTable; + private readonly bool _skipThroughType; /// /// The target type for this relationship attribute. /// For HasOne has HasMany this is trivial: just the right-hand side. - /// For HasManyThrough it is either the ThroughProperty (when the join table is - /// Identifiable) or it is the right-hand side (when the join table is not identifiable) + /// For HasManyThrough it is either the ThroughProperty (when the through resource is + /// Identifiable) or it is the right-hand side (when the through resource is not identifiable) /// public Type RightType { get; } public Type LeftType => Attribute.LeftType; @@ -41,77 +41,77 @@ public RelationshipProxy(RelationshipAttribute attr, Type relatedType, bool isCo IsContextRelation = isContextRelation; if (attr is HasManyThroughAttribute throughAttr) { - _skipJoinTable |= RightType != throughAttr.ThroughType; + _skipThroughType |= RightType != throughAttr.ThroughType; } } /// - /// Gets the relationship value for a given parent entity. + /// Gets the relationship value for a given parent resource. /// Internally knows how to do this depending on the type of RelationshipAttribute /// that this RelationshipProxy encapsulates. /// /// The relationship value. - /// Parent entity. - public object GetValue(IIdentifiable entity) + /// Parent resource. + public object GetValue(IIdentifiable resource) { if (Attribute is HasManyThroughAttribute hasManyThrough) { - if (!_skipJoinTable) + if (!_skipThroughType) { - return hasManyThrough.ThroughProperty.GetValue(entity); + return hasManyThrough.ThroughProperty.GetValue(resource); } var collection = new List(); - var joinEntities = (IEnumerable)hasManyThrough.ThroughProperty.GetValue(entity); - if (joinEntities == null) return null; + var throughResources = (IEnumerable)hasManyThrough.ThroughProperty.GetValue(resource); + if (throughResources == null) return null; - foreach (var joinEntity in joinEntities) + foreach (var throughResource in throughResources) { - var rightEntity = (IIdentifiable)hasManyThrough.RightProperty.GetValue(joinEntity); - if (rightEntity == null) continue; - collection.Add(rightEntity); + var rightResource = (IIdentifiable)hasManyThrough.RightProperty.GetValue(throughResource); + if (rightResource == null) continue; + collection.Add(rightResource); } return collection; } - return Attribute.GetValue(entity); + return Attribute.GetValue(resource); } /// - /// Set the relationship value for a given parent entity. + /// Set the relationship value for a given parent resource. /// Internally knows how to do this depending on the type of RelationshipAttribute /// that this RelationshipProxy encapsulates. /// - /// Parent entity. + /// Parent resource. /// The relationship value. /// - public void SetValue(IIdentifiable entity, object value, IResourceFactory resourceFactory) + public void SetValue(IIdentifiable resource, object value, IResourceFactory resourceFactory) { if (Attribute is HasManyThroughAttribute hasManyThrough) { - if (!_skipJoinTable) + if (!_skipThroughType) { - hasManyThrough.ThroughProperty.SetValue(entity, value); + hasManyThrough.ThroughProperty.SetValue(resource, value); return; } - var joinEntities = (IEnumerable)hasManyThrough.ThroughProperty.GetValue(entity); + var throughResources = (IEnumerable)hasManyThrough.ThroughProperty.GetValue(resource); var filteredList = new List(); - var rightEntities = ((IEnumerable)value).CopyToList(RightType); - foreach (var joinEntity in joinEntities) + var rightResources = ((IEnumerable)value).CopyToList(RightType); + foreach (var throughResource in throughResources) { - if (((IList)rightEntities).Contains(hasManyThrough.RightProperty.GetValue(joinEntity))) + if (((IList)rightResources).Contains(hasManyThrough.RightProperty.GetValue(throughResource))) { - filteredList.Add(joinEntity); + filteredList.Add(throughResource); } } var collectionValue = filteredList.CopyToTypedCollection(hasManyThrough.ThroughProperty.PropertyType); - hasManyThrough.ThroughProperty.SetValue(entity, collectionValue); + hasManyThrough.ThroughProperty.SetValue(resource, collectionValue); return; } - Attribute.SetValue(entity, value, resourceFactory); + Attribute.SetValue(resource, value, resourceFactory); } } } diff --git a/src/JsonApiDotNetCore/Hooks/Traversal/RelationshipsFromPreviousLayer.cs b/src/JsonApiDotNetCore/Hooks/Traversal/RelationshipsFromPreviousLayer.cs index 77b8c4b56b..cb8aa7c63b 100644 --- a/src/JsonApiDotNetCore/Hooks/Traversal/RelationshipsFromPreviousLayer.cs +++ b/src/JsonApiDotNetCore/Hooks/Traversal/RelationshipsFromPreviousLayer.cs @@ -12,15 +12,15 @@ namespace JsonApiDotNetCore.Hooks internal interface IRelationshipsFromPreviousLayer { /// - /// Grouped by relationship to the previous layer, gets all the entities of the current layer + /// Grouped by relationship to the previous layer, gets all the resources of the current layer /// - /// The right side entities. - Dictionary GetRightEntities(); + /// The right side resources. + Dictionary GetRightResources(); /// - /// Grouped by relationship to the previous layer, gets all the entities of the previous layer + /// Grouped by relationship to the previous layer, gets all the resources of the previous layer /// - /// The right side entities. - Dictionary GetLeftEntities(); + /// The right side resources. + Dictionary GetLeftResources(); } internal sealed class RelationshipsFromPreviousLayer : IRelationshipsFromPreviousLayer, IEnumerable> where TRightResource : class, IIdentifiable @@ -33,15 +33,15 @@ public RelationshipsFromPreviousLayer(IEnumerable - public Dictionary GetRightEntities() + public Dictionary GetRightResources() { - return _collection.ToDictionary(rg => rg.Proxy.Attribute, rg => (IEnumerable)rg.RightEntities); + return _collection.ToDictionary(rg => rg.Proxy.Attribute, rg => (IEnumerable)rg.RightResources); } /// - public Dictionary GetLeftEntities() + public Dictionary GetLeftResources() { - return _collection.ToDictionary(rg => rg.Proxy.Attribute, rg => (IEnumerable)rg.LeftEntities); + return _collection.ToDictionary(rg => rg.Proxy.Attribute, rg => (IEnumerable)rg.LeftResources); } public IEnumerator> GetEnumerator() diff --git a/src/JsonApiDotNetCore/Hooks/Traversal/RootNode.cs b/src/JsonApiDotNetCore/Hooks/Traversal/RootNode.cs index 190a082cf9..45d565c653 100644 --- a/src/JsonApiDotNetCore/Hooks/Traversal/RootNode.cs +++ b/src/JsonApiDotNetCore/Hooks/Traversal/RootNode.cs @@ -9,31 +9,31 @@ namespace JsonApiDotNetCore.Hooks { /// - /// The root node class of the breadth-first-traversal of entity data structures + /// The root node class of the breadth-first-traversal of resource data structures /// as performed by the /// - internal sealed class RootNode : INode where TResource : class, IIdentifiable + internal sealed class RootNode : IResourceNode where TResource : class, IIdentifiable { private readonly IdentifiableComparer _comparer = IdentifiableComparer.Instance; private readonly RelationshipProxy[] _allRelationshipsToNextLayer; - private HashSet _uniqueEntities; + private HashSet _uniqueResources; public Type ResourceType { get; } - public IEnumerable UniqueEntities => _uniqueEntities; + public IEnumerable UniqueResources => _uniqueResources; public RelationshipProxy[] RelationshipsToNextLayer { get; } public Dictionary> LeftsToNextLayerByRelationships() { return _allRelationshipsToNextLayer .GroupBy(proxy => proxy.RightType) - .ToDictionary(gdc => gdc.Key, gdc => gdc.ToDictionary(p => p.Attribute, p => UniqueEntities)); + .ToDictionary(gdc => gdc.Key, gdc => gdc.ToDictionary(p => p.Attribute, p => UniqueResources)); } /// - /// The current layer entities grouped by affected relationship to the next layer + /// The current layer resources grouped by affected relationship to the next layer /// public Dictionary LeftsToNextLayer() { - return RelationshipsToNextLayer.ToDictionary(p => p.Attribute, p => UniqueEntities); + return RelationshipsToNextLayer.ToDictionary(p => p.Attribute, p => UniqueResources); } /// @@ -41,28 +41,28 @@ public Dictionary LeftsToNextLayer() /// public IRelationshipsFromPreviousLayer RelationshipsFromPreviousLayer => null; - public RootNode(IEnumerable uniqueEntities, RelationshipProxy[] populatedRelationships, RelationshipProxy[] allRelationships) + public RootNode(IEnumerable uniqueResources, RelationshipProxy[] populatedRelationships, RelationshipProxy[] allRelationships) { ResourceType = typeof(TResource); - _uniqueEntities = new HashSet(uniqueEntities); + _uniqueResources = new HashSet(uniqueResources); RelationshipsToNextLayer = populatedRelationships; _allRelationshipsToNextLayer = allRelationships; } /// - /// Update the internal list of affected entities. + /// Update the internal list of affected resources. /// /// Updated. public void UpdateUnique(IEnumerable updated) { var cast = updated.Cast().ToList(); - var intersected = _uniqueEntities.Intersect(cast, _comparer).Cast(); - _uniqueEntities = new HashSet(intersected); + var intersected = _uniqueResources.Intersect(cast, _comparer).Cast(); + _uniqueResources = new HashSet(intersected); } public void Reassign(IResourceFactory resourceFactory, IEnumerable source = null) { - var ids = _uniqueEntities.Select(ue => ue.StringId); + var ids = _uniqueResources.Select(ue => ue.StringId); if (source is HashSet hashSet) { diff --git a/src/JsonApiDotNetCore/Hooks/Traversal/TraversalHelper.cs b/src/JsonApiDotNetCore/Hooks/Traversal/TraversalHelper.cs index 5eaacb26c1..014705604b 100644 --- a/src/JsonApiDotNetCore/Hooks/Traversal/TraversalHelper.cs +++ b/src/JsonApiDotNetCore/Hooks/Traversal/TraversalHelper.cs @@ -16,10 +16,10 @@ namespace JsonApiDotNetCore.Hooks { /// /// A helper class used by the to traverse through - /// entity data structures (trees), allowing for a breadth-first-traversal + /// resource data structures (trees), allowing for a breadth-first-traversal /// /// It creates nodes for each layer. - /// Typically, the first layer is homogeneous (all entities have the same type), + /// Typically, the first layer is homogeneous (all resources have the same type), /// and further nodes can be mixed. /// internal sealed class TraversalHelper : ITraversalHelper @@ -28,10 +28,10 @@ internal sealed class TraversalHelper : ITraversalHelper private readonly IResourceGraph _resourceGraph; private readonly ITargetedFields _targetedFields; /// - /// Keeps track of which entities has already been traversed through, to prevent + /// Keeps track of which resources has already been traversed through, to prevent /// infinite loops in eg cyclic data structures. /// - private Dictionary> _processedEntities; + private Dictionary> _processedResources; /// /// A mapper from to . /// See the latter for more details. @@ -51,16 +51,16 @@ public TraversalHelper( /// there can be no relationships to previous layers, only to next layers. /// /// The root node. - /// Root entities. + /// Root resources. /// The 1st type parameter. - public RootNode CreateRootNode(IEnumerable rootEntities) where TResource : class, IIdentifiable + public RootNode CreateRootNode(IEnumerable rootResources) where TResource : class, IIdentifiable { - _processedEntities = new Dictionary>(); + _processedResources = new Dictionary>(); RegisterRelationshipProxies(typeof(TResource)); - var uniqueEntities = ProcessEntities(rootEntities); - var populatedRelationshipsToNextLayer = GetPopulatedRelationships(typeof(TResource), uniqueEntities); + var uniqueResources = ProcessResources(rootResources); + var populatedRelationshipsToNextLayer = GetPopulatedRelationships(typeof(TResource), uniqueResources); var allRelationshipsFromType = _relationshipProxies.Select(entry => entry.Value).Where(proxy => proxy.LeftType == typeof(TResource)).ToArray(); - return new RootNode(uniqueEntities, populatedRelationshipsToNextLayer, allRelationshipsFromType); + return new RootNode(uniqueResources, populatedRelationshipsToNextLayer, allRelationshipsFromType); } /// @@ -68,7 +68,7 @@ public RootNode CreateRootNode(IEnumerable root /// /// The next layer. /// Root node. - public NodeLayer CreateNextLayer(INode rootNode) + public NodeLayer CreateNextLayer(IResourceNode rootNode) { return CreateNextLayer(new[] { rootNode }); } @@ -78,11 +78,11 @@ public NodeLayer CreateNextLayer(INode rootNode) /// /// The next layer. /// Nodes. - public NodeLayer CreateNextLayer(IEnumerable nodes) + public NodeLayer CreateNextLayer(IEnumerable nodes) { - // first extract entities by parsing populated relationships in the entities + // first extract resources by parsing populated relationships in the resources // of previous layer - var (lefts, rights) = ExtractEntities(nodes); + var (lefts, rights) = ExtractResources(nodes); // group them conveniently so we can make ChildNodes of them: // there might be several relationship attributes in rights dictionary @@ -106,7 +106,6 @@ public NodeLayer CreateNextLayer(IEnumerable nodes) return CreateNodeInstance(nextNodeType, populatedRelationships.ToArray(), relationshipsToPreviousLayer); }).ToList(); - // wrap the child nodes in a EntityChildLayer return new NodeLayer(nextNodes); } @@ -120,53 +119,53 @@ private Dictionary - /// Extracts the entities for the current layer by going through all populated relationships - /// of the (left entities of the previous layer. + /// Extracts the resources for the current layer by going through all populated relationships + /// of the (left resources of the previous layer. /// - private (Dictionary>, Dictionary>) ExtractEntities(IEnumerable leftNodes) + private (Dictionary>, Dictionary>) ExtractResources(IEnumerable leftNodes) { - var leftEntitiesGrouped = new Dictionary>(); // RelationshipAttr_prevLayer->currentLayer => prevLayerEntities - var rightEntitiesGrouped = new Dictionary>(); // RelationshipAttr_prevLayer->currentLayer => currentLayerEntities + var leftResourcesGrouped = new Dictionary>(); // RelationshipAttr_prevLayer->currentLayer => prevLayerResources + var rightResourcesGrouped = new Dictionary>(); // RelationshipAttr_prevLayer->currentLayer => currentLayerResources foreach (var node in leftNodes) { - var leftEntities = node.UniqueEntities; + var leftResources = node.UniqueResources; var relationships = node.RelationshipsToNextLayer; - foreach (IIdentifiable leftEntity in leftEntities) + foreach (IIdentifiable leftResource in leftResources) { foreach (var proxy in relationships) { - var relationshipValue = proxy.GetValue(leftEntity); + var relationshipValue = proxy.GetValue(leftResource); // skip this relationship if it's not populated if (!proxy.IsContextRelation && relationshipValue == null) continue; - if (!(relationshipValue is IEnumerable rightEntities)) + if (!(relationshipValue is IEnumerable rightResources)) { // in the case of a to-one relationship, the assigned value // will not be a list. We therefore first wrap it in a list. var list = TypeHelper.CreateListFor(proxy.RightType); if (relationshipValue != null) list.Add(relationshipValue); - rightEntities = list; + rightResources = list; } - var uniqueRightEntities = UniqueInTree(rightEntities.Cast(), proxy.RightType); - if (proxy.IsContextRelation || uniqueRightEntities.Any()) + var uniqueRightResources = UniqueInTree(rightResources.Cast(), proxy.RightType); + if (proxy.IsContextRelation || uniqueRightResources.Any()) { - AddToRelationshipGroup(rightEntitiesGrouped, proxy, uniqueRightEntities); - AddToRelationshipGroup(leftEntitiesGrouped, proxy, new[] { leftEntity }); + AddToRelationshipGroup(rightResourcesGrouped, proxy, uniqueRightResources); + AddToRelationshipGroup(leftResourcesGrouped, proxy, new[] { leftResource }); } } } } - var processEntitiesMethod = GetType().GetMethod(nameof(ProcessEntities), BindingFlags.NonPublic | BindingFlags.Instance); - foreach (var kvp in rightEntitiesGrouped) + var processResourcesMethod = GetType().GetMethod(nameof(ProcessResources), BindingFlags.NonPublic | BindingFlags.Instance); + foreach (var kvp in rightResourcesGrouped) { var type = kvp.Key.RightType; var list = kvp.Value.CopyToList(type); - processEntitiesMethod.MakeGenericMethod(type).Invoke(this, new object[] { list }); + processResourcesMethod.MakeGenericMethod(type).Invoke(this, new object[] { list }); } - return (leftEntitiesGrouped, rightEntitiesGrouped); + return (leftResourcesGrouped, rightResourcesGrouped); } /// @@ -181,17 +180,17 @@ private RelationshipProxy[] GetPopulatedRelationships(LeftType leftType, IEnumer } /// - /// Registers the entities as "seen" in the tree traversal, extracts any new s from it. + /// Registers the resources as "seen" in the tree traversal, extracts any new s from it. /// - /// The entities. - /// Incoming entities. + /// The resources. + /// Incoming resources. /// The 1st type parameter. - private HashSet ProcessEntities(IEnumerable incomingEntities) where TResource : class, IIdentifiable + private HashSet ProcessResources(IEnumerable incomingResources) where TResource : class, IIdentifiable { Type type = typeof(TResource); - var newEntities = UniqueInTree(incomingEntities, type); - RegisterProcessedEntities(newEntities, type); - return newEntities; + var newResources = UniqueInTree(incomingResources, type); + RegisterProcessedResources(newResources, type); + return newResources; } /// @@ -217,49 +216,47 @@ private void RegisterRelationshipProxies(RightType type) } /// - /// Registers the processed entities in the dictionary grouped by type + /// Registers the processed resources in the dictionary grouped by type /// - /// Entities to register - /// Entity type. - private void RegisterProcessedEntities(IEnumerable entities, Type entityType) + /// Resources to register + /// Resource type. + private void RegisterProcessedResources(IEnumerable resources, Type resourceType) { - var processedEntities = GetProcessedEntities(entityType); - processedEntities.UnionWith(new HashSet(entities)); + var processedResources = GetProcessedResources(resourceType); + processedResources.UnionWith(new HashSet(resources)); } /// - /// Gets the processed entities for a given type, instantiates the collection if new. + /// Gets the processed resources for a given type, instantiates the collection if new. /// - /// The processed entities. - /// Entity type. - private HashSet GetProcessedEntities(Type entityType) + /// The processed resources. + /// Resource type. + private HashSet GetProcessedResources(Type resourceType) { - if (!_processedEntities.TryGetValue(entityType, out HashSet processedEntities)) + if (!_processedResources.TryGetValue(resourceType, out HashSet processedResources)) { - processedEntities = new HashSet(); - _processedEntities[entityType] = processedEntities; + processedResources = new HashSet(); + _processedResources[resourceType] = processedResources; } - return processedEntities; + return processedResources; } /// - /// Using the register of processed entities, determines the unique and new - /// entities with respect to previous iterations. + /// Using the register of processed resources, determines the unique and new + /// resources with respect to previous iterations. /// /// The in tree. - /// Entities. - /// Entity type. - private HashSet UniqueInTree(IEnumerable entities, Type entityType) where TResource : class, IIdentifiable + private HashSet UniqueInTree(IEnumerable resources, Type resourceType) where TResource : class, IIdentifiable { - var newEntities = entities.Except(GetProcessedEntities(entityType), _comparer).Cast(); - return new HashSet(newEntities); + var newResources = resources.Except(GetProcessedResources(resourceType), _comparer).Cast(); + return new HashSet(newResources); } /// /// Gets the type from relationship attribute. If the attribute is - /// HasManyThrough, and the join table entity is identifiable, then the target - /// type is the join entity instead of the right-hand side, because hooks might be - /// implemented for the join table entity. + /// HasManyThrough, and the through type is identifiable, then the target + /// type is the through type instead of the right type, because hooks might be + /// implemented for the through resource. /// /// The target type for traversal /// Relationship attribute @@ -272,23 +269,23 @@ private RightType GetRightTypeFromRelationship(RelationshipAttribute attr) return attr.RightType; } - private void AddToRelationshipGroup(Dictionary> target, RelationshipProxy proxy, IEnumerable newEntities) + private void AddToRelationshipGroup(Dictionary> target, RelationshipProxy proxy, IEnumerable newResources) { - if (!target.TryGetValue(proxy, out List entities)) + if (!target.TryGetValue(proxy, out List resources)) { - entities = new List(); - target[proxy] = entities; + resources = new List(); + target[proxy] = resources; } - entities.AddRange(newEntities); + resources.AddRange(newResources); } /// /// Reflective helper method to create an instance of ; /// - private INode CreateNodeInstance(RightType nodeType, RelationshipProxy[] relationshipsToNext, IEnumerable relationshipsFromPrev) + private IResourceNode CreateNodeInstance(RightType nodeType, RelationshipProxy[] relationshipsToNext, IEnumerable relationshipsFromPrev) { IRelationshipsFromPreviousLayer prev = CreateRelationshipsFromInstance(nodeType, relationshipsFromPrev); - return (INode)TypeHelper.CreateInstanceOfOpenType(typeof(ChildNode<>), nodeType, relationshipsToNext, prev); + return (IResourceNode)TypeHelper.CreateInstanceOfOpenType(typeof(ChildNode<>), nodeType, relationshipsToNext, prev); } /// @@ -303,33 +300,33 @@ private IRelationshipsFromPreviousLayer CreateRelationshipsFromInstance(RightTyp /// /// Reflective helper method to create an instance of ; /// - private IRelationshipGroup CreateRelationshipGroupInstance(Type thisLayerType, RelationshipProxy proxy, List leftEntities, List rightEntities) + private IRelationshipGroup CreateRelationshipGroupInstance(Type thisLayerType, RelationshipProxy proxy, List leftResources, List rightResources) { - var rightEntitiesHashed = TypeHelper.CreateInstanceOfOpenType(typeof(HashSet<>), thisLayerType, rightEntities.CopyToList(thisLayerType)); + var rightResourcesHashed = TypeHelper.CreateInstanceOfOpenType(typeof(HashSet<>), thisLayerType, rightResources.CopyToList(thisLayerType)); return (IRelationshipGroup)TypeHelper.CreateInstanceOfOpenType(typeof(RelationshipGroup<>), - thisLayerType, proxy, new HashSet(leftEntities), rightEntitiesHashed); + thisLayerType, proxy, new HashSet(leftResources), rightResourcesHashed); } } /// - /// A helper class that represents all entities in the current layer that + /// A helper class that represents all resources in the current layer that /// are being traversed for which hooks will be executed (see IResourceHookExecutor) /// - internal sealed class NodeLayer : IEnumerable + internal sealed class NodeLayer : IEnumerable { - private readonly List _collection; + private readonly List _collection; - public bool AnyEntities() + public bool AnyResources() { - return _collection.Any(n => n.UniqueEntities.Cast().Any()); + return _collection.Any(n => n.UniqueResources.Cast().Any()); } - public NodeLayer(List nodes) + public NodeLayer(List nodes) { _collection = nodes; } - public IEnumerator GetEnumerator() + public IEnumerator GetEnumerator() { return _collection.GetEnumerator(); } diff --git a/src/JsonApiDotNetCore/Internal/Contracts/IResourceContextProvider.cs b/src/JsonApiDotNetCore/Internal/Contracts/IResourceContextProvider.cs index cef144a9ab..6956aabc05 100644 --- a/src/JsonApiDotNetCore/Internal/Contracts/IResourceContextProvider.cs +++ b/src/JsonApiDotNetCore/Internal/Contracts/IResourceContextProvider.cs @@ -10,7 +10,7 @@ namespace JsonApiDotNetCore.Internal.Contracts public interface IResourceContextProvider { /// - /// Gets all registered context entities + /// Gets all registered resource contexts. /// IEnumerable GetResourceContexts(); diff --git a/src/JsonApiDotNetCore/Internal/Contracts/IResourceGraphExplorer.cs b/src/JsonApiDotNetCore/Internal/Contracts/IResourceGraph.cs similarity index 100% rename from src/JsonApiDotNetCore/Internal/Contracts/IResourceGraphExplorer.cs rename to src/JsonApiDotNetCore/Internal/Contracts/IResourceGraph.cs diff --git a/src/JsonApiDotNetCore/Internal/Generics/RepositoryRelationshipUpdateHelper.cs b/src/JsonApiDotNetCore/Internal/Generics/RepositoryRelationshipUpdateHelper.cs index 28d34e76b4..58b25e58dd 100644 --- a/src/JsonApiDotNetCore/Internal/Generics/RepositoryRelationshipUpdateHelper.cs +++ b/src/JsonApiDotNetCore/Internal/Generics/RepositoryRelationshipUpdateHelper.cs @@ -105,7 +105,7 @@ private async Task UpdateOneToManyAsync(IIdentifiable parent, RelationshipAttrib private async Task UpdateManyToManyAsync(IIdentifiable parent, HasManyThroughAttribute relationship, IEnumerable relationshipIds) { // we need to create a transaction for the HasManyThrough case so we can get and remove any existing - // join entities and only commit if all operations are successful + // through resources and only commit if all operations are successful var transaction = await _context.GetCurrentOrCreateTransactionAsync(); // ArticleTag ParameterExpression parameter = Expression.Parameter(relationship.ThroughType); diff --git a/src/JsonApiDotNetCore/Internal/IJsonApiRoutingConvention.cs b/src/JsonApiDotNetCore/Internal/IJsonApiRoutingConvention.cs index 00eed0b4c0..7f616fbefa 100644 --- a/src/JsonApiDotNetCore/Internal/IJsonApiRoutingConvention.cs +++ b/src/JsonApiDotNetCore/Internal/IJsonApiRoutingConvention.cs @@ -1,9 +1,9 @@ -using Microsoft.AspNetCore.Mvc.ApplicationModels; +using Microsoft.AspNetCore.Mvc.ApplicationModels; namespace JsonApiDotNetCore.Internal { /// - /// Service for specifying which routing convention to use. This can be overriden to customize + /// Service for specifying which routing convention to use. This can be overridden to customize /// the relation between controllers and mapped routes. /// public interface IJsonApiRoutingConvention : IApplicationModelConvention, IControllerResourceMapping { } diff --git a/src/JsonApiDotNetCore/Internal/IPaginationContext.cs b/src/JsonApiDotNetCore/Internal/IPaginationContext.cs new file mode 100644 index 0000000000..2e5f817585 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/IPaginationContext.cs @@ -0,0 +1,34 @@ +using JsonApiDotNetCore.Configuration; + +namespace JsonApiDotNetCore.Internal +{ + /// + /// Tracks values used for pagination, which is a combined effort from options, query string parsing and fetching the total number of rows. + /// + public interface IPaginationContext + { + /// + /// The value 1, unless specified from query string. Never null. + /// Cannot be higher than options.MaximumPageNumber. + /// + PageNumber PageNumber { get; set; } + + /// + /// The default page size from options, unless specified in query string. Can be null, which means no paging. + /// Cannot be higher than options.MaximumPageSize. + /// + PageSize PageSize { get; set; } + + /// + /// The total number of resources. + /// null when is set to false. + /// + int? TotalResourceCount { get; set; } + + /// + /// The total number of resource pages. + /// null when is set to false or is null. + /// + int? TotalPageCount { get; } + } +} diff --git a/src/JsonApiDotNetCore/Internal/IResourceFactory.cs b/src/JsonApiDotNetCore/Internal/IResourceFactory.cs index 67ea569592..230aa25e8a 100644 --- a/src/JsonApiDotNetCore/Internal/IResourceFactory.cs +++ b/src/JsonApiDotNetCore/Internal/IResourceFactory.cs @@ -14,11 +14,11 @@ public interface IResourceFactory public NewExpression CreateNewExpression(Type resourceType); } - internal sealed class DefaultResourceFactory : IResourceFactory + internal sealed class ResourceFactory : IResourceFactory { private readonly IServiceProvider _serviceProvider; - public DefaultResourceFactory(IServiceProvider serviceProvider) + public ResourceFactory(IServiceProvider serviceProvider) { _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); } diff --git a/src/JsonApiDotNetCore/Internal/InverseRelationships.cs b/src/JsonApiDotNetCore/Internal/InverseRelationships.cs index 52326ab10a..49b36053e6 100644 --- a/src/JsonApiDotNetCore/Internal/InverseRelationships.cs +++ b/src/JsonApiDotNetCore/Internal/InverseRelationships.cs @@ -41,7 +41,7 @@ public InverseRelationships(IResourceContextProvider provider, IDbContextResolve /// public void Resolve() { - if (EntityFrameworkCoreIsEnabled()) + if (IsEntityFrameworkCoreEnabled()) { DbContext context = _resolver.GetContext(); @@ -62,7 +62,7 @@ public void Resolve() /// /// If EF Core is not being used, we're expecting the resolver to not be registered. /// - /// true, if entity framework core was enabled, false otherwise. - private bool EntityFrameworkCoreIsEnabled() => _resolver != null; + /// true, if Entity Framework Core was enabled, false otherwise. + private bool IsEntityFrameworkCoreEnabled() => _resolver != null; } } diff --git a/src/JsonApiDotNetCore/Internal/DefaultRoutingConvention.cs b/src/JsonApiDotNetCore/Internal/JsonApiRoutingConvention.cs similarity index 71% rename from src/JsonApiDotNetCore/Internal/DefaultRoutingConvention.cs rename to src/JsonApiDotNetCore/Internal/JsonApiRoutingConvention.cs index d29d3d1222..72067044d2 100644 --- a/src/JsonApiDotNetCore/Internal/DefaultRoutingConvention.cs +++ b/src/JsonApiDotNetCore/Internal/JsonApiRoutingConvention.cs @@ -15,32 +15,27 @@ namespace JsonApiDotNetCore.Internal /// /// The default routing convention registers the name of the resource as the route /// using the serializer casing convention. The default for this is - /// a camel case formatter. If the controller directly inherits from JsonApiMixin and there is no + /// a camel case formatter. If the controller directly inherits from and there is no /// resource directly associated, it uses the name of the controller instead of the name of the type. /// - /// - /// public class SomeResourceController: JsonApiController{SomeResource} { } - /// // => /someResources/relationship/relatedResource + /// { } // => /someResources/relationship/relatedResource /// - /// public class RandomNameController{SomeResource} : JsonApiController{SomeResource} { } - /// // => /someResources/relationship/relatedResource + /// public class RandomNameController : JsonApiController { } // => /someResources/relationship/relatedResource /// /// // when using the kebab-case formatter: - /// public class SomeResourceController{SomeResource} : JsonApiController{SomeResource} { } - /// // => /some-resources/relationship/related-resource + /// public class SomeResourceController : JsonApiController { } // => /some-resources/relationship/related-resource /// - /// // when inheriting from JsonApiMixin controller: - /// public class SomeVeryCustomController{SomeResource} : JsonApiMixin { } - /// // => /someVeryCustoms/relationship/relatedResource - /// - public class DefaultRoutingConvention : IJsonApiRoutingConvention + /// public class SomeVeryCustomController : CoreJsonApiController { } // => /someVeryCustoms/relationship/relatedResource + /// ]]> + public class JsonApiRoutingConvention : IJsonApiRoutingConvention { private readonly IJsonApiOptions _options; private readonly ResourceNameFormatter _formatter; private readonly HashSet _registeredTemplates = new HashSet(); private readonly Dictionary _registeredResources = new Dictionary(); - public DefaultRoutingConvention(IJsonApiOptions options) + public JsonApiRoutingConvention(IJsonApiOptions options) { _options = options; _formatter = new ResourceNameFormatter(options); @@ -81,7 +76,7 @@ private bool RoutingConventionDisabled(ControllerModel controller) { var type = controller.ControllerType; var notDisabled = type.GetCustomAttribute() == null; - return notDisabled && type.IsSubclassOf(typeof(JsonApiControllerMixin)); + return notDisabled && type.IsSubclassOf(typeof(CoreJsonApiController)); } /// @@ -123,30 +118,30 @@ private string TemplateFromController(ControllerModel model) /// private Type GetResourceTypeFromController(Type type) { - var controllerBase = typeof(ControllerBase); - var jsonApiMixin = typeof(JsonApiControllerMixin); - var target = typeof(BaseJsonApiController<,>); - var currentBaseType = type; - while (!currentBaseType.IsGenericType || currentBaseType.GetGenericTypeDefinition() != target) + var aspNetControllerType = typeof(ControllerBase); + var coreControllerType = typeof(CoreJsonApiController); + var baseControllerType = typeof(BaseJsonApiController<,>); + var currentType = type; + while (!currentType.IsGenericType || currentType.GetGenericTypeDefinition() != baseControllerType) { - var nextBaseType = currentBaseType.BaseType; + var nextBaseType = currentType.BaseType; - if ( (nextBaseType == controllerBase || nextBaseType == jsonApiMixin) && currentBaseType.IsGenericType) + if ((nextBaseType == aspNetControllerType || nextBaseType == coreControllerType) && currentType.IsGenericType) { - var potentialResource = currentBaseType.GetGenericArguments().FirstOrDefault(t => t.IsOrImplementsInterface(typeof(IIdentifiable))); - if (potentialResource != null) + var resourceType = currentType.GetGenericArguments().FirstOrDefault(t => t.IsOrImplementsInterface(typeof(IIdentifiable))); + if (resourceType != null) { - return potentialResource; + return resourceType; } } - currentBaseType = nextBaseType; + currentType = nextBaseType; if (nextBaseType == null) { break; } } - return currentBaseType?.GetGenericArguments().First(); + return currentType?.GetGenericArguments().First(); } } } diff --git a/src/JsonApiDotNetCore/Internal/PaginationContext.cs b/src/JsonApiDotNetCore/Internal/PaginationContext.cs new file mode 100644 index 0000000000..5a7b2dece6 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/PaginationContext.cs @@ -0,0 +1,22 @@ +using System; + +namespace JsonApiDotNetCore.Internal +{ + /// + internal sealed class PaginationContext : IPaginationContext + { + /// + public PageNumber PageNumber { get; set; } + + /// + public PageSize PageSize { get; set; } + + /// + public int? TotalResourceCount { get; set; } + + /// + public int? TotalPageCount => TotalResourceCount == null || PageSize == null + ? null + : (int?) Math.Ceiling((decimal) TotalResourceCount.Value / PageSize.Value); + } +} diff --git a/src/JsonApiDotNetCore/Internal/Queries/ExpressionInScope.cs b/src/JsonApiDotNetCore/Internal/Queries/ExpressionInScope.cs new file mode 100644 index 0000000000..c4197c6c2b --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Queries/ExpressionInScope.cs @@ -0,0 +1,22 @@ +using System; +using JsonApiDotNetCore.Internal.Queries.Expressions; + +namespace JsonApiDotNetCore.Internal.Queries +{ + public class ExpressionInScope + { + public ResourceFieldChainExpression Scope { get; } + public QueryExpression Expression { get; } + + public ExpressionInScope(ResourceFieldChainExpression scope, QueryExpression expression) + { + Scope = scope; + Expression = expression ?? throw new ArgumentNullException(nameof(expression)); + } + + public override string ToString() + { + return $"{Scope} => {Expression}"; + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/Queries/Expressions/CollectionNotEmptyExpression.cs b/src/JsonApiDotNetCore/Internal/Queries/Expressions/CollectionNotEmptyExpression.cs new file mode 100644 index 0000000000..904bb41113 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Queries/Expressions/CollectionNotEmptyExpression.cs @@ -0,0 +1,25 @@ +using System; +using JsonApiDotNetCore.Internal.Queries.Parsing; + +namespace JsonApiDotNetCore.Internal.Queries.Expressions +{ + public class CollectionNotEmptyExpression : FilterExpression + { + public ResourceFieldChainExpression TargetCollection { get; } + + public CollectionNotEmptyExpression(ResourceFieldChainExpression targetCollection) + { + TargetCollection = targetCollection ?? throw new ArgumentNullException(nameof(targetCollection)); + } + + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitCollectionNotEmpty(this, argument); + } + + public override string ToString() + { + return $"{Keywords.Has}({TargetCollection})"; + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/Queries/Expressions/ComparisonExpression.cs b/src/JsonApiDotNetCore/Internal/Queries/Expressions/ComparisonExpression.cs new file mode 100644 index 0000000000..704359bbfe --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Queries/Expressions/ComparisonExpression.cs @@ -0,0 +1,29 @@ +using System; +using Humanizer; + +namespace JsonApiDotNetCore.Internal.Queries.Expressions +{ + public class ComparisonExpression : FilterExpression + { + public ComparisonOperator Operator { get; } + public QueryExpression Left { get; } + public QueryExpression Right { get; } + + public ComparisonExpression(ComparisonOperator @operator, QueryExpression left, QueryExpression right) + { + Operator = @operator; + Left = left ?? throw new ArgumentNullException(nameof(left)); + Right = right ?? throw new ArgumentNullException(nameof(right)); + } + + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitComparison(this, argument); + } + + public override string ToString() + { + return $"{Operator.ToString().Camelize()}({Left},{Right})"; + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/Queries/Expressions/ComparisonOperator.cs b/src/JsonApiDotNetCore/Internal/Queries/Expressions/ComparisonOperator.cs new file mode 100644 index 0000000000..eaa627c5d5 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Queries/Expressions/ComparisonOperator.cs @@ -0,0 +1,11 @@ +namespace JsonApiDotNetCore.Internal.Queries.Expressions +{ + public enum ComparisonOperator + { + Equals, + GreaterThan, + GreaterOrEqual, + LessThan, + LessOrEqual + } +} diff --git a/src/JsonApiDotNetCore/Internal/Queries/Expressions/CountExpression.cs b/src/JsonApiDotNetCore/Internal/Queries/Expressions/CountExpression.cs new file mode 100644 index 0000000000..ad224105b9 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Queries/Expressions/CountExpression.cs @@ -0,0 +1,25 @@ +using System; +using JsonApiDotNetCore.Internal.Queries.Parsing; + +namespace JsonApiDotNetCore.Internal.Queries.Expressions +{ + public class CountExpression : FunctionExpression + { + public ResourceFieldChainExpression TargetCollection { get; } + + public CountExpression(ResourceFieldChainExpression targetCollection) + { + TargetCollection = targetCollection ?? throw new ArgumentNullException(nameof(targetCollection)); + } + + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitCount(this, argument); + } + + public override string ToString() + { + return $"{Keywords.Count}({TargetCollection})"; + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/Queries/Expressions/EqualsAnyOfExpression.cs b/src/JsonApiDotNetCore/Internal/Queries/Expressions/EqualsAnyOfExpression.cs new file mode 100644 index 0000000000..7e3c44d968 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Queries/Expressions/EqualsAnyOfExpression.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using JsonApiDotNetCore.Internal.Queries.Parsing; + +namespace JsonApiDotNetCore.Internal.Queries.Expressions +{ + public class EqualsAnyOfExpression : FilterExpression + { + public ResourceFieldChainExpression TargetAttribute { get; } + public IReadOnlyCollection Constants { get; } + + public EqualsAnyOfExpression(ResourceFieldChainExpression targetAttribute, + IReadOnlyCollection constants) + { + TargetAttribute = targetAttribute ?? throw new ArgumentNullException(nameof(targetAttribute)); + Constants = constants ?? throw new ArgumentNullException(nameof(constants)); + } + + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitEqualsAnyOf(this, argument); + } + + public override string ToString() + { + var builder = new StringBuilder(); + + builder.Append(Keywords.Any); + builder.Append('('); + builder.Append(TargetAttribute); + builder.Append(','); + builder.Append(string.Join(",", Constants.Select(constant => constant.ToString()))); + builder.Append(')'); + + return builder.ToString(); + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/Queries/Expressions/FilterExpression.cs b/src/JsonApiDotNetCore/Internal/Queries/Expressions/FilterExpression.cs new file mode 100644 index 0000000000..b9013756e0 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Queries/Expressions/FilterExpression.cs @@ -0,0 +1,6 @@ +namespace JsonApiDotNetCore.Internal.Queries.Expressions +{ + public abstract class FilterExpression : FunctionExpression + { + } +} diff --git a/src/JsonApiDotNetCore/Internal/Queries/Expressions/FunctionExpression.cs b/src/JsonApiDotNetCore/Internal/Queries/Expressions/FunctionExpression.cs new file mode 100644 index 0000000000..27c07abb96 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Queries/Expressions/FunctionExpression.cs @@ -0,0 +1,6 @@ +namespace JsonApiDotNetCore.Internal.Queries.Expressions +{ + public abstract class FunctionExpression : QueryExpression + { + } +} diff --git a/src/JsonApiDotNetCore/Internal/Queries/Expressions/IdentifierExpression.cs b/src/JsonApiDotNetCore/Internal/Queries/Expressions/IdentifierExpression.cs new file mode 100644 index 0000000000..7bec701114 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Queries/Expressions/IdentifierExpression.cs @@ -0,0 +1,6 @@ +namespace JsonApiDotNetCore.Internal.Queries.Expressions +{ + public abstract class IdentifierExpression : QueryExpression + { + } +} diff --git a/src/JsonApiDotNetCore/Internal/Queries/Expressions/IncludeExpression.cs b/src/JsonApiDotNetCore/Internal/Queries/Expressions/IncludeExpression.cs new file mode 100644 index 0000000000..135b4daf59 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Queries/Expressions/IncludeExpression.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace JsonApiDotNetCore.Internal.Queries.Expressions +{ + public class IncludeExpression : QueryExpression + { + // TODO: Unfold into a tree of child relationships, so it can be used from ResourceDefinitions. + + public IReadOnlyCollection Chains { get; } + + public static readonly IncludeExpression Empty = new IncludeExpression(); + + private IncludeExpression() + { + Chains = Array.Empty(); + } + + public IncludeExpression(IReadOnlyCollection chains) + { + Chains = chains ?? throw new ArgumentNullException(nameof(chains)); + + if (!chains.Any()) + { + throw new ArgumentException("Must have one or more chains.", nameof(chains)); + } + } + + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitInclude(this, argument); + } + + public override string ToString() + { + return string.Join(",", Chains.Select(child => child.ToString())); + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/Queries/Expressions/LiteralConstantExpression.cs b/src/JsonApiDotNetCore/Internal/Queries/Expressions/LiteralConstantExpression.cs new file mode 100644 index 0000000000..3568e0057e --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Queries/Expressions/LiteralConstantExpression.cs @@ -0,0 +1,25 @@ +using System; + +namespace JsonApiDotNetCore.Internal.Queries.Expressions +{ + public class LiteralConstantExpression : IdentifierExpression + { + public string Value { get; } + + public LiteralConstantExpression(string text) + { + Value = text ?? throw new ArgumentNullException(nameof(text)); + } + + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitLiteralConstant(this, argument); + } + + public override string ToString() + { + string value = Value.Replace("\'", "\'\'"); + return $"'{value}'"; + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/Queries/Expressions/LogicalExpression.cs b/src/JsonApiDotNetCore/Internal/Queries/Expressions/LogicalExpression.cs new file mode 100644 index 0000000000..f91e73d293 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Queries/Expressions/LogicalExpression.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Humanizer; + +namespace JsonApiDotNetCore.Internal.Queries.Expressions +{ + public class LogicalExpression : FilterExpression + { + public LogicalOperator Operator { get; } + public IReadOnlyCollection Terms { get; } + + public LogicalExpression(LogicalOperator @operator, IReadOnlyCollection terms) + { + if (terms == null) + { + throw new ArgumentNullException(nameof(terms)); + } + + if (terms.Count < 2) + { + throw new ArgumentException("At least two terms are required.", nameof(terms)); + } + + Operator = @operator; + Terms = terms; + } + + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitLogical(this, argument); + } + + public override string ToString() + { + var builder = new StringBuilder(); + + builder.Append(Operator.ToString().Camelize()); + builder.Append('('); + builder.Append(string.Join(",", Terms.Select(term => term.ToString()))); + builder.Append(')'); + + return builder.ToString(); + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/Queries/Expressions/LogicalOperator.cs b/src/JsonApiDotNetCore/Internal/Queries/Expressions/LogicalOperator.cs new file mode 100644 index 0000000000..314b88b7d8 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Queries/Expressions/LogicalOperator.cs @@ -0,0 +1,8 @@ +namespace JsonApiDotNetCore.Internal.Queries.Expressions +{ + public enum LogicalOperator + { + And, + Or + } +} diff --git a/src/JsonApiDotNetCore/Internal/Queries/Expressions/MatchTextExpression.cs b/src/JsonApiDotNetCore/Internal/Queries/Expressions/MatchTextExpression.cs new file mode 100644 index 0000000000..e98d2714b4 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Queries/Expressions/MatchTextExpression.cs @@ -0,0 +1,38 @@ +using System; +using System.Text; +using Humanizer; + +namespace JsonApiDotNetCore.Internal.Queries.Expressions +{ + public class MatchTextExpression : FilterExpression + { + public ResourceFieldChainExpression TargetAttribute { get; } + public LiteralConstantExpression TextValue { get; } + public TextMatchKind MatchKind { get; } + + public MatchTextExpression(ResourceFieldChainExpression targetAttribute, LiteralConstantExpression textValue, + TextMatchKind matchKind) + { + TargetAttribute = targetAttribute ?? throw new ArgumentNullException(nameof(targetAttribute)); + TextValue = textValue ?? throw new ArgumentNullException(nameof(textValue)); + MatchKind = matchKind; + } + + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitMatchText(this, argument); + } + + public override string ToString() + { + var builder = new StringBuilder(); + + builder.Append(MatchKind.ToString().Camelize()); + builder.Append('('); + builder.Append(string.Join(",", TargetAttribute, TextValue)); + builder.Append(')'); + + return builder.ToString(); + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/Queries/Expressions/NotExpression.cs b/src/JsonApiDotNetCore/Internal/Queries/Expressions/NotExpression.cs new file mode 100644 index 0000000000..9bf4267225 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Queries/Expressions/NotExpression.cs @@ -0,0 +1,25 @@ +using System; +using JsonApiDotNetCore.Internal.Queries.Parsing; + +namespace JsonApiDotNetCore.Internal.Queries.Expressions +{ + public class NotExpression : FilterExpression + { + public QueryExpression Child { get; } + + public NotExpression(QueryExpression child) + { + Child = child ?? throw new ArgumentNullException(nameof(child)); + } + + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitNot(this, argument); + } + + public override string ToString() + { + return $"{Keywords.Not}({Child})"; + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/Queries/Expressions/NullConstantExpression.cs b/src/JsonApiDotNetCore/Internal/Queries/Expressions/NullConstantExpression.cs new file mode 100644 index 0000000000..244fd3479a --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Queries/Expressions/NullConstantExpression.cs @@ -0,0 +1,17 @@ +using JsonApiDotNetCore.Internal.Queries.Parsing; + +namespace JsonApiDotNetCore.Internal.Queries.Expressions +{ + public class NullConstantExpression : IdentifierExpression + { + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitNullConstant(this, argument); + } + + public override string ToString() + { + return Keywords.Null; + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/Queries/Expressions/PaginationElementQueryStringValueExpression.cs b/src/JsonApiDotNetCore/Internal/Queries/Expressions/PaginationElementQueryStringValueExpression.cs new file mode 100644 index 0000000000..9904271289 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Queries/Expressions/PaginationElementQueryStringValueExpression.cs @@ -0,0 +1,24 @@ +namespace JsonApiDotNetCore.Internal.Queries.Expressions +{ + public class PaginationElementQueryStringValueExpression : QueryExpression + { + public ResourceFieldChainExpression Scope { get; } + public int Value { get; } + + public PaginationElementQueryStringValueExpression(ResourceFieldChainExpression scope, int value) + { + Scope = scope; + Value = value; + } + + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.PaginationElementQueryStringValue(this, argument); + } + + public override string ToString() + { + return Scope == null ? Value.ToString() : $"{Scope}: {Value}"; + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/Queries/Expressions/PaginationExpression.cs b/src/JsonApiDotNetCore/Internal/Queries/Expressions/PaginationExpression.cs new file mode 100644 index 0000000000..85c30ee674 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Queries/Expressions/PaginationExpression.cs @@ -0,0 +1,26 @@ +using System; + +namespace JsonApiDotNetCore.Internal.Queries.Expressions +{ + public class PaginationExpression : QueryExpression + { + public PageNumber PageNumber { get; } + public PageSize PageSize { get; } + + public PaginationExpression(PageNumber pageNumber, PageSize pageSize) + { + PageNumber = pageNumber ?? throw new ArgumentNullException(nameof(pageNumber)); + PageSize = pageSize; + } + + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitPagination(this, argument); + } + + public override string ToString() + { + return PageSize != null ? $"Page number: {PageNumber}, size: {PageSize}" : "(none)"; + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/Queries/Expressions/PaginationQueryStringValueExpression.cs b/src/JsonApiDotNetCore/Internal/Queries/Expressions/PaginationQueryStringValueExpression.cs new file mode 100644 index 0000000000..868acaaf9d --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Queries/Expressions/PaginationQueryStringValueExpression.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace JsonApiDotNetCore.Internal.Queries.Expressions +{ + public class PaginationQueryStringValueExpression : QueryExpression + { + public IReadOnlyCollection Elements { get; } + + public PaginationQueryStringValueExpression( + IReadOnlyCollection elements) + { + Elements = elements ?? throw new ArgumentNullException(nameof(elements)); + + if (!Elements.Any()) + { + throw new ArgumentException("Must have one or more elements.", nameof(elements)); + } + } + + public override TResult Accept(QueryExpressionVisitor visitor, + TArgument argument) + { + return visitor.PaginationQueryStringValue(this, argument); + } + + public override string ToString() + { + return string.Join(",", Elements.Select(constant => constant.ToString())); + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/Queries/Expressions/QueryExpression.cs b/src/JsonApiDotNetCore/Internal/Queries/Expressions/QueryExpression.cs new file mode 100644 index 0000000000..ae80a245ec --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Queries/Expressions/QueryExpression.cs @@ -0,0 +1,8 @@ +namespace JsonApiDotNetCore.Internal.Queries.Expressions +{ + public abstract class QueryExpression + { + public abstract TResult + Accept(QueryExpressionVisitor visitor, TArgument argument); + } +} diff --git a/src/JsonApiDotNetCore/Internal/Queries/Expressions/QueryExpressionVisitor.cs b/src/JsonApiDotNetCore/Internal/Queries/Expressions/QueryExpressionVisitor.cs new file mode 100644 index 0000000000..5efab045c1 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Queries/Expressions/QueryExpressionVisitor.cs @@ -0,0 +1,110 @@ +namespace JsonApiDotNetCore.Internal.Queries.Expressions +{ + public class QueryExpressionVisitor + { + public virtual TResult Visit(QueryExpression expression, TArgument argument) + { + return expression.Accept(this, argument); + } + + public virtual TResult DefaultVisit(QueryExpression expression, TArgument argument) + { + return default; + } + + public virtual TResult VisitComparison(ComparisonExpression expression, TArgument argument) + { + return DefaultVisit(expression, argument); + } + + public virtual TResult VisitResourceFieldChain(ResourceFieldChainExpression expression, TArgument argument) + { + return DefaultVisit(expression, argument); + } + + public virtual TResult VisitLiteralConstant(LiteralConstantExpression expression, TArgument argument) + { + return DefaultVisit(expression, argument); + } + + public virtual TResult VisitNullConstant(NullConstantExpression expression, TArgument argument) + { + return DefaultVisit(expression, argument); + } + + public virtual TResult VisitLogical(LogicalExpression expression, TArgument argument) + { + return DefaultVisit(expression, argument); + } + + public virtual TResult VisitNot(NotExpression expression, TArgument argument) + { + return DefaultVisit(expression, argument); + } + + public virtual TResult VisitCollectionNotEmpty(CollectionNotEmptyExpression expression, TArgument argument) + { + return DefaultVisit(expression, argument); + } + + public virtual TResult VisitSortElement(SortElementExpression expression, TArgument argument) + { + return DefaultVisit(expression, argument); + } + + public virtual TResult VisitSort(SortExpression expression, TArgument argument) + { + return DefaultVisit(expression, argument); + } + + public virtual TResult VisitPagination(PaginationExpression expression, TArgument argument) + { + return DefaultVisit(expression, argument); + } + + public virtual TResult VisitCount(CountExpression expression, TArgument argument) + { + return DefaultVisit(expression, argument); + } + + public virtual TResult VisitMatchText(MatchTextExpression expression, TArgument argument) + { + return DefaultVisit(expression, argument); + } + + public virtual TResult VisitEqualsAnyOf(EqualsAnyOfExpression expression, TArgument argument) + { + return DefaultVisit(expression, argument); + } + + public virtual TResult VisitSparseFieldSet(SparseFieldSetExpression expression, TArgument argument) + { + return DefaultVisit(expression, argument); + } + + public virtual TResult VisitQueryStringParameterScope(QueryStringParameterScopeExpression expression, TArgument argument) + { + return DefaultVisit(expression, argument); + } + + public virtual TResult PaginationQueryStringValue(PaginationQueryStringValueExpression expression, TArgument argument) + { + return DefaultVisit(expression, argument); + } + + public virtual TResult PaginationElementQueryStringValue(PaginationElementQueryStringValueExpression expression, TArgument argument) + { + return DefaultVisit(expression, argument); + } + + public virtual TResult VisitInclude(IncludeExpression expression, TArgument argument) + { + return DefaultVisit(expression, argument); + } + + public virtual TResult VisitQueryableHandler(QueryableHandlerExpression expression, TArgument argument) + { + return DefaultVisit(expression, argument); + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/Queries/Expressions/QueryStringParameterScopeExpression.cs b/src/JsonApiDotNetCore/Internal/Queries/Expressions/QueryStringParameterScopeExpression.cs new file mode 100644 index 0000000000..8214829718 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Queries/Expressions/QueryStringParameterScopeExpression.cs @@ -0,0 +1,26 @@ +using System; + +namespace JsonApiDotNetCore.Internal.Queries.Expressions +{ + public class QueryStringParameterScopeExpression : QueryExpression + { + public LiteralConstantExpression ParameterName { get; } + public ResourceFieldChainExpression Scope { get; } + + public QueryStringParameterScopeExpression(LiteralConstantExpression parameterName, ResourceFieldChainExpression scope) + { + ParameterName = parameterName ?? throw new ArgumentNullException(nameof(parameterName)); + Scope = scope; + } + + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitQueryStringParameterScope(this, argument); + } + + public override string ToString() + { + return Scope == null ? ParameterName.ToString() : $"{ParameterName}: {Scope}"; + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/Queries/Expressions/QueryableHandlerExpression.cs b/src/JsonApiDotNetCore/Internal/Queries/Expressions/QueryableHandlerExpression.cs new file mode 100644 index 0000000000..73a3e7dc9d --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Queries/Expressions/QueryableHandlerExpression.cs @@ -0,0 +1,36 @@ +using System; +using System.Linq; +using JsonApiDotNetCore.Models; +using Microsoft.Extensions.Primitives; + +namespace JsonApiDotNetCore.Internal.Queries.Expressions +{ + public class QueryableHandlerExpression : QueryExpression + { + private readonly object _queryableHandler; + private readonly StringValues _parameterValue; + + public QueryableHandlerExpression(object queryableHandler, StringValues parameterValue) + { + _queryableHandler = queryableHandler ?? throw new ArgumentNullException(nameof(queryableHandler)); + _parameterValue = parameterValue; + } + + public IQueryable Apply(IQueryable query) + where TResource : class, IIdentifiable + { + var handler = (Func, StringValues, IQueryable>) _queryableHandler; + return handler(query, _parameterValue); + } + + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitQueryableHandler(this, argument); + } + + public override string ToString() + { + return $"handler('{_parameterValue}')"; + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/Queries/Expressions/ResourceFieldChainExpression.cs b/src/JsonApiDotNetCore/Internal/Queries/Expressions/ResourceFieldChainExpression.cs new file mode 100644 index 0000000000..b09fbec6b3 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Queries/Expressions/ResourceFieldChainExpression.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using JsonApiDotNetCore.Models.Annotation; + +namespace JsonApiDotNetCore.Internal.Queries.Expressions +{ + public class ResourceFieldChainExpression : IdentifierExpression, IEquatable + { + public IReadOnlyCollection Fields { get; } + + public ResourceFieldChainExpression(ResourceFieldAttribute field) + { + if (field == null) + { + throw new ArgumentNullException(nameof(field)); + } + + Fields = new[] {field}; + } + + public ResourceFieldChainExpression(IReadOnlyCollection fields) + { + Fields = fields ?? throw new ArgumentNullException(nameof(fields)); + + if (!fields.Any()) + { + throw new ArgumentException("Must have one or more fields.", nameof(fields)); + } + } + + public override TResult Accept(QueryExpressionVisitor visitor, + TArgument argument) + { + return visitor.VisitResourceFieldChain(this, argument); + } + + public override string ToString() + { + return string.Join(".", Fields.Select(field => field.PublicName)); + } + + public bool Equals(ResourceFieldChainExpression other) + { + if (other is null) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + return Fields.SequenceEqual(other.Fields); + } + + public override bool Equals(object other) + { + return Equals(other as ResourceFieldChainExpression); + } + + public override int GetHashCode() + { + return Fields.Aggregate(0, HashCode.Combine); + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/Queries/Expressions/SortElementExpression.cs b/src/JsonApiDotNetCore/Internal/Queries/Expressions/SortElementExpression.cs new file mode 100644 index 0000000000..6472e1e751 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Queries/Expressions/SortElementExpression.cs @@ -0,0 +1,50 @@ +using System; +using System.Text; + +namespace JsonApiDotNetCore.Internal.Queries.Expressions +{ + public class SortElementExpression : QueryExpression + { + public ResourceFieldChainExpression TargetAttribute { get; } + public CountExpression Count { get; } + public bool IsAscending { get; } + + public SortElementExpression(ResourceFieldChainExpression targetAttribute, bool isAscending) + { + TargetAttribute = targetAttribute ?? throw new ArgumentNullException(nameof(targetAttribute)); + IsAscending = isAscending; + } + + public SortElementExpression(CountExpression count, in bool isAscending) + { + Count = count ?? throw new ArgumentNullException(nameof(count)); + IsAscending = isAscending; + } + + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitSortElement(this, argument); + } + + public override string ToString() + { + var builder = new StringBuilder(); + + if (!IsAscending) + { + builder.Append('-'); + } + + if (TargetAttribute != null) + { + builder.Append(TargetAttribute); + } + else if (Count != null) + { + builder.Append(Count); + } + + return builder.ToString(); + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/Queries/Expressions/SortExpression.cs b/src/JsonApiDotNetCore/Internal/Queries/Expressions/SortExpression.cs new file mode 100644 index 0000000000..b49f4684f1 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Queries/Expressions/SortExpression.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace JsonApiDotNetCore.Internal.Queries.Expressions +{ + public class SortExpression : QueryExpression + { + public IReadOnlyCollection Elements { get; } + + public SortExpression(IReadOnlyCollection elements) + { + Elements = elements ?? throw new ArgumentNullException(nameof(elements)); + + if (!elements.Any()) + { + throw new ArgumentException("Must have one or more elements.", nameof(elements)); + } + } + + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitSort(this, argument); + } + + public override string ToString() + { + return string.Join(",", Elements.Select(child => child.ToString())); + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/Queries/Expressions/SparseFieldSetExpression.cs b/src/JsonApiDotNetCore/Internal/Queries/Expressions/SparseFieldSetExpression.cs new file mode 100644 index 0000000000..6f87b62ef6 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Queries/Expressions/SparseFieldSetExpression.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using JsonApiDotNetCore.Models.Annotation; + +namespace JsonApiDotNetCore.Internal.Queries.Expressions +{ + public class SparseFieldSetExpression : QueryExpression + { + public IReadOnlyCollection Attributes { get; } + + public SparseFieldSetExpression(IReadOnlyCollection attributes) + { + Attributes = attributes ?? throw new ArgumentNullException(nameof(attributes)); + + if (!attributes.Any()) + { + throw new ArgumentException("Must have one or more attributes.", nameof(attributes)); + } + } + + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitSparseFieldSet(this, argument); + } + + public override string ToString() + { + return string.Join(",", Attributes.Select(child => child.PublicName)); + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/Queries/Expressions/TextMatchKind.cs b/src/JsonApiDotNetCore/Internal/Queries/Expressions/TextMatchKind.cs new file mode 100644 index 0000000000..e44ba6e190 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Queries/Expressions/TextMatchKind.cs @@ -0,0 +1,9 @@ +namespace JsonApiDotNetCore.Internal.Queries.Expressions +{ + public enum TextMatchKind + { + Contains, + StartsWith, + EndsWith + } +} diff --git a/src/JsonApiDotNetCore/Internal/Queries/IQueryConstraintProvider.cs b/src/JsonApiDotNetCore/Internal/Queries/IQueryConstraintProvider.cs new file mode 100644 index 0000000000..026a9413be --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Queries/IQueryConstraintProvider.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace JsonApiDotNetCore.Internal.Queries +{ + public interface IQueryConstraintProvider + { + public IReadOnlyCollection GetConstraints(); + } +} diff --git a/src/JsonApiDotNetCore/Internal/Queries/Parsing/FilterParser.cs b/src/JsonApiDotNetCore/Internal/Queries/Parsing/FilterParser.cs new file mode 100644 index 0000000000..877433f50f --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Queries/Parsing/FilterParser.cs @@ -0,0 +1,289 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Humanizer; +using JsonApiDotNetCore.Internal.Queries.Expressions; +using JsonApiDotNetCore.Models; + +namespace JsonApiDotNetCore.Internal.Queries.Parsing +{ + // TODO: Combine callbacks into parsers to make them reusable from ResourceDefinitions. + public class FilterParser : QueryParser + { + private readonly ResolveFieldChainCallback _resolveFieldChainCallback; + private readonly Func _resolveStringId; + + public FilterParser(string source, ResolveFieldChainCallback resolveFieldChainCallback, Func resolveStringId) + : base(source, resolveFieldChainCallback) + { + _resolveFieldChainCallback = resolveFieldChainCallback; + _resolveStringId = resolveStringId ?? throw new ArgumentNullException(nameof(resolveStringId)); + } + + public FilterExpression Parse() + { + var expression = ParseFilter(); + + AssertTokenStackIsEmpty(); + + return expression; + } + + protected FilterExpression ParseFilter() + { + if (TokenStack.TryPeek(out Token nextToken) && nextToken.Kind == TokenKind.Text) + { + switch (nextToken.Value) + { + case Keywords.Not: + { + return ParseNot(); + } + case Keywords.And: + case Keywords.Or: + { + return ParseLogical(nextToken.Value); + } + case Keywords.Equals: + case Keywords.LessThan: + case Keywords.LessOrEqual: + case Keywords.GreaterThan: + case Keywords.GreaterOrEqual: + { + return ParseComparison(nextToken.Value); + } + case Keywords.Contains: + case Keywords.StartsWith: + case Keywords.EndsWith: + { + return ParseTextMatch(nextToken.Value); + } + case Keywords.Any: + { + return ParseAny(); + } + case Keywords.Has: + { + return ParseHas(); + } + } + } + + throw new QueryParseException("Filter function expected."); + } + + protected NotExpression ParseNot() + { + EatText(Keywords.Not); + EatSingleCharacterToken(TokenKind.OpenParen); + + FilterExpression child = ParseFilter(); + + EatSingleCharacterToken(TokenKind.CloseParen); + + return new NotExpression(child); + } + + protected LogicalExpression ParseLogical(string operatorName) + { + EatText(operatorName); + EatSingleCharacterToken(TokenKind.OpenParen); + + var terms = new List(); + + FilterExpression term = ParseFilter(); + terms.Add(term); + + EatSingleCharacterToken(TokenKind.Comma); + + term = ParseFilter(); + terms.Add(term); + + while (TokenStack.TryPeek(out Token nextToken) && nextToken.Kind == TokenKind.Comma) + { + EatSingleCharacterToken(TokenKind.Comma); + + term = ParseFilter(); + terms.Add(term); + } + + EatSingleCharacterToken(TokenKind.CloseParen); + + var logicalOperator = Enum.Parse(operatorName.Pascalize()); + return new LogicalExpression(logicalOperator, terms); + } + + protected ComparisonExpression ParseComparison(string operatorName) + { + var comparisonOperator = Enum.Parse(operatorName.Pascalize()); + + EatText(operatorName); + EatSingleCharacterToken(TokenKind.OpenParen); + + var leftChainRequirements = comparisonOperator == ComparisonOperator.Equals + ? FieldChainRequirements.EndsInAttribute | FieldChainRequirements.EndsInToOne + : FieldChainRequirements.EndsInAttribute; + + QueryExpression leftTerm = ParseCountOrField(leftChainRequirements); + + EatSingleCharacterToken(TokenKind.Comma); + + QueryExpression rightTerm = ParseCountOrConstantOrNullOrField(FieldChainRequirements.EndsInAttribute); + + EatSingleCharacterToken(TokenKind.CloseParen); + + if (leftTerm is ResourceFieldChainExpression leftChain) + { + if (leftChainRequirements.HasFlag(FieldChainRequirements.EndsInToOne) && + !(rightTerm is NullConstantExpression)) + { + // Run another pass over left chain to have it fail when chain ends in relationship. + _resolveFieldChainCallback(leftChain.ToString(), FieldChainRequirements.EndsInAttribute); + } + + PropertyInfo leftProperty = leftChain.Fields.Last().Property; + if (leftProperty.Name == nameof(Identifiable.Id) && rightTerm is LiteralConstantExpression rightConstant) + { + string id = _resolveStringId(leftProperty.ReflectedType, rightConstant.Value); + rightTerm = new LiteralConstantExpression(id); + } + } + + return new ComparisonExpression(comparisonOperator, leftTerm, rightTerm); + } + + protected MatchTextExpression ParseTextMatch(string matchFunctionName) + { + EatText(matchFunctionName); + EatSingleCharacterToken(TokenKind.OpenParen); + + ResourceFieldChainExpression targetAttribute = ParseFieldChain(FieldChainRequirements.EndsInAttribute, null); + + EatSingleCharacterToken(TokenKind.Comma); + + LiteralConstantExpression constant = ParseConstant(); + + EatSingleCharacterToken(TokenKind.CloseParen); + + var matchKind = Enum.Parse(matchFunctionName.Pascalize()); + return new MatchTextExpression(targetAttribute, constant, matchKind); + } + + protected EqualsAnyOfExpression ParseAny() + { + EatText(Keywords.Any); + EatSingleCharacterToken(TokenKind.OpenParen); + + ResourceFieldChainExpression targetAttribute = ParseFieldChain(FieldChainRequirements.EndsInAttribute, null); + + EatSingleCharacterToken(TokenKind.Comma); + + var constants = new List(); + + LiteralConstantExpression constant = ParseConstant(); + constants.Add(constant); + + EatSingleCharacterToken(TokenKind.Comma); + + constant = ParseConstant(); + constants.Add(constant); + + while (TokenStack.TryPeek(out Token nextToken) && nextToken.Kind == TokenKind.Comma) + { + EatSingleCharacterToken(TokenKind.Comma); + + constant = ParseConstant(); + constants.Add(constant); + } + + EatSingleCharacterToken(TokenKind.CloseParen); + + PropertyInfo targetAttributeProperty = targetAttribute.Fields.Last().Property; + if (targetAttributeProperty.Name == nameof(Identifiable.Id)) + { + for (int index = 0; index < constants.Count; index++) + { + string stringId = constants[index].Value; + string id = _resolveStringId(targetAttributeProperty.ReflectedType, stringId); + constants[index] = new LiteralConstantExpression(id); + } + } + + return new EqualsAnyOfExpression(targetAttribute, constants); + } + + protected CollectionNotEmptyExpression ParseHas() + { + EatText(Keywords.Has); + EatSingleCharacterToken(TokenKind.OpenParen); + + ResourceFieldChainExpression targetCollection = ParseFieldChain(FieldChainRequirements.EndsInToMany, null); + + EatSingleCharacterToken(TokenKind.CloseParen); + + return new CollectionNotEmptyExpression(targetCollection); + } + + protected QueryExpression ParseCountOrField(FieldChainRequirements chainRequirements) + { + CountExpression count = TryParseCount(); + + if (count != null) + { + return count; + } + + return ParseFieldChain(chainRequirements, "Count function or field name expected."); + } + + protected QueryExpression ParseCountOrConstantOrNullOrField(FieldChainRequirements chainRequirements) + { + CountExpression count = TryParseCount(); + + if (count != null) + { + return count; + } + + IdentifierExpression constantOrNull = TryParseConstantOrNull(); + + if (constantOrNull != null) + { + return constantOrNull; + } + + return ParseFieldChain(chainRequirements, "Count function, value between quotes, null or field name expected."); + } + + protected IdentifierExpression TryParseConstantOrNull() + { + if (TokenStack.TryPeek(out Token nextToken)) + { + if (nextToken.Kind == TokenKind.Text && nextToken.Value == Keywords.Null) + { + TokenStack.Pop(); + return new NullConstantExpression(); + } + + if (nextToken.Kind == TokenKind.QuotedText) + { + TokenStack.Pop(); + return new LiteralConstantExpression(nextToken.Value); + } + } + + return null; + } + + protected LiteralConstantExpression ParseConstant() + { + if (TokenStack.TryPop(out Token token) && token.Kind == TokenKind.QuotedText) + { + return new LiteralConstantExpression(token.Value); + } + + throw new QueryParseException("Value between quotes expected."); + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/Queries/Parsing/IncludeParser.cs b/src/JsonApiDotNetCore/Internal/Queries/Parsing/IncludeParser.cs new file mode 100644 index 0000000000..87c8d77387 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Queries/Parsing/IncludeParser.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using System.Linq; +using JsonApiDotNetCore.Internal.Queries.Expressions; + +namespace JsonApiDotNetCore.Internal.Queries.Parsing +{ + public class IncludeParser : QueryParser + { + public IncludeParser(string source, ResolveFieldChainCallback resolveFieldChainCallback) + : base(source, resolveFieldChainCallback) + { + } + + public IncludeExpression Parse() + { + var expression = ParseInclude(); + + AssertTokenStackIsEmpty(); + + return expression; + } + + protected IncludeExpression ParseInclude() + { + ResourceFieldChainExpression firstChain = + ParseFieldChain(FieldChainRequirements.IsRelationship, "Relationship name expected."); + + var chains = new List + { + firstChain + }; + + while (TokenStack.Any()) + { + EatSingleCharacterToken(TokenKind.Comma); + + var nextChain = ParseFieldChain(FieldChainRequirements.IsRelationship, "Relationship name expected."); + chains.Add(nextChain); + } + + return new IncludeExpression(chains); + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/Queries/Parsing/Keywords.cs b/src/JsonApiDotNetCore/Internal/Queries/Parsing/Keywords.cs new file mode 100644 index 0000000000..3bea05fe3e --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Queries/Parsing/Keywords.cs @@ -0,0 +1,21 @@ +namespace JsonApiDotNetCore.Internal.Queries.Parsing +{ + public static class Keywords + { + public const string Null = "null"; + public const string Not = "not"; + public const string And = "and"; + public const string Or = "or"; + public new const string Equals = "equals"; + public const string GreaterThan = "greaterThan"; + public const string GreaterOrEqual = "greaterOrEqual"; + public const string LessThan = "lessThan"; + public const string LessOrEqual = "lessOrEqual"; + public const string Contains = "contains"; + public const string StartsWith = "startsWith"; + public const string EndsWith = "endsWith"; + public const string Any = "any"; + public const string Count = "count"; + public const string Has = "has"; + } +} diff --git a/src/JsonApiDotNetCore/Internal/Queries/Parsing/PaginationParser.cs b/src/JsonApiDotNetCore/Internal/Queries/Parsing/PaginationParser.cs new file mode 100644 index 0000000000..0633554fed --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Queries/Parsing/PaginationParser.cs @@ -0,0 +1,91 @@ +using System.Collections.Generic; +using System.Linq; +using JsonApiDotNetCore.Internal.Queries.Expressions; + +namespace JsonApiDotNetCore.Internal.Queries.Parsing +{ + public class PaginationParser : QueryParser + { + public PaginationParser(string source, ResolveFieldChainCallback resolveFieldChainCallback) + : base(source, resolveFieldChainCallback) + { + } + + public PaginationQueryStringValueExpression Parse() + { + var expression = ParsePagination(); + + AssertTokenStackIsEmpty(); + + return expression; + } + + protected PaginationQueryStringValueExpression ParsePagination() + { + var elements = new List(); + + var element = ParsePaginationElement(); + elements.Add(element); + + while (TokenStack.Any()) + { + EatSingleCharacterToken(TokenKind.Comma); + + element = ParsePaginationElement(); + elements.Add(element); + } + + return new PaginationQueryStringValueExpression(elements); + } + + protected PaginationElementQueryStringValueExpression ParsePaginationElement() + { + var number = TryParseNumber(); + if (number != null) + { + return new PaginationElementQueryStringValueExpression(null, number.Value); + } + + var scope = ParseFieldChain(FieldChainRequirements.EndsInToMany, "Number or relationship name expected."); + + EatSingleCharacterToken(TokenKind.Colon); + + number = TryParseNumber(); + if (number == null) + { + throw new QueryParseException("Number expected."); + } + + return new PaginationElementQueryStringValueExpression(scope, number.Value); + } + + protected int? TryParseNumber() + { + if (TokenStack.TryPeek(out Token nextToken)) + { + int number; + + if (nextToken.Kind == TokenKind.Minus) + { + TokenStack.Pop(); + + if (TokenStack.TryPop(out Token token) && token.Kind == TokenKind.Text && + int.TryParse(token.Value, out number)) + { + return -number; + } + + throw new QueryParseException("Digits expected."); + } + + if (nextToken.Kind == TokenKind.Text && int.TryParse(nextToken.Value, out number)) + { + TokenStack.Pop(); + return number; + } + } + + return null; + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/Queries/Parsing/QueryParseException.cs b/src/JsonApiDotNetCore/Internal/Queries/Parsing/QueryParseException.cs new file mode 100644 index 0000000000..adf73b2da2 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Queries/Parsing/QueryParseException.cs @@ -0,0 +1,11 @@ +using System; + +namespace JsonApiDotNetCore.Internal.Queries.Parsing +{ + public sealed class QueryParseException : Exception + { + public QueryParseException(string message) : base(message) + { + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/Queries/Parsing/QueryParser.cs b/src/JsonApiDotNetCore/Internal/Queries/Parsing/QueryParser.cs new file mode 100644 index 0000000000..68febbb6b3 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Queries/Parsing/QueryParser.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using JsonApiDotNetCore.Internal.Queries.Expressions; + +namespace JsonApiDotNetCore.Internal.Queries.Parsing +{ + public abstract class QueryParser + { + private readonly ResolveFieldChainCallback _resolveFieldChainCallback; + + protected Stack TokenStack { get; } + + protected QueryParser(string source, ResolveFieldChainCallback resolveFieldChainCallback) + { + _resolveFieldChainCallback = resolveFieldChainCallback ?? throw new ArgumentNullException(nameof(resolveFieldChainCallback)); + + var tokenizer = new QueryTokenizer(source); + TokenStack = new Stack(tokenizer.EnumerateTokens().Reverse()); + } + + protected ResourceFieldChainExpression ParseFieldChain(FieldChainRequirements chainRequirements, string alternativeErrorMessage) + { + if (TokenStack.TryPop(out Token token) && token.Kind == TokenKind.Text) + { + var chain = _resolveFieldChainCallback(token.Value, chainRequirements); + if (chain.Any()) + { + return new ResourceFieldChainExpression(chain); + } + } + + throw new QueryParseException(alternativeErrorMessage ?? "Field name expected."); + } + + protected CountExpression TryParseCount() + { + if (TokenStack.TryPeek(out Token nextToken) && nextToken.Kind == TokenKind.Text && nextToken.Value == Keywords.Count) + { + TokenStack.Pop(); + + EatSingleCharacterToken(TokenKind.OpenParen); + + ResourceFieldChainExpression targetCollection = ParseFieldChain(FieldChainRequirements.EndsInToMany, null); + + EatSingleCharacterToken(TokenKind.CloseParen); + + return new CountExpression(targetCollection); + } + + return null; + } + + protected void EatText(string text) + { + if (!TokenStack.TryPop(out Token token) || token.Kind != TokenKind.Text || token.Value != text) + { + throw new QueryParseException(text + " expected."); + } + } + + protected void EatSingleCharacterToken(TokenKind kind) + { + if (!TokenStack.TryPop(out Token token) || token.Kind != kind) + { + char ch = QueryTokenizer.SingleCharacterToTokenKinds.Single(pair => pair.Value == kind).Key; + throw new QueryParseException(ch + " expected."); + } + } + + protected void AssertTokenStackIsEmpty() + { + if (TokenStack.Any()) + { + throw new QueryParseException("End of expression expected."); + } + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/Queries/Parsing/QueryStringParameterScopeParser.cs b/src/JsonApiDotNetCore/Internal/Queries/Parsing/QueryStringParameterScopeParser.cs new file mode 100644 index 0000000000..8dcf2a3d5b --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Queries/Parsing/QueryStringParameterScopeParser.cs @@ -0,0 +1,44 @@ +using JsonApiDotNetCore.Internal.Queries.Expressions; + +namespace JsonApiDotNetCore.Internal.Queries.Parsing +{ + public class QueryStringParameterScopeParser : QueryParser + { + public QueryStringParameterScopeParser(string source, ResolveFieldChainCallback resolveFieldChainCallback) + : base(source, resolveFieldChainCallback) + { + } + + public QueryStringParameterScopeExpression Parse(FieldChainRequirements chainRequirements) + { + var expression = ParseQueryStringParameterScope(chainRequirements); + + AssertTokenStackIsEmpty(); + + return expression; + } + + protected QueryStringParameterScopeExpression ParseQueryStringParameterScope(FieldChainRequirements chainRequirements) + { + if (!TokenStack.TryPop(out Token token) || token.Kind != TokenKind.Text) + { + throw new QueryParseException("Parameter name expected."); + } + + var name = new LiteralConstantExpression(token.Value); + + ResourceFieldChainExpression scope = null; + + if (TokenStack.TryPeek(out Token nextToken) && nextToken.Kind == TokenKind.OpenBracket) + { + TokenStack.Pop(); + + scope = ParseFieldChain(chainRequirements, null); + + EatSingleCharacterToken(TokenKind.CloseBracket); + } + + return new QueryStringParameterScopeExpression(name, scope); + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/Queries/Parsing/QueryTokenizer.cs b/src/JsonApiDotNetCore/Internal/Queries/Parsing/QueryTokenizer.cs new file mode 100644 index 0000000000..b08f0e5971 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Queries/Parsing/QueryTokenizer.cs @@ -0,0 +1,139 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Text; + +namespace JsonApiDotNetCore.Internal.Queries.Parsing +{ + public sealed class QueryTokenizer + { + public static readonly IReadOnlyDictionary SingleCharacterToTokenKinds = + new ReadOnlyDictionary(new Dictionary + { + ['('] = TokenKind.OpenParen, + [')'] = TokenKind.CloseParen, + ['['] = TokenKind.OpenBracket, + [']'] = TokenKind.CloseBracket, + [','] = TokenKind.Comma, + [':'] = TokenKind.Colon, + ['-'] = TokenKind.Minus + }); + + private readonly string _source; + private readonly StringBuilder _textBuffer = new StringBuilder(); + private int _offset; + private bool _isInQuotedSection; + + public QueryTokenizer(string source) + { + _source = source ?? throw new ArgumentNullException(nameof(source)); + } + + public IEnumerable EnumerateTokens() + { + _textBuffer.Clear(); + _isInQuotedSection = false; + _offset = 0; + + while (_offset < _source.Length) + { + char ch = _source[_offset]; + + if (ch == '\'') + { + if (_isInQuotedSection) + { + char? peeked = PeekChar(); + + if (peeked == '\'') + { + _textBuffer.Append(ch); + _offset += 2; + continue; + } + + _isInQuotedSection = false; + + Token literalToken = FlushTextBuffer(true); + yield return literalToken; + } + else + { + if (_textBuffer.Length > 0) + { + throw new QueryParseException("Unexpected ' outside text."); + } + + _isInQuotedSection = true; + } + } + else + { + TokenKind? singleCharacterTokenKind = _isInQuotedSection ? null : TryGetSingleCharacterTokenKind(ch); + + if (singleCharacterTokenKind != null && !IsMinusInsideText(singleCharacterTokenKind.Value)) + { + Token identifierToken = FlushTextBuffer(false); + + if (identifierToken != null) + { + yield return identifierToken; + } + + yield return new Token(singleCharacterTokenKind.Value); + } + else + { + if (_textBuffer.Length == 0 && ch == ' ') + { + throw new QueryParseException("Unexpected whitespace."); + } + + _textBuffer.Append(ch); + } + } + + _offset++; + } + + if (_isInQuotedSection) + { + throw new QueryParseException("' expected."); + } + + Token lastToken = FlushTextBuffer(false); + + if (lastToken != null) + { + yield return lastToken; + } + } + + private bool IsMinusInsideText(TokenKind kind) + { + return kind == TokenKind.Minus && _textBuffer.Length > 0; + } + + private char? PeekChar() + { + return _offset + 1 < _source.Length ? (char?)_source[_offset + 1] : null; + } + + private static TokenKind? TryGetSingleCharacterTokenKind(char ch) + { + return SingleCharacterToTokenKinds.ContainsKey(ch) ? (TokenKind?)SingleCharacterToTokenKinds[ch] : null; + } + + private Token FlushTextBuffer(bool isQuotedText) + { + if (isQuotedText || _textBuffer.Length > 0) + { + string text = _textBuffer.ToString(); + _textBuffer.Clear(); + return new Token(isQuotedText ? TokenKind.QuotedText : TokenKind.Text, text); + } + + return null; + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/Queries/Parsing/ResolveFieldChainCallback.cs b/src/JsonApiDotNetCore/Internal/Queries/Parsing/ResolveFieldChainCallback.cs new file mode 100644 index 0000000000..de8a67e7d7 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Queries/Parsing/ResolveFieldChainCallback.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using JsonApiDotNetCore.Models.Annotation; + +namespace JsonApiDotNetCore.Internal.Queries.Parsing +{ + [Flags] + public enum FieldChainRequirements + { + EndsInAttribute = 1, + EndsInToOne = 2, + EndsInToMany = 4, + + IsRelationship = EndsInToOne | EndsInToMany + } + + public delegate IReadOnlyCollection ResolveFieldChainCallback(string path, FieldChainRequirements requirements); +} diff --git a/src/JsonApiDotNetCore/Internal/Queries/Parsing/SortParser.cs b/src/JsonApiDotNetCore/Internal/Queries/Parsing/SortParser.cs new file mode 100644 index 0000000000..b5be60059e --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Queries/Parsing/SortParser.cs @@ -0,0 +1,64 @@ +using System.Collections.Generic; +using System.Linq; +using JsonApiDotNetCore.Internal.Queries.Expressions; + +namespace JsonApiDotNetCore.Internal.Queries.Parsing +{ + public class SortParser : QueryParser + { + public SortParser(string source, ResolveFieldChainCallback resolveFieldChainCallback) + : base(source, resolveFieldChainCallback) + { + } + + public SortExpression Parse() + { + SortExpression expression = ParseSort(); + + AssertTokenStackIsEmpty(); + + return expression; + } + + protected SortExpression ParseSort() + { + SortElementExpression firstElement = ParseSortElement(); + + var elements = new List + { + firstElement + }; + + while (TokenStack.Any()) + { + EatSingleCharacterToken(TokenKind.Comma); + + SortElementExpression nextElement = ParseSortElement(); + elements.Add(nextElement); + } + + return new SortExpression(elements); + } + + protected SortElementExpression ParseSortElement() + { + bool isAscending = true; + + if (TokenStack.TryPeek(out Token nextToken) && nextToken.Kind == TokenKind.Minus) + { + TokenStack.Pop(); + isAscending = false; + } + + CountExpression count = TryParseCount(); + if (count != null) + { + return new SortElementExpression(count, isAscending); + } + + var errorMessage = isAscending ? "-, count function or field name expected." : "Count function or field name expected."; + ResourceFieldChainExpression targetAttribute = ParseFieldChain(FieldChainRequirements.EndsInAttribute, errorMessage); + return new SortElementExpression(targetAttribute, isAscending); + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/Queries/Parsing/SparseFieldSetParser.cs b/src/JsonApiDotNetCore/Internal/Queries/Parsing/SparseFieldSetParser.cs new file mode 100644 index 0000000000..b3fa4d274c --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Queries/Parsing/SparseFieldSetParser.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using System.Linq; +using JsonApiDotNetCore.Internal.Queries.Expressions; +using JsonApiDotNetCore.Models.Annotation; + +namespace JsonApiDotNetCore.Internal.Queries.Parsing +{ + public class SparseFieldSetParser : QueryParser + { + public SparseFieldSetParser(string source, ResolveFieldChainCallback resolveFieldChainCallback) + : base(source, resolveFieldChainCallback) + { + } + + public SparseFieldSetExpression Parse() + { + var expression = ParseSparseFieldSet(); + + AssertTokenStackIsEmpty(); + + return expression; + } + + protected SparseFieldSetExpression ParseSparseFieldSet() + { + var attributes = new Dictionary(); + + ResourceFieldChainExpression nextChain = ParseFieldChain(FieldChainRequirements.EndsInAttribute, "Attribute name expected."); + AttrAttribute nextAttribute = nextChain.Fields.Cast().Single(); + attributes[nextAttribute.PublicName] = nextAttribute; + + while (TokenStack.Any()) + { + EatSingleCharacterToken(TokenKind.Comma); + + nextChain = ParseFieldChain(FieldChainRequirements.EndsInAttribute, "Attribute name expected."); + nextAttribute = nextChain.Fields.Cast().Single(); + attributes[nextAttribute.PublicName] = nextAttribute; + } + + return new SparseFieldSetExpression(attributes.Values); + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/Queries/Parsing/Token.cs b/src/JsonApiDotNetCore/Internal/Queries/Parsing/Token.cs new file mode 100644 index 0000000000..c99654aa6e --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Queries/Parsing/Token.cs @@ -0,0 +1,19 @@ +namespace JsonApiDotNetCore.Internal.Queries.Parsing +{ + public sealed class Token + { + public TokenKind Kind { get; } + public string Value { get; } + + public Token(TokenKind kind, string value = null) + { + Kind = kind; + Value = value; + } + + public override string ToString() + { + return Value == null ? Kind.ToString() : $"{Kind}: {Value}"; + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/Queries/Parsing/TokenKind.cs b/src/JsonApiDotNetCore/Internal/Queries/Parsing/TokenKind.cs new file mode 100644 index 0000000000..9be908fb14 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Queries/Parsing/TokenKind.cs @@ -0,0 +1,15 @@ +namespace JsonApiDotNetCore.Internal.Queries.Parsing +{ + public enum TokenKind + { + OpenParen, + CloseParen, + OpenBracket, + CloseBracket, + Comma, + Colon, + Minus, + Text, + QuotedText + } +} diff --git a/src/JsonApiDotNetCore/Internal/Queries/QueryLayer.cs b/src/JsonApiDotNetCore/Internal/Queries/QueryLayer.cs new file mode 100644 index 0000000000..a2a0220dfc --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Queries/QueryLayer.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using JsonApiDotNetCore.Internal.Queries.Expressions; +using JsonApiDotNetCore.Models.Annotation; + +namespace JsonApiDotNetCore.Internal.Queries +{ + public sealed class QueryLayer + { + public ResourceContext ResourceContext { get; } + + public IncludeExpression Include { get; set; } + public FilterExpression Filter { get; set; } + public SortExpression Sort { get; set; } + public PaginationExpression Pagination { get; set; } + public IDictionary Projection { get; set; } + + public QueryLayer(ResourceContext resourceContext) + { + ResourceContext = resourceContext ?? throw new ArgumentNullException(nameof(resourceContext)); + } + + public override string ToString() + { + var builder = new StringBuilder(); + + var writer = new IndentingStringWriter(builder); + WriteLayer(writer, this); + + return builder.ToString(); + } + + private static void WriteLayer(IndentingStringWriter writer, QueryLayer layer, string prefix = null) + { + writer.WriteLine(prefix + nameof(QueryLayer) + "<" + layer.ResourceContext.ResourceType.Name + ">"); + + using (writer.Indent()) + { + if (layer.Include != null) + { + writer.WriteLine($"{nameof(Include)}: {layer.Include}"); + } + + if (layer.Filter != null) + { + writer.WriteLine($"{nameof(Filter)}: {layer.Filter}"); + } + + if (layer.Sort != null) + { + writer.WriteLine($"{nameof(Sort)}: {layer.Sort}"); + } + + if (layer.Pagination != null) + { + writer.WriteLine($"{nameof(Pagination)}: {layer.Pagination}"); + } + + if (layer.Projection != null && layer.Projection.Any()) + { + writer.WriteLine(nameof(Projection)); + using (writer.Indent()) + { + foreach (var (field, nextLayer) in layer.Projection) + { + if (nextLayer == null) + { + writer.WriteLine(field.ToString()); + } + else + { + WriteLayer(writer, nextLayer, field.PublicName + ": "); + } + } + } + } + } + } + + private sealed class IndentingStringWriter : IDisposable + { + private readonly StringBuilder _builder; + private int _indentDepth; + + public IndentingStringWriter(StringBuilder builder) + { + _builder = builder; + } + + public void WriteLine(string line) + { + if (_indentDepth > 0) + { + _builder.Append(new string(' ', _indentDepth * 2)); + } + + _builder.AppendLine(line); + } + + public IndentingStringWriter Indent() + { + WriteLine("{"); + _indentDepth++; + return this; + } + + public void Dispose() + { + if (_indentDepth > 0) + { + _indentDepth--; + WriteLine("}"); + } + } + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/Queries/QueryLayerComposer.cs b/src/JsonApiDotNetCore/Internal/Queries/QueryLayerComposer.cs new file mode 100644 index 0000000000..c69236de90 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Queries/QueryLayerComposer.cs @@ -0,0 +1,221 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Internal.Contracts; +using JsonApiDotNetCore.Internal.Queries.Expressions; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; +using JsonApiDotNetCore.Query; + +namespace JsonApiDotNetCore.Internal.Queries +{ + public interface IQueryLayerComposer + { + FilterExpression GetTopFilter(); + QueryLayer Compose(ResourceContext requestResource); + } + + public class QueryLayerComposer : IQueryLayerComposer + { + private readonly IEnumerable _constraintProviders; + private readonly IResourceContextProvider _resourceContextProvider; + private readonly IResourceDefinitionProvider _resourceDefinitionProvider; + private readonly IJsonApiOptions _options; + private readonly IPaginationContext _paginationContext; + + public QueryLayerComposer( + IEnumerable constraintProviders, + IResourceContextProvider resourceContextProvider, + IResourceDefinitionProvider resourceDefinitionProvider, + IJsonApiOptions options, + IPaginationContext paginationContext) + { + _constraintProviders = constraintProviders ?? throw new ArgumentNullException(nameof(constraintProviders)); + _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); + _resourceDefinitionProvider = resourceDefinitionProvider ?? throw new ArgumentNullException(nameof(resourceDefinitionProvider)); + _options = options ?? throw new ArgumentNullException(nameof(options)); + _paginationContext = paginationContext ?? throw new ArgumentNullException(nameof(paginationContext)); + } + + public FilterExpression GetTopFilter() + { + var constraints = _constraintProviders.SelectMany(p => p.GetConstraints()).ToArray(); + + var topFilters = constraints + .Where(c => c.Scope == null) + .Select(c => c.Expression) + .OfType() + .ToArray(); + + if (!topFilters.Any()) + { + return null; + } + + if (topFilters.Length == 1) + { + return topFilters[0]; + } + + return new LogicalExpression(LogicalOperator.And, topFilters); + } + + public QueryLayer Compose(ResourceContext requestResource) + { + if (requestResource == null) + { + throw new ArgumentNullException(nameof(requestResource)); + } + + var constraints = _constraintProviders.SelectMany(p => p.GetConstraints()).ToArray(); + + var topLayer = ComposeTopLayer(constraints, requestResource); + + ComposeChildren(topLayer, constraints); + + return topLayer; + } + + private QueryLayer ComposeTopLayer(IEnumerable constraints, ResourceContext resourceContext) + { + var expressionsInTopScope = constraints + .Where(c => c.Scope == null) + .Select(expressionInScope => expressionInScope.Expression) + .ToArray(); + + var topPagination = GetPagination(expressionsInTopScope, resourceContext); + if (topPagination != null) + { + _paginationContext.PageSize = topPagination.PageSize; + _paginationContext.PageNumber = topPagination.PageNumber; + } + + return new QueryLayer(resourceContext) + { + Include = GetIncludes(expressionsInTopScope), + Filter = GetFilter(expressionsInTopScope, resourceContext), + Sort = GetSort(expressionsInTopScope, resourceContext), + Pagination = ((JsonApiOptions)_options).DisableTopPagination ? null : topPagination, + Projection = GetSparseFieldSetProjection(expressionsInTopScope, resourceContext) + }; + } + + private void ComposeChildren(QueryLayer topLayer, ExpressionInScope[] constraints) + { + if (topLayer.Include == null) + { + return; + } + + foreach (var includeChain in topLayer.Include.Chains) + { + var currentLayer = topLayer; + List currentScope = new List(); + + foreach (var relationship in includeChain.Fields.OfType()) + { + currentScope.Add(relationship); + var currentResourceContext = _resourceContextProvider.GetResourceContext(relationship.RightType); + + currentLayer.Projection ??= new Dictionary(); + + if (!currentLayer.Projection.ContainsKey(relationship)) + { + var expressionsInCurrentScope = constraints + .Where(c => c.Scope != null && c.Scope.Fields.SequenceEqual(currentScope)) + .Select(expressionInScope => expressionInScope.Expression) + .ToArray(); + + var child = new QueryLayer(currentResourceContext) + { + Filter = GetFilter(expressionsInCurrentScope, currentResourceContext), + Sort = GetSort(expressionsInCurrentScope, currentResourceContext), + Pagination = ((JsonApiOptions)_options).DisableChildrenPagination ? null : GetPagination(expressionsInCurrentScope, currentResourceContext), + Projection = GetSparseFieldSetProjection(expressionsInCurrentScope, currentResourceContext) + }; + + currentLayer.Projection.Add(relationship, child); + } + + currentLayer = currentLayer.Projection[relationship]; + } + } + } + + protected virtual IncludeExpression GetIncludes(IEnumerable expressionsInScope) + { + return expressionsInScope.OfType().FirstOrDefault(); + } + + protected virtual FilterExpression GetFilter(IEnumerable expressionsInScope, ResourceContext resourceContext) + { + var filters = expressionsInScope.OfType().ToArray(); + var filter = filters.Length > 1 ? new LogicalExpression(LogicalOperator.And, filters) : filters.FirstOrDefault(); + + var resourceDefinition = _resourceDefinitionProvider.Get(resourceContext.ResourceType); + if (resourceDefinition != null) + { + filter = resourceDefinition.OnApplyFilter(filter); + } + + return filter; + } + + protected virtual SortExpression GetSort(IEnumerable expressionsInScope, ResourceContext resourceContext) + { + var sort = expressionsInScope.OfType().FirstOrDefault(); + + var resourceDefinition = _resourceDefinitionProvider.Get(resourceContext.ResourceType); + if (resourceDefinition != null) + { + sort = resourceDefinition.OnApplySort(sort); + } + + if (sort == null) + { + var idAttribute = resourceContext.Attributes.Single(x => x.Property.Name == nameof(Identifiable.Id)); + sort = new SortExpression(new[] {new SortElementExpression(new ResourceFieldChainExpression(idAttribute), true)}); + } + + return sort; + } + + protected virtual PaginationExpression GetPagination(IEnumerable expressionsInScope, ResourceContext resourceContext) + { + var pagination = expressionsInScope.OfType().FirstOrDefault(); + + var resourceDefinition = _resourceDefinitionProvider.Get(resourceContext.ResourceType); + if (resourceDefinition != null) + { + pagination = resourceDefinition.OnApplyPagination(pagination); + } + + pagination ??= new PaginationExpression(PageNumber.ValueOne, _options.DefaultPageSize); + + return pagination; + } + + protected virtual IDictionary GetSparseFieldSetProjection(IEnumerable expressionsInScope, ResourceContext resourceContext) + { + var attributes = expressionsInScope.OfType().SelectMany(sparseFieldSet => sparseFieldSet.Attributes).ToHashSet(); + + var resourceDefinition = _resourceDefinitionProvider.Get(resourceContext.ResourceType); + if (resourceDefinition != null) + { + var tempExpression = attributes.Any() ? new SparseFieldSetExpression(attributes) : null; + tempExpression = resourceDefinition.OnApplySparseFieldSet(tempExpression); + + attributes = tempExpression == null ? new HashSet() : tempExpression.Attributes.ToHashSet(); + } + + if (attributes.Any()) + { + var idAttribute = resourceContext.Attributes.Single(x => x.Property.Name == nameof(Identifiable.Id)); + attributes.Add(idAttribute); + } + + return attributes.Cast().ToDictionary(key => key, value => (QueryLayer) null); + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/IncludeClauseBuilder.cs b/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/IncludeClauseBuilder.cs new file mode 100644 index 0000000000..e16887c69d --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/IncludeClauseBuilder.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using JsonApiDotNetCore.Internal.Contracts; +using JsonApiDotNetCore.Internal.Queries.Expressions; +using JsonApiDotNetCore.Models.Annotation; +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCore.Internal.Queries.QueryableBuilding +{ + public class IncludeClauseBuilder : QueryClauseBuilder + { + private readonly Expression _source; + private readonly ResourceContext _resourceContext; + private readonly IResourceContextProvider _resourceContextProvider; + + public IncludeClauseBuilder(Expression source, LambdaScope lambdaScope, ResourceContext resourceContext, + IResourceContextProvider resourceContextProvider) + : base(lambdaScope) + { + _source = source ?? throw new ArgumentNullException(nameof(source)); + _resourceContext = resourceContext ?? throw new ArgumentNullException(nameof(resourceContext)); + _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); + } + + public Expression ApplyInclude(IncludeExpression include) + { + if (include == null) + { + throw new ArgumentNullException(nameof(include)); + } + + return Visit(include, null); + } + + public override Expression VisitInclude(IncludeExpression expression, object argument) + { + var source = ApplyEagerLoads(_source, _resourceContext.EagerLoads, null); + + foreach (ResourceFieldChainExpression chain in expression.Chains) + { + string path = null; + + foreach (var relationship in chain.Fields.Cast()) + { + path = path == null ? relationship.RelationshipPath : path + "." + relationship.RelationshipPath; + + var resourceContext = _resourceContextProvider.GetResourceContext(relationship.RightType); + source = ApplyEagerLoads(source, resourceContext.EagerLoads, path); + } + + source = IncludeExtensionMethodCall(source, path); + } + + return source; + } + + private Expression ApplyEagerLoads(Expression source, IEnumerable eagerLoads, string pathPrefix) + { + foreach (var eagerLoad in eagerLoads) + { + string path = pathPrefix != null ? pathPrefix + "." + eagerLoad.Property.Name : eagerLoad.Property.Name; + source = IncludeExtensionMethodCall(source, path); + + source = ApplyEagerLoads(source, eagerLoad.Children, path); + } + + return source; + } + + private Expression IncludeExtensionMethodCall(Expression source, string navigationPropertyPath) + { + Expression navigationExpression = Expression.Constant(navigationPropertyPath); + + return Expression.Call(typeof(EntityFrameworkQueryableExtensions), "Include", new[] + { + LambdaScope.Parameter.Type + }, source, navigationExpression); + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/LambdaParameterNameFactory.cs b/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/LambdaParameterNameFactory.cs new file mode 100644 index 0000000000..438eaebd78 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/LambdaParameterNameFactory.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using Humanizer; + +namespace JsonApiDotNetCore.Internal.Queries.QueryableBuilding +{ + /// + /// Produces unique names for lambda parameters. + /// + public sealed class LambdaParameterNameFactory + { + private readonly HashSet _namesInScope = new HashSet(); + + public LambdaParameterNameScope Create(string typeName) + { + if (typeName == null) + { + throw new ArgumentNullException(nameof(typeName)); + } + + string parameterName = typeName.Camelize(); + parameterName = EnsureNameIsUnique(parameterName); + + _namesInScope.Add(parameterName); + return new LambdaParameterNameScope(parameterName, this); + } + + private string EnsureNameIsUnique(string name) + { + if (!_namesInScope.Contains(name)) + { + return name; + } + + int counter = 1; + string alternativeName; + + do + { + counter++; + alternativeName = name + counter; + } + while (_namesInScope.Contains(alternativeName)); + + return alternativeName; + } + + public void Release(string parameterName) + { + _namesInScope.Remove(parameterName); + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/LambdaParameterNameScope.cs b/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/LambdaParameterNameScope.cs new file mode 100644 index 0000000000..30e1958e15 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/LambdaParameterNameScope.cs @@ -0,0 +1,22 @@ +using System; + +namespace JsonApiDotNetCore.Internal.Queries.QueryableBuilding +{ + public sealed class LambdaParameterNameScope : IDisposable + { + private readonly LambdaParameterNameFactory _owner; + + public string Name { get; } + + public LambdaParameterNameScope(string name, LambdaParameterNameFactory owner) + { + _owner = owner ?? throw new ArgumentNullException(nameof(owner)); + Name = name ?? throw new ArgumentNullException(nameof(name)); + } + + public void Dispose() + { + _owner.Release(Name); + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/LambdaScope.cs b/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/LambdaScope.cs new file mode 100644 index 0000000000..427cb8a0f1 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/LambdaScope.cs @@ -0,0 +1,41 @@ +using System; +using System.Linq.Expressions; +using JsonApiDotNetCore.Models.Annotation; + +namespace JsonApiDotNetCore.Internal.Queries.QueryableBuilding +{ + public sealed class LambdaScope : IDisposable + { + private readonly LambdaParameterNameScope _parameterNameScope; + + public ParameterExpression Parameter { get; } + public Expression Accessor { get; } + public HasManyThroughAttribute HasManyThrough { get; } + + public LambdaScope(LambdaParameterNameFactory nameFactory, Type elementType, Expression accessorExpression, HasManyThroughAttribute hasManyThrough) + { + _parameterNameScope = nameFactory.Create(elementType.Name); + Parameter = Expression.Parameter(elementType, _parameterNameScope.Name); + + if (accessorExpression != null) + { + Accessor = accessorExpression; + } + else if (hasManyThrough != null) + { + Accessor = Expression.Property(Parameter, hasManyThrough.RightProperty); + } + else + { + Accessor = Parameter; + } + + HasManyThrough = hasManyThrough; + } + + public void Dispose() + { + _parameterNameScope.Dispose(); + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/LambdaScopeFactory.cs b/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/LambdaScopeFactory.cs new file mode 100644 index 0000000000..47fdf29ed3 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/LambdaScopeFactory.cs @@ -0,0 +1,28 @@ +using System; +using System.Linq.Expressions; +using JsonApiDotNetCore.Models.Annotation; + +namespace JsonApiDotNetCore.Internal.Queries.QueryableBuilding +{ + public sealed class LambdaScopeFactory + { + private readonly LambdaParameterNameFactory _nameFactory; + private readonly HasManyThroughAttribute _hasManyThrough; + + public LambdaScopeFactory(LambdaParameterNameFactory nameFactory, HasManyThroughAttribute hasManyThrough = null) + { + _nameFactory = nameFactory ?? throw new ArgumentNullException(nameof(nameFactory)); + _hasManyThrough = hasManyThrough; + } + + public LambdaScope CreateScope(Type elementType, Expression accessorExpression = null) + { + if (elementType == null) + { + throw new ArgumentNullException(nameof(elementType)); + } + + return new LambdaScope(_nameFactory, elementType, accessorExpression, _hasManyThrough); + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/OrderClauseBuilder.cs b/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/OrderClauseBuilder.cs new file mode 100644 index 0000000000..2f5883a440 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/OrderClauseBuilder.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using JsonApiDotNetCore.Internal.Queries.Expressions; +using JsonApiDotNetCore.Models.Annotation; + +namespace JsonApiDotNetCore.Internal.Queries.QueryableBuilding +{ + public class OrderClauseBuilder : QueryClauseBuilder + { + private readonly Expression _source; + private readonly Type _extensionType; + + public OrderClauseBuilder(Expression source, LambdaScope lambdaScope, Type extensionType) + : base(lambdaScope) + { + _source = source ?? throw new ArgumentNullException(nameof(source)); + _extensionType = extensionType ?? throw new ArgumentNullException(nameof(extensionType)); + } + + public Expression ApplyOrderBy(SortExpression expression) + { + if (expression == null) + { + throw new ArgumentNullException(nameof(expression)); + } + + return Visit(expression, null); + } + + public override Expression VisitSort(SortExpression expression, Expression argument) + { + Expression sortExpression = null; + + foreach (SortElementExpression sortElement in expression.Elements) + { + sortExpression = Visit(sortElement, sortExpression); + } + + return sortExpression; + } + + public override Expression VisitSortElement(SortElementExpression expression, Expression previousExpression) + { + Expression body = expression.Count != null + ? Visit(expression.Count, null) + : Visit(expression.TargetAttribute, null); + + LambdaExpression lambda = Expression.Lambda(body, LambdaScope.Parameter); + + string operationName = previousExpression == null ? + expression.IsAscending ? "OrderBy" : "OrderByDescending" : + expression.IsAscending ? "ThenBy" : "ThenByDescending"; + + return ExtensionMethodCall(previousExpression ?? _source, operationName, body.Type, lambda); + } + + private Expression ExtensionMethodCall(Expression source, string operationName, Type keyType, + LambdaExpression keySelector) + { + return Expression.Call(_extensionType, operationName, new[] + { + LambdaScope.Parameter.Type, + keyType + }, source, keySelector); + } + + protected override MemberExpression CreatePropertyExpressionForFieldChain(IReadOnlyCollection chain, Expression source) + { + var components = chain.Select(field => + // In case of a HasManyThrough access (from count() function), we only need to look at the number of entries in the join table. + field is HasManyThroughAttribute hasManyThrough ? hasManyThrough.ThroughProperty.Name : field.Property.Name).ToList(); + + return CreatePropertyExpressionFromComponents(LambdaScope.Accessor, components); + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/QueryClauseBuilder.cs b/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/QueryClauseBuilder.cs new file mode 100644 index 0000000000..b934718e07 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/QueryClauseBuilder.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using JsonApiDotNetCore.Extensions; +using JsonApiDotNetCore.Internal.Queries.Expressions; +using JsonApiDotNetCore.Models.Annotation; + +namespace JsonApiDotNetCore.Internal.Queries.QueryableBuilding +{ + public abstract class QueryClauseBuilder : QueryExpressionVisitor + { + protected LambdaScope LambdaScope { get; } + + protected QueryClauseBuilder(LambdaScope lambdaScope) + { + LambdaScope = lambdaScope ?? throw new ArgumentNullException(nameof(lambdaScope)); + } + + public override Expression VisitCount(CountExpression expression, TArgument argument) + { + var collectionExpression = Visit(expression.TargetCollection, argument); + + var propertyExpression = TryGetCollectionCount(collectionExpression); + if (propertyExpression == null) + { + throw new Exception($"Field '{expression.TargetCollection}' must be a collection."); + } + + return propertyExpression; + } + + private static Expression TryGetCollectionCount(Expression collectionExpression) + { + var properties = new HashSet(collectionExpression.Type.GetProperties()); + if (collectionExpression.Type.IsInterface) + { + properties.AddRange(collectionExpression.Type.GetInterfaces().SelectMany(i => i.GetProperties())); + } + + foreach (var property in properties) + { + if (property.Name == "Count" || property.Name == "Length") + { + return Expression.Property(collectionExpression, property); + } + } + + return null; + } + + public override Expression VisitResourceFieldChain(ResourceFieldChainExpression expression, TArgument argument) + { + return CreatePropertyExpressionForFieldChain(expression.Fields, LambdaScope.Accessor); + } + + protected virtual MemberExpression CreatePropertyExpressionForFieldChain(IReadOnlyCollection chain, Expression source) + { + var components = chain.Select(field => + field is RelationshipAttribute relationship ? relationship.RelationshipPath : field.Property.Name); + + return CreatePropertyExpressionFromComponents(source, components); + } + + protected static MemberExpression CreatePropertyExpressionFromComponents(Expression source, IEnumerable components) + { + MemberExpression property = null; + + foreach (var propertyName in components) + { + Type parentType = property == null ? source.Type : property.Type; + + if (parentType.GetProperty(propertyName) == null) + { + throw new InvalidOperationException( + $"Type '{parentType.Name}' does not contain a property named '{propertyName}'."); + } + + property = property == null + ? Expression.Property(source, propertyName) + : Expression.Property(property, propertyName); + } + + return property; + } + + protected Expression CreateTupleAccessExpressionForConstant(object value, Type type) + { + // To enable efficient query plan caching, inline constants (that vary per request) should be converted into query parameters. + // https://stackoverflow.com/questions/54075758/building-a-parameterized-entityframework-core-expression + + // This method can be used to change a query like: + // SELECT ... FROM ... WHERE x."Age" = 3 + // into: + // SELECT ... FROM ... WHERE x."Age" = @p0 + + // The code below builds the next expression for a type T that is unknown at compile time: + // Expression.Property(Expression.Constant(Tuple.Create(value)), "Item1") + // Which represents the next C# code: + // Tuple.Create(value).Item1; + + MethodInfo tupleCreateMethod = typeof(Tuple).GetMethods() + .Single(m => m.Name == "Create" && m.IsGenericMethod && m.GetGenericArguments().Length == 1); + + MethodInfo constructedTupleCreateMethod = tupleCreateMethod.MakeGenericMethod(type); + + ConstantExpression constantExpression = Expression.Constant(value, type); + + MethodCallExpression tupleCreateCall = Expression.Call(constructedTupleCreateMethod, constantExpression); + return Expression.Property(tupleCreateCall, "Item1"); + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/QueryableBuilder.cs b/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/QueryableBuilder.cs new file mode 100644 index 0000000000..b87f04a9f5 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/QueryableBuilder.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using JsonApiDotNetCore.Internal.Contracts; +using JsonApiDotNetCore.Internal.Queries.Expressions; +using JsonApiDotNetCore.Models.Annotation; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace JsonApiDotNetCore.Internal.Queries.QueryableBuilding +{ + public sealed class QueryableBuilder + { + private readonly Expression _source; + private readonly Type _elementType; + private readonly Type _extensionType; + private readonly LambdaParameterNameFactory _nameFactory; + private readonly IResourceFactory _resourceFactory; + private readonly IResourceContextProvider _resourceContextProvider; + private readonly IModel _entityModel; + private readonly LambdaScopeFactory _lambdaScopeFactory; + + public QueryableBuilder(Expression source, Type elementType, Type extensionType, LambdaParameterNameFactory nameFactory, + IResourceFactory resourceFactory, IResourceContextProvider resourceContextProvider, IModel entityModel, + LambdaScopeFactory lambdaScopeFactory = null) + { + _source = source ?? throw new ArgumentNullException(nameof(source)); + _elementType = elementType ?? throw new ArgumentNullException(nameof(elementType)); + _extensionType = extensionType ?? throw new ArgumentNullException(nameof(extensionType)); + _nameFactory = nameFactory ?? throw new ArgumentNullException(nameof(nameFactory)); + _resourceFactory = resourceFactory ?? throw new ArgumentNullException(nameof(resourceFactory)); + _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); + _entityModel = entityModel ?? throw new ArgumentNullException(nameof(entityModel)); + _lambdaScopeFactory = lambdaScopeFactory ?? new LambdaScopeFactory(_nameFactory); + } + + public Expression ApplyQuery(QueryLayer layer) + { + Expression expression = _source; + + if (layer.Include != null) + { + expression = ApplyInclude(expression, layer.Include, layer.ResourceContext); + } + + if (layer.Filter != null) + { + expression = ApplyFilter(expression, layer.Filter); + } + + if (layer.Sort != null) + { + expression = ApplySort(expression, layer.Sort); + } + + if (layer.Pagination != null) + { + expression = ApplyPagination(expression, layer.Pagination); + } + + if (layer.Projection != null && layer.Projection.Any()) + { + expression = ApplyProjection(expression, layer.Projection, layer.ResourceContext); + } + + return expression; + } + + private Expression ApplyInclude(Expression source, IncludeExpression include, ResourceContext resourceContext) + { + using var lambdaScope = _lambdaScopeFactory.CreateScope(_elementType); + + var builder = new IncludeClauseBuilder(source, lambdaScope, resourceContext, _resourceContextProvider); + return builder.ApplyInclude(include); + } + + private Expression ApplyFilter(Expression source, FilterExpression filter) + { + using var lambdaScope = _lambdaScopeFactory.CreateScope(_elementType); + + var builder = new WhereClauseBuilder(source, lambdaScope, _extensionType); + return builder.ApplyWhere(filter); + } + + private Expression ApplySort(Expression source, SortExpression sort) + { + using var lambdaScope = _lambdaScopeFactory.CreateScope(_elementType); + + var builder = new OrderClauseBuilder(source, lambdaScope, _extensionType); + return builder.ApplyOrderBy(sort); + } + + private Expression ApplyPagination(Expression source, PaginationExpression pagination) + { + using var lambdaScope = _lambdaScopeFactory.CreateScope(_elementType); + + var builder = new SkipTakeClauseBuilder(source, lambdaScope, _extensionType); + return builder.ApplySkipTake(pagination); + } + + private Expression ApplyProjection(Expression source, IDictionary projection, ResourceContext resourceContext) + { + using var lambdaScope = _lambdaScopeFactory.CreateScope(_elementType); + + var builder = new SelectClauseBuilder(source, lambdaScope, _entityModel, _extensionType, _nameFactory, _resourceFactory, _resourceContextProvider); + return builder.ApplySelect(projection, resourceContext); + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/SelectClauseBuilder.cs b/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/SelectClauseBuilder.cs new file mode 100644 index 0000000000..95cc4a535d --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/SelectClauseBuilder.cs @@ -0,0 +1,231 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using JsonApiDotNetCore.Internal.Contracts; +using JsonApiDotNetCore.Models.Annotation; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace JsonApiDotNetCore.Internal.Queries.QueryableBuilding +{ + public class SelectClauseBuilder : QueryClauseBuilder + { + private static readonly ConstantExpression NullConstant = Expression.Constant(null); + + private readonly Expression _source; + private readonly IModel _entityModel; + private readonly Type _extensionType; + private readonly LambdaParameterNameFactory _nameFactory; + private readonly IResourceFactory _resourceFactory; + private readonly IResourceContextProvider _resourceContextProvider; + + public SelectClauseBuilder(Expression source, LambdaScope lambdaScope, IModel entityModel, Type extensionType, + LambdaParameterNameFactory nameFactory, IResourceFactory resourceFactory, IResourceContextProvider resourceContextProvider) + : base(lambdaScope) + { + _source = source ?? throw new ArgumentNullException(nameof(source)); + _entityModel = entityModel ?? throw new ArgumentNullException(nameof(entityModel)); + _extensionType = extensionType ?? throw new ArgumentNullException(nameof(extensionType)); + _nameFactory = nameFactory ?? throw new ArgumentNullException(nameof(nameFactory)); + _resourceFactory = resourceFactory ?? throw new ArgumentNullException(nameof(resourceFactory)); + _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); + } + + public Expression ApplySelect(IDictionary selectors, ResourceContext resourceContext) + { + if (selectors == null) + { + throw new ArgumentNullException(nameof(selectors)); + } + + if (!selectors.Any()) + { + return _source; + } + + Expression bodyInitializer = CreateLambdaBodyInitializer(selectors, resourceContext, LambdaScope, false); + + LambdaExpression lambda = Expression.Lambda(bodyInitializer, LambdaScope.Parameter); + + return SelectExtensionMethodCall(_source, LambdaScope.Parameter.Type, lambda); + } + + private Expression CreateLambdaBodyInitializer(IDictionary selectors, ResourceContext resourceContext, + LambdaScope lambdaScope, bool lambdaAccessorRequiresTestForNull) + { + var propertySelectors = ToPropertySelectors(selectors, resourceContext, lambdaScope.Parameter.Type); + MemberBinding[] propertyAssignments = propertySelectors.Select(selector => CreatePropertyAssignment(selector, lambdaScope)).Cast().ToArray(); + + NewExpression newExpression = _resourceFactory.CreateNewExpression(lambdaScope.Accessor.Type); + Expression memberInit = Expression.MemberInit(newExpression, propertyAssignments); + + if (lambdaScope.HasManyThrough != null) + { + MemberBinding outerPropertyAssignment = Expression.Bind(lambdaScope.HasManyThrough.RightProperty, memberInit); + + NewExpression outerNewExpression = _resourceFactory.CreateNewExpression(lambdaScope.HasManyThrough.ThroughType); + memberInit = Expression.MemberInit(outerNewExpression, outerPropertyAssignment); + } + + if (!lambdaAccessorRequiresTestForNull) + { + return memberInit; + } + + return TestForNull(lambdaScope.Accessor, memberInit); + } + + private ICollection ToPropertySelectors(IDictionary resourceFieldSelectors, + ResourceContext resourceContext, Type elementType) + { + Dictionary propertySelectors = new Dictionary(); + + // If a read-only attribute is selected, its value likely depends on another property, so select all resource properties. + bool includesReadOnlyAttribute = resourceFieldSelectors.Any(selector => + selector.Key is AttrAttribute attribute && attribute.Property.SetMethod == null); + + bool containsOnlyRelationships = resourceFieldSelectors.All(selector => selector.Key is RelationshipAttribute); + + foreach (var fieldSelector in resourceFieldSelectors) + { + var propertySelector = new PropertySelector(fieldSelector.Key, fieldSelector.Value); + if (propertySelector.Property.SetMethod != null) + { + propertySelectors[propertySelector.Property] = propertySelector; + } + } + + if (includesReadOnlyAttribute || containsOnlyRelationships) + { + var entityModel = _entityModel.GetEntityTypes().Single(type => type.ClrType == elementType); + IEnumerable entityProperties = entityModel.GetProperties().Where(p => !p.IsShadowProperty()).ToArray(); + + foreach (var entityProperty in entityProperties) + { + var propertySelector = new PropertySelector(entityProperty.PropertyInfo); + if (propertySelector.Property.SetMethod != null) + { + propertySelectors[propertySelector.Property] = propertySelector; + } + } + } + + foreach (var eagerLoad in resourceContext.EagerLoads) + { + var propertySelector = new PropertySelector(eagerLoad.Property); + propertySelectors[propertySelector.Property] = propertySelector; + } + + return propertySelectors.Values; + } + + private MemberAssignment CreatePropertyAssignment(PropertySelector selector, LambdaScope lambdaScope) + { + MemberExpression propertyAccess = Expression.Property(lambdaScope.Accessor, selector.Property); + + Expression assignmentRightHandSide = propertyAccess; + if (selector.NextLayer != null) + { + HasManyThroughAttribute hasManyThrough = selector.OriginatingField as HasManyThroughAttribute; + var lambdaScopeFactory = new LambdaScopeFactory(_nameFactory, hasManyThrough); + + assignmentRightHandSide = CreateAssignmentRightHandSideForLayer(selector.NextLayer, lambdaScope, propertyAccess, + selector.Property, lambdaScopeFactory); + } + + return Expression.Bind(selector.Property, assignmentRightHandSide); + } + + private Expression CreateAssignmentRightHandSideForLayer(QueryLayer layer, LambdaScope outerLambdaScope, MemberExpression propertyAccess, + PropertyInfo selectorPropertyInfo, LambdaScopeFactory lambdaScopeFactory) + { + Type collectionElementType = TypeHelper.TryGetCollectionElementType(selectorPropertyInfo.PropertyType); + Type bodyElementType = collectionElementType ?? selectorPropertyInfo.PropertyType; + + if (collectionElementType != null) + { + return CreateCollectionInitializer(outerLambdaScope, selectorPropertyInfo, bodyElementType, layer, lambdaScopeFactory); + } + + if (layer.Projection == null || !layer.Projection.Any()) + { + return propertyAccess; + } + + using var scope = lambdaScopeFactory.CreateScope(bodyElementType, propertyAccess); + return CreateLambdaBodyInitializer(layer.Projection, layer.ResourceContext, scope, true); + } + + private Expression CreateCollectionInitializer(LambdaScope lambdaScope, PropertyInfo collectionProperty, + Type elementType, QueryLayer layer, LambdaScopeFactory lambdaScopeFactory) + { + MemberExpression propertyExpression = Expression.Property(lambdaScope.Accessor, collectionProperty); + + var builder = new QueryableBuilder(propertyExpression, elementType, typeof(Enumerable), _nameFactory, + _resourceFactory, _resourceContextProvider, _entityModel, lambdaScopeFactory); + + Expression layerExpression = builder.ApplyQuery(layer); + + Type enumerableOfElementType = typeof(IEnumerable<>).MakeGenericType(elementType); + Type typedCollection = collectionProperty.PropertyType.ToConcreteCollectionType(); + + ConstructorInfo typedCollectionConstructor = typedCollection.GetConstructor(new[] + { + enumerableOfElementType + }); + + if (typedCollectionConstructor == null) + { + throw new Exception( + $"Constructor on '{typedCollection.Name}' that accepts '{enumerableOfElementType.Name}' not found."); + } + + return Expression.New(typedCollectionConstructor, layerExpression); + } + + private static Expression TestForNull(Expression expressionToTest, Expression ifFalseExpression) + { + BinaryExpression equalsNull = Expression.Equal(expressionToTest, NullConstant); + return Expression.Condition(equalsNull, Expression.Convert(NullConstant, expressionToTest.Type), ifFalseExpression); + } + + private Expression SelectExtensionMethodCall(Expression source, Type elementType, Expression selectorBody) + { + return Expression.Call(_extensionType, "Select", new[] + { + elementType, + elementType + }, source, selectorBody); + } + + private sealed class PropertySelector + { + public PropertyInfo Property { get; } + public ResourceFieldAttribute OriginatingField { get; } + public QueryLayer NextLayer { get; } + + public PropertySelector(PropertyInfo property, QueryLayer nextLayer = null) + { + Property = property ?? throw new ArgumentNullException(nameof(property)); + NextLayer = nextLayer; + } + + public PropertySelector(ResourceFieldAttribute field, QueryLayer nextLayer = null) + { + OriginatingField = field ?? throw new ArgumentNullException(nameof(field)); + NextLayer = nextLayer; + + Property = field is HasManyThroughAttribute hasManyThrough + ? hasManyThrough.ThroughProperty + : field.Property; + } + + public override string ToString() + { + return "Property: " + (NextLayer != null ? Property.Name + "..." : Property.Name); + } + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/SkipTakeClauseBuilder.cs b/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/SkipTakeClauseBuilder.cs new file mode 100644 index 0000000000..f87db0cbec --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/SkipTakeClauseBuilder.cs @@ -0,0 +1,58 @@ +using System; +using System.Linq.Expressions; +using JsonApiDotNetCore.Internal.Queries.Expressions; + +namespace JsonApiDotNetCore.Internal.Queries.QueryableBuilding +{ + public class SkipTakeClauseBuilder : QueryClauseBuilder + { + private readonly Expression _source; + private readonly Type _extensionType; + + public SkipTakeClauseBuilder(Expression source, LambdaScope lambdaScope, Type extensionType) + : base(lambdaScope) + { + _source = source ?? throw new ArgumentNullException(nameof(source)); + _extensionType = extensionType ?? throw new ArgumentNullException(nameof(extensionType)); + } + + public Expression ApplySkipTake(PaginationExpression expression) + { + if (expression == null) + { + throw new ArgumentNullException(nameof(expression)); + } + + return Visit(expression, null); + } + + public override Expression VisitPagination(PaginationExpression expression, object argument) + { + Expression skipTakeExpression = _source; + + if (expression.PageSize != null) + { + int skipValue = (expression.PageNumber.OneBasedValue - 1) * expression.PageSize.Value; + + if (skipValue > 0) + { + skipTakeExpression = ExtensionMethodCall(skipTakeExpression, "Skip", skipValue); + } + + skipTakeExpression = ExtensionMethodCall(skipTakeExpression, "Take", expression.PageSize.Value); + } + + return skipTakeExpression; + } + + private Expression ExtensionMethodCall(Expression source, string operationName, int value) + { + Expression constant = CreateTupleAccessExpressionForConstant(value, typeof(int)); + + return Expression.Call(_extensionType, operationName, new[] + { + LambdaScope.Parameter.Type + }, source, constant); + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/WhereClauseBuilder.cs b/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/WhereClauseBuilder.cs new file mode 100644 index 0000000000..5f75ee74b4 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/WhereClauseBuilder.cs @@ -0,0 +1,285 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using JsonApiDotNetCore.Exceptions; +using JsonApiDotNetCore.Internal.Queries.Expressions; +using JsonApiDotNetCore.Models.Annotation; + +namespace JsonApiDotNetCore.Internal.Queries.QueryableBuilding +{ + public class WhereClauseBuilder : QueryClauseBuilder + { + private static readonly ConstantExpression NullConstant = Expression.Constant(null); + + private readonly Expression _source; + private readonly Type _extensionType; + + public WhereClauseBuilder(Expression source, LambdaScope lambdaScope, Type extensionType) + : base(lambdaScope) + { + _source = source ?? throw new ArgumentNullException(nameof(source)); + _extensionType = extensionType ?? throw new ArgumentNullException(nameof(extensionType)); + } + + public Expression ApplyWhere(FilterExpression filter) + { + if (filter == null) + { + throw new ArgumentNullException(nameof(filter)); + } + + Expression body = Visit(filter, null); + LambdaExpression lambda = Expression.Lambda(body, LambdaScope.Parameter); + + return WhereExtensionMethodCall(lambda); + } + + private Expression WhereExtensionMethodCall(LambdaExpression predicate) + { + return Expression.Call(_extensionType, "Where", new[] + { + LambdaScope.Parameter.Type + }, _source, predicate); + } + + public override Expression VisitCollectionNotEmpty(CollectionNotEmptyExpression expression, Type argument) + { + Expression property = Visit(expression.TargetCollection, argument); + + Type elementType = TypeHelper.TryGetCollectionElementType(property.Type); + + if (elementType == null) + { + throw new Exception("Expression must be a collection."); + } + + return AnyExtensionMethodCall(elementType, property); + } + + private static MethodCallExpression AnyExtensionMethodCall(Type elementType, Expression source) + { + return Expression.Call(typeof(Enumerable), "Any", new[] + { + elementType + }, source); + } + + public override Expression VisitMatchText(MatchTextExpression expression, Type argument) + { + Expression property = Visit(expression.TargetAttribute, argument); + + if (property.Type != typeof(string)) + { + throw new Exception("Expression must be a string."); + } + + Expression text = Visit(expression.TextValue, property.Type); + + if (expression.MatchKind == TextMatchKind.StartsWith) + { + return Expression.Call(property, "StartsWith", null, text); + } + + if (expression.MatchKind == TextMatchKind.EndsWith) + { + return Expression.Call(property, "EndsWith", null, text); + } + + return Expression.Call(property, "Contains", null, text); + } + + public override Expression VisitEqualsAnyOf(EqualsAnyOfExpression expression, Type argument) + { + Expression property = Visit(expression.TargetAttribute, argument); + + var valueList = (IList)Activator.CreateInstance(typeof(List<>).MakeGenericType(property.Type)); + + foreach (LiteralConstantExpression constant in expression.Constants) + { + object value = ConvertTextToTargetType(constant.Value, property.Type); + valueList.Add(value); + } + + ConstantExpression collection = Expression.Constant(valueList); + return ContainsExtensionMethodCall(collection, property); + } + + private static Expression ContainsExtensionMethodCall(Expression collection, Expression value) + { + return Expression.Call(typeof(Enumerable), "Contains", new[] + { + value.Type + }, collection, value); + } + + public override Expression VisitLogical(LogicalExpression expression, Type argument) + { + var termQueue = new Queue(expression.Terms.Select(filter => Visit(filter, argument))); + + if (expression.Operator == LogicalOperator.And) + { + return Compose(termQueue, Expression.AndAlso); + } + + if (expression.Operator == LogicalOperator.Or) + { + return Compose(termQueue, Expression.OrElse); + } + + throw new InvalidOperationException($"Unknown logical operator '{expression.Operator}'."); + } + + private static BinaryExpression Compose(Queue argumentQueue, + Func applyOperator) + { + Expression left = argumentQueue.Dequeue(); + Expression right = argumentQueue.Dequeue(); + + BinaryExpression tempExpression = applyOperator(left, right); + + while (argumentQueue.Any()) + { + Expression nextArgument = argumentQueue.Dequeue(); + tempExpression = applyOperator(tempExpression, nextArgument); + } + + return tempExpression; + } + + public override Expression VisitNot(NotExpression expression, Type argument) + { + Expression child = Visit(expression.Child, argument); + return Expression.Not(child); + } + + public override Expression VisitComparison(ComparisonExpression expression, Type argument) + { + Type commonType = TryResolveCommonType(expression.Left, expression.Right); + + Expression left = WrapInConvert(Visit(expression.Left, commonType), commonType); + Expression right = WrapInConvert(Visit(expression.Right, commonType), commonType); + + switch (expression.Operator) + { + case ComparisonOperator.Equals: + { + return Expression.Equal(left, right); + } + case ComparisonOperator.LessThan: + { + return Expression.LessThan(left, right); + } + case ComparisonOperator.LessOrEqual: + { + return Expression.LessThanOrEqual(left, right); + } + case ComparisonOperator.GreaterThan: + { + return Expression.GreaterThan(left, right); + } + case ComparisonOperator.GreaterOrEqual: + { + return Expression.GreaterThanOrEqual(left, right); + } + } + + throw new InvalidOperationException($"Unknown comparison operator '{expression.Operator}'."); + } + + private Type TryResolveCommonType(QueryExpression left, QueryExpression right) + { + var leftType = ResolveFixedType(left); + + if (TypeHelper.CanContainNull(leftType)) + { + return leftType; + } + + if (right is NullConstantExpression) + { + return typeof(Nullable<>).MakeGenericType(leftType); + } + + var rightType = TryResolveFixedType(right); + if (rightType != null && TypeHelper.CanContainNull(rightType)) + { + return rightType; + } + + return leftType; + } + + private Type ResolveFixedType(QueryExpression expression) + { + var result = Visit(expression, null); + return result.Type; + } + + private Type TryResolveFixedType(QueryExpression expression) + { + if (expression is CountExpression) + { + return typeof(int); + } + + if (expression is ResourceFieldChainExpression chain) + { + Expression child = Visit(chain, null); + return child.Type; + } + + return null; + } + + private static Expression WrapInConvert(Expression expression, Type targetType) + { + try + { + return targetType != null && expression.Type != targetType + ? Expression.Convert(expression, targetType) + : expression; + } + catch (InvalidOperationException exception) + { + throw new InvalidQueryException("Query creation failed due to incompatible types.", exception); + } + } + + public override Expression VisitNullConstant(NullConstantExpression expression, Type expressionType) + { + return NullConstant; + } + + public override Expression VisitLiteralConstant(LiteralConstantExpression expression, Type expressionType) + { + var convertedValue = expressionType != null + ? ConvertTextToTargetType(expression.Value, expressionType) + : expression.Value; + + return CreateTupleAccessExpressionForConstant(convertedValue, expressionType ?? typeof(string)); + } + + private static object ConvertTextToTargetType(string text, Type targetType) + { + try + { + return TypeHelper.ConvertType(text, targetType); + } + catch (FormatException exception) + { + throw new InvalidQueryException("Query creation failed due to incompatible types.", exception); + } + } + + protected override MemberExpression CreatePropertyExpressionForFieldChain(IReadOnlyCollection chain, Expression source) + { + var components = chain.Select(field => + // In case of a HasManyThrough access (from count() or has() function), we only need to look at the number of entries in the join table. + field is HasManyThroughAttribute hasManyThrough ? hasManyThrough.ThroughProperty.Name : field.Property.Name).ToList(); + + return CreatePropertyExpressionFromComponents(LambdaScope.Accessor, components); + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/Query/BaseQuery.cs b/src/JsonApiDotNetCore/Internal/Query/BaseQuery.cs deleted file mode 100644 index 75c760ed03..0000000000 --- a/src/JsonApiDotNetCore/Internal/Query/BaseQuery.cs +++ /dev/null @@ -1,26 +0,0 @@ -namespace JsonApiDotNetCore.Internal.Query -{ - /// - /// represents what FilterQuery and SortQuery have in common: a target. - /// (sort=TARGET, or filter[TARGET]=123). - /// - public abstract class BaseQuery - { - protected BaseQuery(string target) - { - Target = target; - var properties = target.Split(QueryConstants.DOT); - if (properties.Length > 1) - { - Relationship = properties[0]; - Attribute = properties[1]; - } - else - Attribute = properties[0]; - } - - public string Target { get; } - public string Attribute { get; } - public string Relationship { get; } - } -} diff --git a/src/JsonApiDotNetCore/Internal/Query/BaseQueryContext.cs b/src/JsonApiDotNetCore/Internal/Query/BaseQueryContext.cs deleted file mode 100644 index 0f6d7cca8c..0000000000 --- a/src/JsonApiDotNetCore/Internal/Query/BaseQueryContext.cs +++ /dev/null @@ -1,31 +0,0 @@ -using JsonApiDotNetCore.Models.Annotation; - -namespace JsonApiDotNetCore.Internal.Query -{ - /// - /// A context class that provides extra meta data for a - /// that is used when applying url queries internally. - /// - public abstract class BaseQueryContext where TQuery : BaseQuery - { - protected BaseQueryContext(TQuery query) - { - Query = query; - } - - public bool IsCustom { get; internal set; } - public AttrAttribute Attribute { get; internal set; } - public RelationshipAttribute Relationship { get; internal set; } - public bool IsAttributeOfRelationship => Relationship != null; - - public TQuery Query { get; } - - public string GetPropertyPath() - { - if (IsAttributeOfRelationship) - return $"{Relationship.Property.Name}.{Attribute.Property.Name}"; - - return Attribute.Property.Name; - } - } -} diff --git a/src/JsonApiDotNetCore/Internal/Query/FilterOperations.cs b/src/JsonApiDotNetCore/Internal/Query/FilterOperations.cs deleted file mode 100644 index aee022cd20..0000000000 --- a/src/JsonApiDotNetCore/Internal/Query/FilterOperations.cs +++ /dev/null @@ -1,18 +0,0 @@ -// ReSharper disable InconsistentNaming -namespace JsonApiDotNetCore.Internal.Query -{ - public enum FilterOperation - { - eq = 0, - lt = 1, - gt = 2, - le = 3, - ge = 4, - like = 5, - ne = 6, - @in = 7, // prefix with @ to use keyword - nin = 8, - isnull = 9, - isnotnull = 10 - } -} diff --git a/src/JsonApiDotNetCore/Internal/Query/FilterQuery.cs b/src/JsonApiDotNetCore/Internal/Query/FilterQuery.cs deleted file mode 100644 index 40588e672b..0000000000 --- a/src/JsonApiDotNetCore/Internal/Query/FilterQuery.cs +++ /dev/null @@ -1,21 +0,0 @@ -namespace JsonApiDotNetCore.Internal.Query -{ - /// - /// Internal representation of the raw articles?filter[X]=Y query from the URL. - /// - public class FilterQuery : BaseQuery - { - public FilterQuery(string target, string value, string operation) - : base(target) - { - Value = value; - Operation = operation; - } - - public string Value { get; set; } - /// - /// See . Can also be a custom operation. - /// - public string Operation { get; set; } - } -} diff --git a/src/JsonApiDotNetCore/Internal/Query/FilterQueryContext.cs b/src/JsonApiDotNetCore/Internal/Query/FilterQueryContext.cs deleted file mode 100644 index e1754d4ca7..0000000000 --- a/src/JsonApiDotNetCore/Internal/Query/FilterQueryContext.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System; - -namespace JsonApiDotNetCore.Internal.Query -{ - /// - /// Wrapper class for filter queries. Provides the internals - /// with metadata it needs to perform the url filter queries on the targeted dataset. - /// - public class FilterQueryContext : BaseQueryContext - { - public FilterQueryContext(FilterQuery query) : base(query) { } - public object CustomQuery { get; set; } - public string Value => Query.Value; - public FilterOperation Operation - { - get - { - if (!Enum.TryParse(Query.Operation, out var result)) - return FilterOperation.eq; - return result; - } - } - } -} diff --git a/src/JsonApiDotNetCore/Internal/Query/QueryConstants.cs b/src/JsonApiDotNetCore/Internal/Query/QueryConstants.cs deleted file mode 100644 index 14189017da..0000000000 --- a/src/JsonApiDotNetCore/Internal/Query/QueryConstants.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace JsonApiDotNetCore.Internal.Query -{ - public static class QueryConstants { - public const char OPEN_BRACKET = '['; - public const char CLOSE_BRACKET = ']'; - public const char COMMA = ','; - public const char COLON = ':'; - public const string COLON_STR = ":"; - public const char DOT = '.'; - } -} diff --git a/src/JsonApiDotNetCore/Internal/Query/SortDirection.cs b/src/JsonApiDotNetCore/Internal/Query/SortDirection.cs deleted file mode 100644 index 2917c852dc..0000000000 --- a/src/JsonApiDotNetCore/Internal/Query/SortDirection.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace JsonApiDotNetCore.Internal.Query -{ - public enum SortDirection - { - Ascending = 1, - Descending = 2 - } -} \ No newline at end of file diff --git a/src/JsonApiDotNetCore/Internal/Query/SortQuery.cs b/src/JsonApiDotNetCore/Internal/Query/SortQuery.cs deleted file mode 100644 index 36c703e71b..0000000000 --- a/src/JsonApiDotNetCore/Internal/Query/SortQuery.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace JsonApiDotNetCore.Internal.Query -{ - /// - /// Internal representation of the raw articles?sort[field] query from the URL. - /// - public class SortQuery : BaseQuery - { - public SortQuery(string target, SortDirection direction) - : base(target) - { - Direction = direction; - } - - /// - /// Direction the sort should be applied - /// - public SortDirection Direction { get; set; } - } -} diff --git a/src/JsonApiDotNetCore/Internal/Query/SortQueryContext.cs b/src/JsonApiDotNetCore/Internal/Query/SortQueryContext.cs deleted file mode 100644 index 68d591e5e9..0000000000 --- a/src/JsonApiDotNetCore/Internal/Query/SortQueryContext.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace JsonApiDotNetCore.Internal.Query -{ - /// - /// Wrapper class for sort queries. Provides the internals - /// with metadata it needs to perform the url sort queries on the targeted dataset. - /// - public class SortQueryContext : BaseQueryContext - { - public SortQueryContext(SortQuery sortQuery) : base(sortQuery) { } - - public SortDirection Direction => Query.Direction; - } -} diff --git a/src/JsonApiDotNetCore/QueryParameterServices/DefaultsService.cs b/src/JsonApiDotNetCore/Internal/QueryStrings/DefaultsQueryStringParameterReader.cs similarity index 58% rename from src/JsonApiDotNetCore/QueryParameterServices/DefaultsService.cs rename to src/JsonApiDotNetCore/Internal/QueryStrings/DefaultsQueryStringParameterReader.cs index aa8c597884..8f8a314cc4 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/DefaultsService.cs +++ b/src/JsonApiDotNetCore/Internal/QueryStrings/DefaultsQueryStringParameterReader.cs @@ -4,43 +4,47 @@ using Microsoft.Extensions.Primitives; using Newtonsoft.Json; -namespace JsonApiDotNetCore.Query +namespace JsonApiDotNetCore.Internal.QueryStrings { - /// - public class DefaultsService : QueryParameterService, IDefaultsService + public interface IDefaultsQueryStringParameterReader : IQueryStringParameterReader + { + /// + /// Contains the effective value of default configuration and query string override, after parsing has occured. + /// + DefaultValueHandling SerializerDefaultValueHandling { get; } + } + + public class DefaultsQueryStringParameterReader : IDefaultsQueryStringParameterReader { private readonly IJsonApiOptions _options; - public DefaultsService(IJsonApiOptions options) + /// + public DefaultValueHandling SerializerDefaultValueHandling { get; private set; } + + public DefaultsQueryStringParameterReader(IJsonApiOptions options) { SerializerDefaultValueHandling = options.SerializerSettings.DefaultValueHandling; _options = options; } - /// - public DefaultValueHandling SerializerDefaultValueHandling { get; private set; } - - /// public bool IsEnabled(DisableQueryAttribute disableQueryAttribute) { return _options.AllowQueryStringOverrideForSerializerDefaultValueHandling && !disableQueryAttribute.ContainsParameter(StandardQueryStringParameters.Defaults); } - /// - public bool CanParse(string parameterName) + public bool CanRead(string parameterName) { return parameterName == "defaults"; } - /// - public virtual void Parse(string parameterName, StringValues parameterValue) + public void Read(string parameterName, StringValues parameterValue) { if (!bool.TryParse(parameterValue, out var result)) { throw new InvalidQueryStringParameterException(parameterName, - "The specified query string value must be 'true' or 'false'.", - $"The value '{parameterValue}' for parameter '{parameterName}' is not a valid boolean value."); + "The specified defaults is invalid.", + $"The value '{parameterValue}' must be 'true' or 'false'."); } SerializerDefaultValueHandling = result ? DefaultValueHandling.Include : DefaultValueHandling.Ignore; diff --git a/src/JsonApiDotNetCore/Internal/QueryStrings/FilterQueryStringParameterReader.cs b/src/JsonApiDotNetCore/Internal/QueryStrings/FilterQueryStringParameterReader.cs new file mode 100644 index 0000000000..e8deb14f06 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/QueryStrings/FilterQueryStringParameterReader.cs @@ -0,0 +1,183 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Exceptions; +using JsonApiDotNetCore.Internal.Contracts; +using JsonApiDotNetCore.Internal.Queries; +using JsonApiDotNetCore.Internal.Queries.Expressions; +using JsonApiDotNetCore.Internal.Queries.Parsing; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; +using JsonApiDotNetCore.RequestServices.Contracts; +using Microsoft.Extensions.Primitives; + +namespace JsonApiDotNetCore.Internal.QueryStrings +{ + /// + /// Reads the 'filter' query string parameter and produces a set of query constraints from it. + /// + public interface IFilterQueryStringParameterReader : IQueryStringParameterReader, IQueryConstraintProvider + { + } + + public class FilterQueryStringParameterReader : QueryStringParameterReader, IFilterQueryStringParameterReader + { + private static readonly LegacyFilterNotationConverter _legacyConverter = new LegacyFilterNotationConverter(); + + private readonly IResourceFactory _resourceFactory; + private readonly IJsonApiOptions _options; + private readonly List _filtersInGlobalScope = new List(); + private readonly Dictionary> _filtersPerScope = new Dictionary>(); + private string _lastParameterName; + + public FilterQueryStringParameterReader(ICurrentRequest currentRequest, + IResourceContextProvider resourceContextProvider, IResourceFactory resourceFactory, IJsonApiOptions options) + : base(currentRequest, resourceContextProvider) + { + _resourceFactory = resourceFactory; + _options = options; + } + + public bool IsEnabled(DisableQueryAttribute disableQueryAttribute) + { + return !disableQueryAttribute.ContainsParameter(StandardQueryStringParameters.Filter); + } + + public bool CanRead(string parameterName) + { + var isNested = parameterName.StartsWith("filter[") && parameterName.EndsWith("]"); + return parameterName == "filter" || isNested; + } + + public void Read(string parameterName, StringValues parameterValues) + { + _lastParameterName = parameterName; + + foreach (string parameterValue in parameterValues) + { + ReadSingleValue(parameterName, parameterValue); + } + } + + private void ReadSingleValue(string parameterName, string parameterValue) + { + try + { + if (_options.EnableLegacyFilterNotation) + { + (parameterName, parameterValue) = _legacyConverter.Convert(parameterName, parameterValue); + } + + ResourceFieldChainExpression scope = GetScope(parameterName); + FilterExpression filter = GetFilter(parameterValue, scope); + + StoreFilterInScope(filter, scope); + } + catch (QueryParseException exception) + { + throw new InvalidQueryStringParameterException(_lastParameterName, "The specified filter is invalid.", exception.Message, exception); + } + } + + private ResourceFieldChainExpression GetScope(string parameterName) + { + var parser = new QueryStringParameterScopeParser(parameterName, + (path, _) => ChainResolver.ResolveToManyChain(RequestResource, path)); + + var parameterScope = parser.Parse(FieldChainRequirements.EndsInToMany); + + if (parameterScope.Scope == null) + { + AssertIsCollectionRequest(); + } + + return parameterScope.Scope; + } + + private FilterExpression GetFilter(string parameterValue, ResourceFieldChainExpression scope) + { + ResourceContext resourceContextInScope = GetResourceContextForScope(scope); + + var parser = new FilterParser(parameterValue, + (path, chainRequirements) => ResolveChainInFilter(chainRequirements, resourceContextInScope, path), + (resourceType, stringId) => TypeHelper.ConvertStringIdToTypedId(resourceType, stringId, _resourceFactory).ToString()); + + return parser.Parse(); + } + + private IReadOnlyCollection ResolveChainInFilter(FieldChainRequirements chainRequirements, + ResourceContext resourceContextInScope, string path) + { + if (chainRequirements == FieldChainRequirements.EndsInToMany) + { + return ChainResolver.ResolveToOneChainEndingInToMany(resourceContextInScope, path); + } + + if (chainRequirements == FieldChainRequirements.EndsInAttribute) + { + return ChainResolver.ResolveToOneChainEndingInAttribute(resourceContextInScope, path, ValidateFilter); + } + + if (chainRequirements.HasFlag(FieldChainRequirements.EndsInAttribute) && + chainRequirements.HasFlag(FieldChainRequirements.EndsInToOne)) + { + return ChainResolver.ResolveToOneChainEndingInAttributeOrToOne(resourceContextInScope, path, ValidateFilter); + } + + throw new InvalidOperationException($"Unexpected combination of chain requirement flags '{chainRequirements}'."); + } + + private void ValidateFilter(ResourceFieldAttribute field, ResourceContext resourceContext, string path) + { + if (field is AttrAttribute attribute && !attribute.Capabilities.HasFlag(AttrCapabilities.AllowFilter)) + { + throw new InvalidQueryStringParameterException(_lastParameterName, "Filtering on the requested attribute is not allowed.", + $"Filtering on attribute '{attribute.PublicName}' is not allowed."); + } + } + + private void StoreFilterInScope(FilterExpression filter, ResourceFieldChainExpression scope) + { + if (scope == null) + { + _filtersInGlobalScope.Add(filter); + } + else + { + if (!_filtersPerScope.ContainsKey(scope)) + { + _filtersPerScope[scope] = new List(); + } + + _filtersPerScope[scope].Add(filter); + } + } + + public IReadOnlyCollection GetConstraints() + { + return EnumerateFiltersInScopes().ToList().AsReadOnly(); + } + + private IEnumerable EnumerateFiltersInScopes() + { + if (_filtersInGlobalScope.Any()) + { + var filter = MergeFilters(_filtersInGlobalScope); + yield return new ExpressionInScope(null, filter); + } + + foreach (var (scope, filters) in _filtersPerScope) + { + var filter = MergeFilters(filters); + yield return new ExpressionInScope(scope, filter); + } + } + + private static FilterExpression MergeFilters(IReadOnlyCollection filters) + { + return filters.Count > 1 ? new LogicalExpression(LogicalOperator.Or, filters) : filters.First(); + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/QueryStrings/IQueryStringParameterReader.cs b/src/JsonApiDotNetCore/Internal/QueryStrings/IQueryStringParameterReader.cs new file mode 100644 index 0000000000..f51111f795 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/QueryStrings/IQueryStringParameterReader.cs @@ -0,0 +1,26 @@ +using JsonApiDotNetCore.Controllers; +using Microsoft.Extensions.Primitives; + +namespace JsonApiDotNetCore.Internal.QueryStrings +{ + /// + /// The interface to implement for processing a specific type of query string parameter. + /// + public interface IQueryStringParameterReader + { + /// + /// Indicates whether usage of this query string parameter is blocked using on a controller. + /// + bool IsEnabled(DisableQueryAttribute disableQueryAttribute); + + /// + /// Indicates whether this reader can handle the specified query string parameter. + /// + bool CanRead(string parameterName); + + /// + /// Reads the value of the query string parameter. + /// + void Read(string parameterName, StringValues parameterValue); + } +} diff --git a/src/JsonApiDotNetCore/Internal/QueryStrings/IQueryStringReader.cs b/src/JsonApiDotNetCore/Internal/QueryStrings/IQueryStringReader.cs new file mode 100644 index 0000000000..c1608b858d --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/QueryStrings/IQueryStringReader.cs @@ -0,0 +1,18 @@ +using JsonApiDotNetCore.Controllers; + +namespace JsonApiDotNetCore.Internal.QueryStrings +{ + /// + /// Reads and processes the various query string parameters. + /// + public interface IQueryStringReader + { + /// + /// Reads and processes the key/value pairs from the request query string. + /// + /// + /// The if set on the controller that is targeted by the current request. + /// + void ReadAll(DisableQueryAttribute disableQueryAttribute); + } +} diff --git a/src/JsonApiDotNetCore/QueryParameterServices/Common/IRequestQueryStringAccessor.cs b/src/JsonApiDotNetCore/Internal/QueryStrings/IRequestQueryStringAccessor.cs similarity index 75% rename from src/JsonApiDotNetCore/QueryParameterServices/Common/IRequestQueryStringAccessor.cs rename to src/JsonApiDotNetCore/Internal/QueryStrings/IRequestQueryStringAccessor.cs index 8ec146a292..46cee4b9fe 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/Common/IRequestQueryStringAccessor.cs +++ b/src/JsonApiDotNetCore/Internal/QueryStrings/IRequestQueryStringAccessor.cs @@ -1,6 +1,6 @@ using Microsoft.AspNetCore.Http; -namespace JsonApiDotNetCore.QueryParameterServices.Common +namespace JsonApiDotNetCore.Internal.QueryStrings { public interface IRequestQueryStringAccessor { diff --git a/src/JsonApiDotNetCore/Internal/QueryStrings/IncludeQueryStringParameterReader.cs b/src/JsonApiDotNetCore/Internal/QueryStrings/IncludeQueryStringParameterReader.cs new file mode 100644 index 0000000000..baf01716ef --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/QueryStrings/IncludeQueryStringParameterReader.cs @@ -0,0 +1,85 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Exceptions; +using JsonApiDotNetCore.Internal.Contracts; +using JsonApiDotNetCore.Internal.Queries; +using JsonApiDotNetCore.Internal.Queries.Expressions; +using JsonApiDotNetCore.Internal.Queries.Parsing; +using JsonApiDotNetCore.Models.Annotation; +using JsonApiDotNetCore.RequestServices.Contracts; +using Microsoft.Extensions.Primitives; + +namespace JsonApiDotNetCore.Internal.QueryStrings +{ + /// + /// Reads the 'include' query string parameter and produces a set of query constraints from it. + /// + public interface IIncludeQueryStringParameterReader : IQueryStringParameterReader, IQueryConstraintProvider + { + } + + public class IncludeQueryStringParameterReader : QueryStringParameterReader, IIncludeQueryStringParameterReader + { + private IncludeExpression _includeExpression; + private string _lastParameterName; + + public IncludeQueryStringParameterReader(ICurrentRequest currentRequest, IResourceContextProvider resourceContextProvider) + : base(currentRequest, resourceContextProvider) + { + } + + public bool IsEnabled(DisableQueryAttribute disableQueryAttribute) + { + return !disableQueryAttribute.ContainsParameter(StandardQueryStringParameters.Include); + } + + public bool CanRead(string parameterName) + { + return parameterName == "include"; + } + + public void Read(string parameterName, StringValues parameterValue) + { + _lastParameterName = parameterName; + + try + { + _includeExpression = GetInclude(parameterValue); + } + catch (QueryParseException exception) + { + throw new InvalidQueryStringParameterException(parameterName, "The specified include is invalid.", + exception.Message, exception); + } + } + + private IncludeExpression GetInclude(string parameterValue) + { + var parser = new IncludeParser(parameterValue, + (path, _) => ChainResolver.ResolveRelationshipChain(RequestResource, path, ValidateInclude)); + + return parser.Parse(); + } + + private void ValidateInclude(RelationshipAttribute relationship, ResourceContext resourceContext, string path) + { + if (!relationship.CanInclude) + { + throw new InvalidQueryStringParameterException(_lastParameterName, + "Including the requested relationship is not allowed.", + path == relationship.PublicName + ? $"Including the relationship '{relationship.PublicName}' on '{resourceContext.ResourceName}' is not allowed." + : $"Including the relationship '{relationship.PublicName}' in '{path}' on '{resourceContext.ResourceName}' is not allowed."); + } + } + + public IReadOnlyCollection GetConstraints() + { + var expressionInScope = _includeExpression != null + ? new ExpressionInScope(null, _includeExpression) + : new ExpressionInScope(null, IncludeExpression.Empty); + + return new[] {expressionInScope}; + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/QueryStrings/LegacyFilterNotationConverter.cs b/src/JsonApiDotNetCore/Internal/QueryStrings/LegacyFilterNotationConverter.cs new file mode 100644 index 0000000000..ed950d0928 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/QueryStrings/LegacyFilterNotationConverter.cs @@ -0,0 +1,117 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Internal.Queries.Parsing; + +namespace JsonApiDotNetCore.Internal.QueryStrings +{ + public sealed class LegacyFilterNotationConverter + { + private const string _parameterNamePrefix = "filter["; + private const string _parameterNameSuffix = "]"; + private const string _outputParameterName = "filter"; + + private const string _expressionPrefix = "expr:"; + private const string _notEqualsPrefix = "ne:"; + private const string _inPrefix = "in:"; + private const string _notInPrefix = "nin:"; + + private static readonly Dictionary _prefixConversionTable = new Dictionary + { + ["eq:"] = Keywords.Equals, + ["lt:"] = Keywords.LessThan, + ["le:"] = Keywords.LessOrEqual, + ["gt:"] = Keywords.GreaterThan, + ["ge:"] = Keywords.GreaterOrEqual, + ["like:"] = Keywords.Contains + }; + + public (string parameterName, string parameterValue) Convert(string parameterName, string parameterValue) + { + if (parameterValue.StartsWith(_expressionPrefix)) + { + string expression = parameterValue.Substring(_expressionPrefix.Length); + return (parameterName, expression); + } + + var attributeName = ExtractAttributeName(parameterName); + + foreach (var (prefix, keyword) in _prefixConversionTable) + { + if (parameterValue.StartsWith(prefix)) + { + var value = parameterValue.Substring(prefix.Length); + string escapedValue = EscapeQuotes(value); + string expression = $"{keyword}({attributeName},'{escapedValue}')"; + + return (_outputParameterName, expression); + } + } + + if (parameterValue.StartsWith(_notEqualsPrefix)) + { + var value = parameterValue.Substring(_notEqualsPrefix.Length); + string escapedValue = EscapeQuotes(value); + string expression = $"{Keywords.Not}({Keywords.Equals}({attributeName},'{escapedValue}'))"; + + return (_outputParameterName, expression); + } + + if (parameterValue.StartsWith(_inPrefix)) + { + string[] valueParts = parameterValue.Substring(_inPrefix.Length).Split(","); + var valueList = "'" + string.Join("','", valueParts) + "'"; + string expression = $"{Keywords.Any}({attributeName},{valueList})"; + + return (_outputParameterName, expression); + } + + if (parameterValue.StartsWith(_notInPrefix)) + { + string[] valueParts = parameterValue.Substring(_notInPrefix.Length).Split(","); + var valueList = "'" + string.Join("','", valueParts) + "'"; + string expression = $"{Keywords.Not}({Keywords.Any}({attributeName},{valueList}))"; + + return (_outputParameterName, expression); + } + + if (parameterValue == "isnull:") + { + string expression = $"{Keywords.Equals}({attributeName},null)"; + return (_outputParameterName, expression); + } + + if (parameterValue == "isnotnull:") + { + string expression = $"{Keywords.Not}({Keywords.Equals}({attributeName},null))"; + return (_outputParameterName, expression); + } + + { + string escapedValue = EscapeQuotes(parameterValue); + string expression = $"{Keywords.Equals}({attributeName},'{escapedValue}')"; + + return (_outputParameterName, expression); + } + } + + private static string ExtractAttributeName(string parameterName) + { + if (parameterName.StartsWith(_parameterNamePrefix) && parameterName.EndsWith(_parameterNameSuffix)) + { + string attributeName = parameterName.Substring(_parameterNamePrefix.Length, + parameterName.Length - _parameterNamePrefix.Length - _parameterNameSuffix.Length); + + if (attributeName.Length > 0) + { + return attributeName; + } + } + + throw new QueryParseException("Expected field name between brackets in filter parameter name."); + } + + private static string EscapeQuotes(string text) + { + return text.Replace("'", "''"); + } + } +} diff --git a/src/JsonApiDotNetCore/QueryParameterServices/NullsService.cs b/src/JsonApiDotNetCore/Internal/QueryStrings/NullsQueryStringParameterReader.cs similarity index 57% rename from src/JsonApiDotNetCore/QueryParameterServices/NullsService.cs rename to src/JsonApiDotNetCore/Internal/QueryStrings/NullsQueryStringParameterReader.cs index df3f06a14e..865d3a1f91 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/NullsService.cs +++ b/src/JsonApiDotNetCore/Internal/QueryStrings/NullsQueryStringParameterReader.cs @@ -4,43 +4,47 @@ using Microsoft.Extensions.Primitives; using Newtonsoft.Json; -namespace JsonApiDotNetCore.Query +namespace JsonApiDotNetCore.Internal.QueryStrings { - /// - public class NullsService : QueryParameterService, INullsService + public interface INullsQueryStringParameterReader : IQueryStringParameterReader + { + /// + /// Contains the effective value of default configuration and query string override, after parsing has occured. + /// + NullValueHandling SerializerNullValueHandling { get; } + } + + public class NullsQueryStringParameterReader : INullsQueryStringParameterReader { private readonly IJsonApiOptions _options; - public NullsService(IJsonApiOptions options) + /// + public NullValueHandling SerializerNullValueHandling { get; private set; } + + public NullsQueryStringParameterReader(IJsonApiOptions options) { SerializerNullValueHandling = options.SerializerSettings.NullValueHandling; _options = options; } - /// - public NullValueHandling SerializerNullValueHandling { get; private set; } - - /// public bool IsEnabled(DisableQueryAttribute disableQueryAttribute) { - return _options.AllowQueryStringOverrideForSerializerNullValueHandling && + return _options.AllowQueryStringOverrideForSerializerNullValueHandling && !disableQueryAttribute.ContainsParameter(StandardQueryStringParameters.Nulls); } - /// - public bool CanParse(string parameterName) + public bool CanRead(string parameterName) { return parameterName == "nulls"; } - /// - public virtual void Parse(string parameterName, StringValues parameterValue) + public void Read(string parameterName, StringValues parameterValue) { if (!bool.TryParse(parameterValue, out var result)) { throw new InvalidQueryStringParameterException(parameterName, - "The specified query string value must be 'true' or 'false'.", - $"The value '{parameterValue}' for parameter '{parameterName}' is not a valid boolean value."); + "The specified nulls is invalid.", + $"The value '{parameterValue}' must be 'true' or 'false'."); } SerializerNullValueHandling = result ? NullValueHandling.Include : NullValueHandling.Ignore; diff --git a/src/JsonApiDotNetCore/Internal/QueryStrings/PaginationQueryStringParameterReader.cs b/src/JsonApiDotNetCore/Internal/QueryStrings/PaginationQueryStringParameterReader.cs new file mode 100644 index 0000000000..9a6d52e9b8 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/QueryStrings/PaginationQueryStringParameterReader.cs @@ -0,0 +1,206 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Exceptions; +using JsonApiDotNetCore.Internal.Contracts; +using JsonApiDotNetCore.Internal.Queries; +using JsonApiDotNetCore.Internal.Queries.Expressions; +using JsonApiDotNetCore.Internal.Queries.Parsing; +using JsonApiDotNetCore.RequestServices.Contracts; +using Microsoft.Extensions.Primitives; + +namespace JsonApiDotNetCore.Internal.QueryStrings +{ + /// + /// Reads the 'page' query string parameter and produces a set of query constraints from it. + /// + public interface IPaginationQueryStringParameterReader : IQueryStringParameterReader, IQueryConstraintProvider + { + } + + public class PaginationQueryStringParameterReader : QueryStringParameterReader, IPaginationQueryStringParameterReader + { + private const string _pageSizeParameterName = "page[size]"; + private const string _pageNumberParameterName = "page[number]"; + + private readonly IJsonApiOptions _options; + + private PaginationQueryStringValueExpression _pageSizeConstraint; + private PaginationQueryStringValueExpression _pageNumberConstraint; + + public PaginationQueryStringParameterReader(ICurrentRequest currentRequest, IResourceContextProvider resourceContextProvider, IJsonApiOptions options) + : base(currentRequest, resourceContextProvider) + { + _options = options; + } + + public bool IsEnabled(DisableQueryAttribute disableQueryAttribute) + { + return !disableQueryAttribute.ContainsParameter(StandardQueryStringParameters.Page); + } + + public bool CanRead(string parameterName) + { + return parameterName == _pageSizeParameterName || parameterName == _pageNumberParameterName; + } + + public void Read(string parameterName, StringValues parameterValue) + { + try + { + var constraint = GetPageConstraint(parameterValue); + + if (constraint.Elements.Any(element => element.Scope == null)) + { + AssertIsCollectionRequest(); + } + + if (parameterName == _pageSizeParameterName) + { + ValidatePageSize(constraint); + _pageSizeConstraint = constraint; + } + else + { + ValidatePageNumber(constraint); + _pageNumberConstraint = constraint; + } + } + catch (QueryParseException exception) + { + throw new InvalidQueryStringParameterException(parameterName, "The specified paging is invalid.", exception.Message, exception); + } + } + + private PaginationQueryStringValueExpression GetPageConstraint(string parameterValue) + { + var parser = new PaginationParser(parameterValue, + (path, _) => ChainResolver.ResolveToManyChain(RequestResource, path)); + + return parser.Parse(); + } + + private void ValidatePageSize(PaginationQueryStringValueExpression constraint) + { + if (_options.MaximumPageSize != null) + { + if (constraint.Elements.Any(element => element.Value > _options.MaximumPageSize.Value)) + { + throw new QueryParseException($"Page size cannot be higher than {_options.MaximumPageSize}."); + } + + if (constraint.Elements.Any(element => element.Value == 0)) + { + throw new QueryParseException("Page size cannot be unconstrained."); + } + } + + if (constraint.Elements.Any(element => element.Value < 0)) + { + throw new QueryParseException("Page size cannot be negative."); + } + } + + private void ValidatePageNumber(PaginationQueryStringValueExpression constraint) + { + if (_options.MaximumPageNumber != null && + constraint.Elements.Any(element => element.Value > _options.MaximumPageNumber.OneBasedValue)) + { + throw new QueryParseException($"Page number cannot be higher than {_options.MaximumPageNumber}."); + } + + if (constraint.Elements.Any(element => element.Value < 1)) + { + throw new QueryParseException("Page number cannot be negative or zero."); + } + } + + public IReadOnlyCollection GetConstraints() + { + var context = new PaginationContext(); + + foreach (var element in _pageSizeConstraint?.Elements ?? Array.Empty()) + { + var entry = context.ResolveEntryInScope(element.Scope); + entry.PageSize = element.Value == 0 ? null : new PageSize(element.Value); + entry.HasSetPageSize = true; + } + + foreach (var element in _pageNumberConstraint?.Elements ?? Array.Empty()) + { + var entry = context.ResolveEntryInScope(element.Scope); + entry.PageNumber = new PageNumber(element.Value); + } + + context.ApplyOptions(_options); + + return context.GetExpressionsInScope(); + } + + private sealed class PaginationContext + { + private readonly MutablePaginationEntry _globalScope = new MutablePaginationEntry(); + private readonly Dictionary _nestedScopes = new Dictionary(); + + public MutablePaginationEntry ResolveEntryInScope(ResourceFieldChainExpression scope) + { + if (scope == null) + { + return _globalScope; + } + + if (!_nestedScopes.ContainsKey(scope)) + { + _nestedScopes.Add(scope, new MutablePaginationEntry()); + } + + return _nestedScopes[scope]; + } + + public void ApplyOptions(IJsonApiOptions options) + { + ApplyOptionsInEntry(_globalScope, options); + + foreach (var (_, entry) in _nestedScopes) + { + ApplyOptionsInEntry(entry, options); + } + } + + private void ApplyOptionsInEntry(MutablePaginationEntry entry, IJsonApiOptions options) + { + if (!entry.HasSetPageSize) + { + entry.PageSize = options.DefaultPageSize; + } + + entry.PageNumber ??= PageNumber.ValueOne; + } + + public IReadOnlyCollection GetExpressionsInScope() + { + return EnumerateExpressionsInScope().ToList(); + } + + private IEnumerable EnumerateExpressionsInScope() + { + yield return new ExpressionInScope(null, new PaginationExpression(_globalScope.PageNumber, _globalScope.PageSize)); + + foreach (var (scope, entry) in _nestedScopes) + { + yield return new ExpressionInScope(scope, new PaginationExpression(entry.PageNumber, entry.PageSize)); + } + } + } + + private sealed class MutablePaginationEntry + { + public PageSize PageSize { get; set; } + public bool HasSetPageSize { get; set; } + + public PageNumber PageNumber { get; set; } + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/QueryStrings/QueryStringParameterReader.cs b/src/JsonApiDotNetCore/Internal/QueryStrings/QueryStringParameterReader.cs new file mode 100644 index 0000000000..1c125afa61 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/QueryStrings/QueryStringParameterReader.cs @@ -0,0 +1,53 @@ +using System; +using System.Linq; +using JsonApiDotNetCore.Internal.Contracts; +using JsonApiDotNetCore.Internal.Queries.Expressions; +using JsonApiDotNetCore.Internal.Queries.Parsing; +using JsonApiDotNetCore.Models.Annotation; +using JsonApiDotNetCore.RequestServices.Contracts; + +namespace JsonApiDotNetCore.Internal.QueryStrings +{ + public abstract class QueryStringParameterReader + { + private readonly bool _isCollectionRequest; + + protected IResourceContextProvider ResourceContextProvider { get; } + protected ResourceFieldChainResolver ChainResolver { get; } + protected ResourceContext RequestResource { get; } + + protected QueryStringParameterReader(ICurrentRequest currentRequest, IResourceContextProvider resourceContextProvider) + { + if (currentRequest == null) + { + throw new ArgumentNullException(nameof(currentRequest)); + } + + ResourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); + ChainResolver = new ResourceFieldChainResolver(resourceContextProvider); + RequestResource = currentRequest.SecondaryResource ?? currentRequest.PrimaryResource; + _isCollectionRequest = currentRequest.IsCollection; + } + + protected ResourceContext GetResourceContextForScope(ResourceFieldChainExpression scope) + { + if (scope == null) + { + return RequestResource; + } + + var lastField = scope.Fields.Last(); + var type = lastField is RelationshipAttribute relationship ? relationship.RightType : lastField.Property.PropertyType; + + return ResourceContextProvider.GetResourceContext(type); + } + + protected void AssertIsCollectionRequest() + { + if (!_isCollectionRequest) + { + throw new QueryParseException("This query string parameter can only be used on a collection of resources (not on a single resource)."); + } + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/QueryStrings/QueryStringReader.cs b/src/JsonApiDotNetCore/Internal/QueryStrings/QueryStringReader.cs new file mode 100644 index 0000000000..2698536acd --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/QueryStrings/QueryStringReader.cs @@ -0,0 +1,67 @@ +using System.Collections.Generic; +using System.Linq; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Exceptions; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCore.Internal.QueryStrings +{ + /// + public class QueryStringReader : IQueryStringReader + { + private readonly IJsonApiOptions _options; + private readonly IRequestQueryStringAccessor _queryStringAccessor; + private readonly IEnumerable _parameterReaders; + private readonly ILogger _logger; + + public QueryStringReader(IJsonApiOptions options, IRequestQueryStringAccessor queryStringAccessor, + IEnumerable parameterReaders, ILoggerFactory loggerFactory) + { + _options = options; + _queryStringAccessor = queryStringAccessor; + _parameterReaders = parameterReaders; + + _logger = loggerFactory.CreateLogger(); + } + + /// + public virtual void ReadAll(DisableQueryAttribute disableQueryAttribute) + { + disableQueryAttribute ??= DisableQueryAttribute.Empty; + + foreach (var (parameterName, parameterValue) in _queryStringAccessor.Query) + { + if (string.IsNullOrEmpty(parameterValue)) + { + throw new InvalidQueryStringParameterException(parameterName, + "Missing query string parameter value.", + $"Missing value for '{parameterName}' query string parameter."); + } + + var reader = _parameterReaders.FirstOrDefault(r => r.CanRead(parameterName)); + if (reader != null) + { + _logger.LogDebug( + $"Query string parameter '{parameterName}' with value '{parameterValue}' was accepted by {reader.GetType().Name}."); + + if (!reader.IsEnabled(disableQueryAttribute)) + { + throw new InvalidQueryStringParameterException(parameterName, + "Usage of one or more query string parameters is not allowed at the requested endpoint.", + $"The parameter '{parameterName}' cannot be used at this endpoint."); + } + + reader.Read(parameterName, parameterValue); + _logger.LogDebug($"Query string parameter '{parameterName}' was successfully read."); + } + else if (!_options.AllowUnknownQueryStringParameters) + { + throw new InvalidQueryStringParameterException(parameterName, "Unknown query string parameter.", + $"Query string parameter '{parameterName}' is unknown. " + + $"Set '{nameof(IJsonApiOptions.AllowUnknownQueryStringParameters)}' to 'true' in options to ignore unknown parameters."); + } + } + } + } +} diff --git a/src/JsonApiDotNetCore/QueryParameterServices/Common/RequestQueryStringAccessor.cs b/src/JsonApiDotNetCore/Internal/QueryStrings/RequestQueryStringAccessor.cs similarity index 90% rename from src/JsonApiDotNetCore/QueryParameterServices/Common/RequestQueryStringAccessor.cs rename to src/JsonApiDotNetCore/Internal/QueryStrings/RequestQueryStringAccessor.cs index 974e79bc5e..6ff13bb2c5 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/Common/RequestQueryStringAccessor.cs +++ b/src/JsonApiDotNetCore/Internal/QueryStrings/RequestQueryStringAccessor.cs @@ -1,6 +1,6 @@ using Microsoft.AspNetCore.Http; -namespace JsonApiDotNetCore.QueryParameterServices.Common +namespace JsonApiDotNetCore.Internal.QueryStrings { internal sealed class RequestQueryStringAccessor : IRequestQueryStringAccessor { diff --git a/src/JsonApiDotNetCore/Internal/QueryStrings/ResourceDefinitionQueryableParameterReader.cs b/src/JsonApiDotNetCore/Internal/QueryStrings/ResourceDefinitionQueryableParameterReader.cs new file mode 100644 index 0000000000..354b9abe38 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/QueryStrings/ResourceDefinitionQueryableParameterReader.cs @@ -0,0 +1,70 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Exceptions; +using JsonApiDotNetCore.Internal.Queries; +using JsonApiDotNetCore.Internal.Queries.Expressions; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Query; +using JsonApiDotNetCore.RequestServices.Contracts; +using Microsoft.Extensions.Primitives; + +namespace JsonApiDotNetCore.Internal.QueryStrings +{ + /// + /// Reads custom query string parameters for which handlers on are registered + /// and produces a set of query constraints from it. + /// + public interface IResourceDefinitionQueryableParameterReader : IQueryStringParameterReader, IQueryConstraintProvider + { + } + + public class ResourceDefinitionQueryableParameterReader : IResourceDefinitionQueryableParameterReader + { + private readonly ICurrentRequest _currentRequest; + private readonly IResourceDefinitionProvider _resourceDefinitionProvider; + private readonly List _constraints = new List(); + + public ResourceDefinitionQueryableParameterReader(ICurrentRequest currentRequest, IResourceDefinitionProvider resourceDefinitionProvider) + { + _currentRequest = currentRequest; + _resourceDefinitionProvider = resourceDefinitionProvider; + } + + public bool IsEnabled(DisableQueryAttribute disableQueryAttribute) + { + return true; + } + + public bool CanRead(string parameterName) + { + var queryableHandler = GetQueryableHandler(parameterName); + return queryableHandler != null; + } + + public void Read(string parameterName, StringValues parameterValue) + { + var queryableHandler = GetQueryableHandler(parameterName); + var expressionInScope = new ExpressionInScope(null, new QueryableHandlerExpression(queryableHandler, parameterValue)); + _constraints.Add(expressionInScope); + } + + private object GetQueryableHandler(string parameterName) + { + if (_currentRequest.Kind != EndpointKind.Primary) + { + throw new InvalidQueryStringParameterException(parameterName, + "Custom query string parameters cannot be used on nested resource endpoints.", + $"Query string parameter '{parameterName}' cannot be used on a nested resource endpoint."); + } + + var resourceType = _currentRequest.PrimaryResource.ResourceType; + var resourceDefinition = _resourceDefinitionProvider.Get(resourceType); + return resourceDefinition?.GetQueryableHandlerForQueryStringParameter(parameterName); + } + + public IReadOnlyCollection GetConstraints() + { + return _constraints.AsReadOnly(); + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/QueryStrings/ResourceFieldChainResolver.cs b/src/JsonApiDotNetCore/Internal/QueryStrings/ResourceFieldChainResolver.cs new file mode 100644 index 0000000000..b5baa8093d --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/QueryStrings/ResourceFieldChainResolver.cs @@ -0,0 +1,261 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using JsonApiDotNetCore.Internal.Contracts; +using JsonApiDotNetCore.Internal.Queries.Parsing; +using JsonApiDotNetCore.Models.Annotation; + +namespace JsonApiDotNetCore.Internal.QueryStrings +{ + public class ResourceFieldChainResolver + { + private readonly IResourceContextProvider _resourceContextProvider; + + public ResourceFieldChainResolver(IResourceContextProvider resourceContextProvider) + { + _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); + } + + /// + /// Resolves a chain of relationships that ends in a to-many relationship, for example: blogs.owner.articles.comments + /// + public IReadOnlyCollection ResolveToManyChain(ResourceContext resourceContext, string path, + Action validateCallback = null) + { + var chain = new List(); + + var publicNameParts = path.Split("."); + + foreach (string publicName in publicNameParts[..^1]) + { + var relationship = GetRelationship(publicName, resourceContext, path); + + validateCallback?.Invoke(relationship, resourceContext, path); + + chain.Add(relationship); + resourceContext = _resourceContextProvider.GetResourceContext(relationship.RightType); + } + + string lastName = publicNameParts[^1]; + var lastToManyRelationship = GetToManyRelationship(lastName, resourceContext, path); + + validateCallback?.Invoke(lastToManyRelationship, resourceContext, path); + + chain.Add(lastToManyRelationship); + return chain; + } + + /// + /// Resolves a chain of relationships. + /// + /// blogs.articles.comments + /// + /// + /// author.address + /// + /// + /// articles.revisions.author + /// + /// + public IReadOnlyCollection ResolveRelationshipChain(ResourceContext resourceContext, string path, + Action validateCallback = null) + { + var chain = new List(); + + foreach (string publicName in path.Split(".")) + { + var relationship = GetRelationship(publicName, resourceContext, path); + + validateCallback?.Invoke(relationship, resourceContext, path); + + chain.Add(relationship); + resourceContext = _resourceContextProvider.GetResourceContext(relationship.RightType); + } + + return chain; + } + + /// + /// Resolves a chain of to-one relationships that ends in an attribute. + /// + /// author.address.country.name + /// + /// + /// name + /// + /// + public IReadOnlyCollection ResolveToOneChainEndingInAttribute(ResourceContext resourceContext, string path, + Action validateCallback = null) + { + List chain = new List(); + + var publicNameParts = path.Split("."); + + foreach (string publicName in publicNameParts[..^1]) + { + var toOneRelationship = GetToOneRelationship(publicName, resourceContext, path); + + validateCallback?.Invoke(toOneRelationship, resourceContext, path); + + chain.Add(toOneRelationship); + resourceContext = _resourceContextProvider.GetResourceContext(toOneRelationship.RightType); + } + + string lastName = publicNameParts[^1]; + var lastAttribute = GetAttribute(lastName, resourceContext, path); + + validateCallback?.Invoke(lastAttribute, resourceContext, path); + + chain.Add(lastAttribute); + return chain; + } + + /// + /// Resolves a chain of to-one relationships that ends in a to-many relationship. + /// + /// article.comments + /// + /// + /// comments + /// + /// + public IReadOnlyCollection ResolveToOneChainEndingInToMany(ResourceContext resourceContext, string path, + Action validateCallback = null) + { + List chain = new List(); + + var publicNameParts = path.Split("."); + + foreach (string publicName in publicNameParts[..^1]) + { + var toOneRelationship = GetToOneRelationship(publicName, resourceContext, path); + + validateCallback?.Invoke(toOneRelationship, resourceContext, path); + + chain.Add(toOneRelationship); + resourceContext = _resourceContextProvider.GetResourceContext(toOneRelationship.RightType); + } + + string lastName = publicNameParts[^1]; + + var toManyRelationship = GetToManyRelationship(lastName, resourceContext, path); + + validateCallback?.Invoke(toManyRelationship, resourceContext, path); + + chain.Add(toManyRelationship); + return chain; + } + + /// + /// Resolves a chain of to-one relationships that ends in either an attribute or a to-one relationship. + /// + /// author.address.country.name + /// + /// + /// author.address + /// + /// + public IReadOnlyCollection ResolveToOneChainEndingInAttributeOrToOne(ResourceContext resourceContext, string path, + Action validateCallback = null) + { + List chain = new List(); + + var publicNameParts = path.Split("."); + + foreach (string publicName in publicNameParts[..^1]) + { + var toOneRelationship = GetToOneRelationship(publicName, resourceContext, path); + + validateCallback?.Invoke(toOneRelationship, resourceContext, path); + + chain.Add(toOneRelationship); + resourceContext = _resourceContextProvider.GetResourceContext(toOneRelationship.RightType); + } + + string lastName = publicNameParts[^1]; + var lastField = GetField(lastName, resourceContext, path); + + if (lastField is HasManyAttribute) + { + throw new QueryParseException(path == lastName + ? $"Field '{lastName}' must be an attribute or a to-one relationship on resource '{resourceContext.ResourceName}'." + : $"Field '{lastName}' in '{path}' must be an attribute or a to-one relationship on resource '{resourceContext.ResourceName}'."); + } + + validateCallback?.Invoke(lastField, resourceContext, path); + + chain.Add(lastField); + return chain; + } + + public RelationshipAttribute GetRelationship(string publicName, ResourceContext resourceContext, string path) + { + var relationship = resourceContext.Relationships.FirstOrDefault(r => r.PublicName == publicName); + + if (relationship == null) + { + throw new QueryParseException(path == publicName + ? $"Relationship '{publicName}' does not exist on resource '{resourceContext.ResourceName}'." + : $"Relationship '{publicName}' in '{path}' does not exist on resource '{resourceContext.ResourceName}'."); + } + + return relationship; + } + + public RelationshipAttribute GetToManyRelationship(string publicName, ResourceContext resourceContext, string path) + { + var relationship = GetRelationship(publicName, resourceContext, path); + + if (!(relationship is HasManyAttribute)) + { + throw new QueryParseException(path == publicName + ? $"Relationship '{publicName}' must be a to-many relationship on resource '{resourceContext.ResourceName}'." + : $"Relationship '{publicName}' in '{path}' must be a to-many relationship on resource '{resourceContext.ResourceName}'."); + } + + return relationship; + } + + public RelationshipAttribute GetToOneRelationship(string publicName, ResourceContext resourceContext, string path) + { + var relationship = GetRelationship(publicName, resourceContext, path); + + if (!(relationship is HasOneAttribute)) + { + throw new QueryParseException(path == publicName + ? $"Relationship '{publicName}' must be a to-one relationship on resource '{resourceContext.ResourceName}'." + : $"Relationship '{publicName}' in '{path}' must be a to-one relationship on resource '{resourceContext.ResourceName}'."); + } + + return relationship; + } + + public AttrAttribute GetAttribute(string publicName, ResourceContext resourceContext, string path) + { + var attribute = resourceContext.Attributes.FirstOrDefault(a => a.PublicName == publicName); + + if (attribute == null) + { + throw new QueryParseException(path == publicName + ? $"Attribute '{publicName}' does not exist on resource '{resourceContext.ResourceName}'." + : $"Attribute '{publicName}' in '{path}' does not exist on resource '{resourceContext.ResourceName}'."); + } + + return attribute; + } + + public ResourceFieldAttribute GetField(string publicName, ResourceContext resourceContext, string path) + { + var field = resourceContext.Fields.FirstOrDefault(a => a.PublicName == publicName); + + if (field == null) + { + throw new QueryParseException(path == publicName + ? $"Field '{publicName}' does not exist on resource '{resourceContext.ResourceName}'." + : $"Field '{publicName}' in '{path}' does not exist on resource '{resourceContext.ResourceName}'."); + } + + return field; + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/QueryStrings/SortQueryStringParameterReader.cs b/src/JsonApiDotNetCore/Internal/QueryStrings/SortQueryStringParameterReader.cs new file mode 100644 index 0000000000..14bdd5bfed --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/QueryStrings/SortQueryStringParameterReader.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Exceptions; +using JsonApiDotNetCore.Internal.Contracts; +using JsonApiDotNetCore.Internal.Queries; +using JsonApiDotNetCore.Internal.Queries.Expressions; +using JsonApiDotNetCore.Internal.Queries.Parsing; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; +using JsonApiDotNetCore.RequestServices.Contracts; +using Microsoft.Extensions.Primitives; + +namespace JsonApiDotNetCore.Internal.QueryStrings +{ + /// + /// Reads the 'sort' query string parameter and produces a set of query constraints from it. + /// + public interface ISortQueryStringParameterReader : IQueryStringParameterReader, IQueryConstraintProvider + { + } + + public class SortQueryStringParameterReader : QueryStringParameterReader, ISortQueryStringParameterReader + { + private readonly List _constraints = new List(); + private string _lastParameterName; + + public SortQueryStringParameterReader(ICurrentRequest currentRequest, IResourceContextProvider resourceContextProvider) + : base(currentRequest, resourceContextProvider) + { + } + + public bool IsEnabled(DisableQueryAttribute disableQueryAttribute) + { + return !disableQueryAttribute.ContainsParameter(StandardQueryStringParameters.Sort); + } + + public bool CanRead(string parameterName) + { + var isNested = parameterName.StartsWith("sort[") && parameterName.EndsWith("]"); + return parameterName == "sort" || isNested; + } + + public void Read(string parameterName, StringValues parameterValue) + { + _lastParameterName = parameterName; + + try + { + ResourceFieldChainExpression scope = GetScope(parameterName); + SortExpression sort = GetSort(parameterValue, scope); + + var expressionInScope = new ExpressionInScope(scope, sort); + _constraints.Add(expressionInScope); + } + catch (QueryParseException exception) + { + throw new InvalidQueryStringParameterException(parameterName, "The specified sort is invalid.", exception.Message, exception); + } + } + + private ResourceFieldChainExpression GetScope(string parameterName) + { + var parser = new QueryStringParameterScopeParser(parameterName, + (path, _) => ChainResolver.ResolveToManyChain(RequestResource, path)); + + var parameterScope = parser.Parse(FieldChainRequirements.EndsInToMany); + + if (parameterScope.Scope == null) + { + AssertIsCollectionRequest(); + } + + return parameterScope.Scope; + } + + private SortExpression GetSort(string parameterValue, ResourceFieldChainExpression scope) + { + ResourceContext resourceContextInScope = GetResourceContextForScope(scope); + + var parser = new SortParser(parameterValue, + (path, chainRequirements) => ResolveChainInSort(chainRequirements, resourceContextInScope, path)); + + return parser.Parse(); + } + + private IReadOnlyCollection ResolveChainInSort(FieldChainRequirements chainRequirements, + ResourceContext resourceContextInScope, string path) + { + if (chainRequirements == FieldChainRequirements.EndsInToMany) + { + return ChainResolver.ResolveToOneChainEndingInToMany(resourceContextInScope, path); + } + + if (chainRequirements == FieldChainRequirements.EndsInAttribute) + { + return ChainResolver.ResolveToOneChainEndingInAttribute(resourceContextInScope, path, ValidateSort); + } + + throw new InvalidOperationException($"Unexpected combination of chain requirement flags '{chainRequirements}'."); + } + + private void ValidateSort(ResourceFieldAttribute field, ResourceContext resourceContext, string path) + { + if (field is AttrAttribute attribute && !attribute.Capabilities.HasFlag(AttrCapabilities.AllowSort)) + { + throw new InvalidQueryStringParameterException(_lastParameterName, "Sorting on the requested attribute is not allowed.", + $"Sorting on attribute '{attribute.PublicName}' is not allowed."); + } + } + + public IReadOnlyCollection GetConstraints() + { + return _constraints.AsReadOnly(); + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/QueryStrings/SparseFieldSetQueryStringParameterReader.cs b/src/JsonApiDotNetCore/Internal/QueryStrings/SparseFieldSetQueryStringParameterReader.cs new file mode 100644 index 0000000000..d5bcae3884 --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/QueryStrings/SparseFieldSetQueryStringParameterReader.cs @@ -0,0 +1,105 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Exceptions; +using JsonApiDotNetCore.Internal.Contracts; +using JsonApiDotNetCore.Internal.Queries; +using JsonApiDotNetCore.Internal.Queries.Expressions; +using JsonApiDotNetCore.Internal.Queries.Parsing; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; +using JsonApiDotNetCore.RequestServices.Contracts; +using Microsoft.Extensions.Primitives; + +namespace JsonApiDotNetCore.Internal.QueryStrings +{ + /// + /// Reads the 'fields' query string parameter and produces a set of query constraints from it. + /// + public interface ISparseFieldSetQueryStringParameterReader : IQueryStringParameterReader, IQueryConstraintProvider + { + } + + public class SparseFieldSetQueryStringParameterReader : QueryStringParameterReader, ISparseFieldSetQueryStringParameterReader + { + private readonly List _constraints = new List(); + + private string _lastParameterName; + + public SparseFieldSetQueryStringParameterReader(ICurrentRequest currentRequest, IResourceContextProvider resourceContextProvider) + : base(currentRequest, resourceContextProvider) + { + } + + public bool IsEnabled(DisableQueryAttribute disableQueryAttribute) + { + return !disableQueryAttribute.ContainsParameter(StandardQueryStringParameters.Fields); + } + + public bool CanRead(string parameterName) + { + var isNested = parameterName.StartsWith("fields[") && parameterName.EndsWith("]"); + return parameterName == "fields" || isNested; + } + + public void Read(string parameterName, StringValues parameterValue) + { + _lastParameterName = parameterName; + + try + { + ResourceFieldChainExpression scope = GetScope(parameterName); + SparseFieldSetExpression sparseFieldSet = GetSparseFieldSet(parameterValue, scope); + + var expressionInScope = new ExpressionInScope(scope, sparseFieldSet); + _constraints.Add(expressionInScope); + } + catch (QueryParseException exception) + { + throw new InvalidQueryStringParameterException(parameterName, "The specified fieldset is invalid.", + exception.Message, exception); + } + } + + private ResourceFieldChainExpression GetScope(string parameterName) + { + var parser = new QueryStringParameterScopeParser(parameterName, + (path, _) => ChainResolver.ResolveRelationshipChain(RequestResource, path)); + + var parameterScope = parser.Parse(FieldChainRequirements.IsRelationship); + return parameterScope.Scope; + } + + private SparseFieldSetExpression GetSparseFieldSet(string parameterValue, ResourceFieldChainExpression scope) + { + ResourceContext resourceContextInScope = GetResourceContextForScope(scope); + + var parser = new SparseFieldSetParser(parameterValue, + (path, _) => ResolveSingleAttribute(path, resourceContextInScope)); + + return parser.Parse(); + } + + protected IReadOnlyCollection ResolveSingleAttribute(string path, ResourceContext resourceContext) + { + var attribute = ChainResolver.GetAttribute(path, resourceContext, path); + + ValidateAttribute(attribute); + + return new[] {attribute}; + } + + private void ValidateAttribute(AttrAttribute attribute) + { + if (!attribute.Capabilities.HasFlag(AttrCapabilities.AllowView)) + { + throw new InvalidQueryStringParameterException(_lastParameterName, "Retrieving the requested attribute is not allowed.", + $"Retrieving the attribute '{attribute.PublicName}' is not allowed."); + } + } + + public IReadOnlyCollection GetConstraints() + { + return _constraints.AsReadOnly(); + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/ResourceContext.cs b/src/JsonApiDotNetCore/Internal/ResourceContext.cs index 694ae89f35..a8e2a1fab1 100644 --- a/src/JsonApiDotNetCore/Internal/ResourceContext.cs +++ b/src/JsonApiDotNetCore/Internal/ResourceContext.cs @@ -4,7 +4,7 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Models.Annotation; -using JsonApiDotNetCore.Models.Links; +using JsonApiDotNetCore.Models.JsonApiDocuments; namespace JsonApiDotNetCore.Internal { @@ -53,28 +53,32 @@ public class ResourceContext /// /// Configures which links to show in the - /// object for this resource. If set to , - /// the configuration will be read from . - /// Defaults to . + /// object for this resource. If set to , + /// the configuration will be read from . + /// Defaults to . /// - public Link TopLevelLinks { get; internal set; } = Link.NotConfigured; + public Links TopLevelLinks { get; internal set; } = Links.NotConfigured; /// /// Configures which links to show in the - /// object for this resource. If set to , - /// the configuration will be read from . - /// Defaults to . + /// object for this resource. If set to , + /// the configuration will be read from . + /// Defaults to . /// - public Link ResourceLinks { get; internal set; } = Link.NotConfigured; + public Links ResourceLinks { get; internal set; } = Links.NotConfigured; /// /// Configures which links to show in the /// for all relationships of the resource for which this attribute was instantiated. - /// If set to , the configuration will + /// If set to , the configuration will /// be read from or - /// . Defaults to . + /// . Defaults to . /// - public Link RelationshipLinks { get; internal set; } = Link.NotConfigured; + public Links RelationshipLinks { get; internal set; } = Links.NotConfigured; + public override string ToString() + { + return ResourceName; + } } } diff --git a/src/JsonApiDotNetCore/Internal/ResourceGraph.cs b/src/JsonApiDotNetCore/Internal/ResourceGraph.cs index 548d1d24f8..e2a70d9a76 100644 --- a/src/JsonApiDotNetCore/Internal/ResourceGraph.cs +++ b/src/JsonApiDotNetCore/Internal/ResourceGraph.cs @@ -88,7 +88,8 @@ private IEnumerable Getter(Expression model.Field1 + { + // model => model.Field1 try { targeted.Add(available.Single(f => f.Property.Name == memberExpression.Member.Name)); @@ -101,7 +102,8 @@ private IEnumerable Getter(Expression new { model.Field1, model.Field2 } + { + // model => new { model.Field1, model.Field2 } string memberName = null; try { @@ -123,7 +125,7 @@ private IEnumerable Getter(Expression article.Title' or 'article => new {{ article.Title, article.PageCount }}'."); + "For example: 'article => article.Title' or 'article => new { article.Title, article.PageCount }'."); } private static Expression RemoveConvert(Expression expression) diff --git a/src/JsonApiDotNetCore/Internal/TypeHelper.cs b/src/JsonApiDotNetCore/Internal/TypeHelper.cs index 94f1b9998d..3792d2c5cb 100644 --- a/src/JsonApiDotNetCore/Internal/TypeHelper.cs +++ b/src/JsonApiDotNetCore/Internal/TypeHelper.cs @@ -14,47 +14,76 @@ internal static class TypeHelper { public static object ConvertType(object value, Type type) { - if (value == null && !CanBeNull(type)) - throw new FormatException("Cannot convert null to a non-nullable type"); + if (type == null) + { + throw new ArgumentNullException(nameof(type)); + } if (value == null) + { + if (!CanContainNull(type)) + { + throw new FormatException($"Failed to convert 'null' to type '{type.Name}'."); + } + return null; + } Type runtimeType = value.GetType(); - - try + if (type == runtimeType || type.IsAssignableFrom(runtimeType)) { - if (runtimeType == type || type.IsAssignableFrom(runtimeType)) - return value; + return value; + } - type = Nullable.GetUnderlyingType(type) ?? type; + string stringValue = value.ToString(); + if (string.IsNullOrEmpty(stringValue)) + { + return GetDefaultValue(type); + } - var stringValue = value.ToString(); + bool isNullableTypeRequested = type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>); + Type nonNullableType = Nullable.GetUnderlyingType(type) ?? type; - if (string.IsNullOrEmpty(stringValue)) - return GetDefaultValue(type); + try + { + if (nonNullableType == typeof(Guid)) + { + Guid convertedValue = Guid.Parse(stringValue); + return isNullableTypeRequested ? (Guid?) convertedValue : convertedValue; + } - if (type == typeof(Guid)) - return Guid.Parse(stringValue); + if (nonNullableType == typeof(DateTimeOffset)) + { + DateTimeOffset convertedValue = DateTimeOffset.Parse(stringValue); + return isNullableTypeRequested ? (DateTimeOffset?) convertedValue : convertedValue; + } - if (type == typeof(DateTimeOffset)) - return DateTimeOffset.Parse(stringValue); + if (nonNullableType == typeof(TimeSpan)) + { + TimeSpan convertedValue = TimeSpan.Parse(stringValue); + return isNullableTypeRequested ? (TimeSpan?) convertedValue : convertedValue; + } - if (type == typeof(TimeSpan)) - return TimeSpan.Parse(stringValue); + if (nonNullableType.IsEnum) + { + object convertedValue = Enum.Parse(nonNullableType, stringValue); - if (type.IsEnum) - return Enum.Parse(type, stringValue); + // https://bradwilson.typepad.com/blog/2008/07/creating-nullab.html + return convertedValue; + } - return Convert.ChangeType(stringValue, type); + // https://bradwilson.typepad.com/blog/2008/07/creating-nullab.html + return Convert.ChangeType(stringValue, nonNullableType); } - catch (Exception exception) + catch (Exception exception) when (exception is FormatException || exception is OverflowException || + exception is InvalidCastException || exception is ArgumentException) { - throw new FormatException($"Failed to convert '{value}' of type '{runtimeType}' to type '{type}'.", exception); + throw new FormatException( + $"Failed to convert '{value}' of type '{runtimeType.Name}' to type '{type.Name}'.", exception); } } - private static bool CanBeNull(Type type) + public static bool CanContainNull(Type type) { return !type.IsValueType || Nullable.GetUnderlyingType(type) != null; } @@ -137,10 +166,10 @@ public static Dictionary> ConvertRelat /// Converts a dictionary of AttrAttributes to the underlying PropertyInfo that is referenced /// /// - /// - public static Dictionary> ConvertAttributeDictionary(List attributes, HashSet entities) + /// + public static Dictionary> ConvertAttributeDictionary(List attributes, HashSet resources) { - return attributes?.ToDictionary(attr => attr.Property, attr => entities); + return attributes?.ToDictionary(attr => attr.Property, attr => resources); } /// @@ -193,7 +222,7 @@ public static Type ToConcreteCollectionType(this Type collectionType) { if (collectionType.IsInterface && collectionType.IsGenericType) { - var genericTypeDefinition = collectionType.GetGenericTypeDefinition(); + Type genericTypeDefinition = collectionType.GetGenericTypeDefinition(); if (genericTypeDefinition == typeof(ICollection<>) || genericTypeDefinition == typeof(ISet<>) || genericTypeDefinition == typeof(IEnumerable<>) || genericTypeDefinition == typeof(IReadOnlyCollection<>)) @@ -222,15 +251,19 @@ public static Type GetIdType(Type resourceType) public static object CreateInstance(Type type) { - if (type == null) throw new ArgumentNullException(nameof(type)); - + if (type == null) + { + throw new ArgumentNullException(nameof(type)); + } + try { return Activator.CreateInstance(type); } catch (Exception exception) { - throw new InvalidOperationException($"Failed to create an instance of '{type.FullName}' using its default constructor.", exception); + throw new InvalidOperationException( + $"Failed to create an instance of '{type.FullName}' using its default constructor.", exception); } } diff --git a/src/JsonApiDotNetCore/Middleware/DefaultExceptionHandler.cs b/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs similarity index 91% rename from src/JsonApiDotNetCore/Middleware/DefaultExceptionHandler.cs rename to src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs index 877166aafe..3c5407d475 100644 --- a/src/JsonApiDotNetCore/Middleware/DefaultExceptionHandler.cs +++ b/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs @@ -8,15 +8,15 @@ namespace JsonApiDotNetCore.Middleware { - public class DefaultExceptionHandler : IExceptionHandler + public class ExceptionHandler : IExceptionHandler { private readonly IJsonApiOptions _options; private readonly ILogger _logger; - public DefaultExceptionHandler(ILoggerFactory loggerFactory, IJsonApiOptions options) + public ExceptionHandler(ILoggerFactory loggerFactory, IJsonApiOptions options) { _options = options; - _logger = loggerFactory.CreateLogger(); + _logger = loggerFactory.CreateLogger(); } public ErrorDocument HandleException(Exception exception) diff --git a/src/JsonApiDotNetCore/Middleware/IJsonApiExceptionFilterProvider.cs b/src/JsonApiDotNetCore/Middleware/IJsonApiExceptionFilterProvider.cs index 6400fa3a50..53077d8315 100644 --- a/src/JsonApiDotNetCore/Middleware/IJsonApiExceptionFilterProvider.cs +++ b/src/JsonApiDotNetCore/Middleware/IJsonApiExceptionFilterProvider.cs @@ -5,7 +5,7 @@ namespace JsonApiDotNetCore.Middleware /// /// Provides the type of the global exception filter that is configured in MVC during startup. /// This can be overridden to let JADNC use your own exception filter. The default exception filter used - /// is + /// is /// public interface IJsonApiExceptionFilterProvider { @@ -15,6 +15,6 @@ public interface IJsonApiExceptionFilterProvider /// public class JsonApiExceptionFilterProvider : IJsonApiExceptionFilterProvider { - public Type Get() => typeof(DefaultExceptionFilter); + public Type Get() => typeof(JsonApiExceptionFilter); } } diff --git a/src/JsonApiDotNetCore/Middleware/IJsonApiTypeMatchFilterProvider.cs b/src/JsonApiDotNetCore/Middleware/IJsonApiTypeMatchFilterProvider.cs index 50d2476890..c3232c636b 100644 --- a/src/JsonApiDotNetCore/Middleware/IJsonApiTypeMatchFilterProvider.cs +++ b/src/JsonApiDotNetCore/Middleware/IJsonApiTypeMatchFilterProvider.cs @@ -5,7 +5,7 @@ namespace JsonApiDotNetCore.Middleware /// /// Provides the type of the global action filter that is configured in MVC during startup. /// This can be overridden to let JADNC use your own action filter. The default action filter used - /// is + /// is /// public interface IJsonApiTypeMatchFilterProvider { @@ -15,6 +15,6 @@ public interface IJsonApiTypeMatchFilterProvider /// public class JsonApiTypeMatchFilterProvider : IJsonApiTypeMatchFilterProvider { - public Type Get() => typeof(DefaultTypeMatchFilter); + public Type Get() => typeof(IncomingTypeMatchFilter); } } diff --git a/src/JsonApiDotNetCore/Middleware/IQueryParameterActionFilter.cs b/src/JsonApiDotNetCore/Middleware/IQueryStringActionFilter.cs similarity index 82% rename from src/JsonApiDotNetCore/Middleware/IQueryParameterActionFilter.cs rename to src/JsonApiDotNetCore/Middleware/IQueryStringActionFilter.cs index 2c843d9d99..f534bfddac 100644 --- a/src/JsonApiDotNetCore/Middleware/IQueryParameterActionFilter.cs +++ b/src/JsonApiDotNetCore/Middleware/IQueryStringActionFilter.cs @@ -3,7 +3,7 @@ namespace JsonApiDotNetCore.Middleware { - public interface IQueryParameterActionFilter + public interface IQueryStringActionFilter { Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next); } diff --git a/src/JsonApiDotNetCore/Middleware/DefaultTypeMatchFilter.cs b/src/JsonApiDotNetCore/Middleware/IncomingTypeMatchFilter.cs similarity index 92% rename from src/JsonApiDotNetCore/Middleware/DefaultTypeMatchFilter.cs rename to src/JsonApiDotNetCore/Middleware/IncomingTypeMatchFilter.cs index 2ddd341372..e46805cecc 100644 --- a/src/JsonApiDotNetCore/Middleware/DefaultTypeMatchFilter.cs +++ b/src/JsonApiDotNetCore/Middleware/IncomingTypeMatchFilter.cs @@ -11,11 +11,11 @@ namespace JsonApiDotNetCore.Middleware /// /// Action filter used to verify the incoming type matches the target type, else return a 409 /// - public sealed class DefaultTypeMatchFilter : IActionFilter + public sealed class IncomingTypeMatchFilter : IActionFilter { private readonly IResourceContextProvider _provider; - public DefaultTypeMatchFilter(IResourceContextProvider provider) + public IncomingTypeMatchFilter(IResourceContextProvider provider) { _provider = provider; } diff --git a/src/JsonApiDotNetCore/Middleware/DefaultExceptionFilter.cs b/src/JsonApiDotNetCore/Middleware/JsonApiExceptionFilter.cs similarity index 87% rename from src/JsonApiDotNetCore/Middleware/DefaultExceptionFilter.cs rename to src/JsonApiDotNetCore/Middleware/JsonApiExceptionFilter.cs index 78acd1ca8a..75474a3f41 100644 --- a/src/JsonApiDotNetCore/Middleware/DefaultExceptionFilter.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiExceptionFilter.cs @@ -7,11 +7,11 @@ namespace JsonApiDotNetCore.Middleware /// /// Global exception filter that wraps any thrown error with a JsonApiException. /// - public class DefaultExceptionFilter : ActionFilterAttribute, IExceptionFilter + public class JsonApiExceptionFilter : ActionFilterAttribute, IExceptionFilter { private readonly IExceptionHandler _exceptionHandler; - public DefaultExceptionFilter(IExceptionHandler exceptionHandler) + public JsonApiExceptionFilter(IExceptionHandler exceptionHandler) { _exceptionHandler = exceptionHandler; } diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs b/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs index 8add1495c2..f161e9a78b 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs @@ -1,7 +1,7 @@ -using System.Collections.Generic; using System.IO; using System.Linq; using System.Net; +using System.Net.Http; using System.Net.Http.Headers; using System.Text; using System.Threading.Tasks; @@ -9,8 +9,10 @@ using JsonApiDotNetCore.Extensions; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Managers.Contracts; +using JsonApiDotNetCore.Models.Annotation; using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCore.RequestServices; +using JsonApiDotNetCore.RequestServices.Contracts; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; @@ -35,12 +37,12 @@ public async Task Invoke(HttpContext httpContext, IControllerResourceMapping controllerResourceMapping, IJsonApiOptions options, ICurrentRequest currentRequest, - IResourceGraph resourceGraph) + IResourceContextProvider resourceContextProvider) { var routeValues = httpContext.GetRouteData().Values; - var resourceContext = CreateResourceContext(routeValues, controllerResourceMapping, resourceGraph); - if (resourceContext != null) + var primaryResourceContext = CreatePrimaryResourceContext(routeValues, controllerResourceMapping, resourceContextProvider); + if (primaryResourceContext != null) { if (!await ValidateContentTypeHeaderAsync(httpContext, options.SerializerSettings) || !await ValidateAcceptHeaderAsync(httpContext, options.SerializerSettings)) @@ -48,7 +50,7 @@ public async Task Invoke(HttpContext httpContext, return; } - SetupCurrentRequest(currentRequest, resourceContext, routeValues, options, httpContext.Request); + SetupCurrentRequest((CurrentRequest)currentRequest, primaryResourceContext, routeValues, options, resourceContextProvider, httpContext.Request); httpContext.SetJsonApiRequest(); } @@ -56,8 +58,8 @@ public async Task Invoke(HttpContext httpContext, await _next(httpContext); } - private static ResourceContext CreateResourceContext(RouteValueDictionary routeValues, - IControllerResourceMapping controllerResourceMapping, IResourceContextProvider resourceGraph) + private static ResourceContext CreatePrimaryResourceContext(RouteValueDictionary routeValues, + IControllerResourceMapping controllerResourceMapping, IResourceContextProvider resourceContextProvider) { var controllerName = (string) routeValues["controller"]; if (controllerName == null) @@ -66,7 +68,7 @@ private static ResourceContext CreateResourceContext(RouteValueDictionary routeV } var resourceType = controllerResourceMapping.GetAssociatedResource(controllerName); - return resourceGraph.GetResourceContext(resourceType); + return resourceContextProvider.GetResourceContext(resourceType); } private static async Task ValidateContentTypeHeaderAsync(HttpContext httpContext, JsonSerializerSettings serializerSettings) @@ -149,24 +151,36 @@ private static async Task FlushResponseAsync(HttpResponse httpResponse, JsonSeri await httpResponse.Body.FlushAsync(); } - private static void SetupCurrentRequest(ICurrentRequest currentRequest, ResourceContext resourceContext, - RouteValueDictionary routeValues, IJsonApiOptions options, HttpRequest httpRequest) + private static void SetupCurrentRequest(CurrentRequest currentRequest, ResourceContext primaryResourceContext, + RouteValueDictionary routeValues, IJsonApiOptions options, IResourceContextProvider resourceContextProvider, + HttpRequest httpRequest) { - currentRequest.SetRequestResource(resourceContext); - currentRequest.BaseId = GetBaseId(routeValues); - currentRequest.BasePath = GetBasePath(resourceContext.ResourceName, options, httpRequest); - currentRequest.IsRelationshipPath = GetIsRelationshipPath(routeValues); - currentRequest.RelationshipId = GetRelationshipId(currentRequest.IsRelationshipPath, httpRequest.Path.Value, options.Namespace); - - if (routeValues.TryGetValue("relationshipName", out object relationshipName)) + currentRequest.IsReadOnly = httpRequest.Method == HttpMethod.Get.Method; + currentRequest.Kind = EndpointKind.Primary; + currentRequest.PrimaryResource = primaryResourceContext; + currentRequest.PrimaryId = GetPrimaryRequestId(routeValues); + currentRequest.BasePath = GetBasePath(primaryResourceContext.ResourceName, options, httpRequest); + + var relationshipName = GetRelationshipNameForSecondaryRequest(routeValues); + if (relationshipName != null) { - currentRequest.RequestRelationship = - resourceContext.Relationships.SingleOrDefault(relationship => - relationship.PublicName == (string) relationshipName); + currentRequest.Kind = IsRouteForRelationship(routeValues) ? EndpointKind.Relationship : EndpointKind.Secondary; + + var requestRelationship = + primaryResourceContext.Relationships.SingleOrDefault(relationship => + relationship.PublicName == relationshipName); + + if (requestRelationship != null) + { + currentRequest.Relationship = requestRelationship; + currentRequest.SecondaryResource = resourceContextProvider.GetResourceContext(requestRelationship.RightType); + } } + + currentRequest.IsCollection = currentRequest.PrimaryId == null || currentRequest.Relationship is HasManyAttribute; } - private static string GetBaseId(RouteValueDictionary routeValues) + private static string GetPrimaryRequestId(RouteValueDictionary routeValues) { return routeValues.TryGetValue("id", out var id) ? (string) id : null; } @@ -175,7 +189,7 @@ private static string GetBasePath(string resourceName, IJsonApiOptions options, { var builder = new StringBuilder(); - if (!options.RelativeLinks) + if (!options.UseRelativeLinks) { builder.Append(httpRequest.Scheme); builder.Append("://"); @@ -213,30 +227,15 @@ private static string GetCustomRoute(string resourceName, string apiNamespace, H return null; } - private static bool GetIsRelationshipPath(RouteValueDictionary routeValues) + private static string GetRelationshipNameForSecondaryRequest(RouteValueDictionary routeValues) { - var actionName = (string)routeValues["action"]; - return actionName.ToLowerInvariant().Contains("relationships"); + return routeValues.TryGetValue("relationshipName", out object routeValue) ? (string) routeValue : null; } - private static string GetRelationshipId(bool currentRequestIsRelationshipPath, string requestPath, - string apiNamespace) + private static bool IsRouteForRelationship(RouteValueDictionary routeValues) { - if (!currentRequestIsRelationshipPath) - { - return null; - } - - var components = SplitCurrentPath(requestPath, apiNamespace); - return components.ElementAtOrDefault(4); - } - - private static IEnumerable SplitCurrentPath(string requestPath, string apiNamespace) - { - var namespacePrefix = $"/{apiNamespace}"; - var nonNameSpaced = requestPath.Replace(namespacePrefix, ""); - nonNameSpaced = nonNameSpaced.Trim('/'); - return nonNameSpaced.Split('/'); + var actionName = (string)routeValues["action"]; + return actionName.EndsWith("Relationship"); } } } diff --git a/src/JsonApiDotNetCore/Middleware/QueryParameterFilter.cs b/src/JsonApiDotNetCore/Middleware/QueryStringActionFilter.cs similarity index 53% rename from src/JsonApiDotNetCore/Middleware/QueryParameterFilter.cs rename to src/JsonApiDotNetCore/Middleware/QueryStringActionFilter.cs index a3f5e5bdf7..ae538f7a9e 100644 --- a/src/JsonApiDotNetCore/Middleware/QueryParameterFilter.cs +++ b/src/JsonApiDotNetCore/Middleware/QueryStringActionFilter.cs @@ -1,21 +1,25 @@ using System.Reflection; -using System.Threading.Tasks; +using System.Threading.Tasks; using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Services; +using JsonApiDotNetCore.Internal.QueryStrings; using Microsoft.AspNetCore.Mvc.Filters; namespace JsonApiDotNetCore.Middleware { - public sealed class QueryParameterActionFilter : IAsyncActionFilter, IQueryParameterActionFilter + public sealed class QueryStringActionFilter : IAsyncActionFilter, IQueryStringActionFilter { - private readonly IQueryParameterParser _queryParser; - public QueryParameterActionFilter(IQueryParameterParser queryParser) => _queryParser = queryParser; + private readonly IQueryStringReader _queryStringReader; + + public QueryStringActionFilter(IQueryStringReader queryStringReader) + { + _queryStringReader = queryStringReader; + } public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) { DisableQueryAttribute disableQueryAttribute = context.Controller.GetType().GetCustomAttribute(); - _queryParser.Parse(disableQueryAttribute); + _queryStringReader.ReadAll(disableQueryAttribute); await next(); } } diff --git a/src/JsonApiDotNetCore/Models/Annotation/AttrAttribute.cs b/src/JsonApiDotNetCore/Models/Annotation/AttrAttribute.cs index 217191df4a..79871f9ce6 100644 --- a/src/JsonApiDotNetCore/Models/Annotation/AttrAttribute.cs +++ b/src/JsonApiDotNetCore/Models/Annotation/AttrAttribute.cs @@ -71,11 +71,11 @@ public AttrAttribute(string publicName, AttrCapabilities capabilities) /// Returns null if the attribute does not belong to the /// provided object. /// - public object GetValue(object entity) + public object GetValue(object resource) { - if (entity == null) + if (resource == null) { - throw new ArgumentNullException(nameof(entity)); + throw new ArgumentNullException(nameof(resource)); } if (Property.GetMethod == null) @@ -83,17 +83,17 @@ public object GetValue(object entity) throw new InvalidOperationException($"Property '{Property.DeclaringType?.Name}.{Property.Name}' is write-only."); } - return Property.GetValue(entity); + return Property.GetValue(resource); } /// /// Sets the value of the attribute on the given object. /// - public void SetValue(object entity, object newValue) + public void SetValue(object resource, object newValue) { - if (entity == null) + if (resource == null) { - throw new ArgumentNullException(nameof(entity)); + throw new ArgumentNullException(nameof(resource)); } if (Property.SetMethod == null) @@ -103,7 +103,7 @@ public void SetValue(object entity, object newValue) } var convertedValue = TypeHelper.ConvertType(newValue, Property.PropertyType); - Property.SetValue(entity, convertedValue); + Property.SetValue(resource, convertedValue); } } } diff --git a/src/JsonApiDotNetCore/Models/Annotation/HasManyAttribute.cs b/src/JsonApiDotNetCore/Models/Annotation/HasManyAttribute.cs index aa63f90613..8d6121a775 100644 --- a/src/JsonApiDotNetCore/Models/Annotation/HasManyAttribute.cs +++ b/src/JsonApiDotNetCore/Models/Annotation/HasManyAttribute.cs @@ -1,5 +1,5 @@ using System; -using JsonApiDotNetCore.Models.Links; +using JsonApiDotNetCore.Models.JsonApiDocuments; namespace JsonApiDotNetCore.Models.Annotation { @@ -7,11 +7,11 @@ namespace JsonApiDotNetCore.Models.Annotation public class HasManyAttribute : RelationshipAttribute { /// - /// Create a HasMany relational link to another entity + /// Create a HasMany relational link to another resource /// /// /// The relationship name as exposed by the API - /// Which links are available. Defaults to + /// Which links are available. Defaults to /// Whether or not this relationship can be included using the ?include=public-name query string /// /// @@ -23,7 +23,7 @@ public class HasManyAttribute : RelationshipAttribute /// } /// ]]> /// - public HasManyAttribute(string publicName = null, Link relationshipLinks = Link.All, bool canInclude = true, string inverseNavigationProperty = null) + public HasManyAttribute(string publicName = null, Links relationshipLinks = Links.All, bool canInclude = true, string inverseNavigationProperty = null) : base(publicName, relationshipLinks, canInclude) { InverseNavigation = inverseNavigationProperty; diff --git a/src/JsonApiDotNetCore/Models/Annotation/HasManyThroughAttribute.cs b/src/JsonApiDotNetCore/Models/Annotation/HasManyThroughAttribute.cs index 1cb0e980ed..29f18cc92f 100644 --- a/src/JsonApiDotNetCore/Models/Annotation/HasManyThroughAttribute.cs +++ b/src/JsonApiDotNetCore/Models/Annotation/HasManyThroughAttribute.cs @@ -5,7 +5,7 @@ using System.Reflection; using JsonApiDotNetCore.Extensions; using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Models.Links; +using JsonApiDotNetCore.Models.JsonApiDocuments; namespace JsonApiDotNetCore.Models.Annotation { @@ -17,7 +17,7 @@ namespace JsonApiDotNetCore.Models.Annotation /// /// In the following example, we expose a relationship named "tags" /// through the navigation property `ArticleTags`. - /// The `Tags` property is decorated as `NotMapped` so that EF does not try + /// The `Tags` property is decorated with `NotMapped` so that EF does not try /// to map this to a database relationship. /// - /// The navigation property back to the parent resource from the join type. + /// The navigation property back to the parent resource from the through type. /// /// /// @@ -63,7 +63,7 @@ public sealed class HasManyThroughAttribute : HasManyAttribute public PropertyInfo LeftProperty { get; internal set; } /// - /// The id property back to the parent resource from the join type. + /// The id property back to the parent resource from the through type. /// /// /// @@ -78,7 +78,7 @@ public sealed class HasManyThroughAttribute : HasManyAttribute public PropertyInfo LeftIdProperty { get; internal set; } /// - /// The navigation property to the related resource from the join type. + /// The navigation property to the related resource from the through type. /// /// /// @@ -93,7 +93,7 @@ public sealed class HasManyThroughAttribute : HasManyAttribute public PropertyInfo RightProperty { get; internal set; } /// - /// The id property to the related resource from the join type. + /// The id property to the related resource from the through type. /// /// /// @@ -108,7 +108,7 @@ public sealed class HasManyThroughAttribute : HasManyAttribute public PropertyInfo RightIdProperty { get; internal set; } /// - /// The join entity property on the parent resource. + /// The join resource property on the parent resource. /// /// /// @@ -134,15 +134,15 @@ public sealed class HasManyThroughAttribute : HasManyAttribute /// /// /// The name of the navigation property that will be used to get the HasMany relationship - /// Which links are available. Defaults to + /// Which links are available. Defaults to /// Whether or not this relationship can be included using the ?include=public-name query string /// /// /// - /// [HasManyThrough(nameof(ArticleTags), relationshipLinks: Link.All, canInclude: true)] + /// [HasManyThrough(nameof(ArticleTags), relationshipLinks: Links.All, canInclude: true)] /// /// - public HasManyThroughAttribute(string throughPropertyName, Link relationshipLinks = Link.All, bool canInclude = true) + public HasManyThroughAttribute(string throughPropertyName, Links relationshipLinks = Links.All, bool canInclude = true) : base(null, relationshipLinks, canInclude) { ThroughPropertyName = throughPropertyName; @@ -154,58 +154,58 @@ public HasManyThroughAttribute(string throughPropertyName, Link relationshipLink /// /// The relationship name as exposed by the API /// The name of the navigation property that will be used to get the HasMany relationship - /// Which links are available. Defaults to + /// Which links are available. Defaults to /// Whether or not this relationship can be included using the ?include=public-name query string /// /// /// - /// [HasManyThrough("tags", nameof(ArticleTags), relationshipLinks: Link.All, canInclude: true)] + /// [HasManyThrough("tags", nameof(ArticleTags), relationshipLinks: Links.All, canInclude: true)] /// /// - public HasManyThroughAttribute(string publicName, string throughPropertyName, Link relationshipLinks = Link.All, bool canInclude = true) + public HasManyThroughAttribute(string publicName, string throughPropertyName, Links relationshipLinks = Links.All, bool canInclude = true) : base(publicName, relationshipLinks, canInclude) { ThroughPropertyName = throughPropertyName; } /// - /// Traverses through the provided entity and returns the - /// value of the relationship on the other side of a join entity + /// Traverses through the provided resource and returns the + /// value of the relationship on the other side of a through type /// (e.g. Articles.ArticleTags.Tag). /// - public override object GetValue(object entity) + public override object GetValue(object resource) { - IEnumerable joinEntities = (IEnumerable)ThroughProperty.GetValue(entity) ?? Array.Empty(); + IEnumerable throughResources = (IEnumerable)ThroughProperty.GetValue(resource) ?? Array.Empty(); - IEnumerable rightEntities = joinEntities + IEnumerable rightResources = throughResources .Cast() - .Select(rightEntity => RightProperty.GetValue(rightEntity)); + .Select(rightResource => RightProperty.GetValue(rightResource)); - return rightEntities.CopyToTypedCollection(Property.PropertyType); + return rightResources.CopyToTypedCollection(Property.PropertyType); } /// - public override void SetValue(object entity, object newValue, IResourceFactory resourceFactory) + public override void SetValue(object resource, object newValue, IResourceFactory resourceFactory) { - base.SetValue(entity, newValue, resourceFactory); + base.SetValue(resource, newValue, resourceFactory); if (newValue == null) { - ThroughProperty.SetValue(entity, null); + ThroughProperty.SetValue(resource, null); } else { - List joinEntities = new List(); - foreach (IIdentifiable resource in (IEnumerable)newValue) + List throughResources = new List(); + foreach (IIdentifiable identifiable in (IEnumerable)newValue) { - object joinEntity = resourceFactory.CreateInstance(ThroughType); - LeftProperty.SetValue(joinEntity, entity); - RightProperty.SetValue(joinEntity, resource); - joinEntities.Add(joinEntity); + object throughResource = resourceFactory.CreateInstance(ThroughType); + LeftProperty.SetValue(throughResource, resource); + RightProperty.SetValue(throughResource, identifiable); + throughResources.Add(throughResource); } - var typedCollection = joinEntities.CopyToTypedCollection(ThroughProperty.PropertyType); - ThroughProperty.SetValue(entity, typedCollection); + var typedCollection = throughResources.CopyToTypedCollection(ThroughProperty.PropertyType); + ThroughProperty.SetValue(resource, typedCollection); } } } diff --git a/src/JsonApiDotNetCore/Models/Annotation/HasOneAttribute.cs b/src/JsonApiDotNetCore/Models/Annotation/HasOneAttribute.cs index 477e82b30f..c8dba73984 100644 --- a/src/JsonApiDotNetCore/Models/Annotation/HasOneAttribute.cs +++ b/src/JsonApiDotNetCore/Models/Annotation/HasOneAttribute.cs @@ -1,7 +1,7 @@ using System; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Models.Links; +using JsonApiDotNetCore.Models.JsonApiDocuments; namespace JsonApiDotNetCore.Models.Annotation { @@ -19,12 +19,12 @@ public sealed class HasOneAttribute : RelationshipAttribute : _explicitIdentifiablePropertyName; /// - /// Create a HasOne relational link to another entity + /// Create a HasOne relational link to another resource /// /// /// The relationship name as exposed by the API - /// Enum to set which links should be outputted for this relationship. Defaults to which means that the configuration in - /// or is used. + /// Enum to set which links should be outputted for this relationship. Defaults to which means that the configuration in + /// or is used. /// Whether or not this relationship can be included using the ?include=public-name query string /// The foreign key property name. Defaults to "{RelationshipName}Id" /// @@ -40,7 +40,7 @@ public sealed class HasOneAttribute : RelationshipAttribute /// } /// /// - public HasOneAttribute(string publicName = null, Link links = Link.NotConfigured, bool canInclude = true, string withForeignKey = null, string inverseNavigationProperty = null) + public HasOneAttribute(string publicName = null, Links links = Links.NotConfigured, bool canInclude = true, string withForeignKey = null, string inverseNavigationProperty = null) : base(publicName, links, canInclude) { _explicitIdentifiablePropertyName = withForeignKey; @@ -48,14 +48,14 @@ public HasOneAttribute(string publicName = null, Link links = Link.NotConfigured } /// - public override void SetValue(object entity, object newValue, IResourceFactory resourceFactory) + public override void SetValue(object resource, object newValue, IResourceFactory 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 = entity.GetType(); + var resourceType = resource.GetType(); var propertyInfo = resourceType.GetProperty(propertyName); if (propertyInfo == null) @@ -64,7 +64,7 @@ public override void SetValue(object entity, object newValue, IResourceFactory r propertyInfo = resourceType.GetProperty(RelationshipPath); } - propertyInfo.SetValue(entity, newValue); + propertyInfo.SetValue(resource, newValue); } } } diff --git a/src/JsonApiDotNetCore/Models/Annotation/LinksAttribute.cs b/src/JsonApiDotNetCore/Models/Annotation/LinksAttribute.cs index d5f39996b1..3e90dd0bb8 100644 --- a/src/JsonApiDotNetCore/Models/Annotation/LinksAttribute.cs +++ b/src/JsonApiDotNetCore/Models/Annotation/LinksAttribute.cs @@ -1,22 +1,22 @@ using System; using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Models.Links; +using JsonApiDotNetCore.Models.JsonApiDocuments; namespace JsonApiDotNetCore.Models.Annotation { [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Interface)] public sealed class LinksAttribute : Attribute { - public LinksAttribute(Link topLevelLinks = Link.NotConfigured, Link resourceLinks = Link.NotConfigured, Link relationshipLinks = Link.NotConfigured) + public LinksAttribute(Links topLevelLinks = Links.NotConfigured, Links resourceLinks = Links.NotConfigured, Links relationshipLinks = Links.NotConfigured) { - if (topLevelLinks == Link.Related) - throw new JsonApiSetupException($"{Link.Related:g} not allowed for argument {nameof(topLevelLinks)}"); + if (topLevelLinks == Links.Related) + throw new JsonApiSetupException($"{Links.Related:g} not allowed for argument {nameof(topLevelLinks)}"); - if (resourceLinks == Link.Paging) - throw new JsonApiSetupException($"{Link.Paging:g} not allowed for argument {nameof(resourceLinks)}"); + if (resourceLinks == Links.Paging) + throw new JsonApiSetupException($"{Links.Paging:g} not allowed for argument {nameof(resourceLinks)}"); - if (relationshipLinks == Link.Paging) - throw new JsonApiSetupException($"{Link.Paging:g} not allowed for argument {nameof(relationshipLinks)}"); + if (relationshipLinks == Links.Paging) + throw new JsonApiSetupException($"{Links.Paging:g} not allowed for argument {nameof(relationshipLinks)}"); TopLevelLinks = topLevelLinks; ResourceLinks = resourceLinks; @@ -27,18 +27,18 @@ public LinksAttribute(Link topLevelLinks = Link.NotConfigured, Link resourceLink /// Configures which links to show in the /// object for this resource. /// - public Link TopLevelLinks { get; } + public Links TopLevelLinks { get; } /// /// Configures which links to show in the /// object for this resource. /// - public Link ResourceLinks { get; } + public Links ResourceLinks { get; } /// /// Configures which links to show in the /// for all relationships of the resource for which this attribute was instantiated. /// - public Link RelationshipLinks { get; } + public Links RelationshipLinks { get; } } } diff --git a/src/JsonApiDotNetCore/Models/Annotation/RelationshipAttribute.cs b/src/JsonApiDotNetCore/Models/Annotation/RelationshipAttribute.cs index 157e4e2239..df4d3e4918 100644 --- a/src/JsonApiDotNetCore/Models/Annotation/RelationshipAttribute.cs +++ b/src/JsonApiDotNetCore/Models/Annotation/RelationshipAttribute.cs @@ -1,6 +1,6 @@ using System; using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Models.Links; +using JsonApiDotNetCore.Models.JsonApiDocuments; namespace JsonApiDotNetCore.Models.Annotation { @@ -9,7 +9,7 @@ public abstract class RelationshipAttribute : ResourceFieldAttribute public string InverseNavigation { get; internal set; } /// - /// The internal navigation property path to the related entity. + /// The internal navigation property path to the related resource. /// /// /// In all cases except the HasManyThrough relationships, this will just be the property name. @@ -17,7 +17,7 @@ public abstract class RelationshipAttribute : ResourceFieldAttribute public virtual string RelationshipPath => Property.Name; /// - /// The related entity type. This does not necessarily match the navigation property type. + /// The related resource type. This does not necessarily match the navigation property type. /// In the case of a HasMany relationship, this value will be the generic argument type. /// /// @@ -28,7 +28,7 @@ public abstract class RelationshipAttribute : ResourceFieldAttribute public Type RightType { get; internal set; } /// - /// The parent entity type. This is the type of the class in which this attribute was used. + /// The parent resource type. This is the type of the class in which this attribute was used. /// public Type LeftType { get; internal set; } @@ -36,15 +36,15 @@ public abstract class RelationshipAttribute : ResourceFieldAttribute /// Configures which links to show in the /// object for this relationship. /// - public Link RelationshipLinks { get; } + public Links RelationshipLinks { get; } public bool CanInclude { get; } - protected RelationshipAttribute(string publicName, Link relationshipLinks, bool canInclude) + protected RelationshipAttribute(string publicName, Links relationshipLinks, bool canInclude) : base(publicName) { - if (relationshipLinks == Link.Paging) - throw new JsonApiSetupException($"{Link.Paging:g} not allowed for argument {nameof(relationshipLinks)}"); + if (relationshipLinks == Links.Paging) + throw new JsonApiSetupException($"{Links.Paging:g} not allowed for argument {nameof(relationshipLinks)}"); RelationshipLinks = relationshipLinks; CanInclude = canInclude; @@ -53,22 +53,17 @@ protected RelationshipAttribute(string publicName, Link relationshipLinks, bool /// /// Gets the value of the resource property this attributes was declared on. /// - public virtual object GetValue(object entity) + public virtual object GetValue(object resource) { - return Property.GetValue(entity); + return Property.GetValue(resource); } /// /// Sets the value of the resource property this attributes was declared on. /// - public virtual void SetValue(object entity, object newValue, IResourceFactory resourceFactory) + public virtual void SetValue(object resource, object newValue, IResourceFactory resourceFactory) { - Property.SetValue(entity, newValue); - } - - public override string ToString() - { - return base.ToString() + ":" + PublicName; + Property.SetValue(resource, newValue); } public override bool Equals(object obj) diff --git a/src/JsonApiDotNetCore/Models/Annotation/ResourceFieldAttribute.cs b/src/JsonApiDotNetCore/Models/Annotation/ResourceFieldAttribute.cs index 6a3b9ec174..2ce3975179 100644 --- a/src/JsonApiDotNetCore/Models/Annotation/ResourceFieldAttribute.cs +++ b/src/JsonApiDotNetCore/Models/Annotation/ResourceFieldAttribute.cs @@ -32,5 +32,10 @@ protected ResourceFieldAttribute(string publicName) PublicName = publicName; } + + public override string ToString() + { + return PublicName ?? (Property != null ? Property.Name : base.ToString()); + } } } diff --git a/src/JsonApiDotNetCore/Models/AttrCapabilities.cs b/src/JsonApiDotNetCore/Models/AttrCapabilities.cs index 876601beca..c37fa6a681 100644 --- a/src/JsonApiDotNetCore/Models/AttrCapabilities.cs +++ b/src/JsonApiDotNetCore/Models/AttrCapabilities.cs @@ -11,24 +11,30 @@ public enum AttrCapabilities { None = 0, + /// + /// Whether or not GET requests can return the attribute. + /// Attempts to retrieve when disabled will return an HTTP 422 response. + /// + AllowView = 1, + /// /// Whether or not PATCH requests can update the attribute value. /// Attempts to update when disabled will return an HTTP 422 response. /// - AllowMutate = 1, + AllowChange = 2, /// /// Whether or not an attribute can be filtered on via a query string parameter. /// Attempts to sort when disabled will return an HTTP 400 response. /// - AllowFilter = 2, + AllowFilter = 4, /// /// Whether or not an attribute can be sorted on via a query string parameter. /// Attempts to sort when disabled will return an HTTP 400 response. /// - AllowSort = 4, + AllowSort = 8, - All = AllowMutate | AllowFilter | AllowSort + All = AllowView | AllowChange | AllowFilter | AllowSort } } diff --git a/src/JsonApiDotNetCore/Models/JsonApiDocuments/Document.cs b/src/JsonApiDotNetCore/Models/JsonApiDocuments/Document.cs index b42bb75766..ef96baea70 100644 --- a/src/JsonApiDotNetCore/Models/JsonApiDocuments/Document.cs +++ b/src/JsonApiDotNetCore/Models/JsonApiDocuments/Document.cs @@ -1,30 +1,30 @@ using System.Collections.Generic; -using JsonApiDotNetCore.Models.Links; -using Newtonsoft.Json; - -namespace JsonApiDotNetCore.Models -{ - /// - /// https://jsonapi.org/format/#document-structure - /// - public sealed class Document : ExposableData - { - /// - /// see "meta" in https://jsonapi.org/format/#document-top-level - /// - [JsonProperty("meta", NullValueHandling = NullValueHandling.Ignore)] - public Dictionary Meta { get; set; } - - /// - /// see "links" in https://jsonapi.org/format/#document-top-level - /// - [JsonProperty("links", NullValueHandling = NullValueHandling.Ignore)] - public TopLevelLinks Links { get; set; } - - /// - /// see "included" in https://jsonapi.org/format/#document-top-level - /// - [JsonProperty("included", NullValueHandling = NullValueHandling.Ignore, Order = 1)] - public List Included { get; set; } - } -} +using JsonApiDotNetCore.Models.JsonApiDocuments; +using Newtonsoft.Json; + +namespace JsonApiDotNetCore.Models +{ + /// + /// https://jsonapi.org/format/#document-structure + /// + public sealed class Document : ExposableData + { + /// + /// see "meta" in https://jsonapi.org/format/#document-top-level + /// + [JsonProperty("meta", NullValueHandling = NullValueHandling.Ignore)] + public Dictionary Meta { get; set; } + + /// + /// see "links" in https://jsonapi.org/format/#document-top-level + /// + [JsonProperty("links", NullValueHandling = NullValueHandling.Ignore)] + public TopLevelLinks Links { get; set; } + + /// + /// see "included" in https://jsonapi.org/format/#document-top-level + /// + [JsonProperty("included", NullValueHandling = NullValueHandling.Ignore, Order = 1)] + public List Included { get; set; } + } +} diff --git a/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorSource.cs b/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorSource.cs index b5eb4c3f70..7b39aa564e 100644 --- a/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorSource.cs +++ b/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorSource.cs @@ -5,7 +5,7 @@ namespace JsonApiDotNetCore.Models.JsonApiDocuments public sealed class ErrorSource { /// - /// Optional. A JSON Pointer [RFC6901] to the associated entity in the request document [e.g. "/data" for a primary data object, or "/data/attributes/title" for a specific attribute]. + /// Optional. A JSON Pointer [RFC6901] to the associated resource in the request document [e.g. "/data" for a primary data object, or "/data/attributes/title" for a specific attribute]. /// [JsonProperty] public string Pointer { get; set; } diff --git a/src/JsonApiDotNetCore/Models/JsonApiDocuments/Link.cs b/src/JsonApiDotNetCore/Models/JsonApiDocuments/Links.cs similarity index 73% rename from src/JsonApiDotNetCore/Models/JsonApiDocuments/Link.cs rename to src/JsonApiDotNetCore/Models/JsonApiDocuments/Links.cs index 5bba6273a0..f5d4b18d05 100644 --- a/src/JsonApiDotNetCore/Models/JsonApiDocuments/Link.cs +++ b/src/JsonApiDotNetCore/Models/JsonApiDocuments/Links.cs @@ -1,9 +1,9 @@ using System; -namespace JsonApiDotNetCore.Models.Links +namespace JsonApiDotNetCore.Models.JsonApiDocuments { [Flags] - public enum Link + public enum Links { Self = 1 << 0, Related = 1 << 1, diff --git a/src/JsonApiDotNetCore/Models/JsonApiDocuments/RelationshipEntry.cs b/src/JsonApiDotNetCore/Models/JsonApiDocuments/RelationshipEntry.cs index 5a19067f49..9a9359767c 100644 --- a/src/JsonApiDotNetCore/Models/JsonApiDocuments/RelationshipEntry.cs +++ b/src/JsonApiDotNetCore/Models/JsonApiDocuments/RelationshipEntry.cs @@ -1,4 +1,4 @@ -using JsonApiDotNetCore.Models.Links; +using JsonApiDotNetCore.Models.JsonApiDocuments; using Newtonsoft.Json; namespace JsonApiDotNetCore.Models diff --git a/src/JsonApiDotNetCore/Models/JsonApiDocuments/RelationshipLinks.cs b/src/JsonApiDotNetCore/Models/JsonApiDocuments/RelationshipLinks.cs index e8076af318..1ac1d67c7d 100644 --- a/src/JsonApiDotNetCore/Models/JsonApiDocuments/RelationshipLinks.cs +++ b/src/JsonApiDotNetCore/Models/JsonApiDocuments/RelationshipLinks.cs @@ -1,6 +1,6 @@ using Newtonsoft.Json; -namespace JsonApiDotNetCore.Models.Links +namespace JsonApiDotNetCore.Models.JsonApiDocuments { public sealed class RelationshipLinks { diff --git a/src/JsonApiDotNetCore/Models/JsonApiDocuments/ResourceLinks.cs b/src/JsonApiDotNetCore/Models/JsonApiDocuments/ResourceLinks.cs index 0ff07ad7e5..4997d42f27 100644 --- a/src/JsonApiDotNetCore/Models/JsonApiDocuments/ResourceLinks.cs +++ b/src/JsonApiDotNetCore/Models/JsonApiDocuments/ResourceLinks.cs @@ -1,6 +1,6 @@ using Newtonsoft.Json; -namespace JsonApiDotNetCore.Models.Links +namespace JsonApiDotNetCore.Models.JsonApiDocuments { public sealed class ResourceLinks { diff --git a/src/JsonApiDotNetCore/Models/JsonApiDocuments/ResourceObject.cs b/src/JsonApiDotNetCore/Models/JsonApiDocuments/ResourceObject.cs index e46525755b..a9d619191e 100644 --- a/src/JsonApiDotNetCore/Models/JsonApiDocuments/ResourceObject.cs +++ b/src/JsonApiDotNetCore/Models/JsonApiDocuments/ResourceObject.cs @@ -1,5 +1,5 @@ using System.Collections.Generic; -using JsonApiDotNetCore.Models.Links; +using JsonApiDotNetCore.Models.JsonApiDocuments; using Newtonsoft.Json; namespace JsonApiDotNetCore.Models diff --git a/src/JsonApiDotNetCore/Models/JsonApiDocuments/TopLevelLinks.cs b/src/JsonApiDotNetCore/Models/JsonApiDocuments/TopLevelLinks.cs index adb2ddbc6d..9b0992526a 100644 --- a/src/JsonApiDotNetCore/Models/JsonApiDocuments/TopLevelLinks.cs +++ b/src/JsonApiDotNetCore/Models/JsonApiDocuments/TopLevelLinks.cs @@ -1,6 +1,6 @@ using Newtonsoft.Json; -namespace JsonApiDotNetCore.Models.Links +namespace JsonApiDotNetCore.Models.JsonApiDocuments { /// /// see links section in https://jsonapi.org/format/#document-top-level diff --git a/src/JsonApiDotNetCore/Models/ResourceDefinition.cs b/src/JsonApiDotNetCore/Models/ResourceDefinition.cs index 75166357f9..d18fc360a5 100644 --- a/src/JsonApiDotNetCore/Models/ResourceDefinition.cs +++ b/src/JsonApiDotNetCore/Models/ResourceDefinition.cs @@ -1,20 +1,22 @@ using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Internal.Query; using JsonApiDotNetCore.Hooks; using System; using System.Collections.Generic; +using System.ComponentModel; using System.Linq; using System.Linq.Expressions; -using JsonApiDotNetCore.Models.Annotation; +using JsonApiDotNetCore.Internal.Queries.Expressions; +using Microsoft.Extensions.Primitives; namespace JsonApiDotNetCore.Models { public interface IResourceDefinition { - List GetAllowedAttributes(); - List GetAllowedRelationships(); - object GetCustomQueryFilter(string key); - List<(AttrAttribute Attribute, SortDirection SortDirection)> DefaultSort(); + FilterExpression OnApplyFilter(FilterExpression existingFilter); + SortExpression OnApplySort(SortExpression existingSort); + PaginationExpression OnApplyPagination(PaginationExpression existingPagination); + SparseFieldSetExpression OnApplySparseFieldSet(SparseFieldSetExpression existingSparseFieldSet); + object GetQueryableHandlerForQueryStringParameter(string parameterName); } /// @@ -26,149 +28,181 @@ public interface IResourceDefinition /// The resource type public class ResourceDefinition : IResourceDefinition, IResourceHookContainer where TResource : class, IIdentifiable { - private readonly IResourceGraph _resourceGraph; - private List _allowedAttributes; - private List _allowedRelationships; + protected IResourceGraph ResourceGraph { get; } + public ResourceDefinition(IResourceGraph resourceGraph) { - var resourceContext = resourceGraph.GetResourceContext(typeof(TResource)); - _allowedAttributes = resourceContext.Attributes; - _allowedRelationships = resourceContext.Relationships; - _resourceGraph = resourceGraph; + ResourceGraph = resourceGraph ?? throw new ArgumentNullException(nameof(resourceGraph)); } - public List GetAllowedRelationships() => _allowedRelationships; - public List GetAllowedAttributes() => _allowedAttributes; + /// + public virtual void AfterCreate(HashSet resources, ResourcePipeline pipeline) { } + /// + public virtual void AfterRead(HashSet resources, ResourcePipeline pipeline, bool isIncluded = false) { } + /// + public virtual void AfterUpdate(HashSet resources, ResourcePipeline pipeline) { } + /// + public virtual void AfterDelete(HashSet resources, ResourcePipeline pipeline, bool succeeded) { } + /// + public virtual void AfterUpdateRelationship(IRelationshipsDictionary resourcesByRelationship, ResourcePipeline pipeline) { } + /// + public virtual IEnumerable BeforeCreate(IResourceHashSet resources, ResourcePipeline pipeline) { return resources; } + /// + public virtual void BeforeRead(ResourcePipeline pipeline, bool isIncluded = false, string stringId = null) { } + /// + public virtual IEnumerable BeforeUpdate(IDiffableResourceHashSet resources, ResourcePipeline pipeline) { return resources; } + /// + public virtual IEnumerable BeforeDelete(IResourceHashSet resources, ResourcePipeline pipeline) { return resources; } + /// + public virtual IEnumerable BeforeUpdateRelationship(HashSet ids, IRelationshipsDictionary resourcesByRelationship, ResourcePipeline pipeline) { return ids; } + /// + public virtual void BeforeImplicitUpdateRelationship(IRelationshipsDictionary resourcesByRelationship, ResourcePipeline pipeline) { } + /// + public virtual IEnumerable OnReturn(HashSet resources, ResourcePipeline pipeline) { return resources; } /// - /// Hides specified attributes and relationships from the serialized output. Can be called directly in a resource definition implementation or - /// in any resource hook to combine it with eg authorization. + /// Enables to extend, replace or remove a filter that is being applied on a set of this resource type. /// - /// Should be of the form: (TResource e) => new { e.Attribute1, e.Attribute2, e.Relationship1, e.Relationship2 } - public void HideFields(Expression> selector) + /// + /// An optional existing filter, coming from query string. Can be null. + /// + /// + /// The new filter, or null to disable the existing filter. + /// + public virtual FilterExpression OnApplyFilter(FilterExpression existingFilter) { - var fieldsToHide = _resourceGraph.GetFields(selector); - _allowedAttributes = _allowedAttributes.Except(fieldsToHide.Where(f => f is AttrAttribute)).Cast().ToList(); - _allowedRelationships = _allowedRelationships.Except(fieldsToHide.Where(f => f is RelationshipAttribute)).Cast().ToList(); + return existingFilter; } /// - /// Define a set of custom query expressions that can be applied - /// instead of the default query behavior. A common use-case for this - /// is including related resources and filtering on them. + /// Enables to extend, replace or remove a sort order that is being applied on a set of this resource type. + /// Tip: Use to build from a lambda expression. /// - /// + /// + /// An optional existing sort order, coming from query string. Can be null. + /// /// - /// A set of custom queries that will be applied instead of the default - /// queries for the given key. Null will be returned if default behavior - /// is desired. + /// The new sort order, or null to disable the existing sort order and sort by ID. /// - /// + public virtual SortExpression OnApplySort(SortExpression existingSort) + { + return existingSort; + } + + /// + /// Creates a from a lambda expression. + /// /// - /// - /// protected override QueryFilters GetQueryFilters() => { - /// { "facility", (t, value) => t.Include(t => t.Tenant) - /// .Where(t => t.Facility == value) } - /// } - /// - /// - /// If the logic is simply too complex for an in-line expression, you can - /// delegate to a private method: /// new QueryFilters { - /// { "is-active", FilterIsActive } - /// }; - /// - /// private IQueryable FilterIsActive(IQueryable query, string value) + /// var sort = CreateSortExpressionFromLambda(new PropertySortOrder /// { - /// // some complex logic goes here... - /// return query.Where(x => x.IsActive == computedValue); - /// } + /// (model => model.CreatedAt, ListSortDirection.Ascending), + /// (model => model.Password, ListSortDirection.Descending) + /// }); /// ]]> /// - public virtual QueryFilters GetQueryFilters() => null; - - public object GetCustomQueryFilter(string key) + protected SortExpression CreateSortExpressionFromLambda(PropertySortOrder keySelectors) { - var customFilters = GetQueryFilters(); - if (customFilters != null && customFilters.TryGetValue(key, out var query)) - return query; - return null; - } + if (keySelectors == null) + { + throw new ArgumentNullException(nameof(keySelectors)); + } - /// - public virtual void AfterCreate(HashSet entities, ResourcePipeline pipeline) { } - /// - public virtual void AfterRead(HashSet entities, ResourcePipeline pipeline, bool isIncluded = false) { } - /// - public virtual void AfterUpdate(HashSet entities, ResourcePipeline pipeline) { } - /// - public virtual void AfterDelete(HashSet entities, ResourcePipeline pipeline, bool succeeded) { } - /// - public virtual void AfterUpdateRelationship(IRelationshipsDictionary entitiesByRelationship, ResourcePipeline pipeline) { } - /// - public virtual IEnumerable BeforeCreate(IEntityHashSet entities, ResourcePipeline pipeline) { return entities; } - /// - public virtual void BeforeRead(ResourcePipeline pipeline, bool isIncluded = false, string stringId = null) { } - /// - public virtual IEnumerable BeforeUpdate(IDiffableEntityHashSet entities, ResourcePipeline pipeline) { return entities; } - /// - public virtual IEnumerable BeforeDelete(IEntityHashSet entities, ResourcePipeline pipeline) { return entities; } - /// - public virtual IEnumerable BeforeUpdateRelationship(HashSet ids, IRelationshipsDictionary entitiesByRelationship, ResourcePipeline pipeline) { return ids; } - /// - public virtual void BeforeImplicitUpdateRelationship(IRelationshipsDictionary entitiesByRelationship, ResourcePipeline pipeline) { } - /// - public virtual IEnumerable OnReturn(HashSet entities, ResourcePipeline pipeline) { return entities; } + List sortElements = new List(); + + foreach (var (keySelector, sortDirection) in keySelectors) + { + bool isAscending = sortDirection == ListSortDirection.Ascending; + var attribute = ResourceGraph.GetAttributes(keySelector).Single(); + + var sortElement = new SortElementExpression(new ResourceFieldChainExpression(attribute), isAscending); + sortElements.Add(sortElement); + } + return new SortExpression(sortElements); + } /// - /// This is an alias type intended to simplify the implementation's - /// method signature. - /// See for usage details. + /// Enables to extend, replace or remove pagination that is being applied on a set of this resource type. /// - public sealed class QueryFilters : Dictionary, FilterQuery, IQueryable>> { } + /// + /// An optional existing pagination, coming from query string. Can be null. + /// + /// + /// The changed pagination, or null to use the first page with default size from options. + /// To disable paging, set to null. + /// + public virtual PaginationExpression OnApplyPagination(PaginationExpression existingPagination) + { + return existingPagination; + } /// - /// Define the default sort order if no sort key is provided. + /// Enables to extend, replace or remove a sparse fieldset that is being applied on a set of this resource type. + /// Tip: Use and + /// to safely change the fieldset without worrying about nulls. /// + /// + /// An optional existing sparse fieldset, coming from query string. Can be null. + /// /// - /// A list of properties and the direction they should be sorted. + /// The new sparse fieldset, or null to disable the existing sparse fieldset and select all fields. /// + public virtual SparseFieldSetExpression OnApplySparseFieldSet(SparseFieldSetExpression existingSparseFieldSet) + { + return existingSparseFieldSet; + } + + /// + /// Enables to adapt the Entity Framework Core query, based on custom query string parameters. + /// Note this only works on primary resource requests, such as /articles, but not on /blogs/1/articles or /blogs?include=articles. + /// /// - /// - /// public override PropertySortOrder GetDefaultSortOrder() - /// => new PropertySortOrder { - /// (t => t.Prop1, SortDirection.Ascending), - /// (t => t.Prop2, SortDirection.Descending), + /// source + /// .Include(model => model.Children) + /// .Where(model => model.LastUpdateTime > DateTime.Now.AddMonths(-1)), + /// ["isHighRisk"] = FilterByHighRisk /// }; - /// + /// } + /// + /// private static IQueryable FilterByHighRisk(IQueryable source, StringValues parameterValue) + /// { + /// bool isFilterOnHighRisk = bool.Parse(parameterValue); + /// return isFilterOnHighRisk ? source.Where(model => model.RiskLevel >= 5) : source.Where(model => model.RiskLevel < 5); + /// } + /// ]]> /// - public virtual PropertySortOrder GetDefaultSortOrder() => null; - - public List<(AttrAttribute Attribute, SortDirection SortDirection)> DefaultSort() + /// + protected virtual QueryStringParameterHandlers OnRegisterQueryableHandlersForQueryStringParameters() { - var defaultSortOrder = GetDefaultSortOrder(); - if (defaultSortOrder != null && defaultSortOrder.Count > 0) - { - var order = new List<(AttrAttribute Attribute, SortDirection SortDirection)>(); - foreach (var sortProp in defaultSortOrder) - { - order.Add((_resourceGraph.GetAttributes(sortProp.Attribute).Single(), sortProp.SortDirection)); - } + return new QueryStringParameterHandlers(); + } - return order; - } + public object GetQueryableHandlerForQueryStringParameter(string parameterName) + { + var handlers = OnRegisterQueryableHandlersForQueryStringParameters(); + return handlers != null && handlers.ContainsKey(parameterName) ? handlers[parameterName] : null; + } - return null; + /// + /// This is an alias type intended to simplify the implementation's method signature. + /// See for usage details. + /// + public sealed class PropertySortOrder : List<(Expression> KeySelector, ListSortDirection SortDirection)> + { } /// - /// This is an alias type intended to simplify the implementation's - /// method signature. - /// See for usage details. + /// This is an alias type intended to simplify the implementation's method signature. + /// See for usage details. /// - public sealed class PropertySortOrder : List<(Expression> Attribute, SortDirection SortDirection)> { } + public sealed class QueryStringParameterHandlers : Dictionary, StringValues, IQueryable>> + { + } } } diff --git a/src/JsonApiDotNetCore/Models/SparseFieldSetExtensions.cs b/src/JsonApiDotNetCore/Models/SparseFieldSetExtensions.cs new file mode 100644 index 0000000000..26731d4ede --- /dev/null +++ b/src/JsonApiDotNetCore/Models/SparseFieldSetExtensions.cs @@ -0,0 +1,85 @@ +using System; +using System.Linq; +using System.Linq.Expressions; +using JsonApiDotNetCore.Internal.Contracts; +using JsonApiDotNetCore.Internal.Queries.Expressions; +using JsonApiDotNetCore.Models.Annotation; + +namespace JsonApiDotNetCore.Models +{ + public static class SparseFieldSetExtensions + { + public static SparseFieldSetExpression Including(this SparseFieldSetExpression sparseFieldSet, + Expression> attributeSelector, IResourceGraph resourceGraph) + where TResource : IIdentifiable + { + if (attributeSelector == null) + { + throw new ArgumentNullException(nameof(attributeSelector)); + } + + if (resourceGraph == null) + { + throw new ArgumentNullException(nameof(resourceGraph)); + } + + foreach (var attribute in resourceGraph.GetAttributes(attributeSelector)) + { + sparseFieldSet = IncludeAttribute(sparseFieldSet, attribute); + } + + return sparseFieldSet; + } + + private static SparseFieldSetExpression IncludeAttribute(SparseFieldSetExpression sparseFieldSet, AttrAttribute attributeToInclude) + { + if (sparseFieldSet == null || sparseFieldSet.Attributes.Contains(attributeToInclude)) + { + return sparseFieldSet; + } + + var attributeSet = sparseFieldSet.Attributes.ToHashSet(); + attributeSet.Add(attributeToInclude); + return new SparseFieldSetExpression(attributeSet); + } + + public static SparseFieldSetExpression Excluding(this SparseFieldSetExpression sparseFieldSet, + Expression> attributeSelector, IResourceGraph resourceGraph) + where TResource : IIdentifiable + { + if (attributeSelector == null) + { + throw new ArgumentNullException(nameof(attributeSelector)); + } + + if (resourceGraph == null) + { + throw new ArgumentNullException(nameof(resourceGraph)); + } + + foreach (var attribute in resourceGraph.GetAttributes(attributeSelector)) + { + sparseFieldSet = ExcludeAttribute(sparseFieldSet, attribute); + } + + return sparseFieldSet; + } + + private static SparseFieldSetExpression ExcludeAttribute(SparseFieldSetExpression sparseFieldSet, AttrAttribute attributeToExclude) + { + // Design tradeoff: When the sparse fieldset is empty, it means all attributes will be selected. + // Adding an exclusion in that case is a no-op, which results in still retrieving the excluded attribute from data store. + // But later, when serializing the response, the sparse fieldset is first populated with all attributes, + // so then the exclusion will actually be applied and the excluded attribute is not returned to the client. + + if (sparseFieldSet == null || !sparseFieldSet.Attributes.Contains(attributeToExclude)) + { + return sparseFieldSet; + } + + var attributeSet = sparseFieldSet.Attributes.ToHashSet(); + attributeSet.Remove(attributeToExclude); + return new SparseFieldSetExpression(attributeSet); + } + } +} diff --git a/src/JsonApiDotNetCore/PageNumber.cs b/src/JsonApiDotNetCore/PageNumber.cs new file mode 100644 index 0000000000..fc45910efe --- /dev/null +++ b/src/JsonApiDotNetCore/PageNumber.cs @@ -0,0 +1,51 @@ +using System; + +namespace JsonApiDotNetCore +{ + public sealed class PageNumber : IEquatable + { + public static readonly PageNumber ValueOne = new PageNumber(1); + + public int OneBasedValue { get; } + + public PageNumber(int oneBasedValue) + { + if (oneBasedValue < 1) + { + throw new ArgumentOutOfRangeException(nameof(oneBasedValue)); + } + + OneBasedValue = oneBasedValue; + } + + public bool Equals(PageNumber other) + { + if (ReferenceEquals(null, other)) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + return OneBasedValue == other.OneBasedValue; + } + + public override bool Equals(object other) + { + return Equals(other as PageNumber); + } + + public override int GetHashCode() + { + return OneBasedValue.GetHashCode(); + } + + public override string ToString() + { + return OneBasedValue.ToString(); + } + } +} diff --git a/src/JsonApiDotNetCore/PageSize.cs b/src/JsonApiDotNetCore/PageSize.cs new file mode 100644 index 0000000000..56cf9296fc --- /dev/null +++ b/src/JsonApiDotNetCore/PageSize.cs @@ -0,0 +1,49 @@ +using System; + +namespace JsonApiDotNetCore +{ + public sealed class PageSize : IEquatable + { + public int Value { get; } + + public PageSize(int value) + { + if (value < 1) + { + throw new ArgumentOutOfRangeException(nameof(value)); + } + + Value = value; + } + + public bool Equals(PageSize other) + { + if (ReferenceEquals(null, other)) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + return Value == other.Value; + } + + public override bool Equals(object other) + { + return Equals(other as PageSize); + } + + public override int GetHashCode() + { + return Value.GetHashCode(); + } + + public override string ToString() + { + return Value.ToString(); + } + } +} diff --git a/src/JsonApiDotNetCore/QueryParameterServices/Common/IQueryParameterParser.cs b/src/JsonApiDotNetCore/QueryParameterServices/Common/IQueryParameterParser.cs deleted file mode 100644 index 1b0afb9623..0000000000 --- a/src/JsonApiDotNetCore/QueryParameterServices/Common/IQueryParameterParser.cs +++ /dev/null @@ -1,19 +0,0 @@ -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Query; - -namespace JsonApiDotNetCore.Services -{ - /// - /// Responsible for populating the various service implementations of . - /// - public interface IQueryParameterParser - { - /// - /// Parses the parameters from the request query string. - /// - /// - /// The if set on the controller that is targeted by the current request. - /// - void Parse(DisableQueryAttribute disableQueryAttribute); - } -} diff --git a/src/JsonApiDotNetCore/QueryParameterServices/Common/IQueryParameterService.cs b/src/JsonApiDotNetCore/QueryParameterServices/Common/IQueryParameterService.cs deleted file mode 100644 index a67e56ce22..0000000000 --- a/src/JsonApiDotNetCore/QueryParameterServices/Common/IQueryParameterService.cs +++ /dev/null @@ -1,26 +0,0 @@ -using JsonApiDotNetCore.Controllers; -using Microsoft.Extensions.Primitives; - -namespace JsonApiDotNetCore.Query -{ - /// - /// The interface to implement for parsing specific query string parameters. - /// - public interface IQueryParameterService - { - /// - /// Indicates whether using this service is blocked using on a controller. - /// - bool IsEnabled(DisableQueryAttribute disableQueryAttribute); - - /// - /// Indicates whether this service supports parsing the specified query string parameter. - /// - bool CanParse(string parameterName); - - /// - /// Parses the value of the query string parameter. - /// - void Parse(string parameterName, StringValues parameterValue); - } -} diff --git a/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterParser.cs b/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterParser.cs deleted file mode 100644 index 572e425b76..0000000000 --- a/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterParser.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Exceptions; -using JsonApiDotNetCore.Query; -using JsonApiDotNetCore.QueryParameterServices.Common; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCore.Services -{ - /// - public class QueryParameterParser : IQueryParameterParser - { - private readonly IJsonApiOptions _options; - private readonly IRequestQueryStringAccessor _queryStringAccessor; - private readonly IEnumerable _queryServices; - private ILogger _logger; - - public QueryParameterParser(IJsonApiOptions options, IRequestQueryStringAccessor queryStringAccessor, IEnumerable queryServices, ILoggerFactory loggerFactory) - { - _options = options; - _queryStringAccessor = queryStringAccessor; - _queryServices = queryServices; - - _logger = loggerFactory.CreateLogger(); - } - - /// - public virtual void Parse(DisableQueryAttribute disableQueryAttribute) - { - disableQueryAttribute ??= DisableQueryAttribute.Empty; - - foreach (var pair in _queryStringAccessor.Query) - { - if (string.IsNullOrEmpty(pair.Value)) - { - throw new InvalidQueryStringParameterException(pair.Key, "Missing query string parameter value.", - $"Missing value for '{pair.Key}' query string parameter."); - } - - var service = _queryServices.FirstOrDefault(s => s.CanParse(pair.Key)); - if (service != null) - { - _logger.LogDebug($"Query string parameter '{pair.Key}' with value '{pair.Value}' was accepted by {service.GetType().Name}."); - - if (!service.IsEnabled(disableQueryAttribute)) - { - throw new InvalidQueryStringParameterException(pair.Key, - "Usage of one or more query string parameters is not allowed at the requested endpoint.", - $"The parameter '{pair.Key}' cannot be used at this endpoint."); - } - - service.Parse(pair.Key, pair.Value); - _logger.LogDebug($"Query string parameter '{pair.Key}' was successfully parsed."); - } - else if (!_options.AllowCustomQueryStringParameters) - { - throw new InvalidQueryStringParameterException(pair.Key, "Unknown query string parameter.", - $"Query string parameter '{pair.Key}' is unknown. Set '{nameof(IJsonApiOptions.AllowCustomQueryStringParameters)}' to 'true' in options to ignore unknown parameters."); - } - } - } - } -} diff --git a/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterService.cs b/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterService.cs deleted file mode 100644 index 7967119434..0000000000 --- a/src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterService.cs +++ /dev/null @@ -1,79 +0,0 @@ -using System.Linq; -using JsonApiDotNetCore.Exceptions; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Managers.Contracts; -using JsonApiDotNetCore.Models.Annotation; - -namespace JsonApiDotNetCore.Query -{ - /// - /// Base class for query parameters. - /// - public abstract class QueryParameterService - { - protected readonly IResourceGraph _resourceGraph; - protected readonly ResourceContext _requestResource; - private readonly ResourceContext _mainRequestResource; - - protected QueryParameterService() { } - - protected QueryParameterService(IResourceGraph resourceGraph, ICurrentRequest currentRequest) - { - _mainRequestResource = currentRequest.GetRequestResource(); - _resourceGraph = resourceGraph; - _requestResource = currentRequest.RequestRelationship != null - ? resourceGraph.GetResourceContext(currentRequest.RequestRelationship.RightType) - : _mainRequestResource; - } - - /// - /// Helper method for parsing query parameters into attributes - /// - protected AttrAttribute GetAttribute(string queryParameterName, string target, RelationshipAttribute relationship = null) - { - var attribute = relationship != null - ? _resourceGraph.GetAttributes(relationship.RightType).FirstOrDefault(a => target == a.PublicName) - : _requestResource.Attributes.FirstOrDefault(attr => target == attr.PublicName); - - if (attribute == null) - { - throw new InvalidQueryStringParameterException(queryParameterName, - "The attribute requested in query string does not exist.", - $"The attribute '{target}' does not exist on resource '{_requestResource.ResourceName}'."); - } - - return attribute; - } - - /// - /// Helper method for parsing query parameters into relationships attributes - /// - protected RelationshipAttribute GetRelationship(string queryParameterName, string propertyName) - { - if (propertyName == null) return null; - var relationship = _requestResource.Relationships.FirstOrDefault(r => propertyName == r.PublicName); - if (relationship == null) - { - throw new InvalidQueryStringParameterException(queryParameterName, - "The relationship requested in query string does not exist.", - $"The relationship '{propertyName}' does not exist on resource '{_requestResource.ResourceName}'."); - } - - return relationship; - } - - /// - /// Throw an exception if query parameters are requested that are unsupported on nested resource routes. - /// - protected void EnsureNoNestedResourceRoute(string parameterName) - { - if (_requestResource != _mainRequestResource) - { - throw new InvalidQueryStringParameterException(parameterName, - "The specified query string parameter is currently not supported on nested resource endpoints.", - $"Query string parameter '{parameterName}' is currently not supported on nested resource endpoints. (i.e. of the form '/article/1/author?parameterName=...')"); - } - } - } -} diff --git a/src/JsonApiDotNetCore/QueryParameterServices/Contracts/IDefaultsService.cs b/src/JsonApiDotNetCore/QueryParameterServices/Contracts/IDefaultsService.cs deleted file mode 100644 index 0d69326296..0000000000 --- a/src/JsonApiDotNetCore/QueryParameterServices/Contracts/IDefaultsService.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Newtonsoft.Json; - -namespace JsonApiDotNetCore.Query -{ - /// - /// Query parameter service responsible for url queries of the form ?defaults=false - /// - public interface IDefaultsService : IQueryParameterService - { - /// - /// Contains the effective value of default configuration and query string override, after parsing has occured. - /// - DefaultValueHandling SerializerDefaultValueHandling { get; } - } -} diff --git a/src/JsonApiDotNetCore/QueryParameterServices/Contracts/IFilterService.cs b/src/JsonApiDotNetCore/QueryParameterServices/Contracts/IFilterService.cs deleted file mode 100644 index 02e4d623e8..0000000000 --- a/src/JsonApiDotNetCore/QueryParameterServices/Contracts/IFilterService.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Collections.Generic; -using JsonApiDotNetCore.Internal.Query; - -namespace JsonApiDotNetCore.Query -{ - /// - /// Query parameter service responsible for url queries of the form ?filter[X]=Y - /// - public interface IFilterService : IQueryParameterService - { - /// - /// Gets the parsed filter queries - /// - List Get(); - } -} \ No newline at end of file diff --git a/src/JsonApiDotNetCore/QueryParameterServices/Contracts/IIncludeService.cs b/src/JsonApiDotNetCore/QueryParameterServices/Contracts/IIncludeService.cs deleted file mode 100644 index 246d021e06..0000000000 --- a/src/JsonApiDotNetCore/QueryParameterServices/Contracts/IIncludeService.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Collections.Generic; -using JsonApiDotNetCore.Models.Annotation; - -namespace JsonApiDotNetCore.Query -{ - /// - /// Query parameter service responsible for url queries of the form ?include=X.Y.Z,U.V.W - /// - public interface IIncludeService : IQueryParameterService - { - /// - /// Gets the parsed relationship inclusion chains. - /// - List> Get(); - } -} \ No newline at end of file diff --git a/src/JsonApiDotNetCore/QueryParameterServices/Contracts/INullsService.cs b/src/JsonApiDotNetCore/QueryParameterServices/Contracts/INullsService.cs deleted file mode 100644 index 038eaa7153..0000000000 --- a/src/JsonApiDotNetCore/QueryParameterServices/Contracts/INullsService.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Newtonsoft.Json; - -namespace JsonApiDotNetCore.Query -{ - /// - /// Query parameter service responsible for url queries of the form ?nulls=false - /// - public interface INullsService : IQueryParameterService - { - /// - /// Contains the effective value of default configuration and query string override, after parsing has occured. - /// - NullValueHandling SerializerNullValueHandling { get; } - } -} diff --git a/src/JsonApiDotNetCore/QueryParameterServices/Contracts/IPageService.cs b/src/JsonApiDotNetCore/QueryParameterServices/Contracts/IPageService.cs deleted file mode 100644 index 968ed85958..0000000000 --- a/src/JsonApiDotNetCore/QueryParameterServices/Contracts/IPageService.cs +++ /dev/null @@ -1,33 +0,0 @@ -namespace JsonApiDotNetCore.Query -{ - /// - /// Query parameter service responsible for url queries of the form ?page[size]=X&page[number]=Y - /// - public interface IPageService : IQueryParameterService - { - /// - /// Gets the requested or default page size - /// - int PageSize { get; } - /// - /// The page requested. Note that the page number is one-based. - /// - int CurrentPage { get; } - /// - /// Total amount of pages for request - /// - int TotalPages { get; } - /// - /// Denotes if pagination is possible for the current request - /// - bool CanPaginate { get; } - /// - /// Denotes if pagination is backwards - /// - bool Backwards { get; } - /// - /// What the total records are for this output - /// - int? TotalRecords { get; set; } - } -} diff --git a/src/JsonApiDotNetCore/QueryParameterServices/Contracts/ISortService.cs b/src/JsonApiDotNetCore/QueryParameterServices/Contracts/ISortService.cs deleted file mode 100644 index 781da03713..0000000000 --- a/src/JsonApiDotNetCore/QueryParameterServices/Contracts/ISortService.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Collections.Generic; -using JsonApiDotNetCore.Internal.Query; - -namespace JsonApiDotNetCore.Query -{ - /// - /// Query parameter service responsible for url queries of the form ?sort=-X - /// - public interface ISortService : IQueryParameterService - { - /// - /// Gets the parsed sort queries - /// - List Get(); - } -} \ No newline at end of file diff --git a/src/JsonApiDotNetCore/QueryParameterServices/Contracts/ISparseFieldsService.cs b/src/JsonApiDotNetCore/QueryParameterServices/Contracts/ISparseFieldsService.cs deleted file mode 100644 index 080d272de0..0000000000 --- a/src/JsonApiDotNetCore/QueryParameterServices/Contracts/ISparseFieldsService.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Collections.Generic; -using JsonApiDotNetCore.Models.Annotation; - -namespace JsonApiDotNetCore.Query -{ - /// - /// Query parameter service responsible for url queries of the form ?fields[X]=U,V,W - /// - public interface ISparseFieldsService : IQueryParameterService - { - /// - /// Gets the list of targeted fields. If a relationship is supplied, - /// gets the list of targeted fields for that relationship. - /// - List Get(RelationshipAttribute relationship = null); - - /// - /// Gets the set of all targeted fields, including fields for related entities, as a set of dotted property names. - /// - ISet GetAll(); - } -} diff --git a/src/JsonApiDotNetCore/QueryParameterServices/FilterService.cs b/src/JsonApiDotNetCore/QueryParameterServices/FilterService.cs deleted file mode 100644 index 7703c7cd9a..0000000000 --- a/src/JsonApiDotNetCore/QueryParameterServices/FilterService.cs +++ /dev/null @@ -1,141 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Exceptions; -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Internal.Query; -using JsonApiDotNetCore.Managers.Contracts; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Models.Annotation; -using Microsoft.Extensions.Primitives; - -namespace JsonApiDotNetCore.Query -{ - /// - public class FilterService : QueryParameterService, IFilterService - { - private readonly List _filters; - private readonly IResourceDefinition _requestResourceDefinition; - - public FilterService(IResourceDefinitionProvider resourceDefinitionProvider, IResourceGraph resourceGraph, ICurrentRequest currentRequest) : base(resourceGraph, currentRequest) - { - _requestResourceDefinition = resourceDefinitionProvider.Get(_requestResource.ResourceType); - _filters = new List(); - } - - /// - public List Get() - { - return _filters; - } - - /// - public bool IsEnabled(DisableQueryAttribute disableQueryAttribute) - { - return !disableQueryAttribute.ContainsParameter(StandardQueryStringParameters.Filter); - } - - /// - public bool CanParse(string parameterName) - { - return parameterName.StartsWith("filter[") && parameterName.EndsWith("]"); - } - - /// - public virtual void Parse(string parameterName, StringValues parameterValue) - { - EnsureNoNestedResourceRoute(parameterName); - var queries = GetFilterQueries(parameterName, parameterValue); - _filters.AddRange(queries.Select(x => GetQueryContexts(x, parameterName))); - } - - private FilterQueryContext GetQueryContexts(FilterQuery query, string parameterName) - { - var queryContext = new FilterQueryContext(query); - var customQuery = _requestResourceDefinition?.GetCustomQueryFilter(query.Target); - if (customQuery != null) - { - queryContext.IsCustom = true; - queryContext.CustomQuery = customQuery; - return queryContext; - } - - queryContext.Relationship = GetRelationship(parameterName, query.Relationship); - var attribute = GetAttribute(parameterName, query.Attribute, queryContext.Relationship); - - if (queryContext.Relationship is HasManyAttribute) - { - throw new InvalidQueryStringParameterException(parameterName, - "Filtering on one-to-many and many-to-many relationships is currently not supported.", - $"Filtering on the relationship '{queryContext.Relationship.PublicName}.{attribute.PublicName}' is currently not supported."); - } - - if (!attribute.Capabilities.HasFlag(AttrCapabilities.AllowFilter)) - { - throw new InvalidQueryStringParameterException(parameterName, "Filtering on the requested attribute is not allowed.", - $"Filtering on attribute '{attribute.PublicName}' is not allowed."); - } - - queryContext.Attribute = attribute; - - return queryContext; - } - - private List GetFilterQueries(string parameterName, StringValues parameterValue) - { - // expected input = filter[id]=1 - // expected input = filter[id]=eq:1 - var propertyName = parameterName.Split(QueryConstants.OPEN_BRACKET, QueryConstants.CLOSE_BRACKET)[1]; - var queries = new List(); - // InArray case - string op = GetFilterOperation(parameterValue); - if (op == FilterOperation.@in.ToString() || op == FilterOperation.nin.ToString()) - { - var (_, filterValue) = ParseFilterOperation(parameterValue); - queries.Add(new FilterQuery(propertyName, filterValue, op)); - } - else - { - var values = ((string)parameterValue).Split(QueryConstants.COMMA); - foreach (var val in values) - { - var (operation, filterValue) = ParseFilterOperation(val); - queries.Add(new FilterQuery(propertyName, filterValue, operation)); - } - } - return queries; - } - - private (string operation, string value) ParseFilterOperation(string value) - { - if (value.Length < 3) - return (string.Empty, value); - - var operation = GetFilterOperation(value); - var values = value.Split(QueryConstants.COLON); - - if (string.IsNullOrEmpty(operation)) - return (string.Empty, value); - - value = string.Join(QueryConstants.COLON_STR, values.Skip(1)); - - return (operation, value); - } - - private string GetFilterOperation(string value) - { - var values = value.Split(QueryConstants.COLON); - - if (values.Length == 1) - return string.Empty; - - var operation = values[0]; - // remove prefix from value - if (Enum.TryParse(operation, out FilterOperation _) == false) - return string.Empty; - - return operation; - } - } -} diff --git a/src/JsonApiDotNetCore/QueryParameterServices/IncludeService.cs b/src/JsonApiDotNetCore/QueryParameterServices/IncludeService.cs deleted file mode 100644 index ac1c91ac61..0000000000 --- a/src/JsonApiDotNetCore/QueryParameterServices/IncludeService.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Exceptions; -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Internal.Query; -using JsonApiDotNetCore.Managers.Contracts; -using JsonApiDotNetCore.Models.Annotation; -using Microsoft.Extensions.Primitives; - -namespace JsonApiDotNetCore.Query -{ - public class IncludeService : QueryParameterService, IIncludeService - { - private readonly List> _includedChains; - - public IncludeService(IResourceGraph resourceGraph, ICurrentRequest currentRequest) : base(resourceGraph, currentRequest) - { - _includedChains = new List>(); - } - - /// - public List> Get() - { - return _includedChains.Select(chain => chain.ToList()).ToList(); - } - - /// - public bool IsEnabled(DisableQueryAttribute disableQueryAttribute) - { - return !disableQueryAttribute.ContainsParameter(StandardQueryStringParameters.Include); - } - - /// - public bool CanParse(string parameterName) - { - return parameterName == "include"; - } - - /// - public virtual void Parse(string parameterName, StringValues parameterValue) - { - var value = (string)parameterValue; - var chains = value.Split(QueryConstants.COMMA).ToList(); - foreach (var chain in chains) - ParseChain(chain, parameterName); - } - - private void ParseChain(string chain, string parameterName) - { - var parsedChain = new List(); - var chainParts = chain.Split(QueryConstants.DOT); - var resourceContext = _requestResource; - foreach (var relationshipName in chainParts) - { - var relationship = resourceContext.Relationships.SingleOrDefault(r => r.PublicName == relationshipName); - if (relationship == null) - { - throw new InvalidQueryStringParameterException(parameterName, "The requested relationship to include does not exist.", - $"The relationship '{relationshipName}' on '{resourceContext.ResourceName}' does not exist."); - } - - if (!relationship.CanInclude) - { - throw new InvalidQueryStringParameterException(parameterName, "Including the requested relationship is not allowed.", - $"Including the relationship '{relationshipName}' on '{resourceContext.ResourceName}' is not allowed."); - } - - parsedChain.Add(relationship); - resourceContext = _resourceGraph.GetResourceContext(relationship.RightType); - } - _includedChains.Add(parsedChain); - } - } -} diff --git a/src/JsonApiDotNetCore/QueryParameterServices/PageService.cs b/src/JsonApiDotNetCore/QueryParameterServices/PageService.cs deleted file mode 100644 index d1d51f0c7b..0000000000 --- a/src/JsonApiDotNetCore/QueryParameterServices/PageService.cs +++ /dev/null @@ -1,138 +0,0 @@ -using System; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Exceptions; -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Internal.Query; -using JsonApiDotNetCore.Managers.Contracts; -using Microsoft.Extensions.Primitives; - -namespace JsonApiDotNetCore.Query -{ - /// - public class PageService : QueryParameterService, IPageService - { - private readonly IJsonApiOptions _options; - public PageService(IJsonApiOptions options, IResourceGraph resourceGraph, ICurrentRequest currentRequest) : base(resourceGraph, currentRequest) - { - _options = options; - DefaultPageSize = _options.DefaultPageSize; - } - - /// - /// constructor used for unit testing - /// - internal PageService(IJsonApiOptions options) - { - _options = options; - DefaultPageSize = _options.DefaultPageSize; - } - - /// - public int PageSize - { - get - { - if (RequestedPageSize.HasValue) - { - return RequestedPageSize.Value; - } - return DefaultPageSize; - } - } - - /// - public int DefaultPageSize { get; set; } - - /// - public int? RequestedPageSize { get; set; } - - /// - public int CurrentPage { get; set; } = 1; - - /// - public bool Backwards { get; set; } - - /// - public int TotalPages => (TotalRecords == null || PageSize == 0) ? -1 : (int)Math.Ceiling(decimal.Divide(TotalRecords.Value, PageSize)); - - /// - public bool CanPaginate => TotalPages > 1; - - /// - public int? TotalRecords { get; set; } - - /// - public bool IsEnabled(DisableQueryAttribute disableQueryAttribute) - { - return !disableQueryAttribute.ContainsParameter(StandardQueryStringParameters.Page); - } - - /// - public bool CanParse(string parameterName) - { - return parameterName == "page[size]" || parameterName == "page[number]"; - } - - /// - public virtual void Parse(string parameterName, StringValues parameterValue) - { - EnsureNoNestedResourceRoute(parameterName); - // expected input = page[size]= - // page[number]= - var propertyName = parameterName.Split(QueryConstants.OPEN_BRACKET, QueryConstants.CLOSE_BRACKET)[1]; - - if (propertyName == "size") - { - RequestedPageSize = ParsePageSize(parameterValue, _options.MaximumPageSize); - } - else if (propertyName == "number") - { - var number = ParsePageNumber(parameterValue, _options.MaximumPageNumber); - - Backwards = number < 0; - CurrentPage = Backwards ? -number : number; - } - } - - private int ParsePageSize(string parameterValue, int? maxValue) - { - bool success = int.TryParse(parameterValue, out int number); - int minValue = maxValue != null ? 1 : 0; - - if (success && number >= minValue) - { - if (maxValue == null || number <= maxValue) - { - return number; - } - } - - var message = maxValue == null - ? $"Value '{parameterValue}' is invalid, because it must be a whole number that is greater than zero." - : $"Value '{parameterValue}' is invalid, because it must be a whole number that is zero or greater and not higher than {maxValue}."; - - throw new InvalidQueryStringParameterException("page[size]", - "The specified value is not in the range of valid values.", message); - } - - private int ParsePageNumber(string parameterValue, int? maxValue) - { - bool success = int.TryParse(parameterValue, out int number); - if (success && number != 0) - { - if (maxValue == null || (number >= 0 ? number <= maxValue : number >= -maxValue)) - { - return number; - } - } - - var message = maxValue == null - ? $"Value '{parameterValue}' is invalid, because it must be a whole number that is non-zero." - : $"Value '{parameterValue}' is invalid, because it must be a whole number that is non-zero and not higher than {maxValue} or lower than -{maxValue}."; - - throw new InvalidQueryStringParameterException("page[number]", - "The specified value is not in the range of valid values.", message); - } - } -} diff --git a/src/JsonApiDotNetCore/QueryParameterServices/SortService.cs b/src/JsonApiDotNetCore/QueryParameterServices/SortService.cs deleted file mode 100644 index 830c87a360..0000000000 --- a/src/JsonApiDotNetCore/QueryParameterServices/SortService.cs +++ /dev/null @@ -1,115 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Exceptions; -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Internal.Query; -using JsonApiDotNetCore.Managers.Contracts; -using JsonApiDotNetCore.Models; -using Microsoft.Extensions.Primitives; - -namespace JsonApiDotNetCore.Query -{ - /// - public class SortService : QueryParameterService, ISortService - { - private const char DESCENDING_SORT_OPERATOR = '-'; - private readonly IResourceDefinitionProvider _resourceDefinitionProvider; - private List _queries; - - public SortService(IResourceDefinitionProvider resourceDefinitionProvider, - IResourceGraph resourceGraph, - ICurrentRequest currentRequest) - : base(resourceGraph, currentRequest) - { - _resourceDefinitionProvider = resourceDefinitionProvider; - _queries = new List(); - } - - /// - public List Get() - { - if (_queries.Any()) - { - return _queries.ToList(); - } - - var requestResourceDefinition = _resourceDefinitionProvider.Get(_requestResource.ResourceType); - var defaultSort = requestResourceDefinition?.DefaultSort(); - if (defaultSort != null) - { - return defaultSort - .Select(d => BuildQueryContext(new SortQuery(d.Attribute.PublicName, d.SortDirection))) - .ToList(); - } - - return new List(); - } - - /// - public bool IsEnabled(DisableQueryAttribute disableQueryAttribute) - { - return !disableQueryAttribute.ContainsParameter(StandardQueryStringParameters.Sort); - } - - /// - public bool CanParse(string parameterName) - { - return parameterName == "sort"; - } - - /// - public virtual void Parse(string parameterName, StringValues parameterValue) - { - EnsureNoNestedResourceRoute(parameterName); - var queries = BuildQueries(parameterValue, parameterName); - - _queries = queries.Select(BuildQueryContext).ToList(); - } - - private List BuildQueries(string value, string parameterName) - { - var sortParameters = new List(); - - var sortSegments = value.Split(QueryConstants.COMMA); - if (sortSegments.Any(s => s == string.Empty)) - { - throw new InvalidQueryStringParameterException(parameterName, "The list of fields to sort on contains empty elements.", null); - } - - foreach (var sortSegment in sortSegments) - { - var propertyName = sortSegment; - var direction = SortDirection.Ascending; - - if (sortSegment[0] == DESCENDING_SORT_OPERATOR) - { - direction = SortDirection.Descending; - propertyName = propertyName.Substring(1); - } - - sortParameters.Add(new SortQuery(propertyName, direction)); - } - - return sortParameters; - } - - private SortQueryContext BuildQueryContext(SortQuery query) - { - var relationship = GetRelationship("sort", query.Relationship); - var attribute = GetAttribute("sort", query.Attribute, relationship); - - if (!attribute.Capabilities.HasFlag(AttrCapabilities.AllowSort)) - { - throw new InvalidQueryStringParameterException("sort", "Sorting on the requested attribute is not allowed.", - $"Sorting on attribute '{attribute.PublicName}' is not allowed."); - } - - return new SortQueryContext(query) - { - Attribute = attribute, - Relationship = relationship - }; - } - } -} diff --git a/src/JsonApiDotNetCore/QueryParameterServices/SparseFieldsService.cs b/src/JsonApiDotNetCore/QueryParameterServices/SparseFieldsService.cs deleted file mode 100644 index c48c95a72f..0000000000 --- a/src/JsonApiDotNetCore/QueryParameterServices/SparseFieldsService.cs +++ /dev/null @@ -1,184 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Exceptions; -using JsonApiDotNetCore.Extensions; -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Internal.Query; -using JsonApiDotNetCore.Managers.Contracts; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Models.Annotation; -using Microsoft.Extensions.Primitives; - -namespace JsonApiDotNetCore.Query -{ - /// - public class SparseFieldsService : QueryParameterService, ISparseFieldsService - { - /// - /// The selected fields for the primary resource of this request. - /// - private readonly List _selectedFields = new List(); - - /// - /// The selected field for any included relationships - /// - private readonly Dictionary> _selectedRelationshipFields = new Dictionary>(); - - public SparseFieldsService(IResourceGraph resourceGraph, ICurrentRequest currentRequest) - : base(resourceGraph, currentRequest) - { - } - - /// - public List Get(RelationshipAttribute relationship = null) - { - if (relationship == null) - return _selectedFields; - - return _selectedRelationshipFields.TryGetValue(relationship, out var fields) - ? fields - : new List(); - } - - public ISet GetAll() - { - var properties = new HashSet(); - properties.AddRange(_selectedFields.Select(x => x.Property.Name)); - - foreach (var pair in _selectedRelationshipFields) - { - string pathPrefix = pair.Key.RelationshipPath + "."; - properties.AddRange(pair.Value.Select(x => pathPrefix + x.Property.Name)); - } - - return properties; - } - - /// - public bool IsEnabled(DisableQueryAttribute disableQueryAttribute) - { - return !disableQueryAttribute.ContainsParameter(StandardQueryStringParameters.Fields); - } - - /// - public bool CanParse(string parameterName) - { - var isRelated = parameterName.StartsWith("fields[") && parameterName.EndsWith("]"); - return parameterName == "fields" || isRelated; - } - - /// - public virtual void Parse(string parameterName, StringValues parameterValue) - { - // expected: articles?fields=prop1,prop2 - // articles?fields[articles]=prop1,prop2 <-- this form in invalid UNLESS "articles" is actually a relationship on Article - // articles?fields[relationship]=prop1,prop2 - EnsureNoNestedResourceRoute(parameterName); - - HashSet fields = new HashSet(); - fields.Add(nameof(Identifiable.Id).ToLowerInvariant()); - fields.AddRange(((string) parameterValue).Split(QueryConstants.COMMA)); - - var keySplit = parameterName.Split(QueryConstants.OPEN_BRACKET, QueryConstants.CLOSE_BRACKET); - - if (keySplit.Length == 1) - { - // input format: fields=prop1,prop2 - RegisterRequestResourceFields(fields, parameterName); - } - else - { // input format: fields[articles]=prop1,prop2 - string navigation = keySplit[1]; - // it is possible that the request resource has a relationship - // that is equal to the resource name, like with self-referencing data types (eg directory structures) - // if not, no longer support this type of sparse field selection. - if (navigation == _requestResource.ResourceName && !_requestResource.Relationships.Any(a => navigation == a.PublicName)) - { - throw new InvalidQueryStringParameterException(parameterName, - "Square bracket notation in 'filter' is now reserved for relationships only. See https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/555#issuecomment-543100865 for details.", - $"Use '?fields=...' instead of '?fields[{navigation}]=...'."); - } - - if (navigation.Contains(QueryConstants.DOT)) - { - throw new InvalidQueryStringParameterException(parameterName, - "Deeply nested sparse field selection is currently not supported.", - $"Parameter fields[{navigation}] is currently not supported."); - } - - var relationship = _requestResource.Relationships.SingleOrDefault(a => navigation == a.PublicName); - if (relationship == null) - { - throw new InvalidQueryStringParameterException(parameterName, "Sparse field navigation path refers to an invalid relationship.", - $"'{navigation}' in 'fields[{navigation}]' is not a valid relationship of {_requestResource.ResourceName}."); - } - - RegisterRelatedResourceFields(fields, relationship, parameterName); - } - } - - /// - /// Registers field selection of the form articles?fields[author]=firstName,lastName - /// - private void RegisterRelatedResourceFields(IEnumerable fields, RelationshipAttribute relationship, string parameterName) - { - var selectedFields = new List(); - - foreach (var field in fields) - { - var relationProperty = _resourceGraph.GetResourceContext(relationship.RightType); - var attr = relationProperty.Attributes.SingleOrDefault(a => field == a.PublicName); - if (attr == null) - { - throw new InvalidQueryStringParameterException(parameterName, - "The specified field does not exist on the requested related resource.", - $"The field '{field}' does not exist on related resource '{relationship.PublicName}' of type '{relationProperty.ResourceName}'."); - } - - if (attr.Property.SetMethod == null) - { - // A read-only property was selected. Its value likely depends on another property, so include all related fields. - return; - } - - selectedFields.Add(attr); - } - - if (!_selectedRelationshipFields.TryGetValue(relationship, out var registeredFields)) - { - _selectedRelationshipFields.Add(relationship, registeredFields = new List()); - } - registeredFields.AddRange(selectedFields); - } - - /// - /// Registers field selection of the form articles?fields=title,description - /// - private void RegisterRequestResourceFields(IEnumerable fields, string parameterName) - { - var selectedFields = new List(); - - foreach (var field in fields) - { - var attr = _requestResource.Attributes.SingleOrDefault(a => field == a.PublicName); - if (attr == null) - { - throw new InvalidQueryStringParameterException(parameterName, - "The specified field does not exist on the requested resource.", - $"The field '{field}' does not exist on resource '{_requestResource.ResourceName}'."); - } - - if (attr.Property.SetMethod == null) - { - // A read-only property was selected. Its value likely depends on another property, so include all resource fields. - return; - } - - selectedFields.Add(attr); - } - - _selectedFields.AddRange(selectedFields); - } - } -} diff --git a/src/JsonApiDotNetCore/RequestServices/Contracts/EndpointKind.cs b/src/JsonApiDotNetCore/RequestServices/Contracts/EndpointKind.cs new file mode 100644 index 0000000000..e116ab368e --- /dev/null +++ b/src/JsonApiDotNetCore/RequestServices/Contracts/EndpointKind.cs @@ -0,0 +1,20 @@ +namespace JsonApiDotNetCore.RequestServices.Contracts +{ + public enum EndpointKind + { + /// + /// A top-level resource request, for example: "/blogs" or "/blogs/123" + /// + Primary, + + /// + /// A nested resource request, for example: "/blogs/123/author" or "/author/123/articles" + /// + Secondary, + + /// + /// A relationship request, for example: "/blogs/123/relationships/author" or "/author/123/relationships/articles" + /// + Relationship + } +} diff --git a/src/JsonApiDotNetCore/RequestServices/Contracts/ICurrentRequest.cs b/src/JsonApiDotNetCore/RequestServices/Contracts/ICurrentRequest.cs index a6096b74e3..bd066f5ab2 100644 --- a/src/JsonApiDotNetCore/RequestServices/Contracts/ICurrentRequest.cs +++ b/src/JsonApiDotNetCore/RequestServices/Contracts/ICurrentRequest.cs @@ -1,7 +1,8 @@ +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models.Annotation; -namespace JsonApiDotNetCore.Managers.Contracts +namespace JsonApiDotNetCore.RequestServices.Contracts { /// /// Metadata associated to the current json:api request. @@ -9,35 +10,53 @@ namespace JsonApiDotNetCore.Managers.Contracts public interface ICurrentRequest { /// - /// The request namespace. This may be an absolute or relative path - /// depending upon the configuration. + /// Routing information, based on the path of the request URL. + /// + public EndpointKind Kind { get; } + + /// + /// The request URL prefix. This may be an absolute or relative path, depending on . /// /// /// Absolute: https://example.com/api/v1 - /// /// Relative: /api/v1 /// - string BasePath { get; set; } + string BasePath { get; } + + /// + /// The ID of the primary (top-level) resource for this request. + /// This would be null in "/blogs", "123" in "/blogs/123" or "/blogs/123/author". + /// + string PrimaryId { get; } /// - /// If the request is on the `{id}/relationships/{relationshipName}` route + /// The primary (top-level) resource for this request. + /// This would be "blogs" in "/blogs", "/blogs/123" or "/blogs/123/author". /// - bool IsRelationshipPath { get; set; } + ResourceContext PrimaryResource { get; } /// - /// If is true, this property - /// is the relationship attribute associated with the targeted relationship + /// The secondary (nested) resource for this request. + /// This would be null in "/blogs", "/blogs/123" and "/blogs/123/unknownResource" or + /// "people" in "/blogs/123/author" and "/blogs/123/relationships/author". /// - RelationshipAttribute RequestRelationship { get; set; } - string BaseId { get; set; } - string RelationshipId { get; set; } + ResourceContext SecondaryResource { get; } /// - /// Sets the current context entity for this entire request + /// The relationship for this nested request. + /// This would be null in "/blogs", "/blogs/123" and "/blogs/123/unknownResource" or + /// "author" in "/blogs/123/author" and "/blogs/123/relationships/author". /// - /// - void SetRequestResource(ResourceContext currentResourceContext); + RelationshipAttribute Relationship { get; } - ResourceContext GetRequestResource(); + /// + /// Indicates whether this request targets a single resource or a collection of resources. + /// + bool IsCollection { get; } + + /// + /// Indicates whether this request targets only fetching of data (such as resources and relationships). + /// + bool IsReadOnly { get; } } } diff --git a/src/JsonApiDotNetCore/RequestServices/CurrentRequest.cs b/src/JsonApiDotNetCore/RequestServices/CurrentRequest.cs index ac88dcd6e5..9c35d485b9 100644 --- a/src/JsonApiDotNetCore/RequestServices/CurrentRequest.cs +++ b/src/JsonApiDotNetCore/RequestServices/CurrentRequest.cs @@ -1,30 +1,18 @@ using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Managers.Contracts; using JsonApiDotNetCore.Models.Annotation; +using JsonApiDotNetCore.RequestServices.Contracts; -namespace JsonApiDotNetCore.Managers +namespace JsonApiDotNetCore.RequestServices { - internal sealed class CurrentRequest : ICurrentRequest + public sealed class CurrentRequest : ICurrentRequest { - private ResourceContext _resourceContext; + public EndpointKind Kind { get; set; } public string BasePath { get; set; } - public bool IsRelationshipPath { get; set; } - public RelationshipAttribute RequestRelationship { get; set; } - public string BaseId { get; set; } - public string RelationshipId { get; set; } - - /// - /// The main resource of the request. - /// - /// - public ResourceContext GetRequestResource() - { - return _resourceContext; - } - - public void SetRequestResource(ResourceContext primaryResource) - { - _resourceContext = primaryResource; - } + public string PrimaryId { get; set; } + public ResourceContext PrimaryResource { get; set; } + public ResourceContext SecondaryResource { get; set; } + public RelationshipAttribute Relationship { get; set; } + public bool IsCollection { get; set; } + public bool IsReadOnly { get; set; } } } diff --git a/src/JsonApiDotNetCore/RequestServices/DefaultResourceChangeTracker.cs b/src/JsonApiDotNetCore/RequestServices/ResourceChangeTracker.cs similarity index 78% rename from src/JsonApiDotNetCore/RequestServices/DefaultResourceChangeTracker.cs rename to src/JsonApiDotNetCore/RequestServices/ResourceChangeTracker.cs index 6f35745276..5e5199d95e 100644 --- a/src/JsonApiDotNetCore/RequestServices/DefaultResourceChangeTracker.cs +++ b/src/JsonApiDotNetCore/RequestServices/ResourceChangeTracker.cs @@ -14,22 +14,22 @@ namespace JsonApiDotNetCore.RequestServices public interface IResourceChangeTracker where TResource : class, IIdentifiable { /// - /// Sets the exposed entity attributes as stored in database, before applying changes. + /// Sets the exposed resource attributes as stored in database, before applying changes. /// - void SetInitiallyStoredAttributeValues(TResource entity); + void SetInitiallyStoredAttributeValues(TResource resource); /// /// Sets the subset of exposed attributes from the PATCH request. /// - void SetRequestedAttributeValues(TResource entity); + void SetRequestedAttributeValues(TResource resource); /// - /// Sets the exposed entity attributes as stored in database, after applying changes. + /// Sets the exposed resource attributes as stored in database, after applying changes. /// - void SetFinallyStoredAttributeValues(TResource entity); + void SetFinallyStoredAttributeValues(TResource resource); /// - /// Validates if any exposed entity attributes that were not in the PATCH request have been changed. + /// 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. /// /// @@ -38,7 +38,7 @@ public interface IResourceChangeTracker where TResource : class, I bool HasImplicitChanges(); } - public sealed class DefaultResourceChangeTracker : IResourceChangeTracker where TResource : class, IIdentifiable + public sealed class ResourceChangeTracker : IResourceChangeTracker where TResource : class, IIdentifiable { private readonly IJsonApiOptions _options; private readonly IResourceContextProvider _contextProvider; @@ -48,7 +48,7 @@ public sealed class DefaultResourceChangeTracker : IResourceChangeTra private IDictionary _requestedAttributeValues; private IDictionary _finallyStoredAttributeValues; - public DefaultResourceChangeTracker(IJsonApiOptions options, IResourceContextProvider contextProvider, + public ResourceChangeTracker(IJsonApiOptions options, IResourceContextProvider contextProvider, ITargetedFields targetedFields) { _options = options; @@ -56,21 +56,21 @@ public DefaultResourceChangeTracker(IJsonApiOptions options, IResourceContextPro _targetedFields = targetedFields; } - public void SetInitiallyStoredAttributeValues(TResource entity) + public void SetInitiallyStoredAttributeValues(TResource resource) { var resourceContext = _contextProvider.GetResourceContext(); - _initiallyStoredAttributeValues = CreateAttributeDictionary(entity, resourceContext.Attributes); + _initiallyStoredAttributeValues = CreateAttributeDictionary(resource, resourceContext.Attributes); } - public void SetRequestedAttributeValues(TResource entity) + public void SetRequestedAttributeValues(TResource resource) { - _requestedAttributeValues = CreateAttributeDictionary(entity, _targetedFields.Attributes); + _requestedAttributeValues = CreateAttributeDictionary(resource, _targetedFields.Attributes); } - public void SetFinallyStoredAttributeValues(TResource entity) + public void SetFinallyStoredAttributeValues(TResource resource) { var resourceContext = _contextProvider.GetResourceContext(); - _finallyStoredAttributeValues = CreateAttributeDictionary(entity, resourceContext.Attributes); + _finallyStoredAttributeValues = CreateAttributeDictionary(resource, resourceContext.Attributes); } private IDictionary CreateAttributeDictionary(TResource resource, diff --git a/src/JsonApiDotNetCore/Serialization/Client/DeserializedResponse.cs b/src/JsonApiDotNetCore/Serialization/Client/DeserializedResponse.cs index 6bae9a9370..be872d5955 100644 --- a/src/JsonApiDotNetCore/Serialization/Client/DeserializedResponse.cs +++ b/src/JsonApiDotNetCore/Serialization/Client/DeserializedResponse.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Models.Links; +using JsonApiDotNetCore.Models.JsonApiDocuments; namespace JsonApiDotNetCore.Serialization.Client { diff --git a/src/JsonApiDotNetCore/Serialization/Client/IRequestSerializer.cs b/src/JsonApiDotNetCore/Serialization/Client/IRequestSerializer.cs index 69efe7da01..fa53d1bf5a 100644 --- a/src/JsonApiDotNetCore/Serialization/Client/IRequestSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/Client/IRequestSerializer.cs @@ -12,27 +12,28 @@ namespace JsonApiDotNetCore.Serialization.Client public interface IRequestSerializer { /// - /// Creates and serializes a document for a single instance of a resource. + /// Creates and serializes a document for a single resource. /// - /// Entity to serialize /// The serialized content - string Serialize(IIdentifiable entity); + string Serialize(IIdentifiable resource); + /// - /// Creates and serializes a document for for a list of entities of one resource. + /// Creates and serializes a document for a list of resources. /// - /// Entities to serialize /// The serialized content - string Serialize(IEnumerable entities); + string Serialize(IEnumerable resources); + /// /// Sets the attributes that will be included in the serialized payload. /// You can use - /// to conveniently access the desired instances + /// to conveniently access the desired instances. /// public IEnumerable AttributesToSerialize { set; } + /// /// Sets the relationships that will be included in the serialized payload. /// You can use - /// to conveniently access the desired instances + /// to conveniently access the desired instances. /// public IEnumerable RelationshipsToSerialize { set; } } diff --git a/src/JsonApiDotNetCore/Serialization/Client/RequestSerializer.cs b/src/JsonApiDotNetCore/Serialization/Client/RequestSerializer.cs index 2cc39aa6a4..23d981d43c 100644 --- a/src/JsonApiDotNetCore/Serialization/Client/RequestSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/Client/RequestSerializer.cs @@ -25,41 +25,41 @@ public RequestSerializer(IResourceGraph resourceGraph, } /// - public string Serialize(IIdentifiable entity) + public string Serialize(IIdentifiable resource) { - if (entity == null) + if (resource == null) { var empty = Build((IIdentifiable) null, Array.Empty(), Array.Empty()); return SerializeObject(empty, _jsonSerializerSettings); } - _currentTargetedResource = entity.GetType(); - var document = Build(entity, GetAttributesToSerialize(entity), GetRelationshipsToSerialize(entity)); + _currentTargetedResource = resource.GetType(); + var document = Build(resource, GetAttributesToSerialize(resource), GetRelationshipsToSerialize(resource)); _currentTargetedResource = null; return SerializeObject(document, _jsonSerializerSettings); } /// - public string Serialize(IEnumerable entities) + public string Serialize(IEnumerable resources) { - IIdentifiable entity = null; - foreach (IIdentifiable item in entities) + IIdentifiable resource = null; + foreach (IIdentifiable item in resources) { - entity = item; + resource = item; break; } - if (entity == null) + if (resource == null) { - var result = Build(entities, Array.Empty(), Array.Empty()); + var result = Build(resources, Array.Empty(), Array.Empty()); return SerializeObject(result, _jsonSerializerSettings); } - _currentTargetedResource = entity.GetType(); - var attributes = GetAttributesToSerialize(entity); - var relationships = GetRelationshipsToSerialize(entity); - var document = Build(entities, attributes, relationships); + _currentTargetedResource = resource.GetType(); + var attributes = GetAttributesToSerialize(resource); + var relationships = GetRelationshipsToSerialize(resource); + var document = Build(resources, attributes, relationships); _currentTargetedResource = null; return SerializeObject(document, _jsonSerializerSettings); } @@ -75,9 +75,9 @@ public string Serialize(IEnumerable entities) /// unless a list of allowed attributes was supplied using the /// method. For any related resources, attributes are never exposed. /// - private List GetAttributesToSerialize(IIdentifiable entity) + private List GetAttributesToSerialize(IIdentifiable resource) { - var currentResourceType = entity.GetType(); + 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. @@ -91,15 +91,15 @@ private List GetAttributesToSerialize(IIdentifiable entity) /// /// By default, the client serializer does not include any relationships - /// for entities in the primary data unless explicitly included using + /// for resources in the primary data unless explicitly included using /// . /// - private List GetRelationshipsToSerialize(IIdentifiable entity) + private List GetRelationshipsToSerialize(IIdentifiable resource) { - var currentResourceType = entity.GetType(); + var currentResourceType = resource.GetType(); // only allow relationship attributes to be serialized if they were set using // - // and the current is a main entry in the primary data. + // and the current is a primary entry. if (RelationshipsToSerialize == null) return _resourceGraph.GetRelationships(currentResourceType); diff --git a/src/JsonApiDotNetCore/Serialization/Client/ResponseDeserializer.cs b/src/JsonApiDotNetCore/Serialization/Client/ResponseDeserializer.cs index bd01ddb0d4..edf42d7ce4 100644 --- a/src/JsonApiDotNetCore/Serialization/Client/ResponseDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/Client/ResponseDeserializer.cs @@ -19,12 +19,12 @@ public ResponseDeserializer(IResourceContextProvider contextProvider, IResourceF /// public DeserializedSingleResponse DeserializeSingle(string body) where TResource : class, IIdentifiable { - var entity = Deserialize(body); + var resource = Deserialize(body); return new DeserializedSingleResponse { Links = _document.Links, Meta = _document.Meta, - Data = (TResource) entity, + Data = (TResource) resource, JsonApi = null, Errors = null }; @@ -33,12 +33,12 @@ public DeserializedSingleResponse DeserializeSingle(string /// public DeserializedListResponse DeserializeList(string body) where TResource : class, IIdentifiable { - var entities = Deserialize(body); + var resources = Deserialize(body); return new DeserializedListResponse { Links = _document.Links, Meta = _document.Meta, - Data = ((ICollection) entities)?.Cast().ToList(), + Data = ((ICollection) resources)?.Cast().ToList(), JsonApi = null, Errors = null }; @@ -49,10 +49,10 @@ public DeserializedListResponse DeserializeList(string bod /// for parsing the property. When a relationship value is parsed, /// it goes through the included list to set its attributes and relationships. /// - /// The entity that was constructed from the document's body + /// The resource that was constructed from the document's body /// The metadata for the exposed field - /// Relationship data for . Is null when is not a - protected override void AfterProcessField(IIdentifiable entity, ResourceFieldAttribute field, RelationshipEntry data = null) + /// Relationship data for . Is null when is not a + protected override void AfterProcessField(IIdentifiable resource, ResourceFieldAttribute field, RelationshipEntry data = null) { // Client deserializers do not need additional processing for attributes. if (field is AttrAttribute) @@ -66,13 +66,13 @@ protected override void AfterProcessField(IIdentifiable entity, ResourceFieldAtt { // add attributes and relationships of a parsed HasOne relationship var rio = data.SingleData; - hasOneAttr.SetValue(entity, rio == null ? null : ParseIncludedRelationship(hasOneAttr, rio), _resourceFactory); + hasOneAttr.SetValue(resource, rio == null ? null : ParseIncludedRelationship(hasOneAttr, rio), _resourceFactory); } else if (field is HasManyAttribute hasManyAttr) { // add attributes and relationships of a parsed HasMany relationship var items = data.ManyData.Select(rio => ParseIncludedRelationship(hasManyAttr, rio)); var values = items.CopyToTypedCollection(hasManyAttr.Property.PropertyType); - hasManyAttr.SetValue(entity, values, _resourceFactory); + hasManyAttr.SetValue(resource, values, _resourceFactory); } } diff --git a/src/JsonApiDotNetCore/Serialization/Common/BaseDocumentParser.cs b/src/JsonApiDotNetCore/Serialization/Common/BaseDocumentParser.cs index b8f8282449..6e705a8c41 100644 --- a/src/JsonApiDotNetCore/Serialization/Common/BaseDocumentParser.cs +++ b/src/JsonApiDotNetCore/Serialization/Common/BaseDocumentParser.cs @@ -33,7 +33,7 @@ protected BaseDocumentParser(IResourceContextProvider contextProvider, IResource } /// - /// This method is called each time an is constructed + /// This method is called each time an is constructed /// from the serialized content, which is used to do additional processing /// depending on the type of deserializers. /// @@ -41,10 +41,10 @@ protected BaseDocumentParser(IResourceContextProvider contextProvider, IResource /// See the implementation of this method in /// and for examples. /// - /// The entity that was constructed from the document's body + /// The resource that was constructed from the document's body /// The metadata for the exposed field - /// Relationship data for . Is null when is not a - protected abstract void AfterProcessField(IIdentifiable entity, ResourceFieldAttribute field, RelationshipEntry data = null); + /// Relationship data for . Is null when is not a + protected abstract void AfterProcessField(IIdentifiable resource, ResourceFieldAttribute field, RelationshipEntry data = null); /// protected object Deserialize(string body) @@ -64,54 +64,54 @@ protected object Deserialize(string body) } /// - /// Sets the attributes on a parsed entity. + /// Sets the attributes on a parsed resource. /// - /// The parsed entity + /// The parsed resource /// Attributes and their values, as in the serialized content - /// Exposed attributes for + /// Exposed attributes for /// - protected virtual IIdentifiable SetAttributes(IIdentifiable entity, Dictionary attributeValues, List attributes) + protected virtual IIdentifiable SetAttributes(IIdentifiable resource, Dictionary attributeValues, List attributes) { if (attributeValues == null || attributeValues.Count == 0) - return entity; + return resource; foreach (var attr in attributes) { if (attributeValues.TryGetValue(attr.PublicName, out object newValue)) { var convertedValue = ConvertAttrValue(newValue, attr.Property.PropertyType); - attr.SetValue(entity, convertedValue); - AfterProcessField(entity, attr); + attr.SetValue(resource, convertedValue); + AfterProcessField(resource, attr); } } - return entity; + return resource; } /// - /// Sets the relationships on a parsed entity + /// Sets the relationships on a parsed resource /// - /// The parsed entity + /// The parsed resource /// Relationships and their values, as in the serialized content - /// Exposed relationships for + /// Exposed relationships for /// - protected virtual IIdentifiable SetRelationships(IIdentifiable entity, Dictionary relationshipsValues, List relationshipAttributes) + protected virtual IIdentifiable SetRelationships(IIdentifiable resource, Dictionary relationshipsValues, List relationshipAttributes) { if (relationshipsValues == null || relationshipsValues.Count == 0) - return entity; + return resource; - var entityProperties = entity.GetType().GetProperties(); + var resourceProperties = resource.GetType().GetProperties(); foreach (var attr in relationshipAttributes) { if (!relationshipsValues.TryGetValue(attr.PublicName, out RelationshipEntry relationshipData) || !relationshipData.IsPopulated) continue; if (attr is HasOneAttribute hasOneAttribute) - SetHasOneRelationship(entity, entityProperties, hasOneAttribute, relationshipData); + SetHasOneRelationship(resource, resourceProperties, hasOneAttribute, relationshipData); else - SetHasManyRelationship(entity, (HasManyAttribute)attr, relationshipData); + SetHasManyRelationship(resource, (HasManyAttribute)attr, relationshipData); } - return entity; + return resource; } private JToken LoadJToken(string body) @@ -131,7 +131,7 @@ private JToken LoadJToken(string body) /// and sets its attributes and relationships /// /// - /// The parsed entity + /// The parsed resource private IIdentifiable ParseResourceObject(ResourceObject data) { var resourceContext = _contextProvider.GetResourceContext(data.Type); @@ -139,31 +139,31 @@ private IIdentifiable ParseResourceObject(ResourceObject data) { 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, make sure the DbSet matches the expected resource name. " + + "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 AddResource correctly sets the public name.", null); } - var entity = (IIdentifiable)_resourceFactory.CreateInstance(resourceContext.ResourceType); + var resource = (IIdentifiable)_resourceFactory.CreateInstance(resourceContext.ResourceType); - entity = SetAttributes(entity, data.Attributes, resourceContext.Attributes); - entity = SetRelationships(entity, data.Relationships, resourceContext.Relationships); + resource = SetAttributes(resource, data.Attributes, resourceContext.Attributes); + resource = SetRelationships(resource, data.Relationships, resourceContext.Relationships); if (data.Id != null) - entity.StringId = data.Id; + resource.StringId = data.Id; - return entity; + return resource; } /// - /// Sets a HasOne relationship on a parsed entity. If present, also + /// Sets a HasOne relationship on a parsed resource. If present, also /// populates the foreign key. /// - /// - /// + /// + /// /// /// - private void SetHasOneRelationship(IIdentifiable entity, - PropertyInfo[] entityProperties, + private void SetHasOneRelationship(IIdentifiable resource, + PropertyInfo[] resourceProperties, HasOneAttribute attr, RelationshipEntry relationshipData) { @@ -171,25 +171,25 @@ private void SetHasOneRelationship(IIdentifiable entity, var relatedId = rio?.Id; // 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 = entityProperties.FirstOrDefault(p => p.Name == attr.IdentifiablePropertyName); + var foreignKeyProperty = resourceProperties.FirstOrDefault(p => p.Name == attr.IdentifiablePropertyName); if (foreignKeyProperty != null) - // there is a FK from the current entity pointing to the related object, + // 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(entity, foreignKeyProperty, attr, relatedId); + SetForeignKey(resource, foreignKeyProperty, attr, relatedId); - SetNavigation(entity, attr, relatedId); + SetNavigation(resource, attr, relatedId); // depending on if this base parser is used client-side or server-side, // different additional processing per field needs to be executed. - AfterProcessField(entity, attr, relationshipData); + AfterProcessField(resource, attr, relationshipData); } /// /// Sets the dependent side of a HasOne relationship, which means that a /// foreign key also will to be populated. /// - private void SetForeignKey(IIdentifiable entity, PropertyInfo foreignKey, HasOneAttribute attr, string id) + private void SetForeignKey(IIdentifiable resource, PropertyInfo foreignKey, HasOneAttribute attr, string id) { bool foreignKeyPropertyIsNullableType = Nullable.GetUnderlyingType(foreignKey.PropertyType) != null || foreignKey.PropertyType == typeof(string); @@ -201,24 +201,24 @@ private void SetForeignKey(IIdentifiable entity, PropertyInfo foreignKey, HasOne } var typedId = TypeHelper.ConvertStringIdToTypedId(attr.Property.PropertyType, id, _resourceFactory); - foreignKey.SetValue(entity, typedId); + foreignKey.SetValue(resource, typedId); } /// /// Sets the principal side of a HasOne relationship, which means no /// foreign key is involved /// - private void SetNavigation(IIdentifiable entity, HasOneAttribute attr, string relatedId) + private void SetNavigation(IIdentifiable resource, HasOneAttribute attr, string relatedId) { if (relatedId == null) { - attr.SetValue(entity, null, _resourceFactory); + attr.SetValue(resource, null, _resourceFactory); } else { var relatedInstance = (IIdentifiable)_resourceFactory.CreateInstance(attr.RightType); relatedInstance.StringId = relatedId; - attr.SetValue(entity, relatedInstance, _resourceFactory); + attr.SetValue(resource, relatedInstance, _resourceFactory); } } @@ -226,7 +226,7 @@ private void SetNavigation(IIdentifiable entity, HasOneAttribute attr, string re /// Sets a HasMany relationship. /// private void SetHasManyRelationship( - IIdentifiable entity, + IIdentifiable resource, HasManyAttribute attr, RelationshipEntry relationshipData) { @@ -240,10 +240,10 @@ private void SetHasManyRelationship( }); var convertedCollection = relatedResources.CopyToTypedCollection(attr.Property.PropertyType); - attr.SetValue(entity, convertedCollection, _resourceFactory); + attr.SetValue(resource, convertedCollection, _resourceFactory); } - AfterProcessField(entity, attr, relationshipData); + AfterProcessField(resource, attr, relationshipData); } private object ConvertAttrValue(object newValue, Type targetType) diff --git a/src/JsonApiDotNetCore/Serialization/Common/DocumentBuilder.cs b/src/JsonApiDotNetCore/Serialization/Common/DocumentBuilder.cs index 8f35e57f42..148753cd95 100644 --- a/src/JsonApiDotNetCore/Serialization/Common/DocumentBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Common/DocumentBuilder.cs @@ -9,7 +9,7 @@ namespace JsonApiDotNetCore.Serialization { /// /// Abstract base class for serialization. - /// Uses to convert entities in to s and wraps them in a . + /// Uses to convert resources in to s and wraps them in a . /// public abstract class BaseDocumentBuilder { @@ -21,34 +21,34 @@ protected BaseDocumentBuilder(IResourceObjectBuilder resourceObjectBuilder) } /// - /// Builds a for . + /// Builds a for . /// Adds the attributes and relationships that are enlisted in and /// - /// Entity to build a Resource Object for + /// Resource to build a Resource Object for /// Attributes to include in the building process /// Relationships to include in the building process /// The resource object that was built - protected Document Build(IIdentifiable entity, IReadOnlyCollection attributes, IReadOnlyCollection relationships) + protected Document Build(IIdentifiable resource, IReadOnlyCollection attributes, IReadOnlyCollection relationships) { - if (entity == null) + if (resource == null) return new Document(); - return new Document { Data = _resourceObjectBuilder.Build(entity, attributes, relationships) }; + return new Document { Data = _resourceObjectBuilder.Build(resource, attributes, relationships) }; } /// - /// Builds a for . + /// Builds a for . /// Adds the attributes and relationships that are enlisted in and /// - /// Entity to build a Resource Object for + /// Resource to build a Resource Object for /// Attributes to include in the building process /// Relationships to include in the building process /// The resource object that was built - protected Document Build(IEnumerable entities, IReadOnlyCollection attributes, IReadOnlyCollection relationships) + protected Document Build(IEnumerable resources, IReadOnlyCollection attributes, IReadOnlyCollection relationships) { var data = new List(); - foreach (IIdentifiable entity in entities) - data.Add(_resourceObjectBuilder.Build(entity, attributes, relationships)); + foreach (IIdentifiable resource in resources) + data.Add(_resourceObjectBuilder.Build(resource, attributes, relationships)); return new Document { Data = data }; } diff --git a/src/JsonApiDotNetCore/Serialization/Common/IResourceObjectBuilder.cs b/src/JsonApiDotNetCore/Serialization/Common/IResourceObjectBuilder.cs index 91699cac47..31aec7ba6c 100644 --- a/src/JsonApiDotNetCore/Serialization/Common/IResourceObjectBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Common/IResourceObjectBuilder.cs @@ -5,19 +5,19 @@ namespace JsonApiDotNetCore.Serialization { /// - /// Responsible for converting entities in to s + /// Responsible for converting resources into s /// given a list of attributes and relationships. /// public interface IResourceObjectBuilder { /// - /// Converts into a . + /// Converts into a . /// Adds the attributes and relationships that are enlisted in and /// - /// Entity to build a Resource Object for + /// Resource to build a Resource Object for /// Attributes to include in the building process /// Relationships to include in the building process /// The resource object that was built - ResourceObject Build(IIdentifiable entity, IEnumerable attributes, IEnumerable relationships); + ResourceObject Build(IIdentifiable resource, IEnumerable attributes, IEnumerable relationships); } } diff --git a/src/JsonApiDotNetCore/Serialization/Common/ResourceObjectBuilder.cs b/src/JsonApiDotNetCore/Serialization/Common/ResourceObjectBuilder.cs index f224380c72..0ecbccbd3f 100644 --- a/src/JsonApiDotNetCore/Serialization/Common/ResourceObjectBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Common/ResourceObjectBuilder.cs @@ -25,20 +25,20 @@ public ResourceObjectBuilder(IResourceContextProvider provider, ResourceObjectBu } /// - public ResourceObject Build(IIdentifiable entity, IEnumerable attributes = null, IEnumerable relationships = null) + public ResourceObject Build(IIdentifiable resource, IEnumerable attributes = null, IEnumerable relationships = null) { - var resourceContext = _provider.GetResourceContext(entity.GetType()); + var resourceContext = _provider.GetResourceContext(resource.GetType()); // populating the top-level "type" and "id" members. - var ro = new ResourceObject { Type = resourceContext.ResourceName, Id = entity.StringId == string.Empty ? null : entity.StringId }; + var ro = new ResourceObject { Type = resourceContext.ResourceName, Id = resource.StringId == string.Empty ? null : 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 != _identifiablePropertyName)).Any()) - ProcessAttributes(entity, attributes, ro); + ProcessAttributes(resource, attributes, ro); // populating the top-level "relationship" member of a resource object. if (relationships != null) - ProcessRelationships(entity, relationships, ro); + ProcessRelationships(resource, relationships, ro); return ro; } @@ -50,33 +50,33 @@ public ResourceObject Build(IIdentifiable entity, IEnumerable att /// Depending on the requirements of the implementation (server or client serializer), /// this may be overridden. /// - protected virtual RelationshipEntry GetRelationshipData(RelationshipAttribute relationship, IIdentifiable entity) + protected virtual RelationshipEntry GetRelationshipData(RelationshipAttribute relationship, IIdentifiable resource) { - return new RelationshipEntry { Data = GetRelatedResourceLinkage(relationship, entity) }; + return new RelationshipEntry { Data = GetRelatedResourceLinkage(relationship, resource) }; } /// /// Gets the value for the property. /// - protected object GetRelatedResourceLinkage(RelationshipAttribute relationship, IIdentifiable entity) + protected object GetRelatedResourceLinkage(RelationshipAttribute relationship, IIdentifiable resource) { if (relationship is HasOneAttribute hasOne) - return GetRelatedResourceLinkage(hasOne, entity); + return GetRelatedResourceLinkage(hasOne, resource); - return GetRelatedResourceLinkage((HasManyAttribute)relationship, entity); + return GetRelatedResourceLinkage((HasManyAttribute)relationship, resource); } /// /// Builds a for a HasOne relationship /// - private ResourceIdentifierObject GetRelatedResourceLinkage(HasOneAttribute relationship, IIdentifiable entity) + private ResourceIdentifierObject GetRelatedResourceLinkage(HasOneAttribute relationship, IIdentifiable resource) { - var relatedEntity = (IIdentifiable)relationship.GetValue(entity); - if (relatedEntity == null && IsRequiredToOneRelationship(relationship, entity)) + 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 (relatedEntity != null) - return GetResourceIdentifier(relatedEntity); + if (relatedResource != null) + return GetResourceIdentifier(relatedResource); return null; } @@ -84,36 +84,36 @@ private ResourceIdentifierObject GetRelatedResourceLinkage(HasOneAttribute relat /// /// Builds the s for a HasMany relationship /// - private List GetRelatedResourceLinkage(HasManyAttribute relationship, IIdentifiable entity) + private List GetRelatedResourceLinkage(HasManyAttribute relationship, IIdentifiable resource) { - var relatedEntities = (IEnumerable)relationship.GetValue(entity); + var relatedResources = (IEnumerable)relationship.GetValue(resource); var manyData = new List(); - if (relatedEntities != null) - foreach (IIdentifiable relatedEntity in relatedEntities) - manyData.Add(GetResourceIdentifier(relatedEntity)); + if (relatedResources != null) + foreach (IIdentifiable relatedResource in relatedResources) + manyData.Add(GetResourceIdentifier(relatedResource)); return manyData; } /// - /// Creates a from . + /// Creates a from . /// - private ResourceIdentifierObject GetResourceIdentifier(IIdentifiable entity) + private ResourceIdentifierObject GetResourceIdentifier(IIdentifiable resource) { - var resourceName = _provider.GetResourceContext(entity.GetType()).ResourceName; + var resourceName = _provider.GetResourceContext(resource.GetType()).ResourceName; return new ResourceIdentifierObject { Type = resourceName, - Id = entity.StringId + Id = resource.StringId }; } /// /// Checks if the to-one relationship is required by checking if the foreign key is nullable. /// - private bool IsRequiredToOneRelationship(HasOneAttribute attr, IIdentifiable entity) + private bool IsRequiredToOneRelationship(HasOneAttribute attr, IIdentifiable resource) { - var foreignKey = entity.GetType().GetProperty(attr.IdentifiablePropertyName); + var foreignKey = resource.GetType().GetProperty(attr.IdentifiablePropertyName); if (foreignKey != null && Nullable.GetUnderlyingType(foreignKey.PropertyType) == null) return true; @@ -121,27 +121,27 @@ private bool IsRequiredToOneRelationship(HasOneAttribute attr, IIdentifiable ent } /// - /// Puts the relationships of the entity into the resource object. + /// Puts the relationships of the resource into the resource object. /// - private void ProcessRelationships(IIdentifiable entity, IEnumerable relationships, ResourceObject ro) + private void ProcessRelationships(IIdentifiable resource, IEnumerable relationships, ResourceObject ro) { foreach (var rel in relationships) { - var relData = GetRelationshipData(rel, entity); + var relData = GetRelationshipData(rel, resource); if (relData != null) (ro.Relationships ??= new Dictionary()).Add(rel.PublicName, relData); } } /// - /// Puts the attributes of the entity into the resource object. + /// Puts the attributes of the resource into the resource object. /// - private void ProcessAttributes(IIdentifiable entity, IEnumerable attributes, ResourceObject ro) + private void ProcessAttributes(IIdentifiable resource, IEnumerable attributes, ResourceObject ro) { ro.Attributes = new Dictionary(); foreach (var attr in attributes) { - object value = attr.GetValue(entity); + object value = attr.GetValue(resource); if (_settings.SerializerNullValueHandling == NullValueHandling.Ignore && value == null) { diff --git a/src/JsonApiDotNetCore/Serialization/Server/Builders/IncludedResourceObjectBuilder.cs b/src/JsonApiDotNetCore/Serialization/Server/Builders/IncludedResourceObjectBuilder.cs index 7c1ed7d09c..5aafe8a85a 100644 --- a/src/JsonApiDotNetCore/Serialization/Server/Builders/IncludedResourceObjectBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Server/Builders/IncludedResourceObjectBuilder.cs @@ -48,14 +48,14 @@ public List Build() } /// - public void IncludeRelationshipChain(List inclusionChain, IIdentifiable rootEntity) + public void IncludeRelationshipChain(List inclusionChain, IIdentifiable rootResource) { - // We dont have to build a resource object for the root entity because + // We don't have to build a resource object for the root resource because // this one is already encoded in the documents primary data, so we process the chain - // starting from the first related entity. + // starting from the first related resource. var relationship = inclusionChain.First(); var chainRemainder = ShiftChain(inclusionChain); - var related = relationship.GetValue(rootEntity); + var related = relationship.GetValue(rootResource); ProcessChain(relationship, related, chainRemainder); } @@ -105,11 +105,11 @@ private List ShiftChain(List chain /// ProcessRelationships method. /// /// - /// + /// /// - protected override RelationshipEntry GetRelationshipData(RelationshipAttribute relationship, IIdentifiable entity) + protected override RelationshipEntry GetRelationshipData(RelationshipAttribute relationship, IIdentifiable resource) { - return new RelationshipEntry { Links = _linkBuilder.GetRelationshipLinks(relationship, entity) }; + return new RelationshipEntry { Links = _linkBuilder.GetRelationshipLinks(relationship, resource) }; } /// @@ -126,7 +126,7 @@ private ResourceObject GetOrBuildResourceObject(IIdentifiable parent, Relationsh var entry = _included.SingleOrDefault(ro => ro.Type == resourceName && ro.Id == parent.StringId); if (entry == null) { - entry = Build(parent, _fieldsToSerialize.GetAllowedAttributes(type, relationship), _fieldsToSerialize.GetAllowedRelationships(type)); + entry = Build(parent, _fieldsToSerialize.GetAttributes(type, relationship), _fieldsToSerialize.GetRelationships(type)); _included.Add(entry); } return entry; diff --git a/src/JsonApiDotNetCore/Serialization/Server/Builders/LinkBuilder.cs b/src/JsonApiDotNetCore/Serialization/Server/Builders/LinkBuilder.cs index cccc400e58..33525c1a15 100644 --- a/src/JsonApiDotNetCore/Serialization/Server/Builders/LinkBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Server/Builders/LinkBuilder.cs @@ -5,12 +5,11 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Managers.Contracts; +using JsonApiDotNetCore.Internal.QueryStrings; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Models.Annotation; -using JsonApiDotNetCore.Models.Links; -using JsonApiDotNetCore.Query; -using JsonApiDotNetCore.QueryParameterServices.Common; +using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCore.RequestServices.Contracts; using Microsoft.AspNetCore.Http; namespace JsonApiDotNetCore.Serialization.Server.Builders @@ -19,19 +18,19 @@ public class LinkBuilder : ILinkBuilder { private readonly IResourceContextProvider _provider; private readonly IRequestQueryStringAccessor _queryStringAccessor; - private readonly ILinksConfiguration _options; + private readonly IJsonApiOptions _options; private readonly ICurrentRequest _currentRequest; - private readonly IPageService _pageService; + private readonly IPaginationContext _paginationContext; - public LinkBuilder(ILinksConfiguration options, + public LinkBuilder(IJsonApiOptions options, ICurrentRequest currentRequest, - IPageService pageService, + IPaginationContext paginationContext, IResourceContextProvider provider, IRequestQueryStringAccessor queryStringAccessor) { _options = options; _currentRequest = currentRequest; - _pageService = pageService; + _paginationContext = paginationContext; _provider = provider; _queryStringAccessor = queryStringAccessor; } @@ -39,15 +38,15 @@ public LinkBuilder(ILinksConfiguration options, /// public TopLevelLinks GetTopLevelLinks() { - ResourceContext resourceContext = _currentRequest.GetRequestResource(); + ResourceContext resourceContext = _currentRequest.PrimaryResource; TopLevelLinks topLevelLinks = null; - if (ShouldAddTopLevelLink(resourceContext, Link.Self)) + if (ShouldAddTopLevelLink(resourceContext, Links.Self)) { topLevelLinks = new TopLevelLinks { Self = GetSelfTopLevelLink(resourceContext) }; } - if (ShouldAddTopLevelLink(resourceContext, Link.Paging) && _pageService.CanPaginate) + if (ShouldAddTopLevelLink(resourceContext, Links.Paging) && _paginationContext.PageSize != null) { SetPageLinks(resourceContext, topLevelLinks ??= new TopLevelLinks()); } @@ -58,11 +57,11 @@ public TopLevelLinks GetTopLevelLinks() /// /// Checks if the top-level should be added by first checking /// configuration on the , and if not configured, by checking with the - /// global configuration in . + /// global configuration in . /// - private bool ShouldAddTopLevelLink(ResourceContext resourceContext, Link link) + private bool ShouldAddTopLevelLink(ResourceContext resourceContext, Links link) { - if (resourceContext.TopLevelLinks != Link.NotConfigured) + if (resourceContext.TopLevelLinks != Links.NotConfigured) { return resourceContext.TopLevelLinks.HasFlag(link); } @@ -72,21 +71,21 @@ private bool ShouldAddTopLevelLink(ResourceContext resourceContext, Link link) private void SetPageLinks(ResourceContext resourceContext, TopLevelLinks links) { - if (_pageService.CurrentPage > 1) + if (_paginationContext.PageNumber.OneBasedValue > 1) { - links.Prev = GetPageLink(resourceContext, _pageService.CurrentPage - 1, _pageService.PageSize); + links.Prev = GetPageLink(resourceContext, _paginationContext.PageNumber.OneBasedValue - 1, _paginationContext.PageSize); } - if (_pageService.CurrentPage < _pageService.TotalPages) + if (_paginationContext.PageNumber.OneBasedValue < _paginationContext.TotalPageCount) { - links.Next = GetPageLink(resourceContext, _pageService.CurrentPage + 1, _pageService.PageSize); + links.Next = GetPageLink(resourceContext, _paginationContext.PageNumber.OneBasedValue + 1, _paginationContext.PageSize); } - if (_pageService.TotalPages > 0) + if (_paginationContext.TotalPageCount > 0) { - links.Self = GetPageLink(resourceContext, _pageService.CurrentPage, _pageService.PageSize); - links.First = GetPageLink(resourceContext, 1, _pageService.PageSize); - links.Last = GetPageLink(resourceContext, _pageService.TotalPages, _pageService.PageSize); + links.Self = GetPageLink(resourceContext, _paginationContext.PageNumber.OneBasedValue, _paginationContext.PageSize); + links.First = GetPageLink(resourceContext, 1, _paginationContext.PageSize); + links.Last = GetPageLink(resourceContext, _paginationContext.TotalPageCount.Value, _paginationContext.PageSize); } } @@ -97,31 +96,26 @@ private string GetSelfTopLevelLink(ResourceContext resourceContext) builder.Append("/"); builder.Append(resourceContext.ResourceName); - string resourceId = _currentRequest.BaseId; + string resourceId = _currentRequest.PrimaryId; if (resourceId != null) { builder.Append("/"); builder.Append(resourceId); } - if (_currentRequest.RequestRelationship != null) + if (_currentRequest.Relationship != null) { builder.Append("/"); - builder.Append(_currentRequest.RequestRelationship.PublicName); + builder.Append(_currentRequest.Relationship.PublicName); } - builder.Append(_queryStringAccessor.QueryString.Value); + builder.Append(DecodeSpecialCharacters(_queryStringAccessor.QueryString.Value)); return builder.ToString(); } - private string GetPageLink(ResourceContext resourceContext, int pageOffset, int pageSize) + private string GetPageLink(ResourceContext resourceContext, int pageOffset, PageSize pageSize) { - if (_pageService.Backwards) - { - pageOffset = -pageOffset; - } - string queryString = BuildQueryString(parameters => { parameters["page[size]"] = pageSize.ToString(); @@ -137,15 +131,19 @@ private string BuildQueryString(Action> updateAction) updateAction(parameters); string queryString = QueryString.Create(parameters).Value; - queryString = queryString.Replace("%5B", "[").Replace("%5D", "]"); - return queryString; + return DecodeSpecialCharacters(queryString); + } + + private static string DecodeSpecialCharacters(string uri) + { + return uri.Replace("%5B", "[").Replace("%5D", "]").Replace("%27", "'"); } /// public ResourceLinks GetResourceLinks(string resourceName, string id) { var resourceContext = _provider.GetResourceContext(resourceName); - if (ShouldAddResourceLink(resourceContext, Link.Self)) + if (ShouldAddResourceLink(resourceContext, Links.Self)) { return new ResourceLinks { Self = GetSelfResourceLink(resourceName, id) }; } @@ -159,12 +157,12 @@ public RelationshipLinks GetRelationshipLinks(RelationshipAttribute relationship var parentResourceContext = _provider.GetResourceContext(parent.GetType()); var childNavigation = relationship.PublicName; RelationshipLinks links = null; - if (ShouldAddRelationshipLink(parentResourceContext, relationship, Link.Related)) + if (ShouldAddRelationshipLink(parentResourceContext, relationship, Links.Related)) { links = new RelationshipLinks { Related = GetRelatedRelationshipLink(parentResourceContext.ResourceName, parent.StringId, childNavigation) }; } - if (ShouldAddRelationshipLink(parentResourceContext, relationship, Link.Self)) + if (ShouldAddRelationshipLink(parentResourceContext, relationship, Links.Self)) { links ??= new RelationshipLinks(); links.Self = GetSelfRelationshipLink(parentResourceContext.ResourceName, parent.StringId, childNavigation); @@ -192,11 +190,11 @@ private string GetRelatedRelationshipLink(string parent, string parentId, string /// /// Checks if the resource object level should be added by first checking /// configuration on the , and if not configured, by checking with the - /// global configuration in . + /// global configuration in . /// - private bool ShouldAddResourceLink(ResourceContext resourceContext, Link link) + private bool ShouldAddResourceLink(ResourceContext resourceContext, Links link) { - if (resourceContext.ResourceLinks != Link.NotConfigured) + if (resourceContext.ResourceLinks != Links.NotConfigured) { return resourceContext.ResourceLinks.HasFlag(link); } @@ -207,15 +205,15 @@ private bool ShouldAddResourceLink(ResourceContext resourceContext, Link link) /// Checks if the resource object level should be added by first checking /// configuration on the attribute, if not configured by checking /// the , and if not configured by checking with the - /// global configuration in . + /// global configuration in . /// - private bool ShouldAddRelationshipLink(ResourceContext resourceContext, RelationshipAttribute relationship, Link link) + private bool ShouldAddRelationshipLink(ResourceContext resourceContext, RelationshipAttribute relationship, Links link) { - if (relationship.RelationshipLinks != Link.NotConfigured) + if (relationship.RelationshipLinks != Links.NotConfigured) { return relationship.RelationshipLinks.HasFlag(link); } - if (resourceContext.RelationshipLinks != Link.NotConfigured) + if (resourceContext.RelationshipLinks != Links.NotConfigured) { return resourceContext.RelationshipLinks.HasFlag(link); } diff --git a/src/JsonApiDotNetCore/Serialization/Server/Builders/MetaBuilder.cs b/src/JsonApiDotNetCore/Serialization/Server/Builders/MetaBuilder.cs index 1a495909a3..9bece409cc 100644 --- a/src/JsonApiDotNetCore/Serialization/Server/Builders/MetaBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Server/Builders/MetaBuilder.cs @@ -1,8 +1,8 @@ using System.Collections.Generic; using System.Linq; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Query; using JsonApiDotNetCore.Services; namespace JsonApiDotNetCore.Serialization.Server.Builders @@ -11,21 +11,20 @@ namespace JsonApiDotNetCore.Serialization.Server.Builders public class MetaBuilder : IMetaBuilder where T : class, IIdentifiable { private Dictionary _meta = new Dictionary(); - private readonly IPageService _pageService; + private readonly IPaginationContext _paginationContext; private readonly IJsonApiOptions _options; private readonly IRequestMeta _requestMeta; private readonly IHasMeta _resourceMeta; - public MetaBuilder(IPageService pageService, - IJsonApiOptions options, - IRequestMeta requestMeta = null, - ResourceDefinition resourceDefinition = null) + public MetaBuilder(IPaginationContext paginationContext, IJsonApiOptions options, IRequestMeta requestMeta = null, + ResourceDefinition resourceDefinition = null) { - _pageService = pageService; + _paginationContext = paginationContext; _options = options; _requestMeta = requestMeta; _resourceMeta = resourceDefinition as IHasMeta; } + /// public void Add(string key, object value) { @@ -43,17 +42,25 @@ public void Add(Dictionary values) /// public Dictionary GetMeta() { - if (_options.IncludeTotalRecordCount && _pageService.TotalRecords != null) - _meta.Add("total-records", _pageService.TotalRecords); + if (_paginationContext.TotalResourceCount != null) + { + var namingStrategy = _options.SerializerContractResolver.NamingStrategy; + string key = namingStrategy.GetPropertyName("TotalResources", false); + + _meta.Add(key, _paginationContext.TotalResourceCount); + } if (_requestMeta != null) + { Add(_requestMeta.GetMeta()); + } if (_resourceMeta != null) + { Add(_resourceMeta.GetMeta()); + } - if (_meta.Any()) return _meta; - return null; + return _meta.Any() ? _meta : null; } } -} \ No newline at end of file +} diff --git a/src/JsonApiDotNetCore/Serialization/Server/Builders/ResponseResourceObjectBuilder.cs b/src/JsonApiDotNetCore/Serialization/Server/Builders/ResponseResourceObjectBuilder.cs index 8c9512b5ae..58d2fc5458 100644 --- a/src/JsonApiDotNetCore/Serialization/Server/Builders/ResponseResourceObjectBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Server/Builders/ResponseResourceObjectBuilder.cs @@ -1,9 +1,11 @@ using System.Collections.Generic; using System.Linq; using JsonApiDotNetCore.Internal.Contracts; +using JsonApiDotNetCore.Internal.Queries; +using JsonApiDotNetCore.Internal.Queries.Expressions; +using JsonApiDotNetCore.Internal.QueryStrings; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Models.Annotation; -using JsonApiDotNetCore.Query; using JsonApiDotNetCore.Serialization.Server.Builders; namespace JsonApiDotNetCore.Serialization.Server @@ -11,26 +13,26 @@ namespace JsonApiDotNetCore.Serialization.Server public class ResponseResourceObjectBuilder : ResourceObjectBuilder { private readonly IIncludedResourceObjectBuilder _includedBuilder; - private readonly IIncludeService _includeService; + private readonly IEnumerable _constraintProviders; private readonly ILinkBuilder _linkBuilder; private RelationshipAttribute _requestRelationship; public ResponseResourceObjectBuilder(ILinkBuilder linkBuilder, IIncludedResourceObjectBuilder includedBuilder, - IIncludeService includeService, + IEnumerable constraintProviders, IResourceContextProvider provider, IResourceObjectBuilderSettingsProvider settingsProvider) : base(provider, settingsProvider.Get()) { _linkBuilder = linkBuilder; _includedBuilder = includedBuilder; - _includeService = includeService; + _constraintProviders = constraintProviders; } - public RelationshipEntry Build(IIdentifiable entity, RelationshipAttribute requestRelationship) + public RelationshipEntry Build(IIdentifiable resource, RelationshipAttribute requestRelationship) { _requestRelationship = requestRelationship; - return GetRelationshipData(requestRelationship, entity); + return GetRelationshipData(requestRelationship, resource); } /// @@ -40,20 +42,20 @@ public RelationshipEntry Build(IIdentifiable entity, RelationshipAttribute reque /// and links are turned off, the entry would be completely empty, ie { }, which is not conform /// json:api spec. In that case we return null which will omit the entry from the output. /// - protected override RelationshipEntry GetRelationshipData(RelationshipAttribute relationship, IIdentifiable entity) + protected override RelationshipEntry GetRelationshipData(RelationshipAttribute relationship, IIdentifiable resource) { RelationshipEntry relationshipEntry = null; List> relationshipChains = null; - if (Equals(relationship, _requestRelationship) || ShouldInclude(relationship, out relationshipChains )) + if (Equals(relationship, _requestRelationship) || ShouldInclude(relationship, out relationshipChains)) { - relationshipEntry = base.GetRelationshipData(relationship, entity); + relationshipEntry = base.GetRelationshipData(relationship, resource); if (relationshipChains != null && relationshipEntry.HasResource) foreach (var chain in relationshipChains) - // traverses (recursively) and extracts all (nested) related entities for the current inclusion chain. - _includedBuilder.IncludeRelationshipChain(chain, entity); + // traverses (recursively) and extracts all (nested) related resources for the current inclusion chain. + _includedBuilder.IncludeRelationshipChain(chain, resource); } - var links = _linkBuilder.GetRelationshipLinks(relationship, entity); + var links = _linkBuilder.GetRelationshipLinks(relationship, resource); if (links != null) // if links relationshipLinks should be built for this entry, populate the "links" field. (relationshipEntry ??= new RelationshipEntry()).Links = links; @@ -64,13 +66,28 @@ protected override RelationshipEntry GetRelationshipData(RelationshipAttribute r } /// - /// Inspects the included relationship chains (see + /// Inspects the included relationship chains (see /// to see if should be included or not. /// private bool ShouldInclude(RelationshipAttribute relationship, out List> inclusionChain) { - inclusionChain = _includeService.Get()?.Where(l => l.First().Equals(relationship)).ToList(); - return inclusionChain != null && inclusionChain.Any(); + var includes = _constraintProviders + .SelectMany(p => p.GetConstraints()) + .Select(expressionInScope => expressionInScope.Expression) + .OfType() + .ToArray(); + + inclusionChain = new List>(); + + foreach (var chain in includes.SelectMany(x => x.Chains)) + { + if (chain.Fields.First().Equals(relationship)) + { + inclusionChain.Add(chain.Fields.Cast().ToList()); + } + } + + return inclusionChain.Any(); } } } diff --git a/src/JsonApiDotNetCore/Serialization/Server/Contracts/IFieldsToSerialize.cs b/src/JsonApiDotNetCore/Serialization/Server/Contracts/IFieldsToSerialize.cs index 366a842276..1a4478b795 100644 --- a/src/JsonApiDotNetCore/Serialization/Server/Contracts/IFieldsToSerialize.cs +++ b/src/JsonApiDotNetCore/Serialization/Server/Contracts/IFieldsToSerialize.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Models.Annotation; @@ -15,16 +15,15 @@ namespace JsonApiDotNetCore.Serialization.Server public interface IFieldsToSerialize { /// - /// Gets the list of attributes that are allowed to be serialized for - /// resource of type - /// if , it will consider the allowed list of attributes - /// as an included relationship + /// Gets the list of attributes that are to be serialized for resource of type . + /// If is non-null, it will consider the allowed list of attributes + /// as an included relationship. /// - List GetAllowedAttributes(Type type, RelationshipAttribute relationship = null); + IReadOnlyCollection GetAttributes(Type type, RelationshipAttribute relationship = null); + /// - /// Gets the list of relationships that are allowed to be serialized for - /// resource of type + /// Gets the list of relationships that are to be serialized for resource of type . /// - List GetAllowedRelationships(Type type); + IReadOnlyCollection GetRelationships(Type type); } } diff --git a/src/JsonApiDotNetCore/Serialization/Server/Contracts/IIncludedResourceObjectBuilder.cs b/src/JsonApiDotNetCore/Serialization/Server/Contracts/IIncludedResourceObjectBuilder.cs index 7d717a1bee..3a8f10acdc 100644 --- a/src/JsonApiDotNetCore/Serialization/Server/Contracts/IIncludedResourceObjectBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Server/Contracts/IIncludedResourceObjectBuilder.cs @@ -7,13 +7,13 @@ namespace JsonApiDotNetCore.Serialization.Server.Builders public interface IIncludedResourceObjectBuilder { /// - /// Gets the list of resource objects representing the included entities + /// Gets the list of resource objects representing the included resources /// List Build(); /// - /// Extracts the included entities from using the + /// Extracts the included resources from using the /// (arbitrarily deeply nested) included relationships in . /// - void IncludeRelationshipChain(List inclusionChain, IIdentifiable rootEntity); + void IncludeRelationshipChain(List inclusionChain, IIdentifiable rootResource); } } diff --git a/src/JsonApiDotNetCore/Serialization/Server/Contracts/IJsonApiDeserializer.cs b/src/JsonApiDotNetCore/Serialization/Server/Contracts/IJsonApiDeserializer.cs index 680391a755..7edc7f897a 100644 --- a/src/JsonApiDotNetCore/Serialization/Server/Contracts/IJsonApiDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/Server/Contracts/IJsonApiDeserializer.cs @@ -8,11 +8,11 @@ namespace JsonApiDotNetCore.Serialization.Server public interface IJsonApiDeserializer { /// - /// Deserializes JSON in to a and constructs entities + /// Deserializes JSON in to a and constructs resources /// from . /// /// The JSON to be deserialized - /// The entities constructed from the content + /// The resources constructed from the content object Deserialize(string body); } } diff --git a/src/JsonApiDotNetCore/Serialization/Server/Contracts/IJsonApiSerializer.cs b/src/JsonApiDotNetCore/Serialization/Server/Contracts/IJsonApiSerializer.cs index 6d056940c0..b745f499cb 100644 --- a/src/JsonApiDotNetCore/Serialization/Server/Contracts/IJsonApiSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/Server/Contracts/IJsonApiSerializer.cs @@ -1,4 +1,4 @@ -namespace JsonApiDotNetCore.Serialization.Server +namespace JsonApiDotNetCore.Serialization.Server { /// /// Serializer used internally in JsonApiDotNetCore to serialize responses. @@ -6,8 +6,8 @@ public interface IJsonApiSerializer { /// - /// Serializes a single entity or a list of entities. + /// Serializes a single resource or a list of resources. /// string Serialize(object content); } -} \ No newline at end of file +} diff --git a/src/JsonApiDotNetCore/Serialization/Server/Contracts/ILinkBuilder.cs b/src/JsonApiDotNetCore/Serialization/Server/Contracts/ILinkBuilder.cs index 36fbc94931..b44bff6a2f 100644 --- a/src/JsonApiDotNetCore/Serialization/Server/Contracts/ILinkBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Server/Contracts/ILinkBuilder.cs @@ -1,6 +1,6 @@ using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Models.Annotation; -using JsonApiDotNetCore.Models.Links; +using JsonApiDotNetCore.Models.JsonApiDocuments; namespace JsonApiDotNetCore.Serialization.Server.Builders { diff --git a/src/JsonApiDotNetCore/Serialization/Server/FieldsToSerialize.cs b/src/JsonApiDotNetCore/Serialization/Server/FieldsToSerialize.cs index 722795ce35..3c52371700 100644 --- a/src/JsonApiDotNetCore/Serialization/Server/FieldsToSerialize.cs +++ b/src/JsonApiDotNetCore/Serialization/Server/FieldsToSerialize.cs @@ -1,9 +1,12 @@ using JsonApiDotNetCore.Internal.Contracts; using System; using System.Collections.Generic; -using JsonApiDotNetCore.Query; using System.Linq; +using JsonApiDotNetCore.Internal.Queries; +using JsonApiDotNetCore.Internal.Queries.Expressions; +using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Models.Annotation; +using JsonApiDotNetCore.Query; namespace JsonApiDotNetCore.Serialization.Server { @@ -11,34 +14,49 @@ namespace JsonApiDotNetCore.Serialization.Server public class FieldsToSerialize : IFieldsToSerialize { private readonly IResourceGraph _resourceGraph; - private readonly ISparseFieldsService _sparseFieldsService ; - private readonly IResourceDefinitionProvider _provider; + private readonly IEnumerable _constraintProviders; + private readonly IResourceDefinitionProvider _resourceDefinitionProvider; - public FieldsToSerialize(IResourceGraph resourceGraph, - ISparseFieldsService sparseFieldsService, - IResourceDefinitionProvider provider) + public FieldsToSerialize( + IResourceGraph resourceGraph, + IEnumerable constraintProviders, + IResourceDefinitionProvider resourceDefinitionProvider) { _resourceGraph = resourceGraph; - _sparseFieldsService = sparseFieldsService; - _provider = provider; + _constraintProviders = constraintProviders; + _resourceDefinitionProvider = resourceDefinitionProvider; } /// - public List GetAllowedAttributes(Type type, RelationshipAttribute relationship = null) - { // get the list of all exposed attributes for the given type. - var allowed = _resourceGraph.GetAttributes(type); + public IReadOnlyCollection GetAttributes(Type type, RelationshipAttribute relationship = null) + { + var sparseFieldSetAttributes = _constraintProviders + .SelectMany(p => p.GetConstraints()) + .Where(expressionInScope => relationship == null + ? expressionInScope.Scope == null + : expressionInScope.Scope != null && expressionInScope.Scope.Fields.Last().Equals(relationship)) + .Select(expressionInScope => expressionInScope.Expression) + .OfType() + .SelectMany(sparseFieldSet => sparseFieldSet.Attributes) + .ToHashSet(); + + if (!sparseFieldSetAttributes.Any()) + { + sparseFieldSetAttributes = _resourceGraph.GetAttributes(type).ToHashSet(); + } - var resourceDefinition = _provider.Get(type); + sparseFieldSetAttributes.RemoveWhere(attr => !attr.Capabilities.HasFlag(AttrCapabilities.AllowView)); + + var resourceDefinition = _resourceDefinitionProvider.Get(type); if (resourceDefinition != null) - // The set of allowed attributes to be exposed was defined on the resource definition - allowed = allowed.Intersect(resourceDefinition.GetAllowedAttributes()).ToList(); + { + var tempExpression = sparseFieldSetAttributes.Any() ? new SparseFieldSetExpression(sparseFieldSetAttributes) : null; + tempExpression = resourceDefinition.OnApplySparseFieldSet(tempExpression); - var sparseFieldsSelection = _sparseFieldsService.Get(relationship); - if (sparseFieldsSelection.Any()) - // from the allowed attributes, select the ones flagged by sparse field selection. - allowed = allowed.Intersect(sparseFieldsSelection).ToList(); + sparseFieldSetAttributes = tempExpression == null ? new HashSet() : tempExpression.Attributes.ToHashSet(); + } - return allowed; + return sparseFieldSetAttributes; } /// @@ -48,14 +66,8 @@ public List GetAllowedAttributes(Type type, RelationshipAttribute /// is not the same as not including. In the case of the latter, /// we may still want to add the relationship to expose the navigation link to the client. /// - public List GetAllowedRelationships(Type type) + public IReadOnlyCollection GetRelationships(Type type) { - var resourceDefinition = _provider.Get(type); - if (resourceDefinition != null) - // The set of allowed attributes to be exposed was defined on the resource definition - return resourceDefinition.GetAllowedRelationships(); - - // The set of allowed attributes to be exposed was NOT defined on the resource definition: return all return _resourceGraph.GetRelationships(type); } } diff --git a/src/JsonApiDotNetCore/Serialization/Server/RequestDeserializer.cs b/src/JsonApiDotNetCore/Serialization/Server/RequestDeserializer.cs index a8f5c4a475..817e2bf919 100644 --- a/src/JsonApiDotNetCore/Serialization/Server/RequestDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/Server/RequestDeserializer.cs @@ -36,14 +36,14 @@ public RequestDeserializer(IResourceContextProvider contextProvider, IResourceFa /// Additional processing required for server deserialization. Flags a /// processed attribute or relationship as updated using . /// - /// The entity that was constructed from the document's body + /// The resource that was constructed from the document's body /// The metadata for the exposed field - /// Relationship data for . Is null when is not a - protected override void AfterProcessField(IIdentifiable entity, ResourceFieldAttribute field, RelationshipEntry data = null) + /// Relationship data for . Is null when is not a + protected override void AfterProcessField(IIdentifiable resource, ResourceFieldAttribute field, RelationshipEntry data = null) { if (field is AttrAttribute attr) { - if (attr.Capabilities.HasFlag(AttrCapabilities.AllowMutate)) + if (attr.Capabilities.HasFlag(AttrCapabilities.AllowChange)) { _targetedFields.Attributes.Add(attr); } @@ -58,39 +58,39 @@ protected override void AfterProcessField(IIdentifiable entity, ResourceFieldAtt _targetedFields.Relationships.Add(relationship); } - protected override IIdentifiable SetAttributes(IIdentifiable entity, Dictionary attributeValues, List attributes) + protected override IIdentifiable SetAttributes(IIdentifiable resource, Dictionary attributeValues, List attributes) { if (_httpContextAccessor.HttpContext.Request.Method == HttpMethod.Patch.Method) { foreach (AttrAttribute attr in attributes) { - if (attr.PropertyInfo.GetCustomAttribute() != null) + if (attr.Property.GetCustomAttribute() != null) { bool disableValidator = attributeValues == null || attributeValues.Count == 0 || - !attributeValues.TryGetValue(attr.PublicAttributeName, out _); + !attributeValues.TryGetValue(attr.PublicName, out _); if (disableValidator) { - _httpContextAccessor.HttpContext.DisableValidator(attr.PropertyInfo.Name, entity.GetType().Name); + _httpContextAccessor.HttpContext.DisableValidator(attr.Property.Name, resource.GetType().Name); } } } } - return base.SetAttributes(entity, attributeValues, attributes); + return base.SetAttributes(resource, attributeValues, attributes); } - protected override IIdentifiable SetRelationships(IIdentifiable entity, Dictionary relationshipsValues, List relationshipAttributes) + protected override IIdentifiable SetRelationships(IIdentifiable resource, Dictionary relationshipsValues, List relationshipAttributes) { // If there is a relationship included in the data of the POST or PATCH, then the 'IsRequired' attribute will be disabled for any // property within that object. For instance, a new article is posted and has a relationship included to an author. In this case, // the author name (which has the 'IsRequired' attribute) will not be included in the POST. Unless disabled, the POST will fail. foreach (RelationshipAttribute attr in relationshipAttributes) { - _httpContextAccessor.HttpContext.DisableValidator("Relation", attr.PropertyInfo.Name); + _httpContextAccessor.HttpContext.DisableValidator("Relation", attr.Property.Name); } - return base.SetRelationships(entity, relationshipsValues, relationshipAttributes); + return base.SetRelationships(resource, relationshipsValues, relationshipAttributes); } } } diff --git a/src/JsonApiDotNetCore/Serialization/Server/ResourceObjectBuilderSettingsProvider.cs b/src/JsonApiDotNetCore/Serialization/Server/ResourceObjectBuilderSettingsProvider.cs index 60f88112d3..1022887a4d 100644 --- a/src/JsonApiDotNetCore/Serialization/Server/ResourceObjectBuilderSettingsProvider.cs +++ b/src/JsonApiDotNetCore/Serialization/Server/ResourceObjectBuilderSettingsProvider.cs @@ -1,5 +1,5 @@ using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Query; +using JsonApiDotNetCore.Internal.QueryStrings; namespace JsonApiDotNetCore.Serialization.Server { @@ -9,19 +9,19 @@ namespace JsonApiDotNetCore.Serialization.Server /// public sealed class ResourceObjectBuilderSettingsProvider : IResourceObjectBuilderSettingsProvider { - private readonly IDefaultsService _defaultsService; - private readonly INullsService _nullsService; + private readonly IDefaultsQueryStringParameterReader _defaultsReader; + private readonly INullsQueryStringParameterReader _nullsReader; - public ResourceObjectBuilderSettingsProvider(IDefaultsService defaultsService, INullsService nullsService) + public ResourceObjectBuilderSettingsProvider(IDefaultsQueryStringParameterReader defaultsReader, INullsQueryStringParameterReader nullsReader) { - _defaultsService = defaultsService; - _nullsService = nullsService; + _defaultsReader = defaultsReader; + _nullsReader = nullsReader; } /// public ResourceObjectBuilderSettings Get() { - return new ResourceObjectBuilderSettings(_nullsService.SerializerNullValueHandling, _defaultsService.SerializerDefaultValueHandling); + return new ResourceObjectBuilderSettings(_nullsReader.SerializerNullValueHandling, _defaultsReader.SerializerDefaultValueHandling); } } } diff --git a/src/JsonApiDotNetCore/Serialization/Server/ResponseSerializer.cs b/src/JsonApiDotNetCore/Serialization/Server/ResponseSerializer.cs index 35690bcd3a..1c172018c4 100644 --- a/src/JsonApiDotNetCore/Serialization/Server/ResponseSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/Server/ResponseSerializer.cs @@ -4,10 +4,10 @@ using JsonApiDotNetCore.Extensions; using JsonApiDotNetCore.Models; using Newtonsoft.Json; -using JsonApiDotNetCore.Managers.Contracts; using JsonApiDotNetCore.Models.Annotation; using JsonApiDotNetCore.Serialization.Server.Builders; using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCore.RequestServices.Contracts; namespace JsonApiDotNetCore.Serialization.Server { @@ -16,7 +16,7 @@ namespace JsonApiDotNetCore.Serialization.Server /// /// /// Because in JsonApiDotNetCore every json:api request is associated with exactly one - /// resource (the request resource, see ), + /// resource (the primary resource, see ), /// the serializer can leverage this information using generics. /// See for how this is instantiated. /// @@ -26,8 +26,6 @@ public class ResponseSerializer : BaseDocumentBuilder, IJsonApiSerial where TResource : class, IIdentifiable { public RelationshipAttribute RequestRelationship { get; set; } - private readonly Dictionary> _attributesToSerializeCache = new Dictionary>(); - private readonly Dictionary> _relationshipsToSerializeCache = new Dictionary>(); private readonly IFieldsToSerialize _fieldsToSerialize; private readonly IJsonApiOptions _options; private readonly IMetaBuilder _metaBuilder; @@ -78,21 +76,21 @@ private string SerializeErrorDocument(ErrorDocument errorDocument) } /// - /// Convert a single entity into a serialized + /// Convert a single resource into a serialized /// /// /// This method is set internal instead of private for easier testability. /// - internal string SerializeSingle(IIdentifiable entity) + internal string SerializeSingle(IIdentifiable resource) { - if (RequestRelationship != null && entity != null) + if (RequestRelationship != null && resource != null) { - var relationship = ((ResponseResourceObjectBuilder)_resourceObjectBuilder).Build(entity, RequestRelationship); + var relationship = ((ResponseResourceObjectBuilder)_resourceObjectBuilder).Build(resource, RequestRelationship); return SerializeObject(relationship, _options.SerializerSettings, serializer => { serializer.NullValueHandling = NullValueHandling.Include; }); } var (attributes, relationships) = GetFieldsToSerialize(); - var document = Build(entity, attributes, relationships); + var document = Build(resource, attributes, relationships); var resourceObject = document.SingleData; if (resourceObject != null) resourceObject.Links = _linkBuilder.GetResourceLinks(resourceObject.Type, resourceObject.Id); @@ -102,21 +100,21 @@ internal string SerializeSingle(IIdentifiable entity) return SerializeObject(document, _options.SerializerSettings, serializer => { serializer.NullValueHandling = NullValueHandling.Include; }); } - private (List, List) GetFieldsToSerialize() + private (IReadOnlyCollection, IReadOnlyCollection) GetFieldsToSerialize() { - return (GetAttributesToSerialize(_primaryResourceType), GetRelationshipsToSerialize(_primaryResourceType)); + return (_fieldsToSerialize.GetAttributes(_primaryResourceType), _fieldsToSerialize.GetRelationships(_primaryResourceType)); } /// - /// Convert a list of entities into a serialized + /// Convert a list of resources into a serialized /// /// /// This method is set internal instead of private for easier testability. /// - internal string SerializeMany(IEnumerable entities) + internal string SerializeMany(IEnumerable resources) { var (attributes, relationships) = GetFieldsToSerialize(); - var document = Build(entities, attributes, relationships); + var document = Build(resources, attributes, relationships); foreach (ResourceObject resourceObject in document.ManyData) { var links = _linkBuilder.GetResourceLinks(resourceObject.Type, resourceObject.Id); @@ -131,47 +129,6 @@ internal string SerializeMany(IEnumerable entities) return SerializeObject(document, _options.SerializerSettings, serializer => { serializer.NullValueHandling = NullValueHandling.Include; }); } - /// - /// Gets the list of attributes to serialize for the given . - /// Note that the choice of omitting null/default-values is not handled here, - /// but in . - /// - /// Type of entity to be serialized - /// List of allowed attributes in the serialized result - private List GetAttributesToSerialize(Type resourceType) - { - // Check the attributes cache to see if the allowed attrs for this resource type were determined before. - if (_attributesToSerializeCache.TryGetValue(resourceType, out List allowedAttributes)) - return allowedAttributes; - - // Get the list of attributes to be exposed for this type - allowedAttributes = _fieldsToSerialize.GetAllowedAttributes(resourceType); - - // add to cache so we we don't have to look this up next time. - _attributesToSerializeCache.Add(resourceType, allowedAttributes); - return allowedAttributes; - } - - /// - /// By default, the server serializer exposes all defined relationships, unless - /// in the a subset to hide was defined explicitly. - /// - /// Type of entity to be serialized - /// List of allowed relationships in the serialized result - private List GetRelationshipsToSerialize(Type resourceType) - { - // Check the relationships cache to see if the allowed attrs for this resource type were determined before. - if (_relationshipsToSerializeCache.TryGetValue(resourceType, out List allowedRelations)) - return allowedRelations; - - // Get the list of relationships to be exposed for this type - allowedRelations = _fieldsToSerialize.GetAllowedRelationships(resourceType); - // add to cache so we we don't have to look this up next time. - _relationshipsToSerializeCache.Add(resourceType, allowedRelations); - return allowedRelations; - - } - /// /// Adds top-level objects that are only added to a document in the case /// of server-side serialization. diff --git a/src/JsonApiDotNetCore/Serialization/Server/ResponseSerializerFactory.cs b/src/JsonApiDotNetCore/Serialization/Server/ResponseSerializerFactory.cs index 9b39778d45..3b3947d6e6 100644 --- a/src/JsonApiDotNetCore/Serialization/Server/ResponseSerializerFactory.cs +++ b/src/JsonApiDotNetCore/Serialization/Server/ResponseSerializerFactory.cs @@ -1,6 +1,6 @@ -using System; +using System; using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Managers.Contracts; +using JsonApiDotNetCore.RequestServices.Contracts; using JsonApiDotNetCore.Services; namespace JsonApiDotNetCore.Serialization.Server @@ -26,24 +26,20 @@ public ResponseSerializerFactory(ICurrentRequest currentRequest, IScopedServiceP /// public IJsonApiSerializer GetSerializer() { - var targetType = GetDocumentPrimaryType(); - if (targetType == null) - return null; + var targetType = GetDocumentType(); var serializerType = typeof(ResponseSerializer<>).MakeGenericType(targetType); var serializer = (IResponseSerializer)_provider.GetService(serializerType); - if (_currentRequest.RequestRelationship != null && _currentRequest.IsRelationshipPath) - serializer.RequestRelationship = _currentRequest.RequestRelationship; + if (_currentRequest.Kind == EndpointKind.Relationship && _currentRequest.Relationship != null) + serializer.RequestRelationship = _currentRequest.Relationship; return (IJsonApiSerializer)serializer; } - private Type GetDocumentPrimaryType() + private Type GetDocumentType() { - if (_currentRequest.RequestRelationship != null && !_currentRequest.IsRelationshipPath) - return _currentRequest.RequestRelationship.RightType; - - return _currentRequest.GetRequestResource()?.ResourceType; + var resourceContext = _currentRequest.SecondaryResource ?? _currentRequest.PrimaryResource; + return resourceContext.ResourceType; } } } diff --git a/src/JsonApiDotNetCore/Services/Contract/ICreateService.cs b/src/JsonApiDotNetCore/Services/Contract/ICreateService.cs index df4916856d..832e893dd0 100644 --- a/src/JsonApiDotNetCore/Services/Contract/ICreateService.cs +++ b/src/JsonApiDotNetCore/Services/Contract/ICreateService.cs @@ -10,6 +10,6 @@ public interface ICreateService : ICreateService public interface ICreateService where T : class, IIdentifiable { - Task CreateAsync(T entity); + Task CreateAsync(T resource); } } diff --git a/src/JsonApiDotNetCore/Services/Contract/IGetAllService.cs b/src/JsonApiDotNetCore/Services/Contract/IGetAllService.cs index 16b367911c..75eb9c6799 100644 --- a/src/JsonApiDotNetCore/Services/Contract/IGetAllService.cs +++ b/src/JsonApiDotNetCore/Services/Contract/IGetAllService.cs @@ -11,6 +11,6 @@ public interface IGetAllService : IGetAllService public interface IGetAllService where T : class, IIdentifiable { - Task> GetAsync(); + Task> GetAsync(); } } diff --git a/src/JsonApiDotNetCore/Services/Contract/IGetRelationshipService.cs b/src/JsonApiDotNetCore/Services/Contract/IGetRelationshipService.cs index de32b77547..f4eec1eddc 100644 --- a/src/JsonApiDotNetCore/Services/Contract/IGetRelationshipService.cs +++ b/src/JsonApiDotNetCore/Services/Contract/IGetRelationshipService.cs @@ -10,6 +10,6 @@ public interface IGetRelationshipService : IGetRelationshipService public interface IGetRelationshipService where T : class, IIdentifiable { - Task GetRelationshipAsync(TId id, string relationshipName); + Task GetRelationshipAsync(TId id, string relationshipName); } } diff --git a/src/JsonApiDotNetCore/Services/Contract/IGetRelationshipsService.cs b/src/JsonApiDotNetCore/Services/Contract/IGetSecondaryService.cs similarity index 50% rename from src/JsonApiDotNetCore/Services/Contract/IGetRelationshipsService.cs rename to src/JsonApiDotNetCore/Services/Contract/IGetSecondaryService.cs index 9597a88830..6f61eaca0c 100644 --- a/src/JsonApiDotNetCore/Services/Contract/IGetRelationshipsService.cs +++ b/src/JsonApiDotNetCore/Services/Contract/IGetSecondaryService.cs @@ -3,13 +3,13 @@ namespace JsonApiDotNetCore.Services { - public interface IGetRelationshipsService : IGetRelationshipsService + public interface IGetSecondaryService : IGetSecondaryService where T : class, IIdentifiable { } - public interface IGetRelationshipsService + public interface IGetSecondaryService where T : class, IIdentifiable { - Task GetRelationshipsAsync(TId id, string relationshipName); + Task GetSecondaryAsync(TId id, string relationshipName); } } diff --git a/src/JsonApiDotNetCore/Services/Contract/IResourceQueryService.cs b/src/JsonApiDotNetCore/Services/Contract/IResourceQueryService.cs index 1cd4a94cf3..5cc8c03d3a 100644 --- a/src/JsonApiDotNetCore/Services/Contract/IResourceQueryService.cs +++ b/src/JsonApiDotNetCore/Services/Contract/IResourceQueryService.cs @@ -5,8 +5,8 @@ namespace JsonApiDotNetCore.Services public interface IResourceQueryService : IGetAllService, IGetByIdService, - IGetRelationshipsService, IGetRelationshipService, + IGetSecondaryService, IResourceQueryService where T : class, IIdentifiable { } @@ -14,8 +14,8 @@ public interface IResourceQueryService : public interface IResourceQueryService : IGetAllService, IGetByIdService, - IGetRelationshipsService, - IGetRelationshipService + IGetRelationshipService, + IGetSecondaryService where T : class, IIdentifiable { } } diff --git a/src/JsonApiDotNetCore/Services/Contract/IUpdateRelationshipService.cs b/src/JsonApiDotNetCore/Services/Contract/IUpdateRelationshipService.cs index 44b45222f4..85b6acab3d 100644 --- a/src/JsonApiDotNetCore/Services/Contract/IUpdateRelationshipService.cs +++ b/src/JsonApiDotNetCore/Services/Contract/IUpdateRelationshipService.cs @@ -10,6 +10,6 @@ public interface IUpdateRelationshipService : IUpdateRelationshipService where T : class, IIdentifiable { - Task UpdateRelationshipsAsync(TId id, string relationshipName, object relationships); + Task UpdateRelationshipAsync(TId id, string relationshipName, object relationships); } } diff --git a/src/JsonApiDotNetCore/Services/Contract/IUpdateService.cs b/src/JsonApiDotNetCore/Services/Contract/IUpdateService.cs index bd5f13dd60..4f9de8744e 100644 --- a/src/JsonApiDotNetCore/Services/Contract/IUpdateService.cs +++ b/src/JsonApiDotNetCore/Services/Contract/IUpdateService.cs @@ -10,6 +10,6 @@ public interface IUpdateService : IUpdateService public interface IUpdateService where T : class, IIdentifiable { - Task UpdateAsync(TId id, T entity); + Task UpdateAsync(TId id, T resource); } } diff --git a/src/JsonApiDotNetCore/Services/DefaultResourceService.cs b/src/JsonApiDotNetCore/Services/DefaultResourceService.cs deleted file mode 100644 index f217af4916..0000000000 --- a/src/JsonApiDotNetCore/Services/DefaultResourceService.cs +++ /dev/null @@ -1,448 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Data; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Hooks; -using Microsoft.Extensions.Logging; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using JsonApiDotNetCore.Exceptions; -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Query; -using JsonApiDotNetCore.Extensions; -using JsonApiDotNetCore.Models.Annotation; -using JsonApiDotNetCore.RequestServices; -using Microsoft.EntityFrameworkCore; - -namespace JsonApiDotNetCore.Services -{ - /// - /// Entity mapping class - /// - /// - /// - public class DefaultResourceService : - IResourceService - where TResource : class, IIdentifiable - { - private readonly IPageService _pageService; - private readonly IJsonApiOptions _options; - private readonly IFilterService _filterService; - private readonly ISortService _sortService; - private readonly IResourceRepository _repository; - private readonly IResourceChangeTracker _resourceChangeTracker; - private readonly IResourceFactory _resourceFactory; - private readonly ILogger _logger; - private readonly IResourceHookExecutor _hookExecutor; - private readonly IIncludeService _includeService; - private readonly ISparseFieldsService _sparseFieldsService; - private readonly ResourceContext _currentRequestResource; - - public DefaultResourceService( - IEnumerable queryParameters, - IJsonApiOptions options, - ILoggerFactory loggerFactory, - IResourceRepository repository, - IResourceContextProvider provider, - IResourceChangeTracker resourceChangeTracker, - IResourceFactory resourceFactory, - IResourceHookExecutor hookExecutor = null) - { - _includeService = queryParameters.FirstOrDefault(); - _sparseFieldsService = queryParameters.FirstOrDefault(); - _pageService = queryParameters.FirstOrDefault(); - _sortService = queryParameters.FirstOrDefault(); - _filterService = queryParameters.FirstOrDefault(); - _options = options; - _logger = loggerFactory.CreateLogger>(); - _repository = repository; - _resourceChangeTracker = resourceChangeTracker; - _resourceFactory = resourceFactory; - _hookExecutor = hookExecutor; - _currentRequestResource = provider.GetResourceContext(); - } - - public virtual async Task CreateAsync(TResource entity) - { - _logger.LogTrace($"Entering {nameof(CreateAsync)}(object)."); - - entity = IsNull(_hookExecutor) ? entity : _hookExecutor.BeforeCreate(AsList(entity), ResourcePipeline.Post).SingleOrDefault(); - await _repository.CreateAsync(entity); - - entity = await GetWithRelationshipsAsync(entity.Id); - - if (!IsNull(_hookExecutor, entity)) - { - _hookExecutor.AfterCreate(AsList(entity), ResourcePipeline.Post); - entity = _hookExecutor.OnReturn(AsList(entity), ResourcePipeline.Get).SingleOrDefault(); - } - return entity; - } - - public virtual async Task DeleteAsync(TId id) - { - _logger.LogTrace($"Entering {nameof(DeleteAsync)}('{id}')."); - - if (!IsNull(_hookExecutor)) - { - var entity = _resourceFactory.CreateInstance(); - entity.Id = id; - - _hookExecutor.BeforeDelete(AsList(entity), ResourcePipeline.Delete); - } - - var succeeded = await _repository.DeleteAsync(id); - if (!succeeded) - { - string resourceId = TypeHelper.GetResourceStringId(id, _resourceFactory); - throw new ResourceNotFoundException(resourceId, _currentRequestResource.ResourceName); - } - - if (!IsNull(_hookExecutor)) - { - var entity = _resourceFactory.CreateInstance(); - entity.Id = id; - - _hookExecutor.AfterDelete(AsList(entity), ResourcePipeline.Delete, succeeded); - } - } - - public virtual async Task> GetAsync() - { - _logger.LogTrace($"Entering {nameof(GetAsync)}()."); - - _hookExecutor?.BeforeRead(ResourcePipeline.Get); - - var entityQuery = _repository.Get(); - entityQuery = ApplyFilter(entityQuery); - entityQuery = ApplySort(entityQuery); - entityQuery = ApplyInclude(entityQuery); - entityQuery = ApplySelect(entityQuery); - - if (!IsNull(_hookExecutor, entityQuery)) - { - var entities = await _repository.ToListAsync(entityQuery); - _hookExecutor.AfterRead(entities, ResourcePipeline.Get); - entityQuery = _hookExecutor.OnReturn(entities, ResourcePipeline.Get).AsQueryable(); - } - - if (_options.IncludeTotalRecordCount) - _pageService.TotalRecords = await _repository.CountAsync(entityQuery); - - // pagination should be done last since it will execute the query - var pagedEntities = await ApplyPageQueryAsync(entityQuery); - return pagedEntities; - } - - public virtual async Task GetAsync(TId id) - { - _logger.LogTrace($"Entering {nameof(GetAsync)}('{id}')."); - - var pipeline = ResourcePipeline.GetSingle; - _hookExecutor?.BeforeRead(pipeline, id.ToString()); - - var entityQuery = _repository.Get(id); - entityQuery = ApplyFilter(entityQuery); - entityQuery = ApplyInclude(entityQuery); - entityQuery = ApplySelect(entityQuery); - - var entity = await _repository.FirstOrDefaultAsync(entityQuery); - - if (entity == null) - { - string resourceId = TypeHelper.GetResourceStringId(id, _resourceFactory); - throw new ResourceNotFoundException(resourceId, _currentRequestResource.ResourceName); - } - - if (!IsNull(_hookExecutor, entity)) - { - _hookExecutor.AfterRead(AsList(entity), pipeline); - entity = _hookExecutor.OnReturn(AsList(entity), pipeline).SingleOrDefault(); - } - - return entity; - } - - // triggered by GET /articles/1/relationships/{relationshipName} - public virtual async Task GetRelationshipsAsync(TId id, string relationshipName) - { - _logger.LogTrace($"Entering {nameof(GetRelationshipsAsync)}('{id}', '{relationshipName}')."); - - var relationship = GetRelationship(relationshipName); - - // BeforeRead hook execution - _hookExecutor?.BeforeRead(ResourcePipeline.GetRelationship, id.ToString()); - - var entityQuery = ApplyInclude(_repository.Get(id), relationship); - var entity = await _repository.FirstOrDefaultAsync(entityQuery); - - if (entity == null) - { - string resourceId = TypeHelper.GetResourceStringId(id, _resourceFactory); - throw new ResourceNotFoundException(resourceId, _currentRequestResource.ResourceName); - } - - if (!IsNull(_hookExecutor, entity)) - { // AfterRead and OnReturn resource hook execution. - _hookExecutor.AfterRead(AsList(entity), ResourcePipeline.GetRelationship); - entity = _hookExecutor.OnReturn(AsList(entity), ResourcePipeline.GetRelationship).SingleOrDefault(); - } - - return entity; - } - - // triggered by GET /articles/1/{relationshipName} - public virtual async Task GetRelationshipAsync(TId id, string relationshipName) - { - _logger.LogTrace($"Entering {nameof(GetRelationshipAsync)}('{id}', '{relationshipName}')."); - - var relationship = GetRelationship(relationshipName); - var resource = await GetRelationshipsAsync(id, relationshipName); - return relationship.GetValue(resource); - } - - public virtual async Task UpdateAsync(TId id, TResource requestEntity) - { - _logger.LogTrace($"Entering {nameof(UpdateAsync)}('{id}', {(requestEntity == null ? "null" : "object")})."); - - TResource databaseEntity = await _repository.Get(id).FirstOrDefaultAsync(); - if (databaseEntity == null) - { - string resourceId = TypeHelper.GetResourceStringId(id, _resourceFactory); - throw new ResourceNotFoundException(resourceId, _currentRequestResource.ResourceName); - } - - _resourceChangeTracker.SetInitiallyStoredAttributeValues(databaseEntity); - _resourceChangeTracker.SetRequestedAttributeValues(requestEntity); - - requestEntity = IsNull(_hookExecutor) ? requestEntity : _hookExecutor.BeforeUpdate(AsList(requestEntity), ResourcePipeline.Patch).Single(); - - await _repository.UpdateAsync(requestEntity, databaseEntity); - - if (!IsNull(_hookExecutor, databaseEntity)) - { - _hookExecutor.AfterUpdate(AsList(databaseEntity), ResourcePipeline.Patch); - _hookExecutor.OnReturn(AsList(databaseEntity), ResourcePipeline.Patch); - } - - _repository.FlushFromCache(databaseEntity); - TResource afterEntity = await _repository.Get(id).FirstOrDefaultAsync(); - _resourceChangeTracker.SetFinallyStoredAttributeValues(afterEntity); - - bool hasImplicitChanges = _resourceChangeTracker.HasImplicitChanges(); - return hasImplicitChanges ? afterEntity : null; - } - - // triggered by PATCH /articles/1/relationships/{relationshipName} - public virtual async Task UpdateRelationshipsAsync(TId id, string relationshipName, object related) - { - _logger.LogTrace($"Entering {nameof(UpdateRelationshipsAsync)}('{id}', '{relationshipName}', {(related == null ? "null" : "object")})."); - - var relationship = GetRelationship(relationshipName); - var entityQuery = _repository.Include(_repository.Get(id), new[] { relationship }); - var entity = await _repository.FirstOrDefaultAsync(entityQuery); - - if (entity == null) - { - string resourceId = TypeHelper.GetResourceStringId(id, _resourceFactory); - throw new ResourceNotFoundException(resourceId, _currentRequestResource.ResourceName); - } - - entity = IsNull(_hookExecutor) ? entity : _hookExecutor.BeforeUpdate(AsList(entity), ResourcePipeline.PatchRelationship).SingleOrDefault(); - - string[] relationshipIds = null; - if (related != null) - { - relationshipIds = relationship is HasOneAttribute - ? new[] {((IIdentifiable) related).StringId} - : ((IEnumerable) related).Select(e => e.StringId).ToArray(); - } - - await _repository.UpdateRelationshipsAsync(entity, relationship, relationshipIds ?? Array.Empty()); - - if (!IsNull(_hookExecutor, entity)) _hookExecutor.AfterUpdate(AsList(entity), ResourcePipeline.PatchRelationship); - } - - protected virtual async Task> ApplyPageQueryAsync(IQueryable entities) - { - if (_pageService.PageSize == 0) - { - _logger.LogDebug("Fetching complete result set."); - - return await _repository.ToListAsync(entities); - } - - int pageOffset = _pageService.CurrentPage; - if (_pageService.Backwards) - { - pageOffset = -pageOffset; - } - - _logger.LogDebug($"Fetching paged result set at page {pageOffset} with size {_pageService.PageSize}."); - - return await _repository.PageAsync(entities, _pageService.PageSize, pageOffset); - } - - /// - /// Applies sort queries - /// - /// - /// - protected virtual IQueryable ApplySort(IQueryable entities) - { - var queries = _sortService.Get(); - entities = _repository.Sort(entities, queries); - return entities; - } - - /// - /// Applies filter queries - /// - /// - /// - protected virtual IQueryable ApplyFilter(IQueryable entities) - { - var queries = _filterService.Get(); - if (queries != null && queries.Any()) - foreach (var query in queries) - entities = _repository.Filter(entities, query); - - return entities; - } - - /// - /// Applies include queries - /// - protected virtual IQueryable ApplyInclude(IQueryable entities, RelationshipAttribute chainPrefix = null) - { - var chains = _includeService.Get(); - - if (chainPrefix != null) - { - chains.Add(new List()); - } - - foreach (var inclusionChain in chains) - { - if (chainPrefix != null) - { - inclusionChain.Insert(0, chainPrefix); - } - - entities = _repository.Include(entities, inclusionChain); - } - - return entities; - } - - /// - /// Applies sparse field selection to queries - /// - protected virtual IQueryable ApplySelect(IQueryable entities) - { - var propertyNames = _sparseFieldsService.GetAll(); - - if (propertyNames.Any()) - { - // All resources without a sparse fieldset specified must be entirely selected. - EnsureResourcesWithoutSparseFieldSetAreAddedToSelect(propertyNames); - } - - entities = _repository.Select(entities, propertyNames); - return entities; - } - - private void EnsureResourcesWithoutSparseFieldSetAreAddedToSelect(ISet propertyNames) - { - bool hasTopLevelSparseFieldSet = propertyNames.Any(x => !x.Contains(".")); - if (!hasTopLevelSparseFieldSet) - { - var topPropertyNames = _currentRequestResource.Attributes - .Where(x => x.Property.SetMethod != null) - .Select(x => x.Property.Name); - propertyNames.AddRange(topPropertyNames); - } - - var chains = _includeService.Get(); - foreach (var inclusionChain in chains) - { - string relationshipPath = null; - foreach (var relationship in inclusionChain) - { - relationshipPath = relationshipPath == null - ? relationship.RelationshipPath - : $"{relationshipPath}.{relationship.RelationshipPath}"; - } - - if (relationshipPath != null) - { - bool hasRelationSparseFieldSet = propertyNames.Any(x => x.StartsWith(relationshipPath + ".")); - if (!hasRelationSparseFieldSet) - { - propertyNames.Add(relationshipPath); - } - } - } - } - - /// - /// Get the specified id with relationships provided in the post request - /// - private async Task GetWithRelationshipsAsync(TId id) - { - var query = _repository.Get(id); - query = ApplyInclude(query); - query = ApplySelect(query); - - var entity = await _repository.FirstOrDefaultAsync(query); - return entity; - } - - private bool IsNull(params object[] values) - { - foreach (var val in values) - { - if (val == null) return true; - } - return false; - } - - private RelationshipAttribute GetRelationship(string relationshipName) - { - var relationship = _currentRequestResource.Relationships.SingleOrDefault(r => relationshipName == r.PublicName); - if (relationship == null) - { - throw new RelationshipNotFoundException(relationshipName, _currentRequestResource.ResourceName); - } - return relationship; - } - - private List AsList(TResource entity) - { - return new List { entity }; - } - } - - /// - /// No mapping with integer as default - /// - /// - public class DefaultResourceService : DefaultResourceService, - IResourceService - where TResource : class, IIdentifiable - { - public DefaultResourceService( - IEnumerable queryParameters, - IJsonApiOptions options, - ILoggerFactory loggerFactory, - IResourceRepository repository, - IResourceContextProvider provider, - IResourceChangeTracker resourceChangeTracker, - IResourceFactory resourceFactory, - IResourceHookExecutor hookExecutor = null) - : base(queryParameters, options, loggerFactory, repository, provider, resourceChangeTracker, resourceFactory, hookExecutor) - { } - } -} diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs new file mode 100644 index 0000000000..0dee0d2561 --- /dev/null +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -0,0 +1,393 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Data; +using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Hooks; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using JsonApiDotNetCore.Exceptions; +using JsonApiDotNetCore.Internal.Queries; +using JsonApiDotNetCore.Internal.Queries.Expressions; +using JsonApiDotNetCore.Models.Annotation; +using JsonApiDotNetCore.RequestServices; +using JsonApiDotNetCore.RequestServices.Contracts; + +namespace JsonApiDotNetCore.Services +{ + public class JsonApiResourceService : + IResourceService + where TResource : class, IIdentifiable + { + private readonly IResourceRepository _repository; + private readonly IQueryLayerComposer _queryLayerComposer; + private readonly IPaginationContext _paginationContext; + private readonly IJsonApiOptions _options; + private readonly ICurrentRequest _currentRequest; + private readonly ILogger _logger; + private readonly IResourceChangeTracker _resourceChangeTracker; + private readonly IResourceFactory _resourceFactory; + private readonly IResourceHookExecutor _hookExecutor; + + public JsonApiResourceService( + IResourceRepository repository, + IQueryLayerComposer queryLayerComposer, + IPaginationContext paginationContext, + IJsonApiOptions options, + ILoggerFactory loggerFactory, + ICurrentRequest currentRequest, + IResourceChangeTracker resourceChangeTracker, + IResourceFactory resourceFactory, + IResourceHookExecutor hookExecutor = null) + { + _repository = repository; + _queryLayerComposer = queryLayerComposer; + _paginationContext = paginationContext; + _options = options; + _currentRequest = currentRequest; + _logger = loggerFactory.CreateLogger>(); + _resourceChangeTracker = resourceChangeTracker; + _resourceFactory = resourceFactory; + _hookExecutor = hookExecutor; + } + + public virtual async Task CreateAsync(TResource resource) + { + _logger.LogTrace($"Entering {nameof(CreateAsync)}(object)."); + + 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) + { + _logger.LogTrace($"Entering {nameof(DeleteAsync)}('{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); + } + } + + public virtual async Task> GetAsync() + { + _logger.LogTrace($"Entering {nameof(GetAsync)}()."); + + _hookExecutor?.BeforeRead(ResourcePipeline.Get); + + if (_options.IncludeTotalResourceCount) + { + var topFilter = _queryLayerComposer.GetTopFilter(); + _paginationContext.TotalResourceCount = await _repository.CountAsync(topFilter); + } + + var queryLayer = _queryLayerComposer.Compose(_currentRequest.PrimaryResource); + var resources = await _repository.GetAsync(queryLayer); + + if (_hookExecutor != null) + { + _hookExecutor.AfterRead(resources, ResourcePipeline.Get); + return _hookExecutor.OnReturn(resources, ResourcePipeline.Get).ToList(); + } + + return resources; + } + + public virtual async Task GetAsync(TId id) + { + _logger.LogTrace($"Entering {nameof(GetAsync)}('{id}')."); + + _hookExecutor?.BeforeRead(ResourcePipeline.GetSingle, id.ToString()); + + var primaryResource = await GetPrimaryResourceById(id, true); + + if (_hookExecutor != null) + { + _hookExecutor.AfterRead(AsList(primaryResource), ResourcePipeline.GetSingle); + return _hookExecutor.OnReturn(AsList(primaryResource), ResourcePipeline.GetSingle).Single(); + } + + return primaryResource; + } + + private async Task GetPrimaryResourceById(TId id, bool allowTopSparseFieldSet) + { + var primaryLayer = _queryLayerComposer.Compose(_currentRequest.PrimaryResource); + primaryLayer.Sort = null; + primaryLayer.Pagination = null; + primaryLayer.Filter = CreateFilterById(id); + + if (!allowTopSparseFieldSet && primaryLayer.Projection != null) + { + // Discard any ?fields= or attribute exclusions from ResourceDefinition, because we need the full record. + + while (primaryLayer.Projection.Any(p => p.Key is AttrAttribute)) + { + primaryLayer.Projection.Remove(primaryLayer.Projection.First(p => p.Key is AttrAttribute)); + } + } + + var primaryResources = await _repository.GetAsync(primaryLayer); + + var primaryResource = primaryResources.SingleOrDefault(); + AssertPrimaryResourceExists(primaryResource); + + return primaryResource; + } + + private FilterExpression CreateFilterById(TId id) + { + var primaryIdAttribute = _currentRequest.PrimaryResource.Attributes.Single(a => a.Property.Name == nameof(Identifiable.Id)); + + return new ComparisonExpression(ComparisonOperator.Equals, + new ResourceFieldChainExpression(primaryIdAttribute), new LiteralConstantExpression(id.ToString())); + } + + // triggered by GET /articles/1/relationships/{relationshipName} + public virtual async Task GetRelationshipAsync(TId id, string relationshipName) + { + _logger.LogTrace($"Entering {nameof(GetRelationshipAsync)}('{id}', '{relationshipName}')."); + + AssertRelationshipExists(relationshipName); + + _hookExecutor?.BeforeRead(ResourcePipeline.GetRelationship, id.ToString()); + + var secondaryLayer = _queryLayerComposer.Compose(_currentRequest.SecondaryResource); + + var secondaryIdAttribute = _currentRequest.SecondaryResource.Attributes.Single(a => a.Property.Name == nameof(Identifiable.Id)); + + secondaryLayer.Include = null; + secondaryLayer.Projection = new Dictionary + { + [secondaryIdAttribute] = null + }; + + var primaryLayer = GetPrimaryLayerForSecondaryEndpoint(secondaryLayer, id); + + 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(); + } + + return primaryResource; + } + + // triggered by GET /articles/1/{relationshipName} + public virtual async Task GetSecondaryAsync(TId id, string relationshipName) + { + _logger.LogTrace($"Entering {nameof(GetSecondaryAsync)}('{id}', '{relationshipName}')."); + + AssertRelationshipExists(relationshipName); + + _hookExecutor?.BeforeRead(ResourcePipeline.GetRelationship, id.ToString()); + + var secondaryLayer = _queryLayerComposer.Compose(_currentRequest.SecondaryResource); + var primaryLayer = GetPrimaryLayerForSecondaryEndpoint(secondaryLayer, id); + + 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(); + } + + return _currentRequest.Relationship.GetValue(primaryResource); + } + + private QueryLayer GetPrimaryLayerForSecondaryEndpoint(QueryLayer secondaryLayer, TId primaryId) + { + var innerInclude = secondaryLayer.Include; + secondaryLayer.Include = null; + + var primaryIdAttribute = + _currentRequest.PrimaryResource.Attributes.Single(a => a.Property.Name == nameof(Identifiable.Id)); + + return new QueryLayer(_currentRequest.PrimaryResource) + { + Include = RewriteIncludeForSecondaryEndpoint(innerInclude), + Filter = CreateFilterById(primaryId), + Projection = new Dictionary + { + [primaryIdAttribute] = null, + [_currentRequest.Relationship] = secondaryLayer + } + }; + } + + private IncludeExpression RewriteIncludeForSecondaryEndpoint(IncludeExpression relativeInclude) + { + if (relativeInclude != null && relativeInclude.Chains.Any()) + { + var absoluteChains = new List(); + foreach (ResourceFieldChainExpression relativeChain in relativeInclude.Chains) + { + var absoluteFieldsInChain = new List(relativeChain.Fields); + absoluteFieldsInChain.Insert(0, _currentRequest.Relationship); + + var absoluteChain = new ResourceFieldChainExpression(absoluteFieldsInChain); + absoluteChains.Add(absoluteChain); + } + + return new IncludeExpression(absoluteChains); + } + + return new IncludeExpression(new[] {new ResourceFieldChainExpression(_currentRequest.Relationship)}); + } + + public virtual async Task UpdateAsync(TId id, TResource requestResource) + { + _logger.LogTrace($"Entering {nameof(UpdateAsync)}('{id}', {(requestResource == null ? "null" : "object")})."); + + TResource databaseResource = await GetPrimaryResourceById(id, false); + + _resourceChangeTracker.SetInitiallyStoredAttributeValues(databaseResource); + _resourceChangeTracker.SetRequestedAttributeValues(requestResource); + + if (_hookExecutor != null) + { + requestResource = _hookExecutor.BeforeUpdate(AsList(requestResource), ResourcePipeline.Patch).Single(); + } + + await _repository.UpdateAsync(requestResource, databaseResource); + + if (_hookExecutor != null) + { + _hookExecutor.AfterUpdate(AsList(databaseResource), ResourcePipeline.Patch); + _hookExecutor.OnReturn(AsList(databaseResource), ResourcePipeline.Patch); + } + + _repository.FlushFromCache(databaseResource); + TResource afterResource = await GetPrimaryResourceById(id, false); + _resourceChangeTracker.SetFinallyStoredAttributeValues(afterResource); + + bool hasImplicitChanges = _resourceChangeTracker.HasImplicitChanges(); + return hasImplicitChanges ? afterResource : null; + } + + // triggered by PATCH /articles/1/relationships/{relationshipName} + public virtual async Task UpdateRelationshipAsync(TId id, string relationshipName, object related) + { + _logger.LogTrace($"Entering {nameof(UpdateRelationshipAsync)}('{id}', '{relationshipName}', {(related == null ? "null" : "object")})."); + + AssertRelationshipExists(relationshipName); + + var secondaryLayer = _queryLayerComposer.Compose(_currentRequest.SecondaryResource); + var primaryLayer = GetPrimaryLayerForSecondaryEndpoint(secondaryLayer, id); + primaryLayer.Projection = null; + + var primaryResources = await _repository.GetAsync(primaryLayer); + + var primaryResource = primaryResources.SingleOrDefault(); + AssertPrimaryResourceExists(primaryResource); + + if (_hookExecutor != null) + { + primaryResource = _hookExecutor.BeforeUpdate(AsList(primaryResource), ResourcePipeline.PatchRelationship).Single(); + } + + string[] relationshipIds = null; + if (related != null) + { + relationshipIds = _currentRequest.Relationship is HasOneAttribute + ? new[] {((IIdentifiable) related).StringId} + : ((IEnumerable) related).Select(e => e.StringId).ToArray(); + } + + await _repository.UpdateRelationshipsAsync(primaryResource, _currentRequest.Relationship, relationshipIds ?? Array.Empty()); + + if (_hookExecutor != null && primaryResource != null) + { + _hookExecutor.AfterUpdate(AsList(primaryResource), ResourcePipeline.PatchRelationship); + } + } + + private void AssertPrimaryResourceExists(TResource resource) + { + if (resource == null) + { + throw new ResourceNotFoundException(_currentRequest.PrimaryId, _currentRequest.PrimaryResource.ResourceName); + } + } + + private void AssertRelationshipExists(string relationshipName) + { + var relationship = _currentRequest.Relationship; + if (relationship == null) + { + throw new RelationshipNotFoundException(relationshipName, _currentRequest.PrimaryResource.ResourceName); + } + } + + private static List AsList(TResource resource) + { + return new List { resource }; + } + } + + /// + /// No mapping with integer as default + /// + /// + public class JsonApiResourceService : JsonApiResourceService, + IResourceService + where TResource : class, IIdentifiable + { + public JsonApiResourceService( + IResourceRepository repository, + IQueryLayerComposer queryLayerComposer, + IPaginationContext paginationContext, + IJsonApiOptions options, + ILoggerFactory loggerFactory, + ICurrentRequest currentRequest, + IResourceChangeTracker resourceChangeTracker, + IResourceFactory resourceFactory, + IResourceHookExecutor hookExecutor = null) + : base(repository, queryLayerComposer, paginationContext, options, loggerFactory, currentRequest, + resourceChangeTracker, resourceFactory, hookExecutor) + { } + } +} diff --git a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs index 3f1266b3c6..2287f8b61f 100644 --- a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs +++ b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs @@ -7,10 +7,11 @@ using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Internal.Generics; -using JsonApiDotNetCore.Managers.Contracts; +using JsonApiDotNetCore.Internal.Queries; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Query; using JsonApiDotNetCore.RequestServices; +using JsonApiDotNetCore.RequestServices.Contracts; using JsonApiDotNetCore.Serialization; using JsonApiDotNetCore.Serialization.Server.Builders; using JsonApiDotNetCore.Services; @@ -45,8 +46,11 @@ public ServiceDiscoveryFacadeTests() _services.AddScoped(_ => new Mock().Object); _services.AddScoped(_ => new Mock().Object); _services.AddScoped(_ => new Mock().Object); - _services.AddScoped(typeof(IResourceChangeTracker<>), typeof(DefaultResourceChangeTracker<>)); + _services.AddScoped(typeof(IResourceChangeTracker<>), typeof(ResourceChangeTracker<>)); _services.AddScoped(_ => new Mock().Object); + _services.AddScoped(_ => new Mock().Object); + _services.AddScoped(_ => new Mock().Object); + _services.AddTransient(_ => new Mock().Object); _resourceGraphBuilder = new ResourceGraphBuilder(options, NullLoggerFactory.Instance); } @@ -63,11 +67,9 @@ public void AddAssembly_Adds_All_Resources_To_Graph() var resourceGraph = _resourceGraphBuilder.Build(); var personResource = resourceGraph.GetResourceContext(typeof(Person)); var articleResource = resourceGraph.GetResourceContext(typeof(Article)); - var modelResource = resourceGraph.GetResourceContext(typeof(Model)); Assert.NotNull(personResource); Assert.NotNull(articleResource); - Assert.NotNull(modelResource); } [Fact] @@ -107,22 +109,25 @@ public void AddCurrentAssembly_Adds_Repositories_To_Container() public sealed class TestModel : Identifiable { } - public class TestModelService : DefaultResourceService + public class TestModelService : JsonApiResourceService { public TestModelService( - IEnumerable queryParameters, + IResourceRepository repository, + IQueryLayerComposer queryLayerComposer, + IPaginationContext paginationContext, IJsonApiOptions options, ILoggerFactory loggerFactory, - IResourceRepository repository, - IResourceContextProvider provider, + ICurrentRequest currentRequest, IResourceChangeTracker resourceChangeTracker, IResourceFactory resourceFactory, IResourceHookExecutor hookExecutor = null) - : base(queryParameters, options, loggerFactory, repository, provider, resourceChangeTracker, resourceFactory, hookExecutor) - { } + : base(repository, queryLayerComposer, paginationContext, options, loggerFactory, currentRequest, + resourceChangeTracker, resourceFactory, hookExecutor) + { + } } - public class TestModelRepository : DefaultResourceRepository + public class TestModelRepository : EntityFrameworkCoreRepository { internal static IDbContextResolver _dbContextResolver; @@ -131,8 +136,9 @@ public TestModelRepository( IResourceGraph resourceGraph, IGenericServiceFactory genericServiceFactory, IResourceFactory resourceFactory, + IEnumerable constraintProviders, ILoggerFactory loggerFactory) - : base(targetedFields, _dbContextResolver, resourceGraph, genericServiceFactory, resourceFactory, loggerFactory) + : base(targetedFields, _dbContextResolver, resourceGraph, genericServiceFactory, resourceFactory, constraintProviders, loggerFactory) { } } } diff --git a/test/IntegrationTests/Data/EntityFrameworkCoreRepositoryTests.cs b/test/IntegrationTests/Data/EntityFrameworkCoreRepositoryTests.cs new file mode 100644 index 0000000000..b00ab40599 --- /dev/null +++ b/test/IntegrationTests/Data/EntityFrameworkCoreRepositoryTests.cs @@ -0,0 +1,110 @@ +using JsonApiDotNetCore.Builders; +using JsonApiDotNetCore.Data; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Serialization; +using JsonApiDotNetCoreExample.Data; +using JsonApiDotNetCoreExample.Models; +using Microsoft.EntityFrameworkCore; +using Moq; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using IntegrationTests; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Internal.Contracts; +using JsonApiDotNetCore.Internal.Queries; +using JsonApiDotNetCore.Internal.Queries.Expressions; +using JsonApiDotNetCore.Models.Annotation; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace JADNC.IntegrationTests.Data +{ + public sealed class EntityFrameworkCoreRepositoryTests + { + [Fact] + public async Task UpdateAsync_AttributesUpdated_ShouldHaveSpecificallyThoseAttributesUpdated() + { + // Arrange + var itemId = 213; + var seed = Guid.NewGuid(); + + var databaseResource = new TodoItem + { + Id = itemId, + Description = "Before" + }; + + var todoItemUpdates = new TodoItem + { + Id = itemId, + Description = "After" + }; + + await using (var arrangeDbContext = GetDbContext(seed)) + { + var (repository, targetedFields, _) = Setup(arrangeDbContext); + arrangeDbContext.Add(databaseResource); + await arrangeDbContext.SaveChangesAsync(); + + var descAttr = new AttrAttribute("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()); + + // Act + await repository.UpdateAsync(todoItemUpdates, databaseResource); + } + + // Assert - in different context + await using (var assertDbContext = GetDbContext(seed)) + { + var (repository, _, resourceGraph) = Setup(assertDbContext); + + var resourceContext = resourceGraph.GetResourceContext(); + var idAttribute = resourceContext.Attributes.Single(a => a.Property.Name == nameof(Identifiable.Id)); + + var resources = await repository.GetAsync(new QueryLayer(resourceContext) + { + Filter = new ComparisonExpression(ComparisonOperator.Equals, + new ResourceFieldChainExpression(idAttribute), + new LiteralConstantExpression(itemId.ToString())) + }); + + var fetchedTodo = resources.First(); + Assert.NotNull(fetchedTodo); + Assert.Equal(databaseResource.Ordinal, fetchedTodo.Ordinal); + Assert.Equal(todoItemUpdates.Description, fetchedTodo.Description); + } + } + + private (EntityFrameworkCoreRepository Repository, Mock TargetedFields, IResourceGraph resourceGraph) Setup(AppDbContext context) + { + var serviceProvider = ((IInfrastructure) context).Instance; + var resourceFactory = new ResourceFactory(serviceProvider); + var contextResolverMock = new Mock(); + contextResolverMock.Setup(m => m.GetContext()).Returns(context); + var resourceGraph = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance).AddResource().Build(); + var targetedFields = new Mock(); + var repository = new EntityFrameworkCoreRepository(targetedFields.Object, contextResolverMock.Object, resourceGraph, null, resourceFactory, new List(), NullLoggerFactory.Instance); + return (repository, targetedFields, resourceGraph); + } + + private AppDbContext GetDbContext(Guid? seed = null) + { + Guid actualSeed = seed ?? Guid.NewGuid(); + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: $"IntegrationDatabaseRepository{actualSeed}") + .Options; + var context = new AppDbContext(options, new FrozenSystemClock()); + + context.RemoveRange(context.TodoItems); + return context; + } + } +} diff --git a/test/IntegrationTests/Data/EntityRepositoryTests.cs b/test/IntegrationTests/Data/EntityRepositoryTests.cs deleted file mode 100644 index 4d62b0525c..0000000000 --- a/test/IntegrationTests/Data/EntityRepositoryTests.cs +++ /dev/null @@ -1,202 +0,0 @@ -using JsonApiDotNetCore.Builders; -using JsonApiDotNetCore.Data; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Serialization; -using JsonApiDotNetCoreExample.Data; -using JsonApiDotNetCoreExample.Models; -using Microsoft.EntityFrameworkCore; -using Moq; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using IntegrationTests; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Models.Annotation; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.Extensions.Logging.Abstractions; -using Xunit; - -namespace JADNC.IntegrationTests.Data -{ - public sealed class EntityRepositoryTests - { - [Fact] - public async Task UpdateAsync_AttributesUpdated_ShouldHaveSpecificallyThoseAttributesUpdated() - { - // Arrange - var itemId = 213; - var seed = Guid.NewGuid(); - - var databaseEntity = new TodoItem - { - Id = itemId, - Description = "Before" - }; - - var todoItemUpdates = new TodoItem - { - Id = itemId, - Description = "After" - }; - - await using (var arrangeContext = GetContext(seed)) - { - var (repository, targetedFields) = Setup(arrangeContext); - arrangeContext.Add(databaseEntity); - arrangeContext.SaveChanges(); - - var descAttr = new AttrAttribute("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()); - - // Act - await repository.UpdateAsync(todoItemUpdates, databaseEntity); - } - - // Assert - in different context - await using var assertContext = GetContext(seed); - { - var (repository, _) = Setup(assertContext); - - var fetchedTodo = repository.Get(itemId).First(); - Assert.NotNull(fetchedTodo); - Assert.Equal(databaseEntity.Ordinal, fetchedTodo.Ordinal); - Assert.Equal(todoItemUpdates.Description, fetchedTodo.Description); - - } - } - - [Theory] - [InlineData(3, 2, new[] { 4, 5, 6 })] - [InlineData(8, 2, new[] { 9 })] - [InlineData(20, 1, new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9 })] - public async Task Paging_PageNumberIsPositive_ReturnCorrectIdsAtTheFront(int pageSize, int pageNumber, int[] expectedResult) - { - // Arrange - await using var context = GetContext(); - var (repository, _) = Setup(context); - context.AddRange(TodoItems(1, 2, 3, 4, 5, 6, 7, 8, 9).Cast()); - await context.SaveChangesAsync(); - - // Act - var result = await repository.PageAsync(context.Set(), pageSize, pageNumber); - - // Assert - Assert.Equal(TodoItems(expectedResult), result, new IdComparer()); - } - - [Theory] - [InlineData(0)] - [InlineData(-1)] - [InlineData(-10)] - public async Task Paging_PageSizeNonPositive_DoNothing(int pageSize) - { - // Arrange - await using var context = GetContext(); - var (repository, _) = Setup(context); - var items = TodoItems(2, 3, 1); - context.AddRange(items.Cast()); - await context.SaveChangesAsync(); - - // Act - var result = await repository.PageAsync(context.Set(), pageSize, 3); - - // Assert - Assert.Equal(items.ToList(), result.ToList(), new IdComparer()); - } - - [Fact] - public async Task Paging_PageNumberDoesNotExist_ReturnEmptyAQueryable() - { - // Arrange - var items = TodoItems(2, 3, 1); - await using var context = GetContext(); - var (repository, _) = Setup(context); - context.AddRange(items.Cast()); - - // Act - var result = await repository.PageAsync(context.Set(), 2, 3); - - // Assert - Assert.Empty(result); - } - - [Fact] - public async Task Paging_PageNumberIsZero_PretendsItsOne() - { - // Arrange - await using var context = GetContext(); - var (repository, _) = Setup(context); - context.AddRange(TodoItems(2, 3, 4, 5, 6, 7, 8, 9).Cast()); - await context.SaveChangesAsync(); - - // Act - var result = await repository.PageAsync(entities: context.Set(), pageSize: 1, pageNumber: 0); - - // Assert - Assert.Equal(TodoItems(2), result, new IdComparer()); - } - - [Theory] - [InlineData(6, -1, new[] { 9, 8, 7, 6, 5, 4 })] - [InlineData(6, -2, new[] { 3, 2, 1 })] - [InlineData(20, -1, new[] { 9, 8, 7, 6, 5, 4, 3, 2, 1 })] - public async Task Paging_PageNumberIsNegative_GiveBackReverseAmountOfIds(int pageSize, int pageNumber, int[] expectedIds) - { - // Arrange - await using var context = GetContext(); - var repository = Setup(context).Repository; - context.AddRange(TodoItems(1, 2, 3, 4, 5, 6, 7, 8, 9).Cast()); - context.SaveChanges(); - - // Act - var result = await repository.PageAsync(context.Set(), pageSize, pageNumber); - - // Assert - Assert.Equal(TodoItems(expectedIds), result, new IdComparer()); - } - - - private (DefaultResourceRepository Repository, Mock TargetedFields) Setup(AppDbContext context) - { - var serviceProvider = ((IInfrastructure) context).Instance; - var resourceFactory = new DefaultResourceFactory(serviceProvider); - var contextResolverMock = new Mock(); - contextResolverMock.Setup(m => m.GetContext()).Returns(context); - var resourceGraph = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance).AddResource().Build(); - var targetedFields = new Mock(); - var repository = new DefaultResourceRepository(targetedFields.Object, contextResolverMock.Object, resourceGraph, null, resourceFactory, NullLoggerFactory.Instance); - return (repository, targetedFields); - } - - private AppDbContext GetContext(Guid? seed = null) - { - Guid actualSeed = seed ?? Guid.NewGuid(); - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(databaseName: $"IntegrationDatabaseRepository{actualSeed}") - .Options; - var context = new AppDbContext(options, new FrozenSystemClock()); - - context.TodoItems.RemoveRange(context.TodoItems); - return context; - } - - private static TodoItem[] TodoItems(params int[] ids) - { - return ids.Select(id => new TodoItem { Id = id }).ToArray(); - } - - private sealed class IdComparer : IEqualityComparer - where T : IIdentifiable - { - public bool Equals(T x, T y) => x?.StringId == y?.StringId; - - public int GetHashCode(T obj) => obj.StringId?.GetHashCode() ?? 0; - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomControllerTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomControllerTests.cs index e3d86523a7..3c764942f2 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomControllerTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomControllerTests.cs @@ -9,6 +9,7 @@ using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; +using Microsoft.AspNetCore; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; using Newtonsoft.Json; @@ -40,7 +41,7 @@ public CustomControllerTests(TestFixture fixture) public async Task NonJsonApiControllers_DoNotUse_Dasherized_Routes() { // Arrange - var builder = new WebHostBuilder() + var builder = WebHost.CreateDefaultBuilder() .UseStartup(); var httpMethod = new HttpMethod("GET"); var route = "testValues"; @@ -60,7 +61,7 @@ public async Task NonJsonApiControllers_DoNotUse_Dasherized_Routes() public async Task CustomRouteControllers_Uses_Dasherized_Collection_Route() { // Arrange - var builder = new WebHostBuilder() + var builder = WebHost.CreateDefaultBuilder() .UseStartup(); var httpMethod = new HttpMethod("GET"); var route = "/custom/route/todoItems"; @@ -87,7 +88,7 @@ public async Task CustomRouteControllers_Uses_Dasherized_Item_Route() context.TodoItems.Add(todoItem); await context.SaveChangesAsync(); - var builder = new WebHostBuilder() + var builder = WebHost.CreateDefaultBuilder() .UseStartup(); var httpMethod = new HttpMethod("GET"); var route = $"/custom/route/todoItems/{todoItem.Id}"; @@ -114,7 +115,7 @@ public async Task CustomRouteControllers_Creates_Proper_Relationship_Links() context.TodoItems.Add(todoItem); await context.SaveChangesAsync(); - var builder = new WebHostBuilder().UseStartup(); + var builder = WebHost.CreateDefaultBuilder().UseStartup(); var httpMethod = new HttpMethod("GET"); var route = $"/custom/route/todoItems/{todoItem.Id}"; @@ -139,7 +140,7 @@ public async Task CustomRouteControllers_Creates_Proper_Relationship_Links() public async Task ApiController_attribute_transforms_NotFound_action_result_without_arguments_into_ProblemDetails() { // Arrange - var builder = new WebHostBuilder().UseStartup(); + var builder = WebHost.CreateDefaultBuilder().UseStartup(); var route = "/custom/route/todoItems/99999999"; var requestBody = new diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomErrorHandlingTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomErrorHandlingTests.cs index cedd7d61d6..09ede037d2 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomErrorHandlingTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomErrorHandlingTests.cs @@ -33,7 +33,7 @@ public void When_using_custom_exception_handler_it_must_create_error_document_an Assert.Contains("Access is denied.", loggerFactory.Logger.Messages[0].Text); } - public class CustomExceptionHandler : DefaultExceptionHandler + public class CustomExceptionHandler : ExceptionHandler { public CustomExceptionHandler(ILoggerFactory loggerFactory, IJsonApiOptions options) : base(loggerFactory, options) diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/IgnoreDefaultValuesTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/IgnoreDefaultValuesTests.cs index 892664e0fc..ebcd18f4a9 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/IgnoreDefaultValuesTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/IgnoreDefaultValuesTests.cs @@ -8,6 +8,7 @@ using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; +using Microsoft.AspNetCore; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; using Newtonsoft.Json; @@ -24,12 +25,12 @@ public sealed class IgnoreDefaultValuesTests : IAsyncLifetime public IgnoreDefaultValuesTests(TestFixture fixture) { _dbContext = fixture.GetService(); - var todoItem = new TodoItem + _todoItem = new TodoItem { CreatedDate = default, Owner = new Person { Age = default } }; - _todoItem = _dbContext.TodoItems.Add(todoItem).Entity; + _dbContext.TodoItems.Add(_todoItem); } public async Task InitializeAsync() @@ -90,12 +91,12 @@ public Task DisposeAsync() [InlineData(DefaultValueHandling.Include, true, "", null)] public async Task CheckBehaviorCombination(DefaultValueHandling? defaultValue, bool? allowQueryStringOverride, string queryStringValue, DefaultValueHandling? expected) { - var builder = new WebHostBuilder().UseStartup(); + var builder = WebHost.CreateDefaultBuilder().UseStartup(); var server = new TestServer(builder); var services = server.Host.Services; var client = server.CreateClient(); - var options = (IJsonApiOptions)services.GetService(typeof(IJsonApiOptions)); + var options = (JsonApiOptions)services.GetService(typeof(IJsonApiOptions)); if (defaultValue != null) { @@ -149,8 +150,8 @@ public async Task CheckBehaviorCombination(DefaultValueHandling? defaultValue, b var errorDocument = JsonConvert.DeserializeObject(body); Assert.Single(errorDocument.Errors); Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].StatusCode); - Assert.Equal("The specified query string value must be 'true' or 'false'.", errorDocument.Errors[0].Title); - Assert.Equal("The value 'unknown' for parameter 'defaults' is not a valid boolean value.", errorDocument.Errors[0].Detail); + Assert.Equal("The specified defaults is invalid.", errorDocument.Errors[0].Title); + Assert.Equal("The value 'unknown' must be 'true' or 'false'.", errorDocument.Errors[0].Detail); Assert.Equal("defaults", errorDocument.Errors[0].Source.Parameter); } else diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/IgnoreNullValuesTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/IgnoreNullValuesTests.cs index a1dc725a22..a2dc34e045 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/IgnoreNullValuesTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/IgnoreNullValuesTests.cs @@ -8,6 +8,7 @@ using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; +using Microsoft.AspNetCore; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; using Newtonsoft.Json; @@ -24,7 +25,7 @@ public sealed class IgnoreNullValuesTests : IAsyncLifetime public IgnoreNullValuesTests(TestFixture fixture) { _dbContext = fixture.GetService(); - var todoItem = new TodoItem + _todoItem = new TodoItem { Description = null, Ordinal = 1, @@ -32,7 +33,7 @@ public IgnoreNullValuesTests(TestFixture fixture) AchievedDate = new DateTime(2002, 2,4), Owner = new Person { FirstName = "Bob", LastName = null } }; - _todoItem = _dbContext.TodoItems.Add(todoItem).Entity; + _dbContext.TodoItems.Add(_todoItem); } public async Task InitializeAsync() @@ -93,12 +94,12 @@ public Task DisposeAsync() [InlineData(NullValueHandling.Include, true, "", null)] public async Task CheckBehaviorCombination(NullValueHandling? defaultValue, bool? allowQueryStringOverride, string queryStringValue, NullValueHandling? expected) { - var builder = new WebHostBuilder().UseStartup(); + var builder = WebHost.CreateDefaultBuilder().UseStartup(); var server = new TestServer(builder); var services = server.Host.Services; var client = server.CreateClient(); - var options = (IJsonApiOptions)services.GetService(typeof(IJsonApiOptions)); + var options = (JsonApiOptions)services.GetService(typeof(IJsonApiOptions)); if (defaultValue != null) { @@ -152,8 +153,8 @@ public async Task CheckBehaviorCombination(NullValueHandling? defaultValue, bool var errorDocument = JsonConvert.DeserializeObject(body); Assert.Single(errorDocument.Errors); Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].StatusCode); - Assert.Equal("The specified query string value must be 'true' or 'false'.", errorDocument.Errors[0].Title); - Assert.Equal("The value 'unknown' for parameter 'nulls' is not a valid boolean value.", errorDocument.Errors[0].Detail); + Assert.Equal("The specified nulls is invalid.", errorDocument.Errors[0].Title); + Assert.Equal("The value 'unknown' must be 'true' or 'false'.", errorDocument.Errors[0].Detail); Assert.Equal("nulls", errorDocument.Errors[0].Source.Parameter); } else diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/RequestMetaTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/RequestMetaTests.cs index b56c6f0bb3..0fd720a47d 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/RequestMetaTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/RequestMetaTests.cs @@ -1,65 +1,56 @@ using System.Net; -using System.Net.Http; using System.Threading.Tasks; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.TestHost; using Xunit; -using JsonApiDotNetCoreExample.Models; using JsonApiDotNetCore.Models; -using System.Collections; +using System.Collections.Generic; +using FluentAssertions; +using JsonApiDotNetCore.Services; using JsonApiDotNetCoreExample; +using JsonApiDotNetCoreExample.Data; +using Microsoft.Extensions.DependencyInjection; namespace JsonApiDotNetCoreExampleTests.Acceptance.Extensibility { - [Collection("WebHostCollection")] - public sealed class RequestMetaTests + public sealed class RequestMetaTests : IClassFixture> { - private readonly TestFixture _fixture; + private readonly IntegrationTestContext _testContext; - public RequestMetaTests(TestFixture fixture) + public RequestMetaTests(IntegrationTestContext testContext) { - _fixture = fixture; + _testContext = testContext; + + testContext.ConfigureServicesBeforeStartup(services => + { + services.AddScoped(); + }); } [Fact] public async Task Injecting_IRequestMeta_Adds_Meta_Data() { // Arrange - var builder = new WebHostBuilder() - .UseStartup(); - - var httpMethod = new HttpMethod("GET"); var route = "/api/v1/people"; - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(httpMethod, route); - var expectedMeta = (_fixture.GetService>() as IHasMeta).GetMeta(); - // Act - var response = await client.SendAsync(request); - var body = await response.Content.ReadAsStringAsync(); - var meta = _fixture.GetDeserializer().DeserializeList(body).Meta; + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.NotNull(meta); - Assert.NotNull(expectedMeta); - Assert.NotEmpty(expectedMeta); + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - foreach (var hash in expectedMeta) + responseDocument.Meta.Should().NotBeNull(); + responseDocument.Meta.ContainsKey("request-meta").Should().BeTrue(); + responseDocument.Meta["request-meta"].Should().Be("request-meta-value"); + } + } + + public sealed class TestRequestMeta : IRequestMeta + { + public Dictionary GetMeta() + { + return new Dictionary { - if (hash.Value is IList listValue) - { - for (var i = 0; i < listValue.Count; i++) - Assert.Equal(listValue[i].ToString(), ((IList)meta[hash.Key])[i].ToString()); - } - else - { - Assert.Equal(hash.Value, meta[hash.Key]); - } - } - Assert.Equal("request-meta-value", meta["request-meta"]); + {"request-meta", "request-meta-value"} + }; } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/HttpReadOnlyTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/HttpReadOnlyTests.cs index 9154fd08fc..97187de056 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/HttpReadOnlyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/HttpReadOnlyTests.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using JsonApiDotNetCore.Models.JsonApiDocuments; using JsonApiDotNetCoreExample; +using Microsoft.AspNetCore; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; using Newtonsoft.Json; @@ -92,7 +93,7 @@ public async Task Rejects_DELETE_Requests() private async Task MakeRequestAsync(string route, string method) { - var builder = new WebHostBuilder() + var builder = WebHost.CreateDefaultBuilder() .UseStartup(); var httpMethod = new HttpMethod(method); var server = new TestServer(builder); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpDeleteTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpDeleteTests.cs index f77c0c5335..e68da12e2c 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpDeleteTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpDeleteTests.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using JsonApiDotNetCore.Models.JsonApiDocuments; using JsonApiDotNetCoreExample; +using Microsoft.AspNetCore; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; using Newtonsoft.Json; @@ -78,7 +79,7 @@ public async Task Rejects_DELETE_Requests() private async Task MakeRequestAsync(string route, string method) { - var builder = new WebHostBuilder() + var builder = WebHost.CreateDefaultBuilder() .UseStartup(); var httpMethod = new HttpMethod(method); var server = new TestServer(builder); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpPatchTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpPatchTests.cs index a8d9034a3e..034c81f9c9 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpPatchTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpPatchTests.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using JsonApiDotNetCore.Models.JsonApiDocuments; using JsonApiDotNetCoreExample; +using Microsoft.AspNetCore; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; using Newtonsoft.Json; @@ -78,7 +79,7 @@ public async Task Allows_DELETE_Requests() private async Task MakeRequestAsync(string route, string method) { - var builder = new WebHostBuilder() + var builder = WebHost.CreateDefaultBuilder() .UseStartup(); var httpMethod = new HttpMethod(method); var server = new TestServer(builder); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpPostTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpPostTests.cs index c56a18eafa..dae69ee497 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpPostTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpPostTests.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using JsonApiDotNetCore.Models.JsonApiDocuments; using JsonApiDotNetCoreExample; +using Microsoft.AspNetCore; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; using Newtonsoft.Json; @@ -78,7 +79,7 @@ public async Task Allows_DELETE_Requests() private async Task MakeRequestAsync(string route, string method) { - var builder = new WebHostBuilder() + var builder = WebHost.CreateDefaultBuilder() .UseStartup(); var httpMethod = new HttpMethod(method); var server = new TestServer(builder); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/InjectableResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/InjectableResourceTests.cs index f846325b7f..ee009d4b25 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/InjectableResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/InjectableResourceTests.cs @@ -54,7 +54,7 @@ public async Task Can_Get_Single_Passport() passport.BirthCountry = _countryFaker.Generate(); _context.Passports.Add(passport); - _context.SaveChanges(); + await _context.SaveChangesAsync(); var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/passports/" + passport.StringId); @@ -75,8 +75,8 @@ public async Task Can_Get_Single_Passport() public async Task Can_Get_Passports() { // Arrange - _context.Passports.RemoveRange(_context.Passports); - _context.SaveChanges(); + await _context.ClearTableAsync(); + await _context.SaveChangesAsync(); var passports = _passportFaker.Generate(3); foreach (var passport in passports) @@ -85,7 +85,7 @@ public async Task Can_Get_Passports() } _context.Passports.AddRange(passports); - _context.SaveChanges(); + await _context.SaveChangesAsync(); var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/passports"); @@ -108,12 +108,12 @@ public async Task Can_Get_Passports() } } - [Fact] + [Fact(Skip = "Requires fix for https://github.com/dotnet/efcore/issues/20502")] public async Task Can_Get_Passports_With_Filter() { // Arrange - _context.Passports.RemoveRange(_context.Passports); - _context.SaveChanges(); + await _context.ClearTableAsync(); + await _context.SaveChangesAsync(); var passports = _passportFaker.Generate(3); foreach (var passport in passports) @@ -128,9 +128,9 @@ public async Task Can_Get_Passports_With_Filter() passports[2].Person.FirstName= "Joe"; _context.Passports.AddRange(passports); - _context.SaveChanges(); + await _context.SaveChangesAsync(); - var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/passports?include=person&filter[socialSecurityNumber]=12345&filter[person.firstName]=Joe"); + var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/passports?include=person&filter=and(equals(socialSecurityNumber,'12345'),equals(person.firstName,'Joe'))"); // Act var response = await _fixture.Client.SendAsync(request); @@ -152,8 +152,8 @@ public async Task Can_Get_Passports_With_Filter() public async Task Can_Get_Passports_With_Sparse_Fieldset() { // Arrange - _context.Passports.RemoveRange(_context.Passports); - _context.SaveChanges(); + await _context.ClearTableAsync(); + await _context.SaveChangesAsync(); var passports = _passportFaker.Generate(2); foreach (var passport in passports) @@ -163,7 +163,7 @@ public async Task Can_Get_Passports_With_Sparse_Fieldset() } _context.Passports.AddRange(passports); - _context.SaveChanges(); + await _context.SaveChangesAsync(); var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/passports?include=person&fields=socialSecurityNumber&fields[person]=firstName"); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/KebabCaseFormatterTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/KebabCaseFormatterTests.cs index e2e18fef53..6b936df648 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/KebabCaseFormatterTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/KebabCaseFormatterTests.cs @@ -1,22 +1,43 @@ -using System.Linq; +using System; +using System.Linq.Expressions; using System.Net; using System.Threading.Tasks; using Bogus; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Internal.Contracts; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Serialization.Client; +using JsonApiDotNetCoreExample; +using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; -using JsonApiDotNetCoreExampleTests.Acceptance.Spec; -using Newtonsoft.Json; +using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using Newtonsoft.Json.Linq; +using Newtonsoft.Json.Serialization; using Xunit; namespace JsonApiDotNetCoreExampleTests.Acceptance { - public sealed class KebabCaseFormatterTests : FunctionalTestCollection + public sealed class KebabCaseFormatterTests : IClassFixture> { + private readonly IntegrationTestContext _testContext; private readonly Faker _faker; - public KebabCaseFormatterTests(KebabCaseApplicationFactory factory) : base(factory) + public KebabCaseFormatterTests(IntegrationTestContext testContext) { - _faker = new Faker().RuleFor(m => m.CompoundAttr, f => f.Lorem.Sentence()); + _testContext = testContext; + + _faker = new Faker() + .RuleFor(m => m.CompoundAttr, f => f.Lorem.Sentence()); + + testContext.ConfigureServicesAfterStartup(services => + { + var part = new AssemblyPart(typeof(EmptyStartup).Assembly); + services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); + }); } [Fact] @@ -24,16 +45,26 @@ public async Task KebabCaseFormatter_GetAll_IsReturned() { // Arrange var model = _faker.Generate(); - _dbContext.KebabCasedModels.Add(model); - _dbContext.SaveChanges(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.KebabCasedModels.Add(model); + + await dbContext.SaveChangesAsync(); + }); + + var route = "api/v1/kebab-cased-models"; // Act - var (body, response) = await Get("api/v1/kebab-cased-models"); + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); // Assert - AssertEqualStatusCode(HttpStatusCode.OK, response); - var responseItem = _deserializer.DeserializeList(body).Data; - Assert.True(responseItem.Count > 0); + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Id.Should().Be(model.StringId); + responseDocument.ManyData[0].Attributes["compound-attr"].Should().Be(model.CompoundAttr); } [Fact] @@ -41,16 +72,25 @@ public async Task KebabCaseFormatter_GetSingle_IsReturned() { // Arrange var model = _faker.Generate(); - _dbContext.KebabCasedModels.Add(model); - _dbContext.SaveChanges(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.KebabCasedModels.Add(model); + + await dbContext.SaveChangesAsync(); + }); + + var route = "api/v1/kebab-cased-models/" + model.StringId; // Act - var (body, response) = await Get($"api/v1/kebab-cased-models/{model.Id}"); + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); // Assert - AssertEqualStatusCode(HttpStatusCode.OK, response); - var responseItem = _deserializer.DeserializeSingle(body).Data; - Assert.Equal(model.Id, responseItem.Id); + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Id.Should().Be(model.StringId); + responseDocument.SingleData.Attributes["compound-attr"].Should().Be(model.CompoundAttr); } [Fact] @@ -60,13 +100,18 @@ public async Task KebabCaseFormatter_Create_IsCreated() var model = _faker.Generate(); var serializer = GetSerializer(kcm => new { kcm.CompoundAttr }); + var route = "api/v1/kebab-cased-models"; + + var requestBody = serializer.Serialize(model); + // Act - var (body, response) = await Post("api/v1/kebab-cased-models", serializer.Serialize(model)); + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); // Assert - AssertEqualStatusCode(HttpStatusCode.Created, response); - var responseItem = _deserializer.DeserializeSingle(body).Data; - Assert.Equal(model.CompoundAttr, responseItem.CompoundAttr); + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Attributes["compound-attr"].Should().Be(model.CompoundAttr); } [Fact] @@ -74,38 +119,77 @@ public async Task KebabCaseFormatter_Update_IsUpdated() { // Arrange var model = _faker.Generate(); - _dbContext.KebabCasedModels.Add(model); - _dbContext.SaveChanges(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.KebabCasedModels.Add(model); + + await dbContext.SaveChangesAsync(); + }); + model.CompoundAttr = _faker.Generate().CompoundAttr; var serializer = GetSerializer(kcm => new { kcm.CompoundAttr }); + var route = "api/v1/kebab-cased-models/" + model.StringId; + + var requestBody = serializer.Serialize(model); + // Act - var (body, response) = await Patch($"api/v1/kebab-cased-models/{model.Id}", serializer.Serialize(model)); + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert - AssertEqualStatusCode(HttpStatusCode.OK, response); - var responseItem = _deserializer.DeserializeSingle(body).Data; - Assert.Null(responseItem); + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - var stored = _dbContext.KebabCasedModels.Single(x => x.Id == model.Id); - Assert.Equal(model.CompoundAttr, stored.CompoundAttr); + responseDocument.Data.Should().BeNull(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var stored = await dbContext.KebabCasedModels.SingleAsync(x => x.Id == model.Id); + Assert.Equal(model.CompoundAttr, stored.CompoundAttr); + }); } [Fact] public async Task KebabCaseFormatter_ErrorWithStackTrace_CasingConventionIsApplied() { // Arrange - const string content = "{ \"data\": {"; + var route = "api/v1/kebab-cased-models/1"; + + const string requestBody = "{ \"data\": {"; // Act - var (body, response) = await Patch($"api/v1/kebab-cased-models/1", content); + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - // Assert - var document = JsonConvert.DeserializeObject(body); - AssertEqualStatusCode(HttpStatusCode.UnprocessableEntity, response); + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - var meta = document["errors"][0]["meta"]; + var meta = responseDocument["errors"][0]["meta"]; Assert.NotNull(meta["stack-trace"]); } + + private IRequestSerializer GetSerializer(Expression> attributes = null, Expression> relationships = null) where TResource : class, IIdentifiable + { + using var scope = _testContext.Factory.Services.CreateScope(); + var serializer = scope.ServiceProvider.GetRequiredService(); + var graph = scope.ServiceProvider.GetRequiredService(); + + serializer.AttributesToSerialize = attributes != null ? graph.GetAttributes(attributes) : null; + serializer.RelationshipsToSerialize = relationships != null ? graph.GetRelationships(relationships) : null; + + return serializer; + } + } + + public sealed class KebabCaseStartup : TestStartup + { + public KebabCaseStartup(IConfiguration configuration) : base(configuration) + { + } + + protected override void ConfigureJsonApiOptions(JsonApiOptions options) + { + base.ConfigureJsonApiOptions(options); + + ((DefaultContractResolver)options.SerializerSettings.ContractResolver).NamingStrategy = new KebabCaseNamingStrategy(); + } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs index 9ca4b55a70..5aedc9992c 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs @@ -10,6 +10,7 @@ using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; +using Microsoft.AspNetCore; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; using Microsoft.EntityFrameworkCore; @@ -30,109 +31,20 @@ public sealed class ManyToManyTests public ManyToManyTests(TestFixture fixture) { _fixture = fixture; + var context = _fixture.GetService(); _authorFaker = new Faker() - .RuleFor(a => a.Name, f => f.Random.Words(2)); + .RuleFor(a => a.LastName, f => f.Random.Words(2)); _articleFaker = new Faker
() - .RuleFor(a => a.Name, f => f.Random.AlphaNumeric(10)) + .RuleFor(a => a.Caption, f => f.Random.AlphaNumeric(10)) .RuleFor(a => a.Author, f => _authorFaker.Generate()); _tagFaker = new Faker() - .CustomInstantiator(f => new Tag(_fixture.GetService())) + .CustomInstantiator(f => new Tag()) .RuleFor(a => a.Name, f => f.Random.AlphaNumeric(10)); } - [Fact] - public async Task Can_Fetch_Many_To_Many_Through_All() - { - // Arrange - var context = _fixture.GetService(); - var article = _articleFaker.Generate(); - var tag = _tagFaker.Generate(); - - context.Articles.RemoveRange(context.Articles); - await context.SaveChangesAsync(); - - var articleTag = new ArticleTag(context) - { - Article = article, - Tag = tag - }; - context.ArticleTags.Add(articleTag); - await context.SaveChangesAsync(); - var route = "/api/v1/articles?include=tags"; - - // @TODO - Use fixture - var builder = new WebHostBuilder() - .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.NotEmpty(document.Included); - - var articleResponseList = _fixture.GetDeserializer().DeserializeList
(body).Data; - Assert.NotNull(articleResponseList); - - var articleResponse = articleResponseList.FirstOrDefault(a => a.Id == article.Id); - Assert.NotNull(articleResponse); - Assert.Equal(article.Name, articleResponse.Name); - - var tagResponse = Assert.Single(articleResponse.Tags); - Assert.Equal(tag.Id, tagResponse.Id); - Assert.Equal(tag.Name, tagResponse.Name); - } - - [Fact] - public async Task Can_Fetch_Many_To_Many_Through_GetById() - { - // Arrange - var context = _fixture.GetService(); - var article = _articleFaker.Generate(); - var tag = _tagFaker.Generate(); - var articleTag = new ArticleTag(context) - { - Article = article, - Tag = tag - }; - context.ArticleTags.Add(articleTag); - await context.SaveChangesAsync(); - - var route = $"/api/v1/articles/{article.Id}?include=tags"; - - // @TODO - Use fixture - var builder = new WebHostBuilder() - .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.NotEmpty(document.Included); - - var articleResponse = _fixture.GetDeserializer().DeserializeSingle
(body).Data; - Assert.NotNull(articleResponse); - Assert.Equal(article.Id, articleResponse.Id); - - var tagResponse = Assert.Single(articleResponse.Tags); - Assert.Equal(tag.Id, tagResponse.Id); - Assert.Equal(tag.Name, tagResponse.Name); - } - [Fact] public async Task Can_Fetch_Many_To_Many_Through_Id() { @@ -140,7 +52,7 @@ public async Task Can_Fetch_Many_To_Many_Through_Id() var context = _fixture.GetService(); var article = _articleFaker.Generate(); var tag = _tagFaker.Generate(); - var articleTag = new ArticleTag(context) + var articleTag = new ArticleTag { Article = article, Tag = tag @@ -151,7 +63,7 @@ public async Task Can_Fetch_Many_To_Many_Through_Id() var route = $"/api/v1/articles/{article.Id}/tags"; // @TODO - Use fixture - var builder = new WebHostBuilder() + var builder = WebHost.CreateDefaultBuilder() .UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); @@ -179,7 +91,7 @@ public async Task Can_Fetch_Many_To_Many_Through_GetById_Relationship_Link() var context = _fixture.GetService(); var article = _articleFaker.Generate(); var tag = _tagFaker.Generate(); - var articleTag = new ArticleTag(context) + var articleTag = new ArticleTag { Article = article, Tag = tag @@ -190,7 +102,7 @@ public async Task Can_Fetch_Many_To_Many_Through_GetById_Relationship_Link() var route = $"/api/v1/articles/{article.Id}/tags"; // @TODO - Use fixture - var builder = new WebHostBuilder() + var builder = WebHost.CreateDefaultBuilder() .UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); @@ -217,7 +129,7 @@ public async Task Can_Fetch_Many_To_Many_Through_Relationship_Link() var context = _fixture.GetService(); var article = _articleFaker.Generate(); var tag = _tagFaker.Generate(); - var articleTag = new ArticleTag(context) + var articleTag = new ArticleTag { Article = article, Tag = tag @@ -228,7 +140,7 @@ public async Task Can_Fetch_Many_To_Many_Through_Relationship_Link() var route = $"/api/v1/articles/{article.Id}/relationships/tags"; // @TODO - Use fixture - var builder = new WebHostBuilder() + var builder = WebHost.CreateDefaultBuilder() .UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); @@ -255,7 +167,7 @@ public async Task Can_Fetch_Many_To_Many_Without_Include() var context = _fixture.GetService(); var article = _articleFaker.Generate(); var tag = _tagFaker.Generate(); - var articleTag = new ArticleTag(context) + var articleTag = new ArticleTag { Article = article, Tag = tag @@ -265,7 +177,7 @@ public async Task Can_Fetch_Many_To_Many_Without_Include() var route = $"/api/v1/articles/{article.Id}"; // @TODO - Use fixture - var builder = new WebHostBuilder() + var builder = WebHost.CreateDefaultBuilder() .UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); @@ -301,7 +213,7 @@ public async Task Can_Create_Many_To_Many() type = "articles", attributes = new Dictionary { - {"name", "An article with relationships"} + {"caption", "An article with relationships"} }, relationships = new Dictionary { @@ -328,7 +240,7 @@ public async Task Can_Create_Many_To_Many() request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); // @TODO - Use fixture - var builder = new WebHostBuilder() + var builder = WebHost.CreateDefaultBuilder() .UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); @@ -387,7 +299,7 @@ public async Task Can_Update_Many_To_Many() request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); // @TODO - Use fixture - var builder = new WebHostBuilder() + var builder = WebHost.CreateDefaultBuilder() .UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); @@ -418,7 +330,7 @@ public async Task Can_Update_Many_To_Many_With_Complete_Replacement() var context = _fixture.GetService(); var firstTag = _tagFaker.Generate(); var article = _articleFaker.Generate(); - var articleTag = new ArticleTag(context) + var articleTag = new ArticleTag { Article = article, Tag = firstTag @@ -453,7 +365,7 @@ public async Task Can_Update_Many_To_Many_With_Complete_Replacement() request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); // @TODO - Use fixture - var builder = new WebHostBuilder().UseStartup(); + var builder = WebHost.CreateDefaultBuilder().UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); @@ -482,7 +394,7 @@ public async Task Can_Update_Many_To_Many_With_Complete_Replacement_With_Overlap var context = _fixture.GetService(); var firstTag = _tagFaker.Generate(); var article = _articleFaker.Generate(); - var articleTag = new ArticleTag(context) + var articleTag = new ArticleTag { Article = article, Tag = firstTag @@ -521,7 +433,7 @@ public async Task Can_Update_Many_To_Many_With_Complete_Replacement_With_Overlap request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); // @TODO - Use fixture - var builder = new WebHostBuilder().UseStartup(); + var builder = WebHost.CreateDefaultBuilder().UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); @@ -570,7 +482,7 @@ public async Task Can_Update_Many_To_Many_Through_Relationship_Link() request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); // @TODO - Use fixture - var builder = new WebHostBuilder().UseStartup(); + var builder = WebHost.CreateDefaultBuilder().UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/ModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/ModelStateValidationTests.cs index 20ecf96a5a..66a6c7c14c 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/ModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/ModelStateValidationTests.cs @@ -31,14 +31,13 @@ public ModelStateValidationTests(StandardApplicationFactory factory) var context = _factory.GetService(); _authorFaker = new Faker() - .RuleFor(a => a.Name, f => f.Random.Words(2)); + .RuleFor(a => a.LastName, f => f.Random.Words(2)); _articleFaker = new Faker
() - .RuleFor(a => a.Name, f => f.Random.AlphaNumeric(10)) + .RuleFor(a => a.Caption, f => f.Random.AlphaNumeric(10)) .RuleFor(a => a.Author, f => _authorFaker.Generate()); _tagFaker = new Faker() - .CustomInstantiator(f => new Tag(context)) .RuleFor(a => a.Name, f => f.Random.AlphaNumeric(10)); } @@ -46,7 +45,7 @@ public ModelStateValidationTests(StandardApplicationFactory factory) public async Task When_posting_tag_with_invalid_name_it_must_fail() { // Arrange - var tag = new Tag(_dbContext) + var tag = new Tag { Name = "!@#$%^&*().-" }; @@ -79,7 +78,7 @@ public async Task When_posting_tag_with_invalid_name_it_must_fail() public async Task When_posting_tag_with_invalid_name_without_model_state_validation_it_must_succeed() { // Arrange - var tag = new Tag(_dbContext) + var tag = new Tag { Name = "!@#$%^&*().-" }; @@ -107,16 +106,16 @@ public async Task When_posting_tag_with_invalid_name_without_model_state_validat public async Task When_patching_tag_with_invalid_name_it_must_fail() { // Arrange - var existingTag = new Tag(_dbContext) + var existingTag = new Tag { Name = "Technology" }; var context = _factory.GetService(); context.Tags.Add(existingTag); - context.SaveChanges(); + await context.SaveChangesAsync(); - var updatedTag = new Tag(_dbContext) + var updatedTag = new Tag { Id = existingTag.Id, Name = "!@#$%^&*().-" @@ -150,16 +149,16 @@ public async Task When_patching_tag_with_invalid_name_it_must_fail() public async Task When_patching_tag_with_invalid_name_without_model_state_validation_it_must_succeed() { // Arrange - var existingTag = new Tag(_dbContext) + var existingTag = new Tag { Name = "Technology" }; var context = _factory.GetService(); context.Tags.Add(existingTag); - context.SaveChanges(); + await context.SaveChangesAsync(); - var updatedTag = new Tag(_dbContext) + var updatedTag = new Tag { Id = existingTag.Id, Name = "!@#$%^&*().-" @@ -203,7 +202,7 @@ public async Task Create_Article_With_IsRequired_Name_Attribute_Succeeds() type = "articles", attributes = new Dictionary { - {"name", name} + {"caption", name} }, relationships = new Dictionary { @@ -235,7 +234,7 @@ public async Task Create_Article_With_IsRequired_Name_Attribute_Succeeds() var persistedArticle = await _dbContext.Articles .SingleAsync(a => a.Id == articleResponse.Id); - Assert.Equal(name, persistedArticle.Name); + Assert.Equal(name, persistedArticle.Caption); } [Fact] @@ -257,7 +256,7 @@ public async Task Create_Article_With_IsRequired_Name_Attribute_Empty_Succeeds() type = "articles", attributes = new Dictionary { - {"name", name} + {"caption", name} }, relationships = new Dictionary { @@ -289,7 +288,7 @@ public async Task Create_Article_With_IsRequired_Name_Attribute_Empty_Succeeds() var persistedArticle = await _dbContext.Articles .SingleAsync(a => a.Id == articleResponse.Id); - Assert.Equal(name, persistedArticle.Name); + Assert.Equal(name, persistedArticle.Caption); } [Fact] @@ -310,7 +309,7 @@ public async Task Create_Article_With_IsRequired_Name_Attribute_Explicitly_Null_ type = "articles", attributes = new Dictionary { - {"name", null} + {"caption", null} }, relationships = new Dictionary { @@ -339,7 +338,7 @@ public async Task Create_Article_With_IsRequired_Name_Attribute_Explicitly_Null_ Assert.Single(errorDocument.Errors); Assert.Equal("Input validation failed.", errorDocument.Errors[0].Title); Assert.Equal("422", errorDocument.Errors[0].Status); - Assert.Equal("The Name field is required.", errorDocument.Errors[0].Detail); + Assert.Equal("The Caption field is required.", errorDocument.Errors[0].Detail); } [Fact] @@ -385,7 +384,7 @@ public async Task Create_Article_With_IsRequired_Name_Attribute_Missing_Fails() Assert.Single(errorDocument.Errors); Assert.Equal("Input validation failed.", errorDocument.Errors[0].Title); Assert.Equal("422", errorDocument.Errors[0].Status); - Assert.Equal("The Name field is required.", errorDocument.Errors[0].Detail); + Assert.Equal("The Caption field is required.", errorDocument.Errors[0].Detail); } [Fact] @@ -408,7 +407,7 @@ public async Task Update_Article_With_IsRequired_Name_Attribute_Succeeds() id = article.StringId, attributes = new Dictionary { - {"name", name} + {"caption", name} } } }; @@ -425,7 +424,7 @@ public async Task Update_Article_With_IsRequired_Name_Attribute_Succeeds() var persistedArticle = await _dbContext.Articles .SingleOrDefaultAsync(a => a.Id == article.Id); - var updatedName = persistedArticle.Name; + var updatedName = persistedArticle.Caption; Assert.Equal(name, updatedName); } @@ -495,7 +494,7 @@ public async Task Update_Article_With_IsRequired_Name_Attribute_Explicitly_Null_ id = article.StringId, attributes = new Dictionary { - {"name", null} + {"caption", null} } } }; @@ -514,7 +513,7 @@ public async Task Update_Article_With_IsRequired_Name_Attribute_Explicitly_Null_ Assert.Single(errorDocument.Errors); Assert.Equal("Input validation failed.", errorDocument.Errors[0].Title); Assert.Equal("422", errorDocument.Errors[0].Status); - Assert.Equal("The Name field is required.", errorDocument.Errors[0].Detail); + Assert.Equal("The Caption field is required.", errorDocument.Errors[0].Detail); } [Fact] @@ -536,7 +535,7 @@ public async Task Update_Article_With_IsRequired_Name_Attribute_Empty_Succeeds() id = article.StringId, attributes = new Dictionary { - {"name", ""} + {"caption", ""} } } }; @@ -553,7 +552,7 @@ public async Task Update_Article_With_IsRequired_Name_Attribute_Empty_Succeeds() var persistedArticle = await _dbContext.Articles .SingleOrDefaultAsync(a => a.Id == article.Id); - var updatedName = persistedArticle.Name; + var updatedName = persistedArticle.Caption; Assert.Equal("", updatedName); } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/NonJsonApiControllerTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/NonJsonApiControllerTests.cs index eff5795f94..11553edb90 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/NonJsonApiControllerTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/NonJsonApiControllerTests.cs @@ -3,6 +3,7 @@ using System.Net.Http.Headers; using System.Threading.Tasks; using JsonApiDotNetCoreExample; +using Microsoft.AspNetCore; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; using Xunit; @@ -17,7 +18,7 @@ public async Task NonJsonApiController_Skips_Middleware_And_Formatters_On_Get() // Arrange const string route = "testValues"; - var builder = new WebHostBuilder().UseStartup(); + var builder = WebHost.CreateDefaultBuilder().UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, route); @@ -39,7 +40,7 @@ public async Task NonJsonApiController_Skips_Middleware_And_Formatters_On_Post() // Arrange const string route = "testValues?name=Jack"; - var builder = new WebHostBuilder().UseStartup(); + var builder = WebHost.CreateDefaultBuilder().UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Post, route) {Content = new StringContent("XXX")}; @@ -62,7 +63,7 @@ public async Task NonJsonApiController_Skips_Middleware_And_Formatters_On_Patch( // Arrange const string route = "testValues?name=Jack"; - var builder = new WebHostBuilder().UseStartup(); + var builder = WebHost.CreateDefaultBuilder().UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Patch, route) {Content = new StringContent("XXX")}; @@ -84,7 +85,7 @@ public async Task NonJsonApiController_Skips_Middleware_And_Formatters_On_Delete // Arrange const string route = "testValues"; - var builder = new WebHostBuilder().UseStartup(); + var builder = WebHost.CreateDefaultBuilder().UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Delete, route); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/ResourceDefinitions/QueryFiltersTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/ResourceDefinitions/QueryFiltersTests.cs deleted file mode 100644 index e83a63d60d..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/ResourceDefinitions/QueryFiltersTests.cs +++ /dev/null @@ -1,92 +0,0 @@ -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using Bogus; -using JsonApiDotNetCoreExample; -using JsonApiDotNetCoreExample.Data; -using JsonApiDotNetCoreExample.Models; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.TestHost; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.Acceptance -{ - [Collection("WebHostCollection")] - public sealed class QueryFiltersTests - { - private readonly TestFixture _fixture; - private readonly AppDbContext _context; - private readonly Faker _userFaker; - - public QueryFiltersTests(TestFixture fixture) - { - _fixture = fixture; - _context = fixture.GetService(); - _userFaker = new Faker() - .CustomInstantiator(f => new User(_context)) - .RuleFor(u => u.Username, f => f.Internet.UserName()) - .RuleFor(u => u.Password, f => f.Internet.Password()); - } - - [Fact] - public async Task FiltersWithCustomQueryFiltersEquals() - { - // Arrange - var user = _userFaker.Generate(); - var firstUsernameCharacter = user.Username[0]; - _context.Users.Add(user); - _context.SaveChanges(); - - var httpMethod = new HttpMethod("GET"); - var route = $"/api/v1/users?filter[firstCharacter]=eq:{firstUsernameCharacter}"; - var request = new HttpRequestMessage(httpMethod, route); - - // @TODO - Use fixture - var builder = new WebHostBuilder().UseStartup(); - var server = new TestServer(builder); - var client = server.CreateClient(); - - // Act - var response = await client.SendAsync(request); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = await response.Content.ReadAsStringAsync(); - var deserializedBody = _fixture.GetDeserializer().DeserializeList(body).Data; - Assert.True(deserializedBody.All(u => u.Username[0] == firstUsernameCharacter)); - } - - [Fact] - public async Task FiltersWithCustomQueryFiltersLessThan() - { - // Arrange - var aUser = _userFaker.Generate(); - aUser.Username = "alfred"; - var zUser = _userFaker.Generate(); - zUser.Username = "zac"; - _context.Users.AddRange(aUser, zUser); - _context.SaveChanges(); - - var median = 'h'; - - var httpMethod = new HttpMethod("GET"); - var route = $"/api/v1/users?filter[firstCharacter]=lt:{median}"; - var request = new HttpRequestMessage(httpMethod, route); - - // @TODO - Use fixture - var builder = new WebHostBuilder().UseStartup(); - var server = new TestServer(builder); - var client = server.CreateClient(); - - // Act - var response = await client.SendAsync(request); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = await response.Content.ReadAsStringAsync(); - var deserializedBody = _fixture.GetDeserializer().DeserializeList(body).Data; - Assert.True(deserializedBody.All(u => u.Username[0] < median)); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/ResourceDefinitions/ResourceDefinitionTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/ResourceDefinitions/ResourceDefinitionTests.cs index 8629e4f4b9..1ff0617237 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/ResourceDefinitions/ResourceDefinitionTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/ResourceDefinitions/ResourceDefinitionTests.cs @@ -6,24 +6,21 @@ using System.Threading.Tasks; using Bogus; using JsonApiDotNetCore; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Models.JsonApiDocuments; -using JsonApiDotNetCoreExample; -using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; +using JsonApiDotNetCoreExampleTests.Acceptance.Spec; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; using Newtonsoft.Json; using Xunit; using Person = JsonApiDotNetCoreExample.Models.Person; namespace JsonApiDotNetCoreExampleTests.Acceptance { - [Collection("WebHostCollection")] - public sealed class ResourceDefinitionTests + public sealed class ResourceDefinitionTests : FunctionalTestCollection { - private readonly TestFixture _fixture; - - private readonly AppDbContext _context; private readonly Faker _userFaker; private readonly Faker _todoItemFaker; private readonly Faker _personFaker; @@ -31,51 +28,36 @@ public sealed class ResourceDefinitionTests private readonly Faker _authorFaker; private readonly Faker _tagFaker; - public ResourceDefinitionTests(TestFixture fixture) + public ResourceDefinitionTests(ResourceHooksApplicationFactory factory) : base(factory) { - _fixture = fixture; - _context = fixture.GetService(); _authorFaker = new Faker() - .RuleFor(a => a.Name, f => f.Random.Words(2)); + .RuleFor(a => a.LastName, f => f.Random.Words(2)); + _articleFaker = new Faker
() - .RuleFor(a => a.Name, f => f.Random.AlphaNumeric(10)) + .RuleFor(a => a.Caption, f => f.Random.AlphaNumeric(10)) .RuleFor(a => a.Author, f => _authorFaker.Generate()); + _userFaker = new Faker() - .CustomInstantiator(f => new User(_context)) - .RuleFor(u => u.Username, f => f.Internet.UserName()) + .CustomInstantiator(f => new User(_dbContext)) + .RuleFor(u => u.UserName, f => f.Internet.UserName()) .RuleFor(u => u.Password, f => f.Internet.Password()); + _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()); + _tagFaker = new Faker() - .CustomInstantiator(f => new Tag(_context)) + .CustomInstantiator(f => new Tag()) .RuleFor(a => a.Name, f => f.Random.AlphaNumeric(10)); - } - - [Fact] - public async Task Password_Is_Not_Included_In_Response_Payload() - { - // Arrange - var user = _userFaker.Generate(); - _context.Users.Add(user); - _context.SaveChanges(); - - var httpMethod = new HttpMethod("GET"); - var route = $"/api/v1/users/{user.Id}"; - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await _fixture.Client.SendAsync(request); - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = await response.Content.ReadAsStringAsync(); - var document = JsonConvert.DeserializeObject(body); - Assert.False(document.SingleData.Attributes.ContainsKey("password")); + var options = (JsonApiOptions) _factory.Services.GetRequiredService(); + options.DisableTopPagination = false; + options.DisableChildrenPagination = false; } [Fact] @@ -84,9 +66,8 @@ public async Task Can_Create_User_With_Password() // Arrange var user = _userFaker.Generate(); - var serializer = _fixture.GetSerializer(p => new { p.Password, p.Username }); + var serializer = GetSerializer(p => new { p.Password, p.UserName }); - var httpMethod = new HttpMethod("POST"); var route = "/api/v1/users"; @@ -97,21 +78,21 @@ public async Task Can_Create_User_With_Password() request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); // Act - var response = await _fixture.Client.SendAsync(request); + var response = await _client.SendAsync(request); // Assert Assert.Equal(HttpStatusCode.Created, response.StatusCode); // response assertions var body = await response.Content.ReadAsStringAsync(); - var returnedUser = _fixture.GetDeserializer().DeserializeSingle(body).Data; + var returnedUser = _deserializer.DeserializeSingle(body).Data; var document = JsonConvert.DeserializeObject(body); Assert.False(document.SingleData.Attributes.ContainsKey("password")); - Assert.Equal(user.Username, document.SingleData.Attributes["username"]); + Assert.Equal(user.UserName, document.SingleData.Attributes["userName"]); // db assertions - var dbUser = await _context.Users.FindAsync(returnedUser.Id); - Assert.Equal(user.Username, dbUser.Username); + var dbUser = await _dbContext.Users.FindAsync(returnedUser.Id); + Assert.Equal(user.UserName, dbUser.UserName); Assert.Equal(user.Password, dbUser.Password); } @@ -120,10 +101,11 @@ public async Task Can_Update_User_Password() { // Arrange var user = _userFaker.Generate(); - _context.Users.Add(user); - _context.SaveChanges(); + _dbContext.Users.Add(user); + await _dbContext.SaveChangesAsync(); + user.Password = _userFaker.Generate().Password; - var serializer = _fixture.GetSerializer(p => new { p.Password }); + var serializer = GetSerializer(p => new { p.Password }); var httpMethod = new HttpMethod("PATCH"); var route = $"/api/v1/users/{user.Id}"; var request = new HttpRequestMessage(httpMethod, route) @@ -133,7 +115,7 @@ public async Task Can_Update_User_Password() request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); // Act - var response = await _fixture.Client.SendAsync(request); + var response = await _client.SendAsync(request); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -142,10 +124,10 @@ public async Task Can_Update_User_Password() var body = await response.Content.ReadAsStringAsync(); var document = JsonConvert.DeserializeObject(body); Assert.False(document.SingleData.Attributes.ContainsKey("password")); - Assert.Equal(user.Username, document.SingleData.Attributes["username"]); + Assert.Equal(user.UserName, document.SingleData.Attributes["userName"]); // db assertions - var dbUser = _context.Users.AsNoTracking().Single(u => u.Id == user.Id); + var dbUser = _dbContext.Users.AsNoTracking().Single(u => u.Id == user.Id); Assert.Equal(user.Password, dbUser.Password); } @@ -156,7 +138,7 @@ public async Task Unauthorized_TodoItem() var route = "/api/v1/todoItems/1337"; // Act - var response = await _fixture.Client.GetAsync(route); + var response = await _client.GetAsync(route); // Assert var body = await response.Content.ReadAsStringAsync(); @@ -176,7 +158,7 @@ public async Task Unauthorized_Passport() var route = "/api/v1/people/1?include=passport"; // Act - var response = await _fixture.Client.GetAsync(route); + var response = await _client.GetAsync(route); // Assert var body = await response.Content.ReadAsStringAsync(); @@ -193,18 +175,15 @@ public async Task Unauthorized_Passport() public async Task Unauthorized_Article() { // Arrange - var context = _fixture.GetService(); - await context.SaveChangesAsync(); - var article = _articleFaker.Generate(); - article.Name = "Classified"; - context.Articles.Add(article); - await context.SaveChangesAsync(); + article.Caption = "Classified"; + _dbContext.Articles.Add(article); + await _dbContext.SaveChangesAsync(); var route = $"/api/v1/articles/{article.Id}"; // Act - var response = await _fixture.Client.GetAsync(route); + var response = await _client.GetAsync(route); // Assert var body = await response.Content.ReadAsStringAsync(); @@ -221,20 +200,17 @@ public async Task Unauthorized_Article() public async Task Article_Is_Hidden() { // Arrange - var context = _fixture.GetService(); - var articles = _articleFaker.Generate(3); string toBeExcluded = "This should not be included"; - articles[0].Name = toBeExcluded; + articles[0].Caption = toBeExcluded; - - context.Articles.AddRange(articles); - await context.SaveChangesAsync(); + _dbContext.Articles.AddRange(articles); + await _dbContext.SaveChangesAsync(); var route = "/api/v1/articles"; // Act - var response = await _fixture.Client.GetAsync(route); + var response = await _client.GetAsync(route); // Assert var body = await response.Content.ReadAsStringAsync(); @@ -246,9 +222,6 @@ public async Task Article_Is_Hidden() public async Task Tag_Is_Hidden() { // Arrange - var context = _fixture.GetService(); - await context.SaveChangesAsync(); - var article = _articleFaker.Generate(); var tags = _tagFaker.Generate(2); string toBeExcluded = "This should not be included"; @@ -256,24 +229,29 @@ public async Task Tag_Is_Hidden() var articleTags = new[] { - new ArticleTag(context) + new ArticleTag { Article = article, Tag = tags[0] }, - new ArticleTag(context) + new ArticleTag { Article = article, Tag = tags[1] } }; - context.ArticleTags.AddRange(articleTags); - await context.SaveChangesAsync(); + _dbContext.ArticleTags.AddRange(articleTags); + await _dbContext.SaveChangesAsync(); + + // Workaround for https://github.com/dotnet/efcore/issues/21026 + var options = (JsonApiOptions) _factory.Services.GetRequiredService(); + options.DisableTopPagination = false; + options.DisableChildrenPagination = true; var route = "/api/v1/articles?include=tags"; // Act - var response = await _fixture.Client.GetAsync(route); + var response = await _client.GetAsync(route); // Assert var body = await response.Content.ReadAsStringAsync(); @@ -282,7 +260,7 @@ public async Task Tag_Is_Hidden() } ///// ///// In the Cascade Permission Error tests, we ensure that all the relevant - ///// entities are provided in the hook definitions. In this case, + ///// resources are provided in the hook definitions. In this case, ///// re-relating the meta object to a different article would require ///// also a check for the lockedTodo, because we're implicitly updating ///// its foreign key. @@ -291,13 +269,12 @@ public async Task Tag_Is_Hidden() public async Task Cascade_Permission_Error_Create_ToOne_Relationship() { // Arrange - var context = _fixture.GetService(); var lockedPerson = _personFaker.Generate(); lockedPerson.IsLocked = true; - var passport = new Passport(context); + var passport = new Passport(_dbContext); lockedPerson.Passport = passport; - context.People.AddRange(lockedPerson); - await context.SaveChangesAsync(); + _dbContext.People.AddRange(lockedPerson); + await _dbContext.SaveChangesAsync(); var content = new { @@ -324,7 +301,7 @@ public async Task Cascade_Permission_Error_Create_ToOne_Relationship() request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); // Act - var response = await _fixture.Client.SendAsync(request); + var response = await _client.SendAsync(request); // Assert var body = await response.Content.ReadAsStringAsync(); @@ -341,14 +318,13 @@ public async Task Cascade_Permission_Error_Create_ToOne_Relationship() public async Task Cascade_Permission_Error_Updating_ToOne_Relationship() { // Arrange - var context = _fixture.GetService(); var person = _personFaker.Generate(); - var passport = new Passport(context) { IsLocked = true }; + var passport = new Passport(_dbContext) { IsLocked = true }; person.Passport = passport; - context.People.AddRange(person); - var newPassport = new Passport(context); - context.Passports.Add(newPassport); - await context.SaveChangesAsync(); + _dbContext.People.AddRange(person); + var newPassport = new Passport(_dbContext); + _dbContext.Passports.Add(newPassport); + await _dbContext.SaveChangesAsync(); var content = new { @@ -376,7 +352,7 @@ public async Task Cascade_Permission_Error_Updating_ToOne_Relationship() request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); // Act - var response = await _fixture.Client.SendAsync(request); + var response = await _client.SendAsync(request); // Assert var body = await response.Content.ReadAsStringAsync(); @@ -393,14 +369,13 @@ public async Task Cascade_Permission_Error_Updating_ToOne_Relationship() public async Task Cascade_Permission_Error_Updating_ToOne_Relationship_Deletion() { // Arrange - var context = _fixture.GetService(); var person = _personFaker.Generate(); - var passport = new Passport(context) { IsLocked = true }; + var passport = new Passport(_dbContext) { IsLocked = true }; person.Passport = passport; - context.People.AddRange(person); - var newPassport = new Passport(context); - context.Passports.Add(newPassport); - await context.SaveChangesAsync(); + _dbContext.People.AddRange(person); + var newPassport = new Passport(_dbContext); + _dbContext.Passports.Add(newPassport); + await _dbContext.SaveChangesAsync(); var content = new { @@ -428,7 +403,7 @@ public async Task Cascade_Permission_Error_Updating_ToOne_Relationship_Deletion( request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); // Act - var response = await _fixture.Client.SendAsync(request); + var response = await _client.SendAsync(request); // Assert var body = await response.Content.ReadAsStringAsync(); @@ -445,20 +420,19 @@ public async Task Cascade_Permission_Error_Updating_ToOne_Relationship_Deletion( public async Task Cascade_Permission_Error_Delete_ToOne_Relationship() { // Arrange - var context = _fixture.GetService(); var lockedPerson = _personFaker.Generate(); lockedPerson.IsLocked = true; - var passport = new Passport(context); + var passport = new Passport(_dbContext); lockedPerson.Passport = passport; - context.People.AddRange(lockedPerson); - await context.SaveChangesAsync(); + _dbContext.People.AddRange(lockedPerson); + await _dbContext.SaveChangesAsync(); var httpMethod = new HttpMethod("DELETE"); var route = $"/api/v1/passports/{lockedPerson.Passport.StringId}"; var request = new HttpRequestMessage(httpMethod, route); // Act - var response = await _fixture.Client.SendAsync(request); + var response = await _client.SendAsync(request); // Assert var body = await response.Content.ReadAsStringAsync(); @@ -475,13 +449,12 @@ public async Task Cascade_Permission_Error_Delete_ToOne_Relationship() public async Task Cascade_Permission_Error_Create_ToMany_Relationship() { // Arrange - var context = _fixture.GetService(); var persons = _personFaker.Generate(2); var lockedTodo = _todoItemFaker.Generate(); lockedTodo.IsLocked = true; lockedTodo.StakeHolders = persons.ToHashSet(); - context.TodoItems.Add(lockedTodo); - await context.SaveChangesAsync(); + _dbContext.TodoItems.Add(lockedTodo); + await _dbContext.SaveChangesAsync(); var content = new { @@ -513,7 +486,7 @@ public async Task Cascade_Permission_Error_Create_ToMany_Relationship() request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); // Act - var response = await _fixture.Client.SendAsync(request); + var response = await _client.SendAsync(request); // Assert var body = await response.Content.ReadAsStringAsync(); @@ -530,15 +503,14 @@ public async Task Cascade_Permission_Error_Create_ToMany_Relationship() public async Task Cascade_Permission_Error_Updating_ToMany_Relationship() { // Arrange - var context = _fixture.GetService(); var persons = _personFaker.Generate(2); var lockedTodo = _todoItemFaker.Generate(); lockedTodo.IsLocked = true; lockedTodo.StakeHolders = persons.ToHashSet(); - context.TodoItems.Add(lockedTodo); + _dbContext.TodoItems.Add(lockedTodo); var unlockedTodo = _todoItemFaker.Generate(); - context.TodoItems.Add(unlockedTodo); - await context.SaveChangesAsync(); + _dbContext.TodoItems.Add(unlockedTodo); + await _dbContext.SaveChangesAsync(); var content = new { @@ -571,7 +543,7 @@ public async Task Cascade_Permission_Error_Updating_ToMany_Relationship() request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); // Act - var response = await _fixture.Client.SendAsync(request); + var response = await _client.SendAsync(request); // Assert var body = await response.Content.ReadAsStringAsync(); @@ -588,20 +560,19 @@ public async Task Cascade_Permission_Error_Updating_ToMany_Relationship() public async Task Cascade_Permission_Error_Delete_ToMany_Relationship() { // Arrange - var context = _fixture.GetService(); var persons = _personFaker.Generate(2); var lockedTodo = _todoItemFaker.Generate(); lockedTodo.IsLocked = true; lockedTodo.StakeHolders = persons.ToHashSet(); - context.TodoItems.Add(lockedTodo); - await context.SaveChangesAsync(); + _dbContext.TodoItems.Add(lockedTodo); + await _dbContext.SaveChangesAsync(); var httpMethod = new HttpMethod("DELETE"); var route = $"/api/v1/people/{persons[0].Id}"; var request = new HttpRequestMessage(httpMethod, route); // Act - var response = await _fixture.Client.SendAsync(request); + var response = await _client.SendAsync(request); // Assert var body = await response.Content.ReadAsStringAsync(); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/SerializationTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/SerializationTests.cs index 772ae36613..521108669c 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/SerializationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/SerializationTests.cs @@ -30,9 +30,9 @@ public async Task When_getting_person_it_must_match_JSON_text() Category = "Family" }; - _dbContext.People.RemoveRange(_dbContext.People); + await _dbContext.ClearTableAsync(); _dbContext.People.Add(person); - _dbContext.SaveChanges(); + await _dbContext.SaveChangesAsync(); var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/people/" + person.Id); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/AttributeFilterTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/AttributeFilterTests.cs deleted file mode 100644 index 07a3a8f65b..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/AttributeFilterTests.cs +++ /dev/null @@ -1,349 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using Bogus; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Models.JsonApiDocuments; -using JsonApiDotNetCoreExample; -using JsonApiDotNetCoreExample.Data; -using JsonApiDotNetCoreExample.Models; -using Newtonsoft.Json; -using Xunit; -using Person = JsonApiDotNetCoreExample.Models.Person; - -namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec -{ - [Collection("WebHostCollection")] - public sealed class AttributeFilterTests - { - private readonly TestFixture _fixture; - private readonly Faker _todoItemFaker; - private readonly Faker _personFaker; - - public AttributeFilterTests(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 Can_Filter_On_Guid_Properties() - { - // Arrange - var context = _fixture.GetService(); - var todoItem = _todoItemFaker.Generate(); - context.TodoItems.Add(todoItem); - await context.SaveChangesAsync(); - - var httpMethod = new HttpMethod("GET"); - var route = $"/api/v1/todoItems?filter[guidProperty]={todoItem.GuidProperty}"; - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await _fixture.Client.SendAsync(request); - var body = await response.Content.ReadAsStringAsync(); - var list = _fixture.GetDeserializer().DeserializeList(body).Data; - - - var todoItemResponse = list.Single(); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal(todoItem.Id, todoItemResponse.Id); - Assert.Equal(todoItem.GuidProperty, todoItemResponse.GuidProperty); - } - - [Fact] - public async Task Can_Filter_On_Related_Attrs() - { - // Arrange - var context = _fixture.GetService(); - var person = _personFaker.Generate(); - var todoItem = _todoItemFaker.Generate(); - todoItem.Owner = person; - context.TodoItems.Add(todoItem); - await context.SaveChangesAsync(); - - var httpMethod = new HttpMethod("GET"); - var route = $"/api/v1/todoItems?include=owner&filter[owner.firstName]={person.FirstName}"; - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await _fixture.Client.SendAsync(request); - var body = await response.Content.ReadAsStringAsync(); - var list = _fixture.GetDeserializer().DeserializeList(body).Data.First(); - - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - list.Owner.FirstName = person.FirstName; - } - - [Fact] - public async Task Can_Filter_On_Related_Attrs_From_GetById() - { - // Arrange - var context = _fixture.GetService(); - var person = _personFaker.Generate(); - 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}?include=owner&filter[owner.firstName]=SOMETHING-ELSE"; - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await _fixture.Client.SendAsync(request); - var body = await response.Content.ReadAsStringAsync(); - - // Assert - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - } - - [Fact] - public async Task Cannot_Filter_On_Related_ToMany_Attrs() - { - // Arrange - var httpMethod = new HttpMethod("GET"); - var route = "/api/v1/todoItems?include=childrenTodos&filter[childrenTodos.ordinal]=1"; - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await _fixture.Client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].StatusCode); - Assert.Equal("Filtering on one-to-many and many-to-many relationships is currently not supported.", errorDocument.Errors[0].Title); - Assert.Equal("Filtering on the relationship 'childrenTodos.ordinal' is currently not supported.", errorDocument.Errors[0].Detail); - Assert.Equal("filter[childrenTodos.ordinal]", errorDocument.Errors[0].Source.Parameter); - } - - [Fact] - public async Task Cannot_Filter_If_Explicitly_Forbidden() - { - // Arrange - var httpMethod = new HttpMethod("GET"); - var route = $"/api/v1/todoItems?include=owner&filter[achievedDate]={new DateTime(2002, 2, 2).ToShortDateString()}"; - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await _fixture.Client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].StatusCode); - Assert.Equal("Filtering on the requested attribute is not allowed.", errorDocument.Errors[0].Title); - Assert.Equal("Filtering on attribute 'achievedDate' is not allowed.", errorDocument.Errors[0].Detail); - Assert.Equal("filter[achievedDate]", errorDocument.Errors[0].Source.Parameter); - } - - [Fact] - public async Task Cannot_Filter_Equality_If_Type_Mismatch() - { - // Arrange - var httpMethod = new HttpMethod("GET"); - var route = $"/api/v1/todoItems?filter[ordinal]=ABC"; - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await _fixture.Client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].StatusCode); - Assert.Equal("Mismatch between query string parameter value and resource attribute type.", errorDocument.Errors[0].Title); - Assert.Equal("Failed to convert 'ABC' to 'Int64' for filtering on 'ordinal' attribute.", errorDocument.Errors[0].Detail); - Assert.Equal("filter", errorDocument.Errors[0].Source.Parameter); - } - - [Fact] - public async Task Cannot_Filter_In_Set_If_Type_Mismatch() - { - // Arrange - var httpMethod = new HttpMethod("GET"); - var route = $"/api/v1/todoItems?filter[ordinal]=in:1,ABC,2"; - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await _fixture.Client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].StatusCode); - Assert.Equal("Mismatch between query string parameter value and resource attribute type.", errorDocument.Errors[0].Title); - Assert.Equal("Failed to convert 'ABC' in set '1,ABC,2' to 'Int64' for filtering on 'ordinal' attribute.", errorDocument.Errors[0].Detail); - Assert.Equal("filter", errorDocument.Errors[0].Source.Parameter); - } - - [Fact] - public async Task Can_Filter_On_Not_Equal_Values() - { - // Arrange - var context = _fixture.GetService(); - var todoItem = _todoItemFaker.Generate(); - context.TodoItems.Add(todoItem); - await context.SaveChangesAsync(); - - var totalCount = context.TodoItems.Count(); - var httpMethod = new HttpMethod("GET"); - var route = $"/api/v1/todoItems?page[size]={totalCount}&filter[ordinal]=ne:{todoItem.Ordinal}"; - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await _fixture.Client.SendAsync(request); - var body = await response.Content.ReadAsStringAsync(); - var list = _fixture.GetDeserializer().DeserializeList(body).Data; - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.DoesNotContain(list, x => x.Ordinal == todoItem.Ordinal); - } - - [Fact] - public async Task Can_Filter_On_In_Array_Values() - { - // Arrange - var context = _fixture.GetService(); - var todoItems = _todoItemFaker.Generate(5); - var guids = new List(); - var notInGuids = new List(); - foreach (var item in todoItems) - { - context.TodoItems.Add(item); - // Exclude 2 items - if (guids.Count < (todoItems.Count - 2)) - guids.Add(item.GuidProperty); - else - notInGuids.Add(item.GuidProperty); - } - context.SaveChanges(); - - var httpMethod = new HttpMethod("GET"); - var route = $"/api/v1/todoItems?filter[guidProperty]=in:{string.Join(",", guids)}"; - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await _fixture.Client.SendAsync(request); - var body = await response.Content.ReadAsStringAsync(); - var deserializedTodoItems = _fixture - .GetDeserializer() - .DeserializeList(body).Data; - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal(guids.Count, deserializedTodoItems.Count); - foreach (var item in deserializedTodoItems) - { - Assert.Contains(item.GuidProperty, guids); - Assert.DoesNotContain(item.GuidProperty, notInGuids); - } - } - - [Fact] - public async Task Can_Filter_On_Related_In_Array_Values() - { - // Arrange - var context = _fixture.GetService(); - var todoItems = _todoItemFaker.Generate(3); - var ownerFirstNames = new List(); - foreach (var item in todoItems) - { - var person = _personFaker.Generate(); - ownerFirstNames.Add(person.FirstName); - item.Owner = person; - context.TodoItems.Add(item); - } - context.SaveChanges(); - - var httpMethod = new HttpMethod("GET"); - var route = $"/api/v1/todoItems?include=owner&filter[owner.firstName]=in:{string.Join(",", ownerFirstNames)}"; - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await _fixture.Client.SendAsync(request); - var body = await response.Content.ReadAsStringAsync(); - var documents = JsonConvert.DeserializeObject(body); - var included = documents.Included; - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal(ownerFirstNames.Count, documents.ManyData.Count); - Assert.NotNull(included); - Assert.NotEmpty(included); - foreach (var item in included) - Assert.Contains(item.Attributes["firstName"], ownerFirstNames); - - } - - [Fact] - public async Task Can_Filter_On_Not_In_Array_Values() - { - // Arrange - var context = _fixture.GetService(); - context.TodoItems.RemoveRange(context.TodoItems); - context.SaveChanges(); - var todoItems = _todoItemFaker.Generate(5); - var guids = new List(); - var notInGuids = new List(); - foreach (var item in todoItems) - { - context.TodoItems.Add(item); - // Exclude 2 items - if (guids.Count < (todoItems.Count - 2)) - guids.Add(item.GuidProperty); - else - notInGuids.Add(item.GuidProperty); - } - context.SaveChanges(); - - var totalCount = context.TodoItems.Count(); - var httpMethod = new HttpMethod("GET"); - var route = $"/api/v1/todoItems?page[size]={totalCount}&filter[guidProperty]=nin:{string.Join(",", notInGuids)}"; - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await _fixture.Client.SendAsync(request); - var body = await response.Content.ReadAsStringAsync(); - var deserializedTodoItems = _fixture - .GetDeserializer() - .DeserializeList(body).Data; - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal(totalCount - notInGuids.Count, deserializedTodoItems.Count); - foreach (var item in deserializedTodoItems) - { - Assert.DoesNotContain(item.GuidProperty, notInGuids); - } - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/AttributeSortTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/AttributeSortTests.cs deleted file mode 100644 index ab7e249b50..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/AttributeSortTests.cs +++ /dev/null @@ -1,108 +0,0 @@ -using System; -using JsonApiDotNetCoreExample; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Models.JsonApiDocuments; -using JsonApiDotNetCoreExample.Models; -using Newtonsoft.Json; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec -{ - [Collection("WebHostCollection")] - public sealed class AttributeSortTests - { - private readonly TestFixture _fixture; - - public AttributeSortTests(TestFixture fixture) - { - _fixture = fixture; - } - - [Fact] - public async Task Cannot_Sort_If_Explicitly_Forbidden() - { - // Arrange - var httpMethod = new HttpMethod("GET"); - var route = "/api/v1/todoItems?include=owner&sort=achievedDate"; - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await _fixture.Client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].StatusCode); - Assert.Equal("Sorting on the requested attribute is not allowed.", errorDocument.Errors[0].Title); - Assert.Equal("Sorting on attribute 'achievedDate' is not allowed.", errorDocument.Errors[0].Detail); - Assert.Equal("sort", errorDocument.Errors[0].Source.Parameter); - } - - [Fact] - public async Task Can_Sort_On_Multiple_Attributes() - { - // Arrange - var category = Guid.NewGuid().ToString(); - - var persons = new[] - { - new Person - { - Category = category, - FirstName = "Alice", - LastName = "Smith", - Age = 23 - }, - new Person - { - Category = category, - FirstName = "John", - LastName = "Doe", - Age = 49 - }, - new Person - { - Category = category, - FirstName = "John", - LastName = "Doe", - Age = 31 - }, - new Person - { - Category = category, - FirstName = "Jane", - LastName = "Doe", - Age = 19 - } - }; - - _fixture.Context.People.AddRange(persons); - _fixture.Context.SaveChanges(); - - var httpMethod = new HttpMethod("GET"); - var route = "/api/v1/people?filter[category]=" + category + "&sort=lastName,-firstName,the-Age"; - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await _fixture.Client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var document = JsonConvert.DeserializeObject(body); - Assert.Equal(4, document.ManyData.Count); - - Assert.Equal(document.ManyData[0].Id, persons[2].StringId); - Assert.Equal(document.ManyData[1].Id, persons[1].StringId); - Assert.Equal(document.ManyData[2].Id, persons[3].StringId); - Assert.Equal(document.ManyData[3].Id, persons[0].StringId); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ContentNegotiationTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ContentNegotiationTests.cs index 9cb092ee2b..18e03b03d3 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ContentNegotiationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ContentNegotiationTests.cs @@ -6,6 +6,7 @@ using JsonApiDotNetCore.Models.JsonApiDocuments; using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Models; +using Microsoft.AspNetCore; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; using Newtonsoft.Json; @@ -27,7 +28,7 @@ public ContentNegotiationTests(TestFixture fixture) public async Task Server_Sends_Correct_ContentType_Header() { // Arrange - var builder = new WebHostBuilder().UseStartup(); + var builder = WebHost.CreateDefaultBuilder().UseStartup(); var route = "/api/v1/todoItems"; var server = new TestServer(builder); var client = server.CreateClient(); @@ -45,7 +46,7 @@ public async Task Server_Sends_Correct_ContentType_Header() public async Task Respond_415_If_Content_Type_Header_Is_Not_JsonApi_Media_Type() { // Arrange - var builder = new WebHostBuilder().UseStartup(); + var builder = WebHost.CreateDefaultBuilder().UseStartup(); var route = "/api/v1/todoItems"; var server = new TestServer(builder); var client = server.CreateClient(); @@ -73,7 +74,7 @@ public async Task Respond_201_If_Content_Type_Header_Is_JsonApi_Media_Type() var serializer = _fixture.GetSerializer(e => new { e.Description }); var todoItem = new TodoItem {Description = "something not to forget"}; - var builder = new WebHostBuilder().UseStartup(); + var builder = WebHost.CreateDefaultBuilder().UseStartup(); var route = "/api/v1/todoItems"; var server = new TestServer(builder); var client = server.CreateClient(); @@ -91,7 +92,7 @@ public async Task Respond_201_If_Content_Type_Header_Is_JsonApi_Media_Type() public async Task Respond_415_If_Content_Type_Header_Is_JsonApi_Media_Type_With_Profile() { // Arrange - var builder = new WebHostBuilder().UseStartup(); + var builder = WebHost.CreateDefaultBuilder().UseStartup(); var route = "/api/v1/todoItems"; var server = new TestServer(builder); var client = server.CreateClient(); @@ -116,7 +117,7 @@ public async Task Respond_415_If_Content_Type_Header_Is_JsonApi_Media_Type_With_ public async Task Respond_415_If_Content_Type_Header_Is_JsonApi_Media_Type_With_Extension() { // Arrange - var builder = new WebHostBuilder().UseStartup(); + var builder = WebHost.CreateDefaultBuilder().UseStartup(); var route = "/api/v1/todoItems"; var server = new TestServer(builder); var client = server.CreateClient(); @@ -141,7 +142,7 @@ public async Task Respond_415_If_Content_Type_Header_Is_JsonApi_Media_Type_With_ public async Task Respond_415_If_Content_Type_Header_Is_JsonApi_Media_Type_With_CharSet() { // Arrange - var builder = new WebHostBuilder().UseStartup(); + var builder = WebHost.CreateDefaultBuilder().UseStartup(); var route = "/api/v1/todoItems"; var server = new TestServer(builder); var client = server.CreateClient(); @@ -166,7 +167,7 @@ public async Task Respond_415_If_Content_Type_Header_Is_JsonApi_Media_Type_With_ public async Task Respond_415_If_Content_Type_Header_Is_JsonApi_Media_Type_With_Unknown() { // Arrange - var builder = new WebHostBuilder().UseStartup(); + var builder = WebHost.CreateDefaultBuilder().UseStartup(); var route = "/api/v1/todoItems"; var server = new TestServer(builder); var client = server.CreateClient(); @@ -191,7 +192,7 @@ public async Task Respond_415_If_Content_Type_Header_Is_JsonApi_Media_Type_With_ public async Task Respond_200_If_Accept_Headers_Are_Missing() { // Arrange - var builder = new WebHostBuilder().UseStartup(); + var builder = WebHost.CreateDefaultBuilder().UseStartup(); var route = "/api/v1/todoItems"; var server = new TestServer(builder); var client = server.CreateClient(); @@ -208,7 +209,7 @@ public async Task Respond_200_If_Accept_Headers_Are_Missing() public async Task Respond_200_If_Accept_Headers_Include_Any() { // Arrange - var builder = new WebHostBuilder().UseStartup(); + var builder = WebHost.CreateDefaultBuilder().UseStartup(); var route = "/api/v1/todoItems"; var server = new TestServer(builder); var client = server.CreateClient(); @@ -227,7 +228,7 @@ public async Task Respond_200_If_Accept_Headers_Include_Any() public async Task Respond_200_If_Accept_Headers_Include_Application_Prefix() { // Arrange - var builder = new WebHostBuilder().UseStartup(); + var builder = WebHost.CreateDefaultBuilder().UseStartup(); var route = "/api/v1/todoItems"; var server = new TestServer(builder); var client = server.CreateClient(); @@ -246,7 +247,7 @@ public async Task Respond_200_If_Accept_Headers_Include_Application_Prefix() public async Task Respond_200_If_Accept_Headers_Contain_JsonApi_Media_Type() { // Arrange - var builder = new WebHostBuilder().UseStartup(); + var builder = WebHost.CreateDefaultBuilder().UseStartup(); var route = "/api/v1/todoItems"; var server = new TestServer(builder); var client = server.CreateClient(); @@ -268,7 +269,7 @@ public async Task Respond_200_If_Accept_Headers_Contain_JsonApi_Media_Type() public async Task Respond_406_If_Accept_Headers_Only_Contain_JsonApi_Media_Type_With_Parameters() { // Arrange - var builder = new WebHostBuilder().UseStartup(); + var builder = WebHost.CreateDefaultBuilder().UseStartup(); var route = "/api/v1/todoItems"; var server = new TestServer(builder); var client = server.CreateClient(); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs index f821fee096..5bfc0ecac9 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using System.Linq; using System.Net; @@ -34,8 +33,8 @@ public CreatingDataTests(StandardApplicationFactory factory) : base(factory) 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" }; + 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)); @@ -54,7 +53,7 @@ public async Task CreateResource_GuidResource_IsCreated() var serializer = GetSerializer(e => new { }, e => new { e.Owner }); var owner = new Person(); _dbContext.People.Add(owner); - _dbContext.SaveChanges(); + await _dbContext.SaveChangesAsync(); var todoItemCollection = new TodoItemCollection { Owner = owner }; // Act @@ -93,7 +92,7 @@ public async Task CreateWithRelationship_HasMany_IsCreated() var serializer = GetSerializer(e => new { }, e => new { e.TodoItems }); var todoItem = _todoItemFaker.Generate(); _dbContext.TodoItems.Add(todoItem); - _dbContext.SaveChanges(); + await _dbContext.SaveChangesAsync(); var todoCollection = new TodoItemCollection { TodoItems = new HashSet { todoItem } }; // Act @@ -112,7 +111,7 @@ public async Task CreateWithRelationship_HasMany_IsCreated() } [Fact] - public async Task CreateWithRelationship_HasManyAndInclude_IsCreatedAndIncludes() + public async Task CreateWithRelationship_HasManyAndInclude_IsCreatedAndIncluded() { // Arrange var serializer = GetSerializer(e => new { }, e => new { e.TodoItems, e.Owner }); @@ -120,7 +119,7 @@ public async Task CreateWithRelationship_HasManyAndInclude_IsCreatedAndIncludes( var todoItem = new TodoItem { Owner = owner, Description = "Description" }; _dbContext.People.Add(owner); _dbContext.TodoItems.Add(todoItem); - _dbContext.SaveChanges(); + await _dbContext.SaveChangesAsync(); var todoCollection = new TodoItemCollection { Owner = owner, TodoItems = new HashSet { todoItem } }; // Act @@ -135,7 +134,7 @@ public async Task CreateWithRelationship_HasManyAndInclude_IsCreatedAndIncludes( } [Fact] - public async Task CreateWithRelationship_HasManyAndIncludeAndSparseFieldset_IsCreatedAndIncludes() + public async Task CreateWithRelationship_HasManyAndIncludeAndSparseFieldset_IsCreatedAndIncluded() { // Arrange var serializer = GetSerializer(e => new { e.Name }, e => new { e.TodoItems, e.Owner }); @@ -143,7 +142,7 @@ public async Task CreateWithRelationship_HasManyAndIncludeAndSparseFieldset_IsCr var todoItem = new TodoItem { Owner = owner, Ordinal = 123, Description = "Description" }; _dbContext.People.Add(owner); _dbContext.TodoItems.Add(todoItem); - _dbContext.SaveChanges(); + await _dbContext.SaveChangesAsync(); var todoCollection = new TodoItemCollection {Owner = owner, Name = "Jack", TodoItems = new HashSet {todoItem}}; // Act @@ -168,7 +167,7 @@ public async Task CreateWithRelationship_HasOne_IsCreated() var todoItem = new TodoItem(); var owner = new Person(); _dbContext.People.Add(owner); - _dbContext.SaveChanges(); + await _dbContext.SaveChangesAsync(); todoItem.Owner = owner; // Act @@ -184,14 +183,14 @@ public async Task CreateWithRelationship_HasOne_IsCreated() } [Fact] - public async Task CreateWithRelationship_HasOneAndInclude_IsCreatedAndIncludes() + 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); - _dbContext.SaveChanges(); + await _dbContext.SaveChangesAsync(); todoItem.Owner = owner; // Act @@ -206,7 +205,7 @@ public async Task CreateWithRelationship_HasOneAndInclude_IsCreatedAndIncludes() } [Fact] - public async Task CreateWithRelationship_HasOneAndIncludeAndSparseFieldset_IsCreatedAndIncludes() + public async Task CreateWithRelationship_HasOneAndIncludeAndSparseFieldset_IsCreatedAndIncluded() { // Arrange var serializer = GetSerializer(attributes: ti => new { ti.Ordinal }, relationships: ti => new { ti.Owner }); @@ -217,7 +216,7 @@ public async Task CreateWithRelationship_HasOneAndIncludeAndSparseFieldset_IsCre }; var owner = new Person { FirstName = "Alice", LastName = "Cooper" }; _dbContext.People.Add(owner); - _dbContext.SaveChanges(); + await _dbContext.SaveChangesAsync(); todoItem.Owner = owner; // Act @@ -243,7 +242,7 @@ public async Task CreateWithRelationship_HasOneFromIndependentSide_IsCreated() var serializer = GetSerializer(pr => new { }, pr => new { pr.Person }); var person = new Person(); _dbContext.People.Add(person); - _dbContext.SaveChanges(); + await _dbContext.SaveChangesAsync(); var personRole = new PersonRole { Person = person }; // Act @@ -276,7 +275,7 @@ public async Task CreateResource_SimpleResource_HeaderLocationsAreCorrect() } [Fact] - public async Task CreateResource_EntityTypeMismatch_IsConflict() + public async Task CreateResource_ResourceTypeMismatch_IsConflict() { // Arrange string content = JsonConvert.SerializeObject(new @@ -301,7 +300,7 @@ public async Task CreateResource_EntityTypeMismatch_IsConflict() } [Fact] - public async Task CreateResource_UnknownEntityType_Fails() + public async Task CreateResource_UnknownResourceType_Fails() { // Arrange string content = JsonConvert.SerializeObject(new @@ -335,7 +334,7 @@ public async Task CreateRelationship_ToOneWithImplicitRemove_IsCreated() var currentPerson = _personFaker.Generate(); currentPerson.Passport = passport; _dbContext.People.Add(currentPerson); - _dbContext.SaveChanges(); + await _dbContext.SaveChangesAsync(); var newPerson = _personFaker.Generate(); newPerson.Passport = passport; @@ -359,7 +358,7 @@ public async Task CreateRelationship_ToManyWithImplicitRemove_IsCreated() var todoItems = _todoItemFaker.Generate(3); currentPerson.TodoItems = todoItems.ToHashSet(); _dbContext.Add(currentPerson); - _dbContext.SaveChanges(); + await _dbContext.SaveChangesAsync(); var firstTd = todoItems[0]; var secondTd = todoItems[1]; var thirdTd = todoItems[2]; @@ -382,56 +381,4 @@ public async Task CreateRelationship_ToManyWithImplicitRemove_IsCreated() Assert.NotNull(oldPersonDb.TodoItems.SingleOrDefault(ti => ti.Id == thirdTd.Id)); } } - - - public sealed class CreatingDataWithClientEnabledIdTests : FunctionalTestCollection - { - private readonly Faker _todoItemFaker; - - public CreatingDataWithClientEnabledIdTests(ClientEnabledIdsApplicationFactory 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/CreatingDataWithClientGeneratedIdsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataWithClientGeneratedIdsTests.cs new file mode 100644 index 0000000000..67ed04bdca --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataWithClientGeneratedIdsTests.cs @@ -0,0 +1,62 @@ +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/DeeplyNestedInclusionTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeeplyNestedInclusionTests.cs deleted file mode 100644 index 2a52ebe6d4..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeeplyNestedInclusionTests.cs +++ /dev/null @@ -1,337 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Net; -using System.Threading.Tasks; -using JsonApiDotNetCore.Builders; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Serialization.Client; -using JsonApiDotNetCoreExample; -using JsonApiDotNetCoreExample.Data; -using JsonApiDotNetCoreExample.Models; -using JsonApiDotNetCoreExampleTests.Helpers.Extensions; -using JsonApiDotNetCoreExampleTests.Helpers.Models; -using Microsoft.Extensions.Logging.Abstractions; -using Newtonsoft.Json; -using Xunit; -using Person = JsonApiDotNetCoreExample.Models.Person; - -namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec -{ - [Collection("WebHostCollection")] - public sealed class DeeplyNestedInclusionTests - { - private readonly TestFixture _fixture; - - public DeeplyNestedInclusionTests(TestFixture fixture) - { - _fixture = fixture; - } - - private void ResetContext(AppDbContext context) - { - context.TodoItems.RemoveRange(context.TodoItems); - context.TodoItemCollections.RemoveRange(context.TodoItemCollections); - context.People.RemoveRange(context.People); - context.PersonRoles.RemoveRange(context.PersonRoles); - } - - [Fact] - public async Task Can_Include_Nested_Relationships() - { - // Arrange - const string route = "/api/v1/todoItems?include=collection.owner"; - - var options = _fixture.GetService(); - var resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance) - .AddResource("todoItems") - .AddResource() - .AddResource() - .Build(); - var deserializer = new ResponseDeserializer(resourceGraph, new DefaultResourceFactory(_fixture.ServiceProvider)); - var todoItem = new TodoItem - { - Collection = new TodoItemCollection - { - Owner = new Person() - } - }; - - var context = _fixture.GetService(); - context.TodoItems.RemoveRange(context.TodoItems); - context.TodoItems.Add(todoItem); - await context.SaveChangesAsync(); - - // Act - var response = await _fixture.Client.GetAsync(route); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - - var todoItems = deserializer.DeserializeList(body).Data; - - var responseTodoItem = Assert.Single(todoItems); - Assert.NotNull(responseTodoItem); - Assert.NotNull(responseTodoItem.Collection); - Assert.NotNull(responseTodoItem.Collection.Owner); - } - - [Fact] - public async Task Can_Include_Nested_HasMany_Relationships() - { - // Arrange - const string route = "/api/v1/todoItems?include=collection.todoItems"; - - var todoItem = new TodoItem - { - Collection = new TodoItemCollection - { - Owner = new Person(), - TodoItems = new HashSet { - new TodoItem(), - new TodoItem() - } - } - }; - - - var context = _fixture.GetService(); - ResetContext(context); - - context.TodoItems.Add(todoItem); - await context.SaveChangesAsync(); - - // Act - var response = await _fixture.Client.GetAsync(route); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - var documents = JsonConvert.DeserializeObject(body); - var included = documents.Included; - - Assert.Equal(4, included.Count); - - Assert.Equal(3, included.CountOfType("todoItems")); - Assert.Equal(1, included.CountOfType("todoCollections")); - } - - [Fact] - public async Task Can_Include_Nested_HasMany_Relationships_BelongsTo() - { - // Arrange - const string route = "/api/v1/todoItems?include=collection.todoItems.owner"; - - var todoItem = new TodoItem - { - Collection = new TodoItemCollection - { - Owner = new Person(), - TodoItems = new HashSet { - new TodoItem { - Owner = new Person() - }, - new TodoItem() - } - } - }; - - var context = _fixture.GetService(); - ResetContext(context); - - context.TodoItems.Add(todoItem); - await context.SaveChangesAsync(); - - // Act - var response = await _fixture.Client.GetAsync(route); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - var documents = JsonConvert.DeserializeObject(body); - var included = documents.Included; - - Assert.Equal(5, included.Count); - - Assert.Equal(3, included.CountOfType("todoItems")); - Assert.Equal(1, included.CountOfType("people")); - Assert.Equal(1, included.CountOfType("todoCollections")); - } - - [Fact] - public async Task Can_Include_Nested_Relationships_With_Multiple_Paths() - { - // Arrange - const string route = "/api/v1/todoItems?include=collection.owner.role,collection.todoItems.owner"; - - var todoItem = new TodoItem - { - Collection = new TodoItemCollection - { - Owner = new Person - { - Role = new PersonRole() - }, - TodoItems = new HashSet { - new TodoItem { - Owner = new Person() - }, - new TodoItem() - } - } - }; - - var context = _fixture.GetService(); - ResetContext(context); - - context.TodoItems.Add(todoItem); - await context.SaveChangesAsync(); - - // Act - var response = await _fixture.Client.GetAsync(route); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - var documents = JsonConvert.DeserializeObject(body); - var included = documents.Included; - - Assert.Equal(7, included.Count); - - Assert.Equal(3, included.CountOfType("todoItems")); - Assert.Equal(2, included.CountOfType("people")); - Assert.Equal(1, included.CountOfType("personRoles")); - Assert.Equal(1, included.CountOfType("todoCollections")); - } - - [Fact] - public async Task Included_Resources_Are_Correct() - { - // Arrange - var role = new PersonRole(); - var assignee = new Person { Role = role }; - var collectionOwner = new Person(); - var someOtherOwner = new Person(); - var collection = new TodoItemCollection { Owner = collectionOwner }; - var todoItem1 = new TodoItem { Collection = collection, Assignee = assignee }; - var todoItem2 = new TodoItem { Collection = collection, Assignee = assignee }; - var todoItem3 = new TodoItem { Collection = collection, Owner = someOtherOwner }; - var todoItem4 = new TodoItem { Collection = collection, Owner = assignee }; - - var context = _fixture.GetService(); - ResetContext(context); - - context.TodoItems.Add(todoItem1); - context.TodoItems.Add(todoItem2); - context.TodoItems.Add(todoItem3); - context.TodoItems.Add(todoItem4); - context.PersonRoles.Add(role); - context.People.Add(assignee); - context.People.Add(collectionOwner); - context.People.Add(someOtherOwner); - context.TodoItemCollections.Add(collection); - - - await context.SaveChangesAsync(); - - string route = - "/api/v1/todoItems/" + todoItem1.Id + "?include=" + - "collection.owner," + - "assignee.role," + - "assignee.assignedTodoItems"; - - // Act - var response = await _fixture.Client.GetAsync(route); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - var documents = JsonConvert.DeserializeObject(body); - var included = documents.Included; - - // 1 collection, 1 owner, - // 1 assignee, 1 assignee role, - // 2 assigned todo items (including the primary resource) - Assert.Equal(6, included.Count); - - var collectionDocument = included.FindResource("todoCollections", collection.Id); - var ownerDocument = included.FindResource("people", collectionOwner.Id); - var assigneeDocument = included.FindResource("people", assignee.Id); - var roleDocument = included.FindResource("personRoles", role.Id); - var assignedTodo1 = included.FindResource("todoItems", todoItem1.Id); - var assignedTodo2 = included.FindResource("todoItems", todoItem2.Id); - - Assert.NotNull(assignedTodo1); - Assert.Equal(todoItem1.Id.ToString(), assignedTodo1.Id); - - Assert.NotNull(assignedTodo2); - Assert.Equal(todoItem2.Id.ToString(), assignedTodo2.Id); - - Assert.NotNull(collectionDocument); - Assert.Equal(collection.Id.ToString(), collectionDocument.Id); - - Assert.NotNull(ownerDocument); - Assert.Equal(collectionOwner.Id.ToString(), ownerDocument.Id); - - Assert.NotNull(assigneeDocument); - Assert.Equal(assignee.Id.ToString(), assigneeDocument.Id); - - Assert.NotNull(roleDocument); - Assert.Equal(role.Id.ToString(), roleDocument.Id); - } - - [Fact] - public async Task Can_Include_Doubly_HasMany_Relationships() - { - // Arrange - var person = new Person { - todoCollections = new HashSet { - new TodoItemCollection { - TodoItems = new HashSet { - new TodoItem(), - new TodoItem() - } - }, - new TodoItemCollection { - TodoItems = new HashSet { - new TodoItem(), - new TodoItem(), - new TodoItem() - } - } - } - }; - - var context = _fixture.GetService(); - ResetContext(context); - - context.People.Add(person); - - await context.SaveChangesAsync(); - - string route = "/api/v1/people/" + person.Id + "?include=todoCollections.todoItems"; - - // Act - var response = await _fixture.Client.GetAsync(route); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - var documents = JsonConvert.DeserializeObject(body); - var included = documents.Included; - - Assert.Equal(7, included.Count); - - Assert.Equal(5, included.CountOfType("todoItems")); - Assert.Equal(2, included.CountOfType("todoCollections")); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeletingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeletingDataTests.cs index 34975c28e9..810e673627 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeletingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeletingDataTests.cs @@ -4,6 +4,8 @@ using JsonApiDotNetCore.Models.JsonApiDocuments; using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Data; +using JsonApiDotNetCoreExample.Models; +using Microsoft.AspNetCore; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; using Newtonsoft.Json; @@ -22,13 +24,13 @@ public DeletingDataTests(TestFixture fixture) } [Fact] - public async Task Respond_404_If_EntityDoesNotExist() + public async Task Respond_404_If_ResourceDoesNotExist() { // Arrange - _context.TodoItems.RemoveRange(_context.TodoItems); + await _context.ClearTableAsync(); await _context.SaveChangesAsync(); - var builder = new WebHostBuilder() + var builder = WebHost.CreateDefaultBuilder() .UseStartup(); var server = new TestServer(builder); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DisableQueryAttributeTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DisableQueryAttributeTests.cs index c76e118179..de1d3a6ff4 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DisableQueryAttributeTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DisableQueryAttributeTests.cs @@ -23,7 +23,7 @@ public async Task Cannot_Sort_If_Blocked_By_Controller() { // Arrange var httpMethod = new HttpMethod("GET"); - var route = "/api/v1/articles?sort=name"; + var route = "/api/v1/countries?sort=name"; var request = new HttpRequestMessage(httpMethod, route); // Act diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Included.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Included.cs deleted file mode 100644 index 64ba7b4fe6..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Included.cs +++ /dev/null @@ -1,468 +0,0 @@ -using System; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using Bogus; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Models.JsonApiDocuments; -using JsonApiDotNetCoreExample; -using JsonApiDotNetCoreExample.Data; -using JsonApiDotNetCoreExample.Models; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.TestHost; -using Newtonsoft.Json; -using Xunit; -using Person = JsonApiDotNetCoreExample.Models.Person; - -namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec.DocumentTests -{ - [Collection("WebHostCollection")] - public sealed class Included - { - private readonly AppDbContext _context; - private readonly Faker _personFaker; - private readonly Faker _todoItemFaker; - private readonly Faker _todoItemCollectionFaker; - - public Included(TestFixture fixture) - { - _context = fixture.GetService(); - _personFaker = new Faker() - .RuleFor(p => p.FirstName, f => f.Name.FirstName()) - .RuleFor(p => p.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()); - - _todoItemCollectionFaker = new Faker() - .RuleFor(t => t.Name, f => f.Company.CatchPhrase()); - } - - [Fact] - public async Task GET_Included_Contains_SideLoadedData_ForManyToOne() - { - // Arrange - var person = _personFaker.Generate(); - var todoItem = _todoItemFaker.Generate(); - todoItem.Owner = person; - _context.TodoItems.RemoveRange(_context.TodoItems); - _context.TodoItems.Add(todoItem); - _context.SaveChanges(); - - var builder = new WebHostBuilder().UseStartup(); - - var httpMethod = new HttpMethod("GET"); - var route = "/api/v1/todoItems?include=owner"; - - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await client.SendAsync(request); - - // Assert - var json = await response.Content.ReadAsStringAsync(); - var documents = JsonConvert.DeserializeObject(json); - // we only care about counting the todoItems that have owners - var expectedCount = documents.ManyData.Count(d => d.Relationships["owner"].SingleData != null); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.NotEmpty(documents.Included); - Assert.Equal(expectedCount, documents.Included.Count); - - server.Dispose(); - request.Dispose(); - response.Dispose(); - } - - [Fact] - public async Task GET_ById_Included_Contains_SideLoadedData_ForManyToOne() - { - // Arrange - var person = _personFaker.Generate(); - var todoItem = _todoItemFaker.Generate(); - todoItem.Owner = person; - _context.TodoItems.Add(todoItem); - _context.SaveChanges(); - - var builder = new WebHostBuilder() - .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 responseString = await response.Content.ReadAsStringAsync(); - var document = JsonConvert.DeserializeObject(responseString); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.NotEmpty(document.Included); - Assert.Equal(person.Id.ToString(), document.Included[0].Id); - Assert.Equal(person.FirstName, document.Included[0].Attributes["firstName"]); - Assert.Equal(person.LastName, document.Included[0].Attributes["lastName"]); - - server.Dispose(); - request.Dispose(); - response.Dispose(); - } - - [Fact] - public async Task GET_Included_Contains_SideLoadedData_OneToMany() - { - // Arrange - _context.People.RemoveRange(_context.People); // ensure all people have todoItems - _context.TodoItems.RemoveRange(_context.TodoItems); - var person = _personFaker.Generate(); - var todoItem = _todoItemFaker.Generate(); - todoItem.Owner = person; - _context.TodoItems.Add(todoItem); - _context.SaveChanges(); - - var builder = new WebHostBuilder() - .UseStartup(); - - var httpMethod = new HttpMethod("GET"); - var route = "/api/v1/people?include=todoItems"; - - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await client.SendAsync(request); - var documents = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.NotEmpty(documents.Included); - Assert.Equal(documents.ManyData.Count, documents.Included.Count); - - server.Dispose(); - request.Dispose(); - response.Dispose(); - } - - [Fact] - public async Task GET_Included_DoesNot_Duplicate_Records_ForMultipleRelationshipsOfSameType() - { - // Arrange - _context.RemoveRange(_context.TodoItems); - _context.RemoveRange(_context.TodoItemCollections); - _context.RemoveRange(_context.People); // ensure all people have todoItems - _context.SaveChanges(); - var person = _personFaker.Generate(); - var todoItem = _todoItemFaker.Generate(); - todoItem.Owner = person; - todoItem.Assignee = person; - _context.TodoItems.Add(todoItem); - _context.SaveChanges(); - - var builder = new WebHostBuilder() - .UseStartup(); - - var httpMethod = new HttpMethod("GET"); - var route = $"/api/v1/todoItems/{todoItem.Id}?include=owner&include=assignee"; - - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await client.SendAsync(request); - var documents = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.NotEmpty(documents.Included); - Assert.Single(documents.Included); - - server.Dispose(); - request.Dispose(); - response.Dispose(); - } - - [Fact] - public async Task GET_Included_DoesNot_Duplicate_Records_If_HasOne_Exists_Twice() - { - // Arrange - _context.TodoItemCollections.RemoveRange(_context.TodoItemCollections); - _context.People.RemoveRange(_context.People); // ensure all people have todoItems - _context.TodoItems.RemoveRange(_context.TodoItems); - var person = _personFaker.Generate(); - var todoItem1 = _todoItemFaker.Generate(); - var todoItem2 = _todoItemFaker.Generate(); - todoItem1.Owner = person; - todoItem2.Owner = person; - _context.TodoItems.AddRange(todoItem1, todoItem2); - _context.SaveChanges(); - - var builder = new WebHostBuilder() - .UseStartup(); - - var httpMethod = new HttpMethod("GET"); - var route = "/api/v1/todoItems?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 documents = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.NotEmpty(documents.Included); - Assert.Single(documents.Included); - - server.Dispose(); - request.Dispose(); - response.Dispose(); - } - - [Fact] - public async Task GET_ById_Included_Contains_SideloadeData_ForOneToMany() - { - // Arrange - const int numberOfTodoItems = 5; - var person = _personFaker.Generate(); - for (var i = 0; i < numberOfTodoItems; i++) - { - var todoItem = _todoItemFaker.Generate(); - todoItem.Owner = person; - _context.TodoItems.Add(todoItem); - _context.SaveChanges(); - } - - var builder = new WebHostBuilder() - .UseStartup(); - - var httpMethod = new HttpMethod("GET"); - - var route = $"/api/v1/people/{person.Id}?include=todoItems"; - - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await client.SendAsync(request); - var responseString = await response.Content.ReadAsStringAsync(); - var document = JsonConvert.DeserializeObject(responseString); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.NotEmpty(document.Included); - Assert.Equal(numberOfTodoItems, document.Included.Count); - - server.Dispose(); - request.Dispose(); - response.Dispose(); - } - - [Fact] - public async Task Can_Include_MultipleRelationships() - { - // Arrange - var person = _personFaker.Generate(); - var todoItemCollection = _todoItemCollectionFaker.Generate(); - todoItemCollection.Owner = person; - - const int numberOfTodoItems = 5; - for (var i = 0; i < numberOfTodoItems; i++) - { - var todoItem = _todoItemFaker.Generate(); - todoItem.Owner = person; - todoItem.Collection = todoItemCollection; - _context.TodoItems.Add(todoItem); - _context.SaveChanges(); - } - - var builder = new WebHostBuilder() - .UseStartup(); - - var httpMethod = new HttpMethod("GET"); - - var route = $"/api/v1/people/{person.Id}?include=todoItems,todoCollections"; - - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await client.SendAsync(request); - var responseString = await response.Content.ReadAsStringAsync(); - var document = JsonConvert.DeserializeObject(responseString); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.NotEmpty(document.Included); - Assert.Equal(numberOfTodoItems + 1, document.Included.Count); - - server.Dispose(); - request.Dispose(); - response.Dispose(); - } - - [Fact] - public async Task Request_ToIncludeUnknownRelationship_Returns_400() - { - // Arrange - var person = _context.People.First(); - - var builder = new WebHostBuilder() - .UseStartup(); - - var httpMethod = new HttpMethod("GET"); - - var route = $"/api/v1/people/{person.Id}?include=nonExistentRelationship"; - - using var server = new TestServer(builder); - using var client = server.CreateClient(); - using var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].StatusCode); - Assert.Equal("The requested relationship to include does not exist.", errorDocument.Errors[0].Title); - Assert.Equal("The relationship 'nonExistentRelationship' on 'people' does not exist.", errorDocument.Errors[0].Detail); - } - - [Fact] - public async Task Request_ToIncludeDeeplyNestedRelationships_Returns_400() - { - // Arrange - var person = _context.People.First(); - - var builder = new WebHostBuilder() - .UseStartup(); - - var httpMethod = new HttpMethod("GET"); - - var route = $"/api/v1/people/{person.Id}?include=owner.name"; - - using var server = new TestServer(builder); - using var client = server.CreateClient(); - using var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].StatusCode); - Assert.Equal("The requested relationship to include does not exist.", errorDocument.Errors[0].Title); - Assert.Equal("The relationship 'owner' on 'people' does not exist.", errorDocument.Errors[0].Detail); - } - - [Fact] - public async Task Request_ToIncludeRelationshipMarkedCanIncludeFalse_Returns_400() - { - // Arrange - var person = _context.People.First(); - - var builder = new WebHostBuilder() - .UseStartup(); - - var httpMethod = new HttpMethod("GET"); - - var route = $"/api/v1/people/{person.Id}?include=unincludeableItem"; - - using var server = new TestServer(builder); - using var client = server.CreateClient(); - using var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].StatusCode); - Assert.Equal("The requested relationship to include does not exist.", errorDocument.Errors[0].Title); - Assert.Equal("The relationship 'unincludeableItem' on 'people' does not exist.", errorDocument.Errors[0].Detail); - } - - [Fact] - public async Task Can_Ignore_Null_Parent_In_Nested_Include() - { - // Arrange - _context.TodoItems.RemoveRange(_context.TodoItems); - - var todoItem = _todoItemFaker.Generate(); - todoItem.Owner = _personFaker.Generate(); - todoItem.CreatedDate = new DateTime(2002, 2,2); - _context.TodoItems.Add(todoItem); - _context.SaveChanges(); - - var todoItemWithNullOwner = _todoItemFaker.Generate(); - todoItemWithNullOwner.Owner = null; - todoItemWithNullOwner.CreatedDate = new DateTime(2002, 2,2); - _context.TodoItems.Add(todoItemWithNullOwner); - _context.SaveChanges(); - - var builder = new WebHostBuilder() - .UseStartup(); - - var httpMethod = new HttpMethod("GET"); - - var route = "/api/v1/todoItems?sort=-createdDate&page[size]=2&include=owner.role"; // last two todoItems - - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await client.SendAsync(request); - var responseString = await response.Content.ReadAsStringAsync(); - var documents = JsonConvert.DeserializeObject(responseString); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Single(documents.Included); - - var ownerValueNull = documents.ManyData - .First(i => i.Id == todoItemWithNullOwner.StringId) - .Relationships.First(i => i.Key == "owner") - .Value.SingleData; - - Assert.Null(ownerValueNull); - - var ownerValue = documents.ManyData - .First(i => i.Id == todoItem.StringId) - .Relationships.First(i => i.Key == "owner") - .Value.SingleData; - - Assert.NotNull(ownerValue); - - server.Dispose(); - request.Dispose(); - response.Dispose(); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/LinksWithNamespaceTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/LinksWithNamespaceTests.cs index d3944d2464..644f22919c 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/LinksWithNamespaceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/LinksWithNamespaceTests.cs @@ -22,13 +22,13 @@ public async Task GET_RelativeLinks_True_With_Namespace_Returns_RelativeLinks() var person = new Person(); _dbContext.People.Add(person); - _dbContext.SaveChanges(); + await _dbContext.SaveChangesAsync(); var route = "/api/v1/people/" + person.StringId; var request = new HttpRequestMessage(HttpMethod.Get, route); var options = (JsonApiOptions) _factory.GetService(); - options.RelativeLinks = true; + options.UseRelativeLinks = true; // Act var response = await _factory.Client.SendAsync(request); @@ -47,13 +47,13 @@ public async Task GET_RelativeLinks_False_With_Namespace_Returns_AbsoluteLinks() var person = new Person(); _dbContext.People.Add(person); - _dbContext.SaveChanges(); + await _dbContext.SaveChangesAsync(); var route = "/api/v1/people/" + person.StringId; var request = new HttpRequestMessage(HttpMethod.Get, route); var options = (JsonApiOptions) _factory.GetService(); - options.RelativeLinks = false; + options.UseRelativeLinks = false; // Act var response = await _factory.Client.SendAsync(request); @@ -62,7 +62,7 @@ public async Task GET_RelativeLinks_False_With_Namespace_Returns_AbsoluteLinks() // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal($"http://localhost/api/v1/people/" + person.StringId, document.Links.Self); + Assert.Equal("http://localhost/api/v1/people/" + person.StringId, document.Links.Self); } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/LinksWithoutNamespaceTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/LinksWithoutNamespaceTests.cs index d3914c2e12..3f4a9cb994 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/LinksWithoutNamespaceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/LinksWithoutNamespaceTests.cs @@ -1,68 +1,99 @@ using System.Net; -using System.Net.Http; using System.Threading.Tasks; +using FluentAssertions; using JsonApiDotNetCore.Models; -using Newtonsoft.Json; using Xunit; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCoreExample; +using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; +using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec.DocumentTests { - public sealed class LinksWithoutNamespaceTests : FunctionalTestCollection + public sealed class LinksWithoutNamespaceTests : IClassFixture> { - public LinksWithoutNamespaceTests(NoNamespaceApplicationFactory factory) : base(factory) + private readonly IntegrationTestContext _testContext; + + public LinksWithoutNamespaceTests(IntegrationTestContext testContext) { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => + { + var part = new AssemblyPart(typeof(EmptyStartup).Assembly); + services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); + }); } [Fact] public async Task GET_RelativeLinks_True_Without_Namespace_Returns_RelativeLinks() { // Arrange + var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); + options.UseRelativeLinks = true; + var person = new Person(); - _dbContext.People.Add(person); - _dbContext.SaveChanges(); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.People.Add(person); - var route = "/people/" + person.StringId; - var request = new HttpRequestMessage(HttpMethod.Get, route); + await dbContext.SaveChangesAsync(); + }); - var options = (JsonApiOptions) _factory.GetService(); - options.RelativeLinks = true; + var route = "/people/" + person.StringId; // Act - var response = await _factory.Client.SendAsync(request); - var responseString = await response.Content.ReadAsStringAsync(); - var document = JsonConvert.DeserializeObject(responseString); + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal("/people/" + person.StringId, document.Links.Self); + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Links.Self.Should().Be("/people/" + person.StringId); } [Fact] public async Task GET_RelativeLinks_False_Without_Namespace_Returns_AbsoluteLinks() { // Arrange + var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); + options.UseRelativeLinks = false; + var person = new Person(); - _dbContext.People.Add(person); - _dbContext.SaveChanges(); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.People.Add(person); - var route = "/people/" + person.StringId; - var request = new HttpRequestMessage(HttpMethod.Get, route); + await dbContext.SaveChangesAsync(); + }); - var options = (JsonApiOptions) _factory.GetService(); - options.RelativeLinks = false; + var route = "/people/" + person.StringId; // Act - var response = await _factory.Client.SendAsync(request); - var responseString = await response.Content.ReadAsStringAsync(); - var document = JsonConvert.DeserializeObject(responseString); + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal($"http://localhost/people/" + person.StringId, document.Links.Self); + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Links.Self.Should().Be("http://localhost/people/" + person.StringId); + } + } + + public sealed class NoNamespaceStartup : TestStartup + { + public NoNamespaceStartup(IConfiguration configuration) : base(configuration) + { + } + + protected override void ConfigureJsonApiOptions(JsonApiOptions options) + { + base.ConfigureJsonApiOptions(options); + + options.Namespace = null; } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Meta.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Meta.cs index cb122b274f..ec03714fa7 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Meta.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Meta.cs @@ -8,6 +8,7 @@ using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; +using Microsoft.AspNetCore; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; using Newtonsoft.Json; @@ -27,15 +28,15 @@ public Meta(TestFixture fixture) } [Fact] - public async Task Total_Record_Count_Included() + public async Task Total_Resource_Count_Included() { // Arrange - _context.TodoItems.RemoveRange(_context.TodoItems); + await _context.ClearTableAsync(); _context.TodoItems.Add(new TodoItem()); - _context.SaveChanges(); + await _context.SaveChangesAsync(); var expectedCount = 1; - var builder = new WebHostBuilder() - .UseStartup(); + var builder = WebHost.CreateDefaultBuilder() + .UseStartup(); var httpMethod = new HttpMethod("GET"); var route = "/api/v1/todoItems"; @@ -52,17 +53,17 @@ public async Task Total_Record_Count_Included() // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.NotNull(documents.Meta); - Assert.Equal(expectedCount, (long)documents.Meta["total-records"]); + Assert.Equal(expectedCount, (long)documents.Meta["totalResources"]); } [Fact] - public async Task Total_Record_Count_Included_When_None() + public async Task Total_Resource_Count_Included_When_None() { // Arrange - _context.TodoItems.RemoveRange(_context.TodoItems); - _context.SaveChanges(); - var builder = new WebHostBuilder() - .UseStartup(); + await _context.ClearTableAsync(); + await _context.SaveChangesAsync(); + var builder = WebHost.CreateDefaultBuilder() + .UseStartup(); var httpMethod = new HttpMethod("GET"); var route = "/api/v1/todoItems"; @@ -79,17 +80,17 @@ public async Task Total_Record_Count_Included_When_None() // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.NotNull(documents.Meta); - Assert.Equal(0, (long)documents.Meta["total-records"]); + Assert.Equal(0, (long)documents.Meta["totalResources"]); } [Fact] - public async Task Total_Record_Count_Not_Included_In_POST_Response() + public async Task Total_Resource_Count_Not_Included_In_POST_Response() { // Arrange - _context.TodoItems.RemoveRange(_context.TodoItems); - _context.SaveChanges(); - var builder = new WebHostBuilder() - .UseStartup(); + await _context.ClearTableAsync(); + await _context.SaveChangesAsync(); + var builder = WebHost.CreateDefaultBuilder() + .UseStartup(); var httpMethod = new HttpMethod("POST"); var route = "/api/v1/todoItems"; @@ -104,7 +105,7 @@ public async Task Total_Record_Count_Not_Included_In_POST_Response() type = "todoItems", attributes = new { - description = "New Description", + description = "New Description" } } }; @@ -119,19 +120,19 @@ public async Task Total_Record_Count_Not_Included_In_POST_Response() // Assert Assert.Equal(HttpStatusCode.Created, response.StatusCode); - Assert.False(documents.Meta.ContainsKey("total-records")); + Assert.True(documents.Meta?.ContainsKey("totalResources") != true); } [Fact] - public async Task Total_Record_Count_Not_Included_In_PATCH_Response() + public async Task Total_Resource_Count_Not_Included_In_PATCH_Response() { // Arrange - _context.TodoItems.RemoveRange(_context.TodoItems); + await _context.ClearTableAsync(); TodoItem todoItem = new TodoItem(); _context.TodoItems.Add(todoItem); - _context.SaveChanges(); - var builder = new WebHostBuilder() - .UseStartup(); + await _context.SaveChangesAsync(); + var builder = WebHost.CreateDefaultBuilder() + .UseStartup(); var httpMethod = new HttpMethod("PATCH"); var route = $"/api/v1/todoItems/{todoItem.Id}"; @@ -147,7 +148,7 @@ public async Task Total_Record_Count_Not_Included_In_PATCH_Response() id = todoItem.Id, attributes = new { - description = "New Description", + description = "New Description" } } }; @@ -162,15 +163,15 @@ public async Task Total_Record_Count_Not_Included_In_PATCH_Response() // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.False(documents.Meta.ContainsKey("total-records")); + Assert.True(documents.Meta?.ContainsKey("totalResources") != true); } [Fact] - public async Task EntityThatImplements_IHasMeta_Contains_MetaData() + public async Task ResourceThatImplements_IHasMeta_Contains_MetaData() { // Arrange - var builder = new WebHostBuilder() - .UseStartup(); + var builder = WebHost.CreateDefaultBuilder() + .UseStartup(); var httpMethod = new HttpMethod("GET"); var route = "/api/v1/people"; diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Relationships.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Relationships.cs index c12f29c77f..ba2395197c 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Relationships.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Relationships.cs @@ -10,6 +10,7 @@ using JsonApiDotNetCoreExample.Data; using Bogus; using JsonApiDotNetCoreExample.Models; +using Microsoft.AspNetCore; using Person = JsonApiDotNetCoreExample.Models.Person; namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec.DocumentTests @@ -37,7 +38,7 @@ public Relationships(TestFixture fixture) public async Task Correct_RelationshipObjects_For_ManyToOne_Relationships() { // Arrange - _context.TodoItems.RemoveRange(_context.TodoItems); + await _context.ClearTableAsync(); await _context.SaveChangesAsync(); var todoItem = _todoItemFaker.Generate(); @@ -47,7 +48,7 @@ public async Task Correct_RelationshipObjects_For_ManyToOne_Relationships() var httpMethod = new HttpMethod("GET"); var route = "/api/v1/todoItems"; - var builder = new WebHostBuilder().UseStartup(); + var builder = WebHost.CreateDefaultBuilder().UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); var request = new HttpRequestMessage(httpMethod, route); @@ -76,7 +77,7 @@ public async Task Correct_RelationshipObjects_For_ManyToOne_Relationships_ById() var httpMethod = new HttpMethod("GET"); var route = $"/api/v1/todoItems/{todoItem.Id}"; - var builder = new WebHostBuilder().UseStartup(); + var builder = WebHost.CreateDefaultBuilder().UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); var request = new HttpRequestMessage(httpMethod, route); @@ -98,7 +99,7 @@ public async Task Correct_RelationshipObjects_For_ManyToOne_Relationships_ById() public async Task Correct_RelationshipObjects_For_OneToMany_Relationships() { // Arrange - _context.People.RemoveRange(_context.People); + await _context.ClearTableAsync(); await _context.SaveChangesAsync(); var person = _personFaker.Generate(); @@ -108,7 +109,7 @@ public async Task Correct_RelationshipObjects_For_OneToMany_Relationships() var httpMethod = new HttpMethod("GET"); var route = "/api/v1/people"; - var builder = new WebHostBuilder().UseStartup(); + var builder = WebHost.CreateDefaultBuilder().UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); var request = new HttpRequestMessage(httpMethod, route); @@ -137,7 +138,7 @@ public async Task Correct_RelationshipObjects_For_OneToMany_Relationships_ById() var httpMethod = new HttpMethod("GET"); var route = $"/api/v1/people/{person.Id}"; - var builder = new WebHostBuilder().UseStartup(); + var builder = WebHost.CreateDefaultBuilder().UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); var request = new HttpRequestMessage(httpMethod, route); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/EagerLoadTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/EagerLoadTests.cs index 7b4aae679c..59ef0dbf59 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/EagerLoadTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/EagerLoadTests.cs @@ -3,6 +3,7 @@ using System.Net; using System.Threading.Tasks; using Bogus; +using FluentAssertions.Extensions; using JsonApiDotNetCore.Models; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; @@ -57,7 +58,7 @@ public async Task GetSingleResource_TopLevel_AppliesEagerLoad() passport.GrantedVisas = new List { visa1, visa2 }; _dbContext.Add(passport); - _dbContext.SaveChanges(); + await _dbContext.SaveChangesAsync(); // Act var (body, response) = await Get($"/api/v1/passports/{passport.StringId}"); @@ -73,7 +74,29 @@ public async Task GetSingleResource_TopLevel_AppliesEagerLoad() } [Fact] - public async Task GetMultiResource_Nested_AppliesEagerLoad() + public async Task GetSingleResource_TopLevel_with_SparseFieldSet_AppliesEagerLoad() + { + // Arrange + var visa = _visaFaker.Generate(); + visa.TargetCountry = _countryFaker.Generate(); + + _dbContext.Visas.Add(visa); + await _dbContext.SaveChangesAsync(); + + // Act + var (body, response) = await Get($"/api/v1/visas/{visa.StringId}?fields=expiresAt,countryName"); + + // Assert + AssertEqualStatusCode(HttpStatusCode.OK, response); + + var document = JsonConvert.DeserializeObject(body); + Assert.NotNull(document.SingleData); + Assert.Equal(visa.StringId, document.SingleData.Id); + Assert.Equal(visa.TargetCountry.Name, document.SingleData.Attributes["countryName"]); + } + + [Fact] + public async Task GetMultiResource_Secondary_AppliesEagerLoad() { // Arrange var person = _personFaker.Generate(); @@ -84,12 +107,12 @@ public async Task GetMultiResource_Nested_AppliesEagerLoad() visa.TargetCountry = _countryFaker.Generate(); person.Passport.GrantedVisas = new List {visa}; - _dbContext.People.RemoveRange(_dbContext.People); + await _dbContext.ClearTableAsync(); _dbContext.Add(person); - _dbContext.SaveChanges(); + await _dbContext.SaveChangesAsync(); // Act - var (body, response) = await Get($"/api/v1/people?include=passport"); + var (body, response) = await Get("/api/v1/people?include=passport"); // Assert AssertEqualStatusCode(HttpStatusCode.OK, response); @@ -119,7 +142,7 @@ public async Task GetMultiResource_DeeplyNested_AppliesEagerLoad() todo.Owner.Passport.GrantedVisas = new List {visa}; _dbContext.Add(todo); - _dbContext.SaveChanges(); + await _dbContext.SaveChangesAsync(); // Act var (body, response) = await Get($"/api/v1/people/{todo.Assignee.Id}/assignedTodoItems?include=owner.passport"); @@ -149,7 +172,7 @@ public async Task PostSingleResource_TopLevel_AppliesEagerLoad() var content = serializer.Serialize(passport); // Act - var (body, response) = await Post($"/api/v1/passports", content); + var (body, response) = await Post("/api/v1/passports", content); // Assert AssertEqualStatusCode(HttpStatusCode.Created, response); @@ -173,7 +196,7 @@ public async Task PatchResource_TopLevel_AppliesEagerLoad() passport.GrantedVisas = new List { visa }; _dbContext.Add(passport); - _dbContext.SaveChanges(); + await _dbContext.SaveChangesAsync(); passport.SocialSecurityNumber = _passportFaker.Generate().SocialSecurityNumber; passport.BirthCountry.Name = _countryFaker.Generate().Name; diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/EndToEndTest.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/EndToEndTest.cs index ad99a3da18..014d09bbca 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/EndToEndTest.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/EndToEndTest.cs @@ -5,122 +5,18 @@ using System.Net.Http.Headers; using System.Threading.Tasks; using JsonApiDotNetCore; -using JsonApiDotNetCore.Builders; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Graph; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Serialization.Client; using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; -using JsonApiDotNetCoreExampleTests.Helpers.Models; +using Microsoft.AspNetCore; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.Logging.Abstractions; using Xunit; namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec { - public class FunctionalTestCollection : IClassFixture where TFactory : class, IApplicationFactory - { - public static MediaTypeHeaderValue JsonApiContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - protected readonly TFactory _factory; - protected readonly HttpClient _client; - protected readonly AppDbContext _dbContext; - protected IResponseDeserializer _deserializer; - - public FunctionalTestCollection(TFactory factory) - { - _factory = factory; - _client = _factory.CreateClient(); - _dbContext = _factory.GetService(); - _deserializer = GetDeserializer(); - ClearDbContext(); - } - - protected Task<(string, HttpResponseMessage)> Get(string route) - { - return SendRequest("GET", route); - } - - protected Task<(string, HttpResponseMessage)> Post(string route, string content) - { - return SendRequest("POST", route, content); - } - - protected Task<(string, HttpResponseMessage)> Patch(string route, string content) - { - return SendRequest("PATCH", route, content); - } - - protected Task<(string, HttpResponseMessage)> Delete(string route) - { - return SendRequest("DELETE", route); - } - - protected IRequestSerializer GetSerializer(Expression> attributes = null, Expression> relationships = null) where TResource : class, IIdentifiable - { - var serializer = GetService(); - var graph = GetService(); - serializer.AttributesToSerialize = attributes != null ? graph.GetAttributes(attributes) : null; - serializer.RelationshipsToSerialize = relationships != null ? graph.GetRelationships(relationships) : null; - return serializer; - } - - protected IResponseDeserializer GetDeserializer() - { - var options = GetService(); - var formatter = new ResourceNameFormatter(options); - var resourcesContexts = GetService().GetResourceContexts(); - var builder = new ResourceGraphBuilder(options, NullLoggerFactory.Instance); - foreach (var rc in resourcesContexts) - { - if (rc.ResourceType == typeof(TodoItem) || rc.ResourceType == typeof(TodoItemCollection)) - { - continue; - } - builder.AddResource(rc.ResourceType, rc.IdentityType, rc.ResourceName); - } - builder.AddResource(formatter.FormatResourceName(typeof(TodoItem))); - builder.AddResource(formatter.FormatResourceName(typeof(TodoItemCollection))); - return new ResponseDeserializer(builder.Build(), new DefaultResourceFactory(_factory.ServiceProvider)); - } - - protected AppDbContext GetDbContext() => GetService(); - - protected T GetService() => _factory.GetService(); - - 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}"); - } - - protected void ClearDbContext() - { - _dbContext.RemoveRange(_dbContext.TodoItems); - _dbContext.RemoveRange(_dbContext.TodoItemCollections); - _dbContext.RemoveRange(_dbContext.PersonRoles); - _dbContext.RemoveRange(_dbContext.People); - _dbContext.SaveChanges(); - } - - private async Task<(string, HttpResponseMessage)> SendRequest(string method, string route, string content = null) - { - var request = new HttpRequestMessage(new HttpMethod(method), route); - if (content != null) - { - request.Content = new StringContent(content); - request.Content.Headers.ContentType = JsonApiContentType; - } - var response = await _client.SendAsync(request); - var body = await response.Content?.ReadAsStringAsync(); - return (body, response); - } - } } @@ -140,15 +36,15 @@ public EndToEndTest(TestFixture fixture) public AppDbContext PrepareTest() where TStartup : class { - var builder = new WebHostBuilder().UseStartup(); + var builder = WebHost.CreateDefaultBuilder().UseStartup(); var server = new TestServer(builder); _client = server.CreateClient(); var dbContext = GetDbContext(); - dbContext.RemoveRange(dbContext.TodoItems); - dbContext.RemoveRange(dbContext.TodoItemCollections); - dbContext.RemoveRange(dbContext.PersonRoles); - dbContext.RemoveRange(dbContext.People); + dbContext.ClearTable(); + dbContext.ClearTable(); + dbContext.ClearTable(); + dbContext.ClearTable(); dbContext.SaveChanges(); return dbContext; } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingDataTests.cs index cc5a0bb14a..cbfb1051b2 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingDataTests.cs @@ -3,13 +3,16 @@ using System.Threading.Tasks; using Bogus; using JsonApiDotNetCore; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Models.JsonApiDocuments; 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; @@ -40,10 +43,10 @@ public async Task Request_ForEmptyCollection_Returns_EmptyDataCollection() { // Arrange var context = _fixture.GetService(); - context.TodoItems.RemoveRange(context.TodoItems); + await context.ClearTableAsync(); await context.SaveChangesAsync(); - var builder = new WebHostBuilder() + var builder = WebHost.CreateDefaultBuilder() .UseStartup(); var httpMethod = new HttpMethod("GET"); var route = "/api/v1/todoItems"; @@ -62,12 +65,12 @@ public async Task Request_ForEmptyCollection_Returns_EmptyDataCollection() Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(HeaderConstants.MediaType, response.Content.Headers.ContentType.ToString()); Assert.Empty(items); - Assert.Equal(0, int.Parse(meta["total-records"].ToString())); + Assert.Equal(0, int.Parse(meta["totalResources"].ToString())); context.Dispose(); } [Fact] - public async Task Included_Records_Contain_Relationship_Links() + public async Task Included_Resources_Contain_Relationship_Links() { // Arrange var context = _fixture.GetService(); @@ -77,7 +80,7 @@ public async Task Included_Records_Contain_Relationship_Links() context.TodoItems.Add(todoItem); await context.SaveChangesAsync(); - var builder = new WebHostBuilder() + var builder = WebHost.CreateDefaultBuilder() .UseStartup(); var httpMethod = new HttpMethod("GET"); var route = $"/api/v1/todoItems/{todoItem.Id}?include=owner"; @@ -104,18 +107,22 @@ public async Task GetResources_NoDefaultPageSize_ReturnsResources() { // Arrange var context = _fixture.GetService(); - context.TodoItems.RemoveRange(context.TodoItems); + await context.ClearTableAsync(); await context.SaveChangesAsync(); var todoItems = _todoItemFaker.Generate(20); context.TodoItems.AddRange(todoItems); await context.SaveChangesAsync(); - var builder = new WebHostBuilder() - .UseStartup(); + 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); @@ -133,11 +140,11 @@ public async Task GetSingleResource_ResourceDoesNotExist_ReturnsNotFound() { // Arrange var context = _fixture.GetService(); - context.TodoItems.RemoveRange(context.TodoItems); + await context.ClearTableAsync(); await context.SaveChangesAsync(); - var builder = new WebHostBuilder() - .UseStartup(); + var builder = WebHost.CreateDefaultBuilder() + .UseStartup(); var httpMethod = new HttpMethod("GET"); var route = "/api/v1/todoItems/123"; var server = new TestServer(builder); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs index f3b4ccc0f4..f815423007 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs @@ -8,10 +8,13 @@ using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; +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 { @@ -30,6 +33,103 @@ public FetchingRelationshipsTests(TestFixture fixture) .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.GetService(); + 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(); + + Assert.Equal(@"{ + ""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 + @""" + } +}", json); + } + + [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.GetService(); + 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(); + + Assert.Equal(@"{ + ""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 + @""" + } + ] +}", json); + } + [Fact] public async Task When_getting_related_missing_to_one_resource_it_should_succeed_with_null_data() { @@ -41,9 +141,9 @@ public async Task When_getting_related_missing_to_one_resource_it_should_succeed context.TodoItems.Add(todoItem); await context.SaveChangesAsync(); - var route = $"/api/v1/todoItems/{todoItem.Id}/owner"; + var route = $"/api/v1/todoItems/{todoItem.StringId}/owner"; - var builder = new WebHostBuilder().UseStartup(); + var builder = WebHost.CreateDefaultBuilder().UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, route); @@ -55,11 +155,21 @@ public async Task When_getting_related_missing_to_one_resource_it_should_succeed 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); - - Assert.Equal("{\"meta\":{\"copyright\":\"Copyright 2015 Example Corp.\",\"authors\":[\"Jared Nance\",\"Maurits Moeys\",\"Harro van der Kroft\"]},\"links\":{\"self\":\"http://localhost" + route + "\"},\"data\":null}", body); + var json = JsonConvert.DeserializeObject(body).ToString(); + Assert.Equal(@"{ + ""meta"": { + ""copyright"": ""Copyright 2015 Example Corp."", + ""authors"": [ + ""Jared Nance"", + ""Maurits Moeys"", + ""Harro van der Kroft"" + ] + }, + ""links"": { + ""self"": ""http://localhost/api/v1/todoItems/" + todoItem.StringId + @"/owner"" + }, + ""data"": null +}", json); } [Fact] @@ -75,7 +185,7 @@ public async Task When_getting_relationship_for_missing_to_one_resource_it_shoul var route = $"/api/v1/todoItems/{todoItem.Id}/relationships/owner"; - var builder = new WebHostBuilder().UseStartup(); + var builder = WebHost.CreateDefaultBuilder().UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, route); @@ -105,7 +215,7 @@ public async Task When_getting_related_missing_to_many_resource_it_should_succee var route = $"/api/v1/todoItems/{todoItem.Id}/childrenTodos"; - var builder = new WebHostBuilder().UseStartup(); + var builder = WebHost.CreateDefaultBuilder().UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, route); @@ -135,7 +245,7 @@ public async Task When_getting_relationship_for_missing_to_many_resource_it_shou var route = $"/api/v1/todoItems/{todoItem.Id}/relationships/childrenTodos"; - var builder = new WebHostBuilder().UseStartup(); + var builder = WebHost.CreateDefaultBuilder().UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, route); @@ -158,7 +268,7 @@ public async Task When_getting_related_for_missing_parent_resource_it_should_fai // Arrange var route = "/api/v1/todoItems/99999999/owner"; - var builder = new WebHostBuilder().UseStartup(); + var builder = WebHost.CreateDefaultBuilder().UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, route); @@ -182,7 +292,7 @@ public async Task When_getting_relationship_for_missing_parent_resource_it_shoul // Arrange var route = "/api/v1/todoItems/99999999/relationships/owner"; - var builder = new WebHostBuilder().UseStartup(); + var builder = WebHost.CreateDefaultBuilder().UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, route); @@ -212,7 +322,7 @@ public async Task When_getting_unknown_related_resource_it_should_fail() var route = $"/api/v1/todoItems/{todoItem.Id}/invalid"; - var builder = new WebHostBuilder().UseStartup(); + var builder = WebHost.CreateDefaultBuilder().UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, route); @@ -242,7 +352,7 @@ public async Task When_getting_unknown_relationship_for_resource_it_should_fail( var route = $"/api/v1/todoItems/{todoItem.Id}/relationships/invalid"; - var builder = new WebHostBuilder().UseStartup(); + var builder = WebHost.CreateDefaultBuilder().UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, route); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FunctionalTestCollection.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FunctionalTestCollection.cs new file mode 100644 index 0000000000..c235dff461 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FunctionalTestCollection.cs @@ -0,0 +1,121 @@ +using System; +using System.Linq.Expressions; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Builders; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Graph; +using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Internal.Contracts; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Serialization.Client; +using JsonApiDotNetCoreExample.Data; +using JsonApiDotNetCoreExample.Models; +using JsonApiDotNetCoreExampleTests.Helpers.Models; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec +{ + public class FunctionalTestCollection : IClassFixture where TFactory : class, IApplicationFactory + { + public static MediaTypeHeaderValue JsonApiContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); + protected readonly TFactory _factory; + protected readonly HttpClient _client; + protected readonly AppDbContext _dbContext; + protected IResponseDeserializer _deserializer; + + public FunctionalTestCollection(TFactory factory) + { + _factory = factory; + _client = _factory.CreateClient(); + _dbContext = _factory.GetService(); + _deserializer = GetDeserializer(); + ClearDbContext(); + } + + protected Task<(string, HttpResponseMessage)> Get(string route) + { + return SendRequest("GET", route); + } + + protected Task<(string, HttpResponseMessage)> Post(string route, string content) + { + return SendRequest("POST", route, content); + } + + protected Task<(string, HttpResponseMessage)> Patch(string route, string content) + { + return SendRequest("PATCH", route, content); + } + + protected Task<(string, HttpResponseMessage)> Delete(string route) + { + return SendRequest("DELETE", route); + } + + protected IRequestSerializer GetSerializer(Expression> attributes = null, Expression> relationships = null) where TResource : class, IIdentifiable + { + var serializer = GetService(); + var graph = GetService(); + serializer.AttributesToSerialize = attributes != null ? graph.GetAttributes(attributes) : null; + serializer.RelationshipsToSerialize = relationships != null ? graph.GetRelationships(relationships) : null; + return serializer; + } + + protected IResponseDeserializer GetDeserializer() + { + var options = GetService(); + var formatter = new ResourceNameFormatter(options); + var resourcesContexts = GetService().GetResourceContexts(); + var builder = new ResourceGraphBuilder(options, NullLoggerFactory.Instance); + foreach (var rc in resourcesContexts) + { + if (rc.ResourceType == typeof(TodoItem) || rc.ResourceType == typeof(TodoItemCollection)) + { + continue; + } + builder.AddResource(rc.ResourceType, rc.IdentityType, rc.ResourceName); + } + builder.AddResource(formatter.FormatResourceName(typeof(TodoItem))); + builder.AddResource(formatter.FormatResourceName(typeof(TodoItemCollection))); + return new ResponseDeserializer(builder.Build(), new ResourceFactory(_factory.ServiceProvider)); + } + + protected AppDbContext GetDbContext() => GetService(); + + protected T GetService() => _factory.GetService(); + + 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}"); + } + + protected void ClearDbContext() + { + _dbContext.ClearTable(); + _dbContext.ClearTable(); + _dbContext.ClearTable(); + _dbContext.ClearTable(); + _dbContext.SaveChanges(); + } + + private async Task<(string, HttpResponseMessage)> SendRequest(string method, string route, string content = null) + { + var request = new HttpRequestMessage(new HttpMethod(method), route); + if (content != null) + { + request.Content = new StringContent(content); + request.Content.Headers.ContentType = JsonApiContentType; + } + var response = await _client.SendAsync(request); + var body = await response.Content?.ReadAsStringAsync(); + return (body, response); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/PaginationLinkTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/PaginationLinkTests.cs new file mode 100644 index 0000000000..3755d50ff1 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/PaginationLinkTests.cs @@ -0,0 +1,111 @@ +using System.Net; +using System.Threading.Tasks; +using Bogus; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCoreExample.Models; +using Newtonsoft.Json; +using Xunit; +using Person = JsonApiDotNetCoreExample.Models.Person; + +namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec +{ + public class PaginationLinkTests : FunctionalTestCollection + { + private const int _defaultPageSize = 5; + + private readonly Faker _todoItemFaker = new Faker(); + + public PaginationLinkTests(StandardApplicationFactory factory) : base(factory) + { + var options = (JsonApiOptions) GetService(); + + options.DefaultPageSize = new PageSize(_defaultPageSize); + options.MaximumPageSize = null; + options.MaximumPageNumber = null; + options.AllowUnknownQueryStringParameters = true; + } + + [Theory] + [InlineData(1, 1, 1, null, 2, 4)] + [InlineData(2, 2, 1, 1, 3, 4)] + [InlineData(3, 3, 1, 2, 4, 4)] + [InlineData(4, 4, 1, 3, null, 4)] + public async Task When_page_number_is_specified_it_must_display_correct_top_level_links(int pageNumber, + int selfLink, int? firstLink, int? prevLink, int? nextLink, int? lastLink) + { + // Arrange + const int totalCount = 18; + + var person = new Person + { + LastName = "&Ampersand" + }; + + var todoItems = _todoItemFaker.Generate(totalCount); + foreach (var todoItem in todoItems) + { + todoItem.Owner = person; + } + + await _dbContext.ClearTableAsync(); + _dbContext.TodoItems.AddRange(todoItems); + await _dbContext.SaveChangesAsync(); + + string routePrefix = "/api/v1/todoItems?filter=equals(owner.lastName,'" + WebUtility.UrlEncode(person.LastName) + "')" + + "&fields[owner]=firstName&include=owner&sort=ordinal&foo=bar,baz"; + string route = pageNumber != 1 + ? routePrefix + $"&page[size]={_defaultPageSize}&page[number]={pageNumber}" + : routePrefix; + + // Act + var response = await _client.GetAsync(route); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var links = JsonConvert.DeserializeObject(body).Links; + + Assert.EndsWith($"{routePrefix}&page[size]={_defaultPageSize}&page[number]={selfLink}", links.Self); + + if (firstLink.HasValue) + { + Assert.EndsWith($"{routePrefix}&page[size]={_defaultPageSize}&page[number]={firstLink.Value}", + links.First); + } + else + { + Assert.Null(links.First); + } + + if (prevLink.HasValue) + { + Assert.EndsWith($"{routePrefix}&page[size]={_defaultPageSize}&page[number]={prevLink}", links.Prev); + } + else + { + Assert.Null(links.Prev); + } + + if (nextLink.HasValue) + { + Assert.EndsWith($"{routePrefix}&page[size]={_defaultPageSize}&page[number]={nextLink}", links.Next); + } + else + { + Assert.Null(links.Next); + } + + if (lastLink.HasValue) + { + Assert.EndsWith($"{routePrefix}&page[size]={_defaultPageSize}&page[number]={lastLink}", links.Last); + } + else + { + Assert.Null(links.Last); + } + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/PagingTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/PagingTests.cs deleted file mode 100644 index 55fb4cd602..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/PagingTests.cs +++ /dev/null @@ -1,155 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Threading.Tasks; -using Bogus; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCoreExample; -using JsonApiDotNetCoreExample.Models; -using Newtonsoft.Json; -using Xunit; -using Person = JsonApiDotNetCoreExample.Models.Person; - -namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec -{ - [Collection("WebHostCollection")] - public sealed class PagingTests : TestFixture - { - private readonly TestFixture _fixture; - private readonly Faker _todoItemFaker; - - public PagingTests(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()); - } - - [Theory] - [InlineData(1)] - [InlineData(-1)] - public async Task Pagination_WithPageSizeAndPageNumber_ReturnsCorrectSubsetOfResources(int pageNum) - { - // Arrange - const int expectedEntitiesPerPage = 2; - var totalCount = expectedEntitiesPerPage * 2; - var person = new Person(); - var todoItems = _todoItemFaker.Generate(totalCount); - foreach (var todoItem in todoItems) - { - todoItem.Owner = person; - } - Context.TodoItems.RemoveRange(Context.TodoItems); - Context.TodoItems.AddRange(todoItems); - Context.SaveChanges(); - - // Act - var route = $"/api/v1/todoItems?page[size]={expectedEntitiesPerPage}&page[number]={pageNum}"; - var response = await Client.GetAsync(route); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - var deserializedBody = _fixture.GetDeserializer().DeserializeList(body).Data; - - if (pageNum < 0) - { - todoItems.Reverse(); - } - var expectedTodoItems = todoItems.Take(expectedEntitiesPerPage).ToList(); - Assert.Equal(expectedTodoItems, deserializedBody, new IdComparer()); - - } - - [Theory] - [InlineData(1, 1, 1, null, 2, 4)] - [InlineData(2, 2, 1, 1, 3, 4)] - [InlineData(3, 3, 1, 2, 4, 4)] - [InlineData(4, 4, 1, 3, null, 4)] - [InlineData(-1, -1, -1, null, -2, -4)] - [InlineData(-2, -2, -1, -1, -3, -4)] - [InlineData(-3, -3, -1, -2, -4, -4)] - [InlineData(-4, -4, -1, -3, null, -4)] - public async Task Pagination_OnGivenPage_DisplaysCorrectTopLevelLinks(int pageNum, int selfLink, int? firstLink, int? prevLink, int? nextLink, int? lastLink) - { - // Arrange - var totalCount = 20; - var person = new Person - { - LastName = "&Ampersand" - }; - var todoItems = _todoItemFaker.Generate(totalCount); - - foreach (var todoItem in todoItems) - todoItem.Owner = person; - - Context.TodoItems.RemoveRange(Context.TodoItems); - Context.TodoItems.AddRange(todoItems); - Context.SaveChanges(); - - var options = GetService(); - options.AllowCustomQueryStringParameters = true; - - string routePrefix = "/api/v1/todoItems?filter[owner.lastName]=" + WebUtility.UrlEncode(person.LastName) + - "&fields[owner]=firstName&include=owner&sort=ordinal&foo=bar,baz"; - string route = pageNum != 1 ? routePrefix + $"&page[size]=5&page[number]={pageNum}" : routePrefix; - - // Act - var response = await Client.GetAsync(route); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = await response.Content.ReadAsStringAsync(); - var links = JsonConvert.DeserializeObject(body).Links; - - Assert.EndsWith($"{routePrefix}&page[size]=5&page[number]={selfLink}", links.Self); - if (firstLink.HasValue) - { - Assert.EndsWith($"{routePrefix}&page[size]=5&page[number]={firstLink.Value}", links.First); - } - else - { - Assert.Null(links.First); - } - - if (prevLink.HasValue) - { - Assert.EndsWith($"{routePrefix}&page[size]=5&page[number]={prevLink}", links.Prev); - } - else - { - Assert.Null(links.Prev); - } - - if (nextLink.HasValue) - { - Assert.EndsWith($"{routePrefix}&page[size]=5&page[number]={nextLink}", links.Next); - } - else - { - Assert.Null(links.Next); - } - - if (lastLink.HasValue) - { - Assert.EndsWith($"{routePrefix}&page[size]=5&page[number]={lastLink}", links.Last); - } - else - { - Assert.Null(links.Last); - } - } - - private sealed class IdComparer : IEqualityComparer - where T : IIdentifiable - { - public bool Equals(T x, T y) => x?.StringId == y?.StringId; - - public int GetHashCode(T obj) => obj.StringId?.GetHashCode() ?? 0; - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/QueryParameterTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/QueryParameterTests.cs deleted file mode 100644 index df7c7abe75..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/QueryParameterTests.cs +++ /dev/null @@ -1,158 +0,0 @@ -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using JsonApiDotNetCore.Models.JsonApiDocuments; -using JsonApiDotNetCoreExample; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.TestHost; -using Newtonsoft.Json; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec -{ - [Collection("WebHostCollection")] - public sealed class QueryParameterTests - { - [Fact] - public async Task Server_Returns_400_ForUnknownQueryParam() - { - // Arrange - const string queryString = "?someKey=someValue"; - - var builder = new WebHostBuilder().UseStartup(); - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/todoItems" + queryString); - - // Act - var response = await client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].StatusCode); - Assert.Equal("Unknown query string parameter.", errorDocument.Errors[0].Title); - Assert.Equal("Query string parameter 'someKey' is unknown. Set 'AllowCustomQueryStringParameters' to 'true' in options to ignore unknown parameters.", errorDocument.Errors[0].Detail); - Assert.Equal("someKey", errorDocument.Errors[0].Source.Parameter); - } - - [Fact] - public async Task Server_Returns_400_ForMissingQueryParameterValue() - { - // Arrange - const string queryString = "?include="; - - var builder = new WebHostBuilder().UseStartup(); - var httpMethod = new HttpMethod("GET"); - var route = "/api/v1/todoItems" + queryString; - 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.BadRequest, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].StatusCode); - Assert.Equal("Missing query string parameter value.", errorDocument.Errors[0].Title); - Assert.Equal("Missing value for 'include' query string parameter.", errorDocument.Errors[0].Detail); - Assert.Equal("include", errorDocument.Errors[0].Source.Parameter); - } - - [Fact] - public async Task Server_Returns_400_ForUnknownQueryParameter_Attribute() - { - // Arrange - const string queryString = "?sort=notSoGood"; - - var builder = new WebHostBuilder().UseStartup(); - var httpMethod = new HttpMethod("GET"); - var route = "/api/v1/todoItems" + queryString; - 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.BadRequest, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].StatusCode); - Assert.Equal("The attribute requested in query string does not exist.", errorDocument.Errors[0].Title); - Assert.Equal("The attribute 'notSoGood' does not exist on resource 'todoItems'.", errorDocument.Errors[0].Detail); - Assert.Equal("sort", errorDocument.Errors[0].Source.Parameter); - } - - [Fact] - public async Task Server_Returns_400_ForUnknownQueryParameter_RelatedAttribute() - { - // Arrange - const string queryString = "?sort=notSoGood.evenWorse"; - - var builder = new WebHostBuilder().UseStartup(); - var httpMethod = new HttpMethod("GET"); - var route = "/api/v1/todoItems" + queryString; - 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.BadRequest, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].StatusCode); - Assert.Equal("The relationship requested in query string does not exist.", errorDocument.Errors[0].Title); - Assert.Equal("The relationship 'notSoGood' does not exist on resource 'todoItems'.", errorDocument.Errors[0].Detail); - Assert.Equal("sort", errorDocument.Errors[0].Source.Parameter); - } - - [Theory] - [InlineData("filter[ordinal]=1")] - [InlineData("fields=ordinal")] - [InlineData("sort=ordinal")] - [InlineData("page[number]=1")] - [InlineData("page[size]=10")] - public async Task Server_Returns_400_ForQueryParamOnNestedResource(string queryParameter) - { - string parameterName = queryParameter.Split('=')[0]; - - var builder = new WebHostBuilder().UseStartup(); - var httpMethod = new HttpMethod("GET"); - var route = $"/api/v1/people/1/assignedTodoItems?{queryParameter}"; - 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.BadRequest, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].StatusCode); - Assert.Equal("The specified query string parameter is currently not supported on nested resource endpoints.", errorDocument.Errors[0].Title); - Assert.Equal($"Query string parameter '{parameterName}' is currently not supported on nested resource endpoints. (i.e. of the form '/article/1/author?parameterName=...')", errorDocument.Errors[0].Detail); - Assert.Equal(parameterName, errorDocument.Errors[0].Source.Parameter); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/SparseFieldSetTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/SparseFieldSetTests.cs deleted file mode 100644 index 0dbfb5a99e..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/SparseFieldSetTests.cs +++ /dev/null @@ -1,448 +0,0 @@ -using System; -using System.ComponentModel.Design; -using System.Linq; -using System.Net.Http; -using System.Threading.Tasks; -using Bogus; -using JsonApiDotNetCore.Extensions; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCoreExample; -using JsonApiDotNetCoreExample.Data; -using JsonApiDotNetCoreExample.Models; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.TestHost; -using Microsoft.EntityFrameworkCore; -using Newtonsoft.Json; -using Xunit; -using Person = JsonApiDotNetCoreExample.Models.Person; -using System.Net; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Models.JsonApiDocuments; - -namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec -{ - [Collection("WebHostCollection")] - public sealed class SparseFieldSetTests - { - private readonly TestFixture _fixture; - private readonly AppDbContext _dbContext; - private readonly IResourceGraph _resourceGraph; - private readonly Faker _personFaker; - private readonly Faker _todoItemFaker; - - public SparseFieldSetTests(TestFixture fixture) - { - _fixture = fixture; - _dbContext = fixture.GetService(); - _resourceGraph = fixture.GetService(); - _personFaker = new Faker() - .RuleFor(p => p.FirstName, f => f.Name.FirstName()) - .RuleFor(p => p.LastName, f => f.Name.LastName()) - .RuleFor(p => p.Age, f => f.Random.Int(20, 80)); - - _todoItemFaker = new Faker() - .RuleFor(t => t.Description, f => f.Lorem.Sentence()) - .RuleFor(t => t.Ordinal, f => f.Random.Number(1, 10)) - .RuleFor(t => t.CreatedDate, f => f.Date.Past()); - } - - [Fact] - public async Task Can_Select_Sparse_Fieldsets() - { - // Arrange - var todoItem = new TodoItem - { - Description = "description", - Ordinal = 1, - CreatedDate = new DateTime(2002, 2,2), - AchievedDate = new DateTime(2002, 2,4) - }; - _dbContext.TodoItems.Add(todoItem); - await _dbContext.SaveChangesAsync(); - - var properties = _resourceGraph - .GetAttributes(e => new {e.Id, e.Description, e.CreatedDate, e.AchievedDate}) - .Select(x => x.Property.Name); - - var resourceFactory = new DefaultResourceFactory(new ServiceContainer()); - - // Act - var query = _dbContext - .TodoItems - .Where(t => t.Id == todoItem.Id) - .Select(properties, resourceFactory); - - var result = await query.FirstAsync(); - - // Assert - Assert.Equal(0, result.Ordinal); - Assert.Equal(todoItem.Description, result.Description); - Assert.Equal(todoItem.CreatedDate.ToString("G"), result.CreatedDate.ToString("G")); - Assert.Equal(todoItem.AchievedDate.GetValueOrDefault().ToString("G"), result.AchievedDate.GetValueOrDefault().ToString("G")); - } - - [Fact] - public async Task Fields_Query_Selects_Sparse_Field_Sets() - { - // Arrange - var todoItem = new TodoItem - { - Description = "description", - Ordinal = 1, - CreatedDate = new DateTime(2002, 2,2) - }; - _dbContext.TodoItems.Add(todoItem); - await _dbContext.SaveChangesAsync(); - - var builder = new WebHostBuilder() - .UseStartup(); - var httpMethod = new HttpMethod("GET"); - using var server = new TestServer(builder); - var client = server.CreateClient(); - - var route = $"/api/v1/todoItems/{todoItem.Id}?fields=description,createdDate"; - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await client.SendAsync(request); - var body = await response.Content.ReadAsStringAsync(); - var deserializeBody = JsonConvert.DeserializeObject(body); - - // Assert - Assert.Equal(todoItem.StringId, deserializeBody.SingleData.Id); - Assert.Equal(2, deserializeBody.SingleData.Attributes.Count); - Assert.Equal(todoItem.Description, deserializeBody.SingleData.Attributes["description"]); - Assert.Equal(todoItem.CreatedDate.ToString("G"), ((DateTime)deserializeBody.SingleData.Attributes["createdDate"]).ToString("G")); - Assert.DoesNotContain("guidProperty", deserializeBody.SingleData.Attributes.Keys); - } - - [Fact] - public async Task Fields_Query_Selects_Sparse_Field_Sets_With_Type_As_Navigation() - { - // Arrange - var todoItem = new TodoItem - { - Description = "description", - Ordinal = 1, - CreatedDate = new DateTime(2002, 2,2) - }; - _dbContext.TodoItems.Add(todoItem); - await _dbContext.SaveChangesAsync(); - - var builder = new WebHostBuilder() - .UseStartup(); - var httpMethod = new HttpMethod("GET"); - using var server = new TestServer(builder); - var client = server.CreateClient(); - var route = $"/api/v1/todoItems/{todoItem.Id}?fields[todoItems]=description,createdDate"; - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].StatusCode); - Assert.StartsWith("Square bracket notation in 'filter' is now reserved for relationships only", errorDocument.Errors[0].Title); - Assert.Equal("Use '?fields=...' instead of '?fields[todoItems]=...'.", errorDocument.Errors[0].Detail); - } - - [Fact] - public async Task Fields_Query_Selects_All_Fieldset_From_HasOne() - { - // Arrange - _dbContext.TodoItems.RemoveRange(_dbContext.TodoItems); - _dbContext.SaveChanges(); - - var owner = _personFaker.Generate(); - var todoItem = new TodoItem - { - Description = "s", - Ordinal = 123, - CreatedDate = new DateTime(2002, 2,2), - Owner = owner - }; - _dbContext.TodoItems.Add(todoItem); - _dbContext.SaveChanges(); - - var builder = new WebHostBuilder().UseStartup(); - var httpMethod = new HttpMethod("GET"); - using var server = new TestServer(builder); - var client = server.CreateClient(); - - var route = "/api/v1/todoItems?include=owner&fields[owner]=firstName,the-Age"; - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var deserializeBody = JsonConvert.DeserializeObject(body); - - Assert.Equal(todoItem.Description, deserializeBody.ManyData[0].Attributes["description"]); - Assert.Equal(todoItem.Ordinal, deserializeBody.ManyData[0].Attributes["ordinal"]); - - Assert.NotNull(deserializeBody.Included); - Assert.NotEmpty(deserializeBody.Included); - Assert.Equal(owner.StringId, deserializeBody.Included[0].Id); - Assert.Equal(owner.FirstName, deserializeBody.Included[0].Attributes["firstName"]); - Assert.Equal((long)owner.Age, deserializeBody.Included[0].Attributes["the-Age"]); - Assert.DoesNotContain("lastName", deserializeBody.Included[0].Attributes.Keys); - } - - [Fact] - public async Task Fields_Query_Selects_Fieldset_From_HasOne() - { - // Arrange - var owner = _personFaker.Generate(); - var todoItem = new TodoItem - { - Description = "description", - Ordinal = 1, - CreatedDate = new DateTime(2002, 2,2), - Owner = owner - }; - _dbContext.TodoItems.Add(todoItem); - _dbContext.SaveChanges(); - - var builder = new WebHostBuilder() - .UseStartup(); - var httpMethod = new HttpMethod("GET"); - using var server = new TestServer(builder); - var client = server.CreateClient(); - - var route = $"/api/v1/todoItems/{todoItem.Id}?include=owner&fields[owner]=firstName,the-Age"; - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await client.SendAsync(request); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = await response.Content.ReadAsStringAsync(); - var deserializeBody = JsonConvert.DeserializeObject(body); - - Assert.Equal(todoItem.Description, deserializeBody.SingleData.Attributes["description"]); - Assert.Equal(todoItem.Ordinal, deserializeBody.SingleData.Attributes["ordinal"]); - - Assert.Equal(owner.StringId, deserializeBody.Included[0].Id); - Assert.Equal(owner.FirstName, deserializeBody.Included[0].Attributes["firstName"]); - Assert.Equal((long)owner.Age, deserializeBody.Included[0].Attributes["the-Age"]); - Assert.DoesNotContain("lastName", deserializeBody.Included[0].Attributes.Keys); - } - - [Fact] - public async Task Fields_Query_Selects_Fieldset_From_Self_And_HasOne() - { - // Arrange - var owner = _personFaker.Generate(); - var todoItem = new TodoItem - { - Description = "description", - Ordinal = 1, - CreatedDate = new DateTime(2002, 2,2), - Owner = owner - }; - _dbContext.TodoItems.Add(todoItem); - _dbContext.SaveChanges(); - - var builder = new WebHostBuilder() - .UseStartup(); - var httpMethod = new HttpMethod("GET"); - using var server = new TestServer(builder); - var client = server.CreateClient(); - - var route = $"/api/v1/todoItems/{todoItem.Id}?include=owner&fields=ordinal&fields[owner]=firstName,the-Age"; - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await client.SendAsync(request); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = await response.Content.ReadAsStringAsync(); - var deserializeBody = JsonConvert.DeserializeObject(body); - - Assert.Equal(todoItem.Ordinal, deserializeBody.SingleData.Attributes["ordinal"]); - Assert.DoesNotContain("description", deserializeBody.SingleData.Attributes.Keys); - - Assert.NotNull(deserializeBody.Included); - Assert.NotEmpty(deserializeBody.Included); - Assert.Equal(owner.StringId, deserializeBody.Included[0].Id); - Assert.Equal(owner.FirstName, deserializeBody.Included[0].Attributes["firstName"]); - Assert.Equal((long)owner.Age, deserializeBody.Included[0].Attributes["the-Age"]); - Assert.DoesNotContain("lastName", deserializeBody.Included[0].Attributes.Keys); - } - - [Fact] - public async Task Fields_Query_Selects_Fieldset_From_Self_With_HasOne_Include() - { - // Arrange - var owner = _personFaker.Generate(); - var todoItem = new TodoItem - { - Description = "description", - Ordinal = 1, - CreatedDate = new DateTime(2002, 2,2), - Owner = owner - }; - _dbContext.TodoItems.Add(todoItem); - _dbContext.SaveChanges(); - - var builder = new WebHostBuilder() - .UseStartup(); - var httpMethod = new HttpMethod("GET"); - using var server = new TestServer(builder); - var client = server.CreateClient(); - - var route = $"/api/v1/todoItems/{todoItem.Id}?include=owner&fields=ordinal"; - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await client.SendAsync(request); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = await response.Content.ReadAsStringAsync(); - var deserializeBody = JsonConvert.DeserializeObject(body); - - Assert.Equal(todoItem.Ordinal, deserializeBody.SingleData.Attributes["ordinal"]); - Assert.DoesNotContain("description", deserializeBody.SingleData.Attributes.Keys); - - Assert.NotNull(deserializeBody.Included); - Assert.NotEmpty(deserializeBody.Included); - Assert.Equal(owner.StringId, deserializeBody.Included[0].Id); - Assert.Equal(owner.FirstName, deserializeBody.Included[0].Attributes["firstName"]); - Assert.Equal((long)owner.Age, deserializeBody.Included[0].Attributes["the-Age"]); - } - - [Fact] - public async Task Fields_Query_Selects_Fieldset_From_HasMany() - { - // Arrange - var owner = _personFaker.Generate(); - owner.TodoItems = _todoItemFaker.Generate(2).ToHashSet(); - - _dbContext.People.Add(owner); - _dbContext.SaveChanges(); - - var builder = new WebHostBuilder() - .UseStartup(); - var httpMethod = new HttpMethod("GET"); - using var server = new TestServer(builder); - var client = server.CreateClient(); - - var route = $"/api/v1/people/{owner.Id}?include=todoItems&fields[todoItems]=description"; - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await client.SendAsync(request); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = await response.Content.ReadAsStringAsync(); - var deserializeBody = JsonConvert.DeserializeObject(body); - - Assert.Equal(owner.FirstName, deserializeBody.SingleData.Attributes["firstName"]); - Assert.Equal(owner.LastName, deserializeBody.SingleData.Attributes["lastName"]); - - foreach (var include in deserializeBody.Included) - { - var todoItem = owner.TodoItems.Single(i => i.StringId == include.Id); - - Assert.Equal(todoItem.Description, include.Attributes["description"]); - Assert.DoesNotContain("ordinal", include.Attributes.Keys); - Assert.DoesNotContain("createdDate", include.Attributes.Keys); - } - } - - [Fact] - public async Task Fields_Query_Selects_Fieldset_From_Self_And_HasMany() - { - // Arrange - var owner = _personFaker.Generate(); - owner.TodoItems = _todoItemFaker.Generate(2).ToHashSet(); - - _dbContext.People.Add(owner); - _dbContext.SaveChanges(); - - var builder = new WebHostBuilder() - .UseStartup(); - var httpMethod = new HttpMethod("GET"); - using var server = new TestServer(builder); - var client = server.CreateClient(); - - var route = $"/api/v1/people/{owner.Id}?include=todoItems&fields=firstName&fields[todoItems]=description"; - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await client.SendAsync(request); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = await response.Content.ReadAsStringAsync(); - var deserializeBody = JsonConvert.DeserializeObject(body); - - Assert.Equal(owner.FirstName, deserializeBody.SingleData.Attributes["firstName"]); - Assert.DoesNotContain("lastName", deserializeBody.SingleData.Attributes.Keys); - - // check owner attributes - Assert.NotNull(deserializeBody.Included); - Assert.Equal(2, deserializeBody.Included.Count); - foreach (var includedItem in deserializeBody.Included) - { - var todoItem = owner.TodoItems.FirstOrDefault(i => i.StringId == includedItem.Id); - Assert.NotNull(todoItem); - Assert.Equal(todoItem.Description, includedItem.Attributes["description"]); - Assert.DoesNotContain("ordinal", includedItem.Attributes.Keys); - Assert.DoesNotContain("createdDate", includedItem.Attributes.Keys); - } - } - - [Fact] - public async Task Fields_Query_Selects_Fieldset_From_Self_With_HasMany_Include() - { - // Arrange - var owner = _personFaker.Generate(); - owner.TodoItems = _todoItemFaker.Generate(2).ToHashSet(); - - _dbContext.People.Add(owner); - _dbContext.SaveChanges(); - - var builder = new WebHostBuilder() - .UseStartup(); - var httpMethod = new HttpMethod("GET"); - using var server = new TestServer(builder); - var client = server.CreateClient(); - - var route = $"/api/v1/people/{owner.Id}?include=todoItems&fields=firstName"; - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await client.SendAsync(request); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = await response.Content.ReadAsStringAsync(); - var deserializeBody = JsonConvert.DeserializeObject(body); - - Assert.Equal(owner.FirstName, deserializeBody.SingleData.Attributes["firstName"]); - Assert.DoesNotContain("lastName", deserializeBody.SingleData.Attributes.Keys); - - Assert.NotNull(deserializeBody.Included); - Assert.Equal(2, deserializeBody.Included.Count); - foreach (var includedItem in deserializeBody.Included) - { - var todoItem = owner.TodoItems.Single(i => i.StringId == includedItem.Id); - Assert.Equal(todoItem.Description, includedItem.Attributes["description"]); - } - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ThrowingResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ThrowingResourceTests.cs index b582bbe390..852c9d6066 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ThrowingResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ThrowingResourceTests.cs @@ -21,7 +21,7 @@ public async Task GetThrowingResource_Fails() // Arrange var throwingResource = new ThrowingResource(); _dbContext.Add(throwingResource); - _dbContext.SaveChanges(); + await _dbContext.SaveChangesAsync(); // Act var (body, response) = await Get($"/api/v1/throwingResources/{throwingResource.Id}"); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs index 2d6b7a0002..8cb02aed4e 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs @@ -12,6 +12,7 @@ using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; +using Microsoft.AspNetCore; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; @@ -50,16 +51,17 @@ public async Task PatchResource_ModelWithEntityFrameworkInheritance_IsPatched() // Arrange var dbContext = PrepareTest(); - var builder = new WebHostBuilder().UseStartup(); + var builder = WebHost.CreateDefaultBuilder().UseStartup(); var server = new TestServer(builder); var clock = server.Host.Services.GetRequiredService(); - var serializer = TestFixture.GetSerializer(server.Host.Services, e => new { e.SecurityLevel, e.Username, e.Password }); - var superUser = new SuperUser(_context) { SecurityLevel = 1337, Username = "Super", Password = "User", LastPasswordChange = clock.UtcNow.LocalDateTime.AddMinutes(-15) }; + var serializer = TestFixture.GetSerializer(server.Host.Services, e => new { e.SecurityLevel, e.UserName, e.Password }); + var superUser = new SuperUser(_context) { SecurityLevel = 1337, UserName = "Super", Password = "User", LastPasswordChange = clock.UtcNow.LocalDateTime.AddMinutes(-15) }; dbContext.Set().Add(superUser); - dbContext.SaveChanges(); - var su = new SuperUser(_context) { Id = superUser.Id, SecurityLevel = 2674, Username = "Power", Password = "secret" }; + await dbContext.SaveChangesAsync(); + + var su = new SuperUser(_context) { Id = superUser.Id, SecurityLevel = 2674, UserName = "Power", Password = "secret" }; var content = serializer.Serialize(su); // Act @@ -69,15 +71,15 @@ public async Task PatchResource_ModelWithEntityFrameworkInheritance_IsPatched() AssertEqualStatusCode(HttpStatusCode.OK, response); var updated = _deserializer.DeserializeSingle(body).Data; Assert.Equal(su.SecurityLevel, updated.SecurityLevel); - Assert.Equal(su.Username, updated.Username); - Assert.Equal(su.Password, updated.Password); + Assert.Equal(su.UserName, updated.UserName); + Assert.Null(updated.Password); } [Fact] public async Task Response422IfUpdatingNotSettableAttribute() { // Arrange - var builder = new WebHostBuilder().UseStartup(); + var builder = WebHost.CreateDefaultBuilder().UseStartup(); var loggerFactory = new FakeLoggerFactory(); builder.ConfigureLogging(options => @@ -93,7 +95,7 @@ public async Task Response422IfUpdatingNotSettableAttribute() var todoItem = _todoItemFaker.Generate(); _context.TodoItems.Add(todoItem); - _context.SaveChanges(); + await _context.SaveChangesAsync(); var serializer = TestFixture.GetSerializer(server.Host.Services, ti => new { ti.CalculatedValue }); var content = serializer.Serialize(todoItem); @@ -122,16 +124,16 @@ public async Task Response422IfUpdatingNotSettableAttribute() } [Fact] - public async Task Respond_404_If_EntityDoesNotExist() + public async Task Respond_404_If_ResourceDoesNotExist() { // Arrange - _context.TodoItems.RemoveRange(_context.TodoItems); + await _context.ClearTableAsync(); await _context.SaveChangesAsync(); var todoItem = _todoItemFaker.Generate(); todoItem.Id = 100; todoItem.CreatedDate = new DateTime(2002, 2,2); - var builder = new WebHostBuilder() + var builder = WebHost.CreateDefaultBuilder() .UseStartup(); var server = new TestServer(builder); @@ -162,7 +164,7 @@ public async Task Respond_422_If_IdNotInAttributeList() var maxPersonId = _context.TodoItems.ToList().LastOrDefault()?.Id ?? 0; var todoItem = _todoItemFaker.Generate(); todoItem.CreatedDate = new DateTime(2002, 2,2); - var builder = new WebHostBuilder() + var builder = WebHost.CreateDefaultBuilder() .UseStartup(); var server = new TestServer(builder); @@ -195,11 +197,11 @@ public async Task Respond_409_If_IdInUrlIsDifferentFromIdInRequestBody() todoItem.CreatedDate = new DateTime(2002, 2,2); _context.TodoItems.Add(todoItem); - _context.SaveChanges(); + await _context.SaveChangesAsync(); var wrongTodoItemId = todoItem.Id + 1; - var builder = new WebHostBuilder().UseStartup(); + var builder = WebHost.CreateDefaultBuilder().UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); var serializer = TestFixture.GetSerializer(server.Host.Services, ti => new {ti.Description, ti.Ordinal, ti.CreatedDate}); @@ -226,14 +228,14 @@ public async Task Respond_409_If_IdInUrlIsDifferentFromIdInRequestBody() public async Task Respond_422_If_Broken_JSON_Payload() { // Arrange - var builder = new WebHostBuilder() + var builder = WebHost.CreateDefaultBuilder() .UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); - + var content = "{ \"data\" {"; - var request = PrepareRequest("POST", $"/api/v1/todoItems", content); + var request = PrepareRequest("POST", "/api/v1/todoItems", content); // Act var response = await client.SendAsync(request); @@ -252,23 +254,23 @@ public async Task Respond_422_If_Broken_JSON_Payload() } [Fact] - public async Task Can_Patch_Entity() + public async Task Can_Patch_Resource() { // Arrange - _context.RemoveRange(_context.TodoItemCollections); - _context.RemoveRange(_context.TodoItems); - _context.RemoveRange(_context.People); - _context.SaveChanges(); + await _context.ClearTableAsync(); + await _context.ClearTableAsync(); + await _context.ClearTableAsync(); + await _context.SaveChangesAsync(); var todoItem = _todoItemFaker.Generate(); var person = _personFaker.Generate(); todoItem.Owner = person; _context.TodoItems.Add(todoItem); - _context.SaveChanges(); + await _context.SaveChangesAsync(); var newTodoItem = _todoItemFaker.Generate(); newTodoItem.Id = todoItem.Id; - var builder = new WebHostBuilder().UseStartup(); + var builder = WebHost.CreateDefaultBuilder().UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); var serializer = TestFixture.GetSerializer(server.Host.Services, p => new { p.Description, p.Ordinal }); @@ -300,23 +302,22 @@ public async Task Can_Patch_Entity() } [Fact] - public async Task Patch_Entity_With_HasMany_Does_Not_Include_Relationships() + public async Task Patch_Resource_With_HasMany_Does_Not_Include_Relationships() { // Arrange var todoItem = _todoItemFaker.Generate(); - var person = _personFaker.Generate(); - todoItem.Owner = person; + todoItem.Owner = _personFaker.Generate(); _context.TodoItems.Add(todoItem); - _context.SaveChanges(); + await _context.SaveChangesAsync(); var newPerson = _personFaker.Generate(); - newPerson.Id = person.Id; - var builder = new WebHostBuilder().UseStartup(); + newPerson.Id = todoItem.Owner.Id; + var builder = WebHost.CreateDefaultBuilder().UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); var serializer = TestFixture.GetSerializer(server.Host.Services, p => new { p.LastName, p.FirstName }); - var request = PrepareRequest("PATCH", $"/api/v1/people/{person.Id}", serializer.Serialize(newPerson)); + var request = PrepareRequest("PATCH", $"/api/v1/people/{todoItem.Owner.Id}", serializer.Serialize(newPerson)); // Act var response = await client.SendAsync(request); @@ -335,7 +336,7 @@ public async Task Patch_Entity_With_HasMany_Does_Not_Include_Relationships() } [Fact] - public async Task Can_Patch_Entity_And_HasOne_Relationships() + public async Task Can_Patch_Resource_And_HasOne_Relationships() { // Arrange var todoItem = _todoItemFaker.Generate(); @@ -343,10 +344,10 @@ public async Task Can_Patch_Entity_And_HasOne_Relationships() var person = _personFaker.Generate(); _context.TodoItems.Add(todoItem); _context.People.Add(person); - _context.SaveChanges(); + await _context.SaveChangesAsync(); todoItem.Owner = person; - var builder = new WebHostBuilder() + var builder = WebHost.CreateDefaultBuilder() .UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs index df5db50f3f..be48cdfde5 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs @@ -10,6 +10,7 @@ using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; +using Microsoft.AspNetCore; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; using Microsoft.EntityFrameworkCore; @@ -49,10 +50,9 @@ public async Task Can_Update_Cyclic_ToMany_Relationship_By_Patching_Resource() var strayTodoItem = _todoItemFaker.Generate(); _context.TodoItems.Add(todoItem); _context.TodoItems.Add(strayTodoItem); - _context.SaveChanges(); + await _context.SaveChangesAsync(); - - var builder = new WebHostBuilder() + var builder = WebHost.CreateDefaultBuilder() .UseStartup(); var server = new TestServer(builder); @@ -107,10 +107,9 @@ public async Task Can_Update_Cyclic_ToOne_Relationship_By_Patching_Resource() // Arrange var todoItem = _todoItemFaker.Generate(); _context.TodoItems.Add(todoItem); - _context.SaveChanges(); - + await _context.SaveChangesAsync(); - var builder = new WebHostBuilder() + var builder = WebHost.CreateDefaultBuilder() .UseStartup(); var server = new TestServer(builder); @@ -162,10 +161,9 @@ public async Task Can_Update_Both_Cyclic_ToOne_And_ToMany_Relationship_By_Patchi var strayTodoItem = _todoItemFaker.Generate(); _context.TodoItems.Add(todoItem); _context.TodoItems.Add(strayTodoItem); - _context.SaveChanges(); - + await _context.SaveChangesAsync(); - var builder = new WebHostBuilder() + var builder = WebHost.CreateDefaultBuilder() .UseStartup(); var server = new TestServer(builder); @@ -228,14 +226,14 @@ public async Task Can_Update_ToMany_Relationship_By_Patching_Resource() todoCollection.Owner = person; todoCollection.TodoItems.Add(todoItem); _context.TodoItemCollections.Add(todoCollection); - _context.SaveChanges(); + await _context.SaveChangesAsync(); var newTodoItem1 = _todoItemFaker.Generate(); var newTodoItem2 = _todoItemFaker.Generate(); _context.AddRange(newTodoItem1, newTodoItem2); - _context.SaveChanges(); + await _context.SaveChangesAsync(); - var builder = new WebHostBuilder() + var builder = WebHost.CreateDefaultBuilder() .UseStartup(); var server = new TestServer(builder); @@ -258,7 +256,7 @@ public async Task Can_Update_ToMany_Relationship_By_Patching_Resource() } } - }, + } } } }; @@ -288,7 +286,7 @@ public async Task Can_Update_ToMany_Relationship_By_Patching_Resource() [Fact] public async Task Can_Update_ToMany_Relationship_By_Patching_Resource_When_Targets_Already_Attached() { - // It is possible that entities we're creating relationships to + // 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, @@ -302,14 +300,14 @@ public async Task Can_Update_ToMany_Relationship_By_Patching_Resource_When_Targe todoCollection.Name = "PRE-ATTACH-TEST"; todoCollection.TodoItems.Add(todoItem); _context.TodoItemCollections.Add(todoCollection); - _context.SaveChanges(); + await _context.SaveChangesAsync(); var newTodoItem1 = _todoItemFaker.Generate(); var newTodoItem2 = _todoItemFaker.Generate(); _context.AddRange(newTodoItem1, newTodoItem2); - _context.SaveChanges(); + await _context.SaveChangesAsync(); - var builder = new WebHostBuilder() + var builder = WebHost.CreateDefaultBuilder() .UseStartup(); var server = new TestServer(builder); @@ -336,7 +334,7 @@ public async Task Can_Update_ToMany_Relationship_By_Patching_Resource_When_Targe } } - }, + } } } }; @@ -375,15 +373,14 @@ public async Task Can_Update_ToMany_Relationship_By_Patching_Resource_With_Overl todoCollection.TodoItems.Add(todoItem1); todoCollection.TodoItems.Add(todoItem2); _context.TodoItemCollections.Add(todoCollection); - _context.SaveChanges(); + await _context.SaveChangesAsync(); - var builder = new WebHostBuilder() + var builder = WebHost.CreateDefaultBuilder() .UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); - var content = new { data = new @@ -401,7 +398,7 @@ public async Task Can_Update_ToMany_Relationship_By_Patching_Resource_With_Overl } } - }, + } } } }; @@ -438,9 +435,9 @@ public async Task Can_Update_ToMany_Relationship_ThroughLink() var todoItem = _todoItemFaker.Generate(); _context.TodoItems.Add(todoItem); - _context.SaveChanges(); + await _context.SaveChangesAsync(); - var builder = new WebHostBuilder() + var builder = WebHost.CreateDefaultBuilder() .UseStartup(); var server = new TestServer(builder); @@ -489,9 +486,9 @@ public async Task Can_Update_ToOne_Relationship_ThroughLink() var todoItem = _todoItemFaker.Generate(); _context.TodoItems.Add(todoItem); - _context.SaveChanges(); + await _context.SaveChangesAsync(); - var builder = new WebHostBuilder() + var builder = WebHost.CreateDefaultBuilder() .UseStartup(); var server = new TestServer(builder); @@ -525,9 +522,9 @@ public async Task Can_Delete_ToOne_Relationship_By_Patching_Resource() _context.People.Add(person); _context.TodoItems.Add(todoItem); - _context.SaveChanges(); + await _context.SaveChangesAsync(); - var builder = new WebHostBuilder() + var builder = WebHost.CreateDefaultBuilder() .UseStartup(); var server = new TestServer(builder); @@ -578,7 +575,7 @@ public async Task Can_Delete_ToMany_Relationship_By_Patching_Resource() var todoItem = _todoItemFaker.Generate(); person.TodoItems = new HashSet { todoItem }; _context.People.Add(person); - _context.SaveChanges(); + await _context.SaveChangesAsync(); var content = new { @@ -628,9 +625,9 @@ public async Task Can_Delete_Relationship_By_Patching_Relationship() _context.People.Add(person); _context.TodoItems.Add(todoItem); - _context.SaveChanges(); + await _context.SaveChangesAsync(); - var builder = new WebHostBuilder() + var builder = WebHost.CreateDefaultBuilder() .UseStartup(); var server = new TestServer(builder); @@ -783,9 +780,9 @@ public async Task Fails_On_Unknown_Relationship() var todoItem = _todoItemFaker.Generate(); _context.TodoItems.Add(todoItem); - _context.SaveChanges(); + await _context.SaveChangesAsync(); - var builder = new WebHostBuilder() + var builder = WebHost.CreateDefaultBuilder() .UseStartup(); var server = new TestServer(builder); @@ -820,9 +817,9 @@ public async Task Fails_On_Missing_Resource() var person = _personFaker.Generate(); _context.People.Add(person); - _context.SaveChanges(); + await _context.SaveChangesAsync(); - var builder = new WebHostBuilder() + var builder = WebHost.CreateDefaultBuilder() .UseStartup(); var server = new TestServer(builder); @@ -832,7 +829,7 @@ public async Task Fails_On_Missing_Resource() var content = serializer.Serialize(person); var httpMethod = new HttpMethod("PATCH"); - var route = $"/api/v1/todoItems/99999999/relationships/owner"; + 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); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/TestFixture.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/TestFixture.cs index d6dd9fd675..f4f3066c1d 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/TestFixture.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/TestFixture.cs @@ -12,6 +12,7 @@ using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; using JsonApiDotNetCoreExampleTests.Helpers.Models; +using Microsoft.AspNetCore; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; @@ -28,7 +29,7 @@ public class TestFixture : IDisposable where TStartup : class public readonly IServiceProvider ServiceProvider; public TestFixture() { - var builder = new WebHostBuilder().UseStartup(); + var builder = WebHost.CreateDefaultBuilder().UseStartup(); _server = new TestServer(builder); ServiceProvider = _server.Host.Services; @@ -73,7 +74,7 @@ public IResponseDeserializer GetDeserializer() .AddResource() .AddResource("todoItems") .AddResource().Build(); - return new ResponseDeserializer(resourceGraph, new DefaultResourceFactory(ServiceProvider)); + return new ResponseDeserializer(resourceGraph, new ResourceFactory(ServiceProvider)); } public T GetService() => (T)ServiceProvider.GetService(typeof(T)); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemControllerTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemControllerTests.cs new file mode 100644 index 0000000000..30adb44a6d --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemControllerTests.cs @@ -0,0 +1,402 @@ +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; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Models; +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.GetService(); + _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.GetService().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().DeserializeList(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/Acceptance/TodoItemsControllerTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs deleted file mode 100644 index 3ec7095d1b..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs +++ /dev/null @@ -1,808 +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; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Models; -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.GetService(); - _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 - _context.TodoItems.RemoveRange(_context.TodoItems); - _context.SaveChanges(); - int expectedEntitiesPerPage = _fixture.GetService().DefaultPageSize; - var person = new Person(); - var todoItems = _todoItemFaker.Generate(expectedEntitiesPerPage + 1); - - foreach (var todoItem in todoItems) - { - todoItem.Owner = person; - _context.TodoItems.Add(todoItem); - _context.SaveChanges(); - - } - - 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().DeserializeList(body).Data; - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.NotEmpty(deserializedBody); - Assert.True(deserializedBody.Count <= expectedEntitiesPerPage, $"There are more items on the page than the default page size. {deserializedBody.Count} > {expectedEntitiesPerPage}"); - } - - [Fact] - public async Task Can_Filter_By_Resource_Id() - { - // Arrange - var todoItem = _todoItemFaker.Generate(); - _context.TodoItems.Add(todoItem); - _context.SaveChanges(); - - var httpMethod = new HttpMethod("GET"); - var route = $"/api/v1/todoItems?filter[id]={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().DeserializeList(body).Data; - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.NotEmpty(deserializedBody); - Assert.Contains(deserializedBody, (i) => i.Id == todoItem.Id); - } - - [Fact] - public async Task Can_Filter_By_Relationship_Id() - { - // Arrange - var person = new Person(); - var todoItems = _todoItemFaker.Generate(3); - _context.TodoItems.AddRange(todoItems); - todoItems[0].Owner = person; - _context.SaveChanges(); - - var httpMethod = new HttpMethod("GET"); - var route = $"/api/v1/todoItems?filter[owner.id]={person.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().DeserializeList(body).Data; - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.NotEmpty(deserializedBody); - Assert.Contains(deserializedBody, (i) => i.Id == todoItems[0].Id); - } - - [Fact] - public async Task Can_Filter_TodoItems() - { - // Arrange - var person = new Person(); - var todoItem = _todoItemFaker.Generate(); - todoItem.Ordinal = 999999; - todoItem.Owner = person; - _context.TodoItems.Add(todoItem); - _context.SaveChanges(); - - var httpMethod = new HttpMethod("GET"); - var route = $"/api/v1/todoItems?filter[ordinal]={todoItem.Ordinal}"; - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await _fixture.Client.SendAsync(request); - var body = await response.Content.ReadAsStringAsync(); - var deserializedBody = _fixture.GetDeserializer().DeserializeList(body).Data; - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.NotEmpty(deserializedBody); - - foreach (var todoItemResult in deserializedBody) - Assert.Equal(todoItem.Ordinal, todoItemResult.Ordinal); - } - - [Fact] - public async Task Can_Filter_TodoItems_Using_IsNotNull_Operator() - { - // Arrange - var todoItem = _todoItemFaker.Generate(); - todoItem.UpdatedDate = new DateTime(); - - var otherTodoItem = _todoItemFaker.Generate(); - otherTodoItem.UpdatedDate = null; - - _context.TodoItems.AddRange(todoItem, otherTodoItem); - _context.SaveChanges(); - - var httpMethod = new HttpMethod("GET"); - var route = "/api/v1/todoItems?filter[updatedDate]=isnotnull:"; - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await _fixture.Client.SendAsync(request); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - var todoItems = _fixture.GetDeserializer().DeserializeList(body).Data; - - // Assert - Assert.NotEmpty(todoItems); - Assert.All(todoItems, t => Assert.NotNull(t.UpdatedDate)); - } - - [Fact] - public async Task Can_Filter_TodoItems_ByParent_Using_IsNotNull_Operator() - { - // Arrange - var todoItem = _todoItemFaker.Generate(); - todoItem.Assignee = new Person(); - - var otherTodoItem = _todoItemFaker.Generate(); - otherTodoItem.Assignee = null; - - _context.RemoveRange(_context.TodoItems); - _context.TodoItems.AddRange(todoItem, otherTodoItem); - _context.SaveChanges(); - - var httpMethod = new HttpMethod("GET"); - var route = "/api/v1/todoItems?filter[assignee.id]=isnotnull:"; - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await _fixture.Client.SendAsync(request); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - var list = _fixture.GetDeserializer().DeserializeList(body).Data; - - // Assert - Assert.Equal(todoItem.Id, list.Single().Id); - } - - [Fact] - public async Task Can_Filter_TodoItems_Using_IsNull_Operator() - { - // Arrange - var todoItem = _todoItemFaker.Generate(); - todoItem.UpdatedDate = null; - - var otherTodoItem = _todoItemFaker.Generate(); - otherTodoItem.UpdatedDate = new DateTime(); - - _context.TodoItems.AddRange(todoItem, otherTodoItem); - _context.SaveChanges(); - - var httpMethod = new HttpMethod("GET"); - var route = "/api/v1/todoItems?filter[updatedDate]=isnull:"; - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await _fixture.Client.SendAsync(request); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - var todoItems = _fixture.GetDeserializer().DeserializeList(body).Data; - - // Assert - Assert.NotEmpty(todoItems); - Assert.All(todoItems, t => Assert.Null(t.UpdatedDate)); - } - - [Fact] - public async Task Can_Filter_TodoItems_ByParent_Using_IsNull_Operator() - { - // Arrange - var todoItem = _todoItemFaker.Generate(); - todoItem.Assignee = null; - - var otherTodoItem = _todoItemFaker.Generate(); - otherTodoItem.Assignee = new Person(); - - _context.TodoItems.AddRange(todoItem, otherTodoItem); - _context.SaveChanges(); - - var httpMethod = new HttpMethod("GET"); - var route = "/api/v1/todoItems?filter[assignee.id]=isnull:"; - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await _fixture.Client.SendAsync(request); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - var todoItems = _fixture.GetDeserializer().DeserializeList(body).Data; - - // Assert - Assert.NotEmpty(todoItems); - Assert.All(todoItems, t => Assert.Null(t.Assignee)); - } - - [Fact] - public async Task Can_Filter_TodoItems_Using_Like_Operator() - { - // Arrange - var todoItem = _todoItemFaker.Generate(); - todoItem.Ordinal = 999999; - _context.TodoItems.Add(todoItem); - _context.SaveChanges(); - var substring = todoItem.Description.Substring(1, todoItem.Description.Length - 2); - - var httpMethod = new HttpMethod("GET"); - var route = $"/api/v1/todoItems?filter[description]=like:{substring}"; - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await _fixture.Client.SendAsync(request); - var body = await response.Content.ReadAsStringAsync(); - var deserializedBody = _fixture.GetDeserializer().DeserializeList(body).Data; - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.NotEmpty(deserializedBody); - - foreach (var todoItemResult in deserializedBody) - Assert.Contains(substring, todoItemResult.Description); - } - - [Fact] - public async Task Can_Sort_TodoItems_By_Ordinal_Ascending() - { - // Arrange - _context.TodoItems.RemoveRange(_context.TodoItems); - - const int numberOfItems = 5; - var person = new Person(); - - for (var i = 1; i < numberOfItems; i++) - { - var todoItem = _todoItemFaker.Generate(); - todoItem.Ordinal = i; - todoItem.Owner = person; - _context.TodoItems.Add(todoItem); - } - _context.SaveChanges(); - - var httpMethod = new HttpMethod("GET"); - var route = "/api/v1/todoItems?sort=ordinal"; - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await _fixture.Client.SendAsync(request); - var body = await response.Content.ReadAsStringAsync(); - var deserializedBody = _fixture.GetDeserializer().DeserializeList(body).Data; - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.NotEmpty(deserializedBody); - - long priorOrdinal = 0; - foreach (var todoItemResult in deserializedBody) - { - Assert.True(todoItemResult.Ordinal > priorOrdinal); - priorOrdinal = todoItemResult.Ordinal; - } - } - - [Fact] - public async Task Can_Sort_TodoItems_By_Nested_Attribute_Ascending() - { - // Arrange - _context.TodoItems.RemoveRange(_context.TodoItems); - - const int numberOfItems = 10; - - for (var i = 1; i <= numberOfItems; i++) - { - var todoItem = _todoItemFaker.Generate(); - todoItem.Ordinal = i; - todoItem.Owner = _personFaker.Generate(); - _context.TodoItems.Add(todoItem); - } - _context.SaveChanges(); - - var httpMethod = new HttpMethod("GET"); - var route = $"/api/v1/todoItems?page[size]={numberOfItems}&include=owner&sort=owner.the-Age"; - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await _fixture.Client.SendAsync(request); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = await response.Content.ReadAsStringAsync(); - var deserializedBody = _fixture.GetDeserializer().DeserializeList(body).Data; - Assert.NotEmpty(deserializedBody); - - long lastAge = 0; - foreach (var todoItemResult in deserializedBody) - { - Assert.True(todoItemResult.Owner.Age >= lastAge); - lastAge = todoItemResult.Owner.Age; - } - } - - [Fact] - public async Task Can_Sort_TodoItems_By_Nested_Attribute_Descending() - { - // Arrange - _context.TodoItems.RemoveRange(_context.TodoItems); - - const int numberOfItems = 10; - - for (var i = 1; i <= numberOfItems; i++) - { - var todoItem = _todoItemFaker.Generate(); - todoItem.Ordinal = i; - todoItem.Owner = _personFaker.Generate(); - _context.TodoItems.Add(todoItem); - } - _context.SaveChanges(); - - var httpMethod = new HttpMethod("GET"); - var route = $"/api/v1/todoItems?page[size]={numberOfItems}&include=owner&sort=-owner.the-Age"; - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await _fixture.Client.SendAsync(request); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = await response.Content.ReadAsStringAsync(); - var deserializedBody = _fixture.GetDeserializer().DeserializeList(body).Data; - Assert.NotEmpty(deserializedBody); - - int maxAge = deserializedBody.Max(i => i.Owner.Age) + 1; - foreach (var todoItemResult in deserializedBody) - { - Assert.True(todoItemResult.Owner.Age <= maxAge); - maxAge = todoItemResult.Owner.Age; - } - } - - [Fact] - public async Task Can_Sort_TodoItems_By_Ordinal_Descending() - { - // Arrange - _context.TodoItems.RemoveRange(_context.TodoItems); - - const int numberOfItems = 5; - var person = new Person(); - - for (var i = 1; i < numberOfItems; i++) - { - var todoItem = _todoItemFaker.Generate(); - todoItem.Ordinal = i; - todoItem.Owner = person; - _context.TodoItems.Add(todoItem); - } - _context.SaveChanges(); - - var httpMethod = new HttpMethod("GET"); - var route = "/api/v1/todoItems?sort=-ordinal"; - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await _fixture.Client.SendAsync(request); - var body = await response.Content.ReadAsStringAsync(); - var deserializedBody = _fixture.GetDeserializer().DeserializeList(body).Data; - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.NotEmpty(deserializedBody); - - long priorOrdinal = numberOfItems + 1; - foreach (var todoItemResult in deserializedBody) - { - Assert.True(todoItemResult.Ordinal < priorOrdinal); - priorOrdinal = todoItemResult.Ordinal; - } - } - - [Fact] - public async Task Can_Get_TodoItem_ById() - { - // Arrange - var person = new Person(); - var todoItem = _todoItemFaker.Generate(); - todoItem.Owner = person; - _context.TodoItems.Add(todoItem); - _context.SaveChanges(); - - 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_Get_TodoItem_WithOwner() - { - // Arrange - var person = new Person(); - var todoItem = _todoItemFaker.Generate(); - todoItem.Owner = person; - _context.TodoItems.Add(todoItem); - _context.SaveChanges(); - - var httpMethod = new HttpMethod("GET"); - var route = $"/api/v1/todoItems/{todoItem.Id}?include=owner"; - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await _fixture.Client.SendAsync(request); - var body = await response.Content.ReadAsStringAsync(); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var deserializedBody = _fixture.GetDeserializer().DeserializeSingle(body).Data; - - Assert.Equal(person.Id, deserializedBody.Owner.Id); - 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); - _context.SaveChanges(); - - 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); - _context.SaveChanges(); - - 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); - _context.SaveChanges(); - - var todoItem = _todoItemFaker.Generate(); - todoItem.Owner = person; - _context.TodoItems.Add(todoItem); - _context.SaveChanges(); - - 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); - _context.SaveChanges(); - - var todoItem = _todoItemFaker.Generate(); - todoItem.AchievedDate = new DateTime(2002, 2,2); - todoItem.Owner = person; - _context.TodoItems.Add(todoItem); - _context.SaveChanges(); - - 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); - _context.SaveChanges(); - - var todoItem = _todoItemFaker.Generate(); - todoItem.AchievedDate = new DateTime(2002, 2,2); - todoItem.Owner = person; - _context.TodoItems.Add(todoItem); - _context.SaveChanges(); - - 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); - _context.SaveChanges(); - - var todoItem = _todoItemFaker.Generate(); - todoItem.Owner = person; - _context.TodoItems.Add(todoItem); - _context.SaveChanges(); - - 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/AppDbContextExtensions.cs b/test/JsonApiDotNetCoreExampleTests/AppDbContextExtensions.cs new file mode 100644 index 0000000000..092dcd49ae --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/AppDbContextExtensions.cs @@ -0,0 +1,55 @@ +using System; +using System.Threading.Tasks; +using JsonApiDotNetCoreExample.Data; +using Microsoft.EntityFrameworkCore; +using Npgsql; + +namespace JsonApiDotNetCoreExampleTests +{ + public static class AppDbContextExtensions + { + public static async Task ClearTableAsync(this AppDbContext dbContext) where TEntity : class + { + var entityType = dbContext.Model.FindEntityType(typeof(TEntity)); + if (entityType == null) + { + throw new InvalidOperationException($"Table for '{typeof(TEntity).Name}' not found."); + } + + string tableName = entityType.GetTableName(); + + // PERF: We first try to clear the table, which is fast and usually succeeds, unless foreign key constraints are violated. + // In that case, we recursively delete all related data, which is slow. + try + { + await dbContext.Database.ExecuteSqlRawAsync("delete from \"" + tableName + "\""); + } + catch (PostgresException) + { + await dbContext.Database.ExecuteSqlRawAsync("truncate table \"" + tableName + "\" cascade"); + } + } + + public static void ClearTable(this AppDbContext dbContext) where TEntity : class + { + var entityType = dbContext.Model.FindEntityType(typeof(TEntity)); + if (entityType == null) + { + throw new InvalidOperationException($"Table for '{typeof(TEntity).Name}' not found."); + } + + string tableName = entityType.GetTableName(); + + // PERF: We first try to clear the table, which is fast and usually succeeds, unless foreign key constraints are violated. + // In that case, we recursively delete all related data, which is slow. + try + { + dbContext.Database.ExecuteSqlRaw("delete from \"" + tableName + "\""); + } + catch (PostgresException) + { + dbContext.Database.ExecuteSqlRaw("truncate table \"" + tableName + "\" cascade"); + } + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/Factories/ClientEnabledIdsApplicationFactory.cs b/test/JsonApiDotNetCoreExampleTests/Factories/ClientGeneratedIdsApplicationFactory.cs similarity index 77% rename from test/JsonApiDotNetCoreExampleTests/Factories/ClientEnabledIdsApplicationFactory.cs rename to test/JsonApiDotNetCoreExampleTests/Factories/ClientGeneratedIdsApplicationFactory.cs index f1b0bfed37..4407e43642 100644 --- a/test/JsonApiDotNetCoreExampleTests/Factories/ClientEnabledIdsApplicationFactory.cs +++ b/test/JsonApiDotNetCoreExampleTests/Factories/ClientGeneratedIdsApplicationFactory.cs @@ -5,7 +5,7 @@ namespace JsonApiDotNetCoreExampleTests { - public class ClientEnabledIdsApplicationFactory : CustomApplicationFactoryBase + public class ClientGeneratedIdsApplicationFactory : CustomApplicationFactoryBase { protected override void ConfigureWebHost(IWebHostBuilder builder) { @@ -21,9 +21,8 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) services.AddJsonApi(options => { options.Namespace = "api/v1"; - options.DefaultPageSize = 5; - options.IncludeTotalRecordCount = true; - options.LoadDatabaseValues = true; + options.DefaultPageSize = new PageSize(5); + options.IncludeTotalResourceCount = true; options.AllowClientGeneratedIds = true; }, discovery => discovery.AddAssembly(Assembly.Load(nameof(JsonApiDotNetCoreExample)))); diff --git a/test/JsonApiDotNetCoreExampleTests/Factories/KebabCaseApplicationFactory.cs b/test/JsonApiDotNetCoreExampleTests/Factories/KebabCaseApplicationFactory.cs deleted file mode 100644 index 2094e2a89b..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Factories/KebabCaseApplicationFactory.cs +++ /dev/null @@ -1,13 +0,0 @@ -using JsonApiDotNetCoreExample; -using Microsoft.AspNetCore.Hosting; - -namespace JsonApiDotNetCoreExampleTests -{ - public class KebabCaseApplicationFactory : CustomApplicationFactoryBase - { - protected override void ConfigureWebHost(IWebHostBuilder builder) - { - builder.UseStartup(); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Factories/NoNamespaceApplicationFactory.cs b/test/JsonApiDotNetCoreExampleTests/Factories/NoNamespaceApplicationFactory.cs deleted file mode 100644 index e13326ffcf..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Factories/NoNamespaceApplicationFactory.cs +++ /dev/null @@ -1,13 +0,0 @@ -using JsonApiDotNetCoreExample.Startups; -using Microsoft.AspNetCore.Hosting; - -namespace JsonApiDotNetCoreExampleTests -{ - public class NoNamespaceApplicationFactory : CustomApplicationFactoryBase - { - protected override void ConfigureWebHost(IWebHostBuilder builder) - { - builder.UseStartup(); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Factories/ResourceHooksApplicationFactory.cs b/test/JsonApiDotNetCoreExampleTests/Factories/ResourceHooksApplicationFactory.cs new file mode 100644 index 0000000000..5861a3f63b --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Factories/ResourceHooksApplicationFactory.cs @@ -0,0 +1,33 @@ +using System.Reflection; +using JsonApiDotNetCore; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; + +namespace JsonApiDotNetCoreExampleTests +{ + public class ResourceHooksApplicationFactory : 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.EnableResourceHooks = true; + options.LoadDatabaseValues = true; + }, + discovery => discovery.AddAssembly(Assembly.Load(nameof(JsonApiDotNetCoreExample)))); + }); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/Helpers/Extensions/DocumentExtensions.cs b/test/JsonApiDotNetCoreExampleTests/Helpers/Extensions/DocumentExtensions.cs deleted file mode 100644 index 0255fb7e36..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Helpers/Extensions/DocumentExtensions.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using JsonApiDotNetCore.Models; - -namespace JsonApiDotNetCoreExampleTests.Helpers.Extensions -{ - public static class DocumentExtensions - { - public static ResourceObject FindResource(this List included, string type, TId id) - { - var document = included.FirstOrDefault(documentData => - documentData.Type == type && documentData.Id == id.ToString()); - - return document; - } - - public static int CountOfType(this List included, string type) { - return included.Count(documentData => documentData.Type == type); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/HttpResponseMessageExtensions.cs b/test/JsonApiDotNetCoreExampleTests/HttpResponseMessageExtensions.cs new file mode 100644 index 0000000000..8f550a4722 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/HttpResponseMessageExtensions.cs @@ -0,0 +1,59 @@ +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using FluentAssertions; +using FluentAssertions.Primitives; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace JsonApiDotNetCoreExampleTests +{ + public static class HttpResponseMessageExtensions + { + public static HttpResponseMessageAssertions Should(this HttpResponseMessage instance) + { + return new HttpResponseMessageAssertions(instance); + } + + public sealed class HttpResponseMessageAssertions + : ReferenceTypeAssertions + { + protected override string Identifier => "response"; + + public HttpResponseMessageAssertions(HttpResponseMessage instance) + { + Subject = instance; + } + + public AndConstraint HaveStatusCode(HttpStatusCode statusCode) + { + if (Subject.StatusCode != statusCode) + { + string responseText = GetFormattedContentAsync(Subject).Result; + Subject.StatusCode.Should().Be(statusCode, "response body returned was:\n" + responseText); + } + + return new AndConstraint(this); + } + + private static async Task GetFormattedContentAsync(HttpResponseMessage responseMessage) + { + string text = await responseMessage.Content.ReadAsStringAsync(); + + try + { + if (text.Length > 0) + { + return JsonConvert.DeserializeObject(text).ToString(); + } + } + catch + { + // ignored + } + + return text; + } + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTestContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTestContext.cs new file mode 100644 index 0000000000..b96db01b3f --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTestContext.cs @@ -0,0 +1,201 @@ +using System; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using JsonApiDotNetCore; +using JsonApiDotNetCoreExample; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Newtonsoft.Json; + +namespace JsonApiDotNetCoreExampleTests +{ + /// + /// A test context that creates a new database and server instance before running tests and cleans up afterwards. + /// You can either use this as a fixture on your tests class (init/cleanup runs once before/after all tests) or + /// have your tests class inherit from it (init/cleanup runs once before/after each test). See + /// for details on shared context usage. + /// + /// The server Startup class, which can be defined in the test project. + /// The EF Core database context, which can be defined in the test project. + public class IntegrationTestContext : IDisposable + where TStartup : class + where TDbContext : DbContext + { + private readonly Lazy> _lazyFactory; + private Action _beforeServicesConfiguration; + private Action _afterServicesConfiguration; + + public WebApplicationFactory Factory => _lazyFactory.Value; + + public IntegrationTestContext() + { + _lazyFactory = new Lazy>(CreateFactory); + } + + private WebApplicationFactory CreateFactory() + { + string postgresPassword = Environment.GetEnvironmentVariable("PGPASSWORD") ?? "postgres"; + string dbConnectionString = + $"Host=localhost;Port=5432;Database=JsonApiTest-{Guid.NewGuid():N};User ID=postgres;Password={postgresPassword}"; + + var factory = new IntegrationTestWebApplicationFactory(); + + factory.ConfigureServicesBeforeStartup(services => + { + _beforeServicesConfiguration?.Invoke(services); + + services.AddDbContext(options => + { + options.UseNpgsql(dbConnectionString, + postgresOptions => postgresOptions.SetPostgresVersion(new Version(9, 6))); + + options.EnableSensitiveDataLogging(); + options.EnableDetailedErrors(); + }); + }); + + factory.ConfigureServicesAfterStartup(_afterServicesConfiguration); + + using IServiceScope scope = factory.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + dbContext.Database.EnsureCreated(); + + return factory; + } + + public void Dispose() + { + RunOnDatabaseAsync(async context => await context.Database.EnsureDeletedAsync()).Wait(); + + Factory.Dispose(); + } + + public void ConfigureServicesBeforeStartup(Action servicesConfiguration) + { + _beforeServicesConfiguration = servicesConfiguration; + } + + public void ConfigureServicesAfterStartup(Action servicesConfiguration) + { + _afterServicesConfiguration = servicesConfiguration; + } + + public async Task RunOnDatabaseAsync(Func asyncAction) + { + using IServiceScope scope = Factory.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + await asyncAction(dbContext); + } + + public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> + ExecuteGetAsync(string requestUrl) + { + return await ExecuteRequestAsync(HttpMethod.Get, requestUrl); + } + + public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> + ExecutePostAsync(string requestUrl, object requestBody) + { + return await ExecuteRequestAsync(HttpMethod.Post, requestUrl, requestBody); + } + + public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> + ExecutePatchAsync(string requestUrl, object requestBody) + { + return await ExecuteRequestAsync(HttpMethod.Patch, requestUrl, requestBody); + } + + public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> + ExecuteDeleteAsync(string requestUrl) + { + return await ExecuteRequestAsync(HttpMethod.Delete, requestUrl); + } + + private async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> + ExecuteRequestAsync(HttpMethod method, string requestUrl, object requestBody = null) + { + var request = new HttpRequestMessage(method, requestUrl); + string requestText = SerializeRequest(requestBody); + + if (!string.IsNullOrEmpty(requestText)) + { + request.Content = new StringContent(requestText); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); + } + + using HttpClient client = Factory.CreateClient(); + HttpResponseMessage responseMessage = await client.SendAsync(request); + + string responseText = await responseMessage.Content.ReadAsStringAsync(); + var responseDocument = DeserializeResponse(responseText); + + return (responseMessage, responseDocument); + } + + private string SerializeRequest(object requestBody) + { + string requestText = requestBody is string stringRequestBody + ? stringRequestBody + : JsonConvert.SerializeObject(requestBody); + + return requestText; + } + + private TResponseDocument DeserializeResponse(string responseText) + { + if (typeof(TResponseDocument) == typeof(string)) + { + return (TResponseDocument)(object)responseText; + } + + try + { + return JsonConvert.DeserializeObject(responseText); + } + catch (JsonException exception) + { + throw new FormatException($"Failed to deserialize response body to JSON:\n{responseText}", exception); + } + } + + private sealed class IntegrationTestWebApplicationFactory : WebApplicationFactory + { + private Action _beforeServicesConfiguration; + private Action _afterServicesConfiguration; + + public void ConfigureServicesBeforeStartup(Action servicesConfiguration) + { + _beforeServicesConfiguration = servicesConfiguration; + } + + public void ConfigureServicesAfterStartup(Action servicesConfiguration) + { + _afterServicesConfiguration = servicesConfiguration; + } + + protected override IHostBuilder CreateHostBuilder() + { + return Host.CreateDefaultBuilder(null) + .ConfigureWebHostDefaults(webBuilder => + { + webBuilder.ConfigureServices(services => + { + _beforeServicesConfiguration?.Invoke(services); + }); + + webBuilder.UseStartup(); + + webBuilder.ConfigureServices(services => + { + _afterServicesConfiguration?.Invoke(services); + }); + }); + } + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterDataTypeTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterDataTypeTests.cs new file mode 100644 index 0000000000..eb6c6c1320 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterDataTypeTests.cs @@ -0,0 +1,335 @@ +using System; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using FluentAssertions.Extensions; +using Humanizer; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.JsonApiDocuments; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Filtering +{ + public sealed class FilterDataTypeTests : IClassFixture, FilterDbContext>> + { + private readonly IntegrationTestContext, FilterDbContext> _testContext; + + public FilterDataTypeTests(IntegrationTestContext, FilterDbContext> testContext) + { + _testContext = testContext; + + var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); + options.EnableLegacyFilterNotation = false; + } + + [Theory] + [InlineData(nameof(FilterableResource.SomeString), "text")] + [InlineData(nameof(FilterableResource.SomeBoolean), true)] + [InlineData(nameof(FilterableResource.SomeNullableBoolean), true)] + [InlineData(nameof(FilterableResource.SomeInt32), 1)] + [InlineData(nameof(FilterableResource.SomeNullableInt32), 1)] + [InlineData(nameof(FilterableResource.SomeUnsignedInt64), 1ul)] + [InlineData(nameof(FilterableResource.SomeNullableUnsignedInt64), 1ul)] + [InlineData(nameof(FilterableResource.SomeDouble), 0.5d)] + [InlineData(nameof(FilterableResource.SomeNullableDouble), 0.5d)] + [InlineData(nameof(FilterableResource.SomeEnum), DayOfWeek.Saturday)] + [InlineData(nameof(FilterableResource.SomeNullableEnum), DayOfWeek.Saturday)] + public async Task Can_filter_equality_on_type(string propertyName, object value) + { + // Arrange + var resource = new FilterableResource(); + var property = typeof(FilterableResource).GetProperty(propertyName); + property?.SetValue(resource, value); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.RemoveRange(dbContext.FilterableResources); + dbContext.FilterableResources.AddRange(resource, new FilterableResource()); + + await dbContext.SaveChangesAsync(); + }); + + var attributeName = propertyName.Camelize(); + var route = $"/filterableResources?filter=equals({attributeName},'{value}')"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Attributes[attributeName].Should().Be(value is Enum ? value.ToString() : value); + } + + [Fact] + public async Task Can_filter_equality_on_type_Decimal() + { + // Arrange + var resource = new FilterableResource {SomeDecimal = 0.5m}; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.RemoveRange(dbContext.FilterableResources); + dbContext.FilterableResources.AddRange(resource, new FilterableResource()); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/filterableResources?filter=equals(someDecimal,'{resource.SomeDecimal}')"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Attributes["someDecimal"].Should().Be(resource.SomeDecimal); + } + + [Fact] + public async Task Can_filter_equality_on_type_Guid() + { + // Arrange + var resource = new FilterableResource {SomeGuid = Guid.NewGuid()}; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.RemoveRange(dbContext.FilterableResources); + dbContext.FilterableResources.AddRange(resource, new FilterableResource()); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/filterableResources?filter=equals(someGuid,'{resource.SomeGuid}')"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Attributes["someGuid"].Should().Be(resource.SomeGuid.ToString()); + } + + [Fact] + public async Task Can_filter_equality_on_type_DateTime() + { + // Arrange + var resource = new FilterableResource {SomeDateTime = 27.January(2003).At(11, 22, 33, 44)}; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.RemoveRange(dbContext.FilterableResources); + dbContext.FilterableResources.AddRange(resource, new FilterableResource()); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/filterableResources?filter=equals(someDateTime,'{resource.SomeDateTime:O}')"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Attributes["someDateTime"].Should().Be(resource.SomeDateTime); + } + + [Fact] + public async Task Can_filter_equality_on_type_DateTimeOffset() + { + // Arrange + var resource = new FilterableResource + { + SomeDateTimeOffset = new DateTimeOffset(27.January(2003).At(11, 22, 33, 44), TimeSpan.FromHours(3)) + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.RemoveRange(dbContext.FilterableResources); + dbContext.FilterableResources.AddRange(resource, new FilterableResource()); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/filterableResources?filter=equals(someDateTimeOffset,'{WebUtility.UrlEncode(resource.SomeDateTimeOffset.ToString("O"))}')"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Attributes["someDateTimeOffset"].Should().Be(resource.SomeDateTimeOffset.LocalDateTime); + } + + [Fact] + public async Task Can_filter_equality_on_type_TimeSpan() + { + // Arrange + var resource = new FilterableResource {SomeTimeSpan = new TimeSpan(1, 2, 3, 4, 5)}; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.RemoveRange(dbContext.FilterableResources); + dbContext.FilterableResources.AddRange(resource, new FilterableResource()); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/filterableResources?filter=equals(someTimeSpan,'{resource.SomeTimeSpan}')"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Attributes["someTimeSpan"].Should().Be(resource.SomeTimeSpan.ToString()); + } + + [Fact] + public async Task Cannot_filter_equality_on_incompatible_value() + { + // Arrange + var resource = new FilterableResource {SomeInt32 = 1}; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.RemoveRange(dbContext.FilterableResources); + dbContext.FilterableResources.AddRange(resource, new FilterableResource()); + + await dbContext.SaveChangesAsync(); + }); + + var route = "/filterableResources?filter=equals(someInt32,'ABC')"; + + // 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("Query creation failed due to incompatible types."); + responseDocument.Errors[0].Detail.Should().Be("Failed to convert 'ABC' of type 'String' to type 'Int32'."); + responseDocument.Errors[0].Source.Parameter.Should().BeNull(); + } + + [Theory] + [InlineData(nameof(FilterableResource.SomeString))] + [InlineData(nameof(FilterableResource.SomeNullableBoolean))] + [InlineData(nameof(FilterableResource.SomeNullableInt32))] + [InlineData(nameof(FilterableResource.SomeNullableUnsignedInt64))] + [InlineData(nameof(FilterableResource.SomeNullableDecimal))] + [InlineData(nameof(FilterableResource.SomeNullableDouble))] + [InlineData(nameof(FilterableResource.SomeNullableGuid))] + [InlineData(nameof(FilterableResource.SomeNullableDateTime))] + [InlineData(nameof(FilterableResource.SomeNullableDateTimeOffset))] + [InlineData(nameof(FilterableResource.SomeNullableTimeSpan))] + [InlineData(nameof(FilterableResource.SomeNullableEnum))] + public async Task Can_filter_is_null_on_type(string propertyName) + { + // Arrange + var resource = new FilterableResource(); + var property = typeof(FilterableResource).GetProperty(propertyName); + property?.SetValue(resource, null); + + var otherResource = new FilterableResource + { + SomeString = "X", + SomeNullableBoolean = true, + SomeNullableInt32 = 1, + SomeNullableUnsignedInt64 = 1, + SomeNullableDecimal = 1, + SomeNullableDouble = 1, + SomeNullableGuid = Guid.NewGuid(), + SomeNullableDateTime = 1.January(2001), + SomeNullableDateTimeOffset = 1.January(2001), + SomeNullableTimeSpan = TimeSpan.FromHours(1), + SomeNullableEnum = DayOfWeek.Friday + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.RemoveRange(dbContext.FilterableResources); + dbContext.FilterableResources.AddRange(resource, otherResource); + + await dbContext.SaveChangesAsync(); + }); + + var attributeName = propertyName.Camelize(); + var route = $"/filterableResources?filter=equals({attributeName},null)"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Attributes[attributeName].Should().Be(null); + } + + [Theory] + [InlineData(nameof(FilterableResource.SomeString))] + [InlineData(nameof(FilterableResource.SomeNullableBoolean))] + [InlineData(nameof(FilterableResource.SomeNullableInt32))] + [InlineData(nameof(FilterableResource.SomeNullableUnsignedInt64))] + [InlineData(nameof(FilterableResource.SomeNullableDecimal))] + [InlineData(nameof(FilterableResource.SomeNullableDouble))] + [InlineData(nameof(FilterableResource.SomeNullableGuid))] + [InlineData(nameof(FilterableResource.SomeNullableDateTime))] + [InlineData(nameof(FilterableResource.SomeNullableDateTimeOffset))] + [InlineData(nameof(FilterableResource.SomeNullableTimeSpan))] + [InlineData(nameof(FilterableResource.SomeNullableEnum))] + public async Task Can_filter_is_not_null_on_type(string propertyName) + { + // Arrange + var resource = new FilterableResource + { + SomeString = "X", + SomeNullableBoolean = true, + SomeNullableInt32 = 1, + SomeNullableUnsignedInt64 = 1, + SomeNullableDecimal = 1, + SomeNullableDouble = 1, + SomeNullableGuid = Guid.NewGuid(), + SomeNullableDateTime = 1.January(2001), + SomeNullableDateTimeOffset = 1.January(2001), + SomeNullableTimeSpan = TimeSpan.FromHours(1), + SomeNullableEnum = DayOfWeek.Friday + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.RemoveRange(dbContext.FilterableResources); + dbContext.FilterableResources.AddRange(resource, new FilterableResource()); + + await dbContext.SaveChangesAsync(); + }); + + var attributeName = propertyName.Camelize(); + var route = $"/filterableResources?filter=not(equals({attributeName},null))"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Attributes[attributeName].Should().NotBe(null); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterDbContext.cs new file mode 100644 index 0000000000..86db753075 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterDbContext.cs @@ -0,0 +1,13 @@ +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Filtering +{ + public sealed class FilterDbContext : DbContext + { + public DbSet FilterableResources { get; set; } + + public FilterDbContext(DbContextOptions options) : base(options) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterDepthTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterDepthTests.cs new file mode 100644 index 0000000000..eb07d639b5 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterDepthTests.cs @@ -0,0 +1,661 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using FluentAssertions.Extensions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCoreExample; +using JsonApiDotNetCoreExample.Data; +using JsonApiDotNetCoreExample.Models; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Filtering +{ + public sealed class FilterDepthTests : IClassFixture> + { + private readonly IntegrationTestContext _testContext; + + public FilterDepthTests(IntegrationTestContext testContext) + { + _testContext = testContext; + + var options = (JsonApiOptions) testContext.Factory.Services.GetRequiredService(); + options.EnableLegacyFilterNotation = false; + + options.DisableTopPagination = false; + options.DisableChildrenPagination = false; + } + + [Fact] + public async Task Can_filter_in_primary_resources() + { + // Arrange + var articles = new List
+ { + new Article + { + Caption = "One" + }, + new Article + { + Caption = "Two" + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync
(); + dbContext.Articles.AddRange(articles); + + await dbContext.SaveChangesAsync(); + }); + + var route = "/api/v1/articles?filter=equals(caption,'Two')"; + + // 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(articles[1].StringId); + } + + [Fact] + public async Task Cannot_filter_in_single_primary_resource() + { + // Arrange + var article = new Article + { + Caption = "X" + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Articles.Add(article); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/api/v1/articles/{article.StringId}?filter=equals(caption,'Two')"; + + // 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("The specified filter is invalid."); + responseDocument.Errors[0].Detail.Should().Be("This query string parameter can only be used on a collection of resources (not on a single resource)."); + responseDocument.Errors[0].Source.Parameter.Should().Be("filter"); + } + + [Fact] + public async Task Can_filter_in_secondary_resources() + { + // Arrange + var blog = new Blog + { + Articles = new List
+ { + new Article + { + Caption = "One" + }, + new Article + { + Caption = "Two" + } + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Blogs.Add(blog); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/api/v1/blogs/{blog.StringId}/articles?filter=equals(caption,'Two')"; + + // 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(blog.Articles[1].StringId); + } + + [Fact] + public async Task Cannot_filter_in_single_secondary_resource() + { + // Arrange + var article = new Article + { + Caption = "X" + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Articles.Add(article); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/api/v1/articles/{article.StringId}/author?filter=equals(lastName,'Smith')"; + + // 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("The specified filter is invalid."); + responseDocument.Errors[0].Detail.Should().Be("This query string parameter can only be used on a collection of resources (not on a single resource)."); + responseDocument.Errors[0].Source.Parameter.Should().Be("filter"); + } + + [Fact] + public async Task Can_filter_on_HasOne_relationship() + { + // Arrange + var articles = new List
+ { + new Article + { + Caption = "X", + Author = new Author + { + LastName = "Conner" + } + }, + new Article + { + Caption = "X", + Author = new Author + { + LastName = "Smith" + } + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync
(); + dbContext.Articles.AddRange(articles); + + await dbContext.SaveChangesAsync(); + }); + + var route = "/api/v1/articles?include=author&filter=equals(author.lastName,'Smith')"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.Included.Should().HaveCount(1); + + responseDocument.Included[0].Id.Should().Be(articles[1].Author.StringId); + } + + [Fact] + public async Task Can_filter_on_HasMany_relationship() + { + // Arrange + var blogs = new List + { + new Blog(), + new Blog + { + Articles = new List
+ { + new Article + { + Caption = "X" + } + } + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Blogs.AddRange(blogs); + + await dbContext.SaveChangesAsync(); + }); + + var route = "/api/v1/blogs?filter=greaterThan(count(articles),'0')"; + + // 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(blogs[1].StringId); + } + + [Fact] + public async Task Can_filter_on_HasManyThrough_relationship() + { + // Arrange + var articles = new List
+ { + new Article + { + Caption = "X" + }, + new Article + { + Caption = "X", + ArticleTags = new HashSet + { + new ArticleTag + { + Tag = new Tag + { + Name = "Hot" + } + } + } + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync
(); + dbContext.Articles.AddRange(articles); + + await dbContext.SaveChangesAsync(); + }); + + var route = "/api/v1/articles?filter=has(tags)"; + + // 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(articles[1].StringId); + } + + [Fact] + public async Task Can_filter_in_scope_of_HasMany_relationship() + { + // Arrange + var blog = new Blog + { + Articles = new List
+ { + new Article + { + Caption = "One" + }, + new Article + { + Caption = "Two" + } + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Blogs.Add(blog); + + await dbContext.SaveChangesAsync(); + }); + + var route = "/api/v1/blogs?include=articles&filter[articles]=equals(caption,'Two')"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.Included.Should().HaveCount(1); + + responseDocument.Included[0].Id.Should().Be(blog.Articles[1].StringId); + } + + [Fact] + public async Task Can_filter_in_scope_of_HasMany_relationship_on_secondary_resource() + { + // Arrange + var blog = new Blog + { + Owner = new Author + { + LastName = "X", + Articles = new List
+ { + new Article + { + Caption = "One" + }, + new Article + { + Caption = "Two" + } + } + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Blogs.Add(blog); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/api/v1/blogs/{blog.StringId}/owner?include=articles&filter[articles]=equals(caption,'Two')"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.Included.Should().HaveCount(1); + + responseDocument.Included[0].Id.Should().Be(blog.Owner.Articles[1].StringId); + } + + [Fact] + public async Task Can_filter_in_scope_of_HasManyThrough_relationship() + { + // Arrange + var articles = new List
+ { + new Article + { + Caption = "X", + ArticleTags = new HashSet + { + new ArticleTag + { + Tag = new Tag + { + Name = "Cold" + } + } + } + }, + new Article + { + Caption = "X", + ArticleTags = new HashSet + { + new ArticleTag + { + Tag = new Tag + { + Name = "Hot" + } + } + } + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync
(); + dbContext.Articles.AddRange(articles); + + await dbContext.SaveChangesAsync(); + }); + + // Workaround for https://github.com/dotnet/efcore/issues/21026 + var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); + options.DisableTopPagination = false; + options.DisableChildrenPagination = true; + + var route = "/api/v1/articles?include=tags&filter[tags]=equals(name,'Hot')"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(2); + responseDocument.Included.Should().HaveCount(1); + + responseDocument.Included[0].Id.Should().Be(articles[1].ArticleTags.First().Tag.StringId); + } + + [Fact] + public async Task Can_filter_in_scope_of_relationship_chain() + { + // Arrange + var blog = new Blog + { + Owner = new Author + { + LastName = "Smith", + Articles = new List
+ { + new Article + { + Caption = "One" + }, + new Article + { + Caption = "Two" + } + } + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Blogs.Add(blog); + + await dbContext.SaveChangesAsync(); + }); + + var route = "/api/v1/blogs?include=owner.articles&filter[owner.articles]=equals(caption,'Two')"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.Included.Should().HaveCount(2); + + responseDocument.Included[0].Id.Should().Be(blog.Owner.StringId); + responseDocument.Included[1].Id.Should().Be(blog.Owner.Articles[1].StringId); + } + + [Fact] + public async Task Can_filter_in_same_scope_multiple_times() + { + // Arrange + var articles = new List
+ { + new Article + { + Caption = "One" + }, + new Article + { + Caption = "Two" + }, + new Article + { + Caption = "Three" + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync
(); + dbContext.Articles.AddRange(articles); + + await dbContext.SaveChangesAsync(); + }); + + var route = "/api/v1/articles?filter=equals(caption,'One')&filter=equals(caption,'Three')"; + + // 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(articles[0].StringId); + responseDocument.ManyData[1].Id.Should().Be(articles[2].StringId); + } + + [Fact] + public async Task Can_filter_in_same_scope_multiple_times_using_legacy_notation() + { + // Arrange + var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); + options.EnableLegacyFilterNotation = true; + + var articles = new List
+ { + new Article + { + Caption = "One", + Author = new Author + { + FirstName = "Joe", + LastName = "Smith" + } + }, + new Article + { + Caption = "Two", + Author = new Author + { + FirstName = "John", + LastName = "Doe" + } + }, + new Article + { + Caption = "Three", + Author = new Author + { + FirstName = "Jack", + LastName = "Miller" + } + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync
(); + dbContext.Articles.AddRange(articles); + + await dbContext.SaveChangesAsync(); + }); + + var route = "/api/v1/articles?filter[author.firstName]=John&filter[author.lastName]=Smith"; + + // 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(articles[0].StringId); + responseDocument.ManyData[1].Id.Should().Be(articles[1].StringId); + } + + [Fact] + public async Task Can_filter_in_multiple_scopes() + { + // Arrange + var blogs = new List + { + new Blog(), + new Blog + { + Title = "Technology", + Owner = new Author + { + LastName = "Smith", + Articles = new List
+ { + new Article + { + Caption = "One" + }, + new Article + { + Caption = "Two", + Revisions = new List + { + new Revision + { + PublishTime = 1.January(2000) + }, + new Revision + { + PublishTime = 10.January(2010) + } + } + } + } + } + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Blogs.AddRange(blogs); + + await dbContext.SaveChangesAsync(); + }); + + var route = "/api/v1/blogs?include=owner.articles.revisions&" + + "filter=and(equals(title,'Technology'),has(owner.articles),equals(owner.lastName,'Smith'))&" + + "filter[owner.articles]=equals(caption,'Two')&" + + "filter[owner.articles.revisions]=greaterThan(publishTime,'2005-05-05')"; + + // 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(blogs[1].StringId); + + responseDocument.Included.Should().HaveCount(3); + responseDocument.Included[0].Id.Should().Be(blogs[1].Owner.StringId); + responseDocument.Included[1].Id.Should().Be(blogs[1].Owner.Articles[1].StringId); + responseDocument.Included[2].Id.Should().Be(blogs[1].Owner.Articles[1].Revisions.Skip(1).First().StringId); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterOperatorTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterOperatorTests.cs new file mode 100644 index 0000000000..e484aac1db --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterOperatorTests.cs @@ -0,0 +1,570 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Threading.Tasks; +using System.Web; +using FluentAssertions; +using Humanizer; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Internal.Queries.Expressions; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.JsonApiDocuments; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Filtering +{ + public sealed class FilterOperatorTests : IClassFixture, FilterDbContext>> + { + private readonly IntegrationTestContext, FilterDbContext> _testContext; + + public FilterOperatorTests(IntegrationTestContext, FilterDbContext> testContext) + { + _testContext = testContext; + + var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); + options.EnableLegacyFilterNotation = false; + } + + [Fact] + public async Task Can_filter_equality_on_special_characters() + { + // Arrange + var resource = new FilterableResource + { + SomeString = "This, that & more" + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.RemoveRange(dbContext.FilterableResources); + dbContext.FilterableResources.AddRange(resource, new FilterableResource()); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/filterableResources?filter=equals(someString,'{HttpUtility.UrlEncode(resource.SomeString)}')"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Attributes["someString"].Should().Be(resource.SomeString); + } + + [Fact] + public async Task Can_filter_equality_on_two_attributes_of_same_type() + { + // Arrange + var resource = new FilterableResource + { + SomeInt32 = 5, + OtherInt32 = 5 + }; + + var otherResource = new FilterableResource + { + SomeInt32 = 5, + OtherInt32 = 10 + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.RemoveRange(dbContext.FilterableResources); + dbContext.FilterableResources.AddRange(resource, otherResource); + + await dbContext.SaveChangesAsync(); + }); + + var route = "/filterableResources?filter=equals(someInt32,otherInt32)"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Attributes["someInt32"].Should().Be(resource.SomeInt32); + responseDocument.ManyData[0].Attributes["otherInt32"].Should().Be(resource.OtherInt32); + } + + [Fact] + public async Task Can_filter_equality_on_two_attributes_of_same_nullable_type() + { + // Arrange + var resource = new FilterableResource + { + SomeNullableInt32 = 5, + OtherNullableInt32 = 5 + }; + + var otherResource = new FilterableResource + { + SomeNullableInt32 = 5, + OtherNullableInt32 = 10 + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.RemoveRange(dbContext.FilterableResources); + dbContext.FilterableResources.AddRange(resource, otherResource); + + await dbContext.SaveChangesAsync(); + }); + + var route = "/filterableResources?filter=equals(someNullableInt32,otherNullableInt32)"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Attributes["someNullableInt32"].Should().Be(resource.SomeNullableInt32); + responseDocument.ManyData[0].Attributes["otherNullableInt32"].Should().Be(resource.OtherNullableInt32); + } + + [Fact] + public async Task Can_filter_equality_on_two_attributes_with_nullable_at_start() + { + // Arrange + var resource = new FilterableResource + { + SomeInt32 = 5, + SomeNullableInt32 = 5 + }; + + var otherResource = new FilterableResource + { + SomeInt32 = 5, + SomeNullableInt32 = 10 + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.RemoveRange(dbContext.FilterableResources); + dbContext.FilterableResources.AddRange(resource, otherResource); + + await dbContext.SaveChangesAsync(); + }); + + var route = "/filterableResources?filter=equals(someNullableInt32,someInt32)"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Attributes["someInt32"].Should().Be(resource.SomeInt32); + responseDocument.ManyData[0].Attributes["someNullableInt32"].Should().Be(resource.SomeNullableInt32); + } + + [Fact] + public async Task Can_filter_equality_on_two_attributes_with_nullable_at_end() + { + // Arrange + var resource = new FilterableResource + { + SomeInt32 = 5, + SomeNullableInt32 = 5 + }; + + var otherResource = new FilterableResource + { + SomeInt32 = 5, + SomeNullableInt32 = 10 + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.RemoveRange(dbContext.FilterableResources); + dbContext.FilterableResources.AddRange(resource, otherResource); + + await dbContext.SaveChangesAsync(); + }); + + var route = "/filterableResources?filter=equals(someInt32,someNullableInt32)"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Attributes["someInt32"].Should().Be(resource.SomeInt32); + responseDocument.ManyData[0].Attributes["someNullableInt32"].Should().Be(resource.SomeNullableInt32); + } + + [Fact] + public async Task Can_filter_equality_on_two_attributes_of_compatible_types() + { + // Arrange + var resource = new FilterableResource + { + SomeInt32 = 5, + SomeUnsignedInt64 = 5 + }; + + var otherResource = new FilterableResource + { + SomeInt32 = 5, + SomeUnsignedInt64 = 10 + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.RemoveRange(dbContext.FilterableResources); + dbContext.FilterableResources.AddRange(resource, otherResource); + + await dbContext.SaveChangesAsync(); + }); + + var route = "/filterableResources?filter=equals(someInt32,someUnsignedInt64)"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Attributes["someInt32"].Should().Be(resource.SomeInt32); + responseDocument.ManyData[0].Attributes["someUnsignedInt64"].Should().Be(resource.SomeUnsignedInt64); + } + + [Fact] + public async Task Cannot_filter_equality_on_two_attributes_of_incompatible_types() + { + // Arrange + var route = "/filterableResources?filter=equals(someDouble,someTimeSpan)"; + + // 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("Query creation failed due to incompatible types."); + responseDocument.Errors[0].Detail.Should().Be("No coercion operator is defined between types 'System.TimeSpan' and 'System.Double'."); + responseDocument.Errors[0].Source.Parameter.Should().BeNull(); + } + + [Theory] + [InlineData(19, 21, ComparisonOperator.LessThan, 20)] + [InlineData(19, 21, ComparisonOperator.LessThan, 21)] + [InlineData(19, 21, ComparisonOperator.LessOrEqual, 20)] + [InlineData(19, 21, ComparisonOperator.LessOrEqual, 19)] + [InlineData(21, 19, ComparisonOperator.GreaterThan, 20)] + [InlineData(21, 19, ComparisonOperator.GreaterThan, 19)] + [InlineData(21, 19, ComparisonOperator.GreaterOrEqual, 20)] + [InlineData(21, 19, ComparisonOperator.GreaterOrEqual, 21)] + public async Task Can_filter_comparison_on_whole_number(int matchingValue, int nonMatchingValue, ComparisonOperator filterOperator, double filterValue) + { + // Arrange + var resource = new FilterableResource + { + SomeInt32 = matchingValue + }; + + var otherResource = new FilterableResource + { + SomeInt32 = nonMatchingValue + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.RemoveRange(dbContext.FilterableResources); + dbContext.FilterableResources.AddRange(resource, otherResource); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/filterableResources?filter={filterOperator.ToString().Camelize()}(someInt32,'{filterValue}')"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Attributes["someInt32"].Should().Be(resource.SomeInt32); + } + + [Theory] + [InlineData(1.9, 2.1, ComparisonOperator.LessThan, 2.0)] + [InlineData(1.9, 2.1, ComparisonOperator.LessThan, 2.1)] + [InlineData(1.9, 2.1, ComparisonOperator.LessOrEqual, 2.0)] + [InlineData(1.9, 2.1, ComparisonOperator.LessOrEqual, 1.9)] + [InlineData(2.1, 1.9, ComparisonOperator.GreaterThan, 2.0)] + [InlineData(2.1, 1.9, ComparisonOperator.GreaterThan, 1.9)] + [InlineData(2.1, 1.9, ComparisonOperator.GreaterOrEqual, 2.0)] + [InlineData(2.1, 1.9, ComparisonOperator.GreaterOrEqual, 2.1)] + public async Task Can_filter_comparison_on_fractional_number(double matchingValue, double nonMatchingValue, ComparisonOperator filterOperator, double filterValue) + { + // Arrange + var resource = new FilterableResource + { + SomeDouble = matchingValue + }; + + var otherResource = new FilterableResource + { + SomeDouble = nonMatchingValue + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.RemoveRange(dbContext.FilterableResources); + dbContext.FilterableResources.AddRange(resource, otherResource); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/filterableResources?filter={filterOperator.ToString().Camelize()}(someDouble,'{filterValue}')"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Attributes["someDouble"].Should().Be(resource.SomeDouble); + } + + [Theory] + [InlineData("2001-01-01", "2001-01-09", ComparisonOperator.LessThan, "2001-01-05")] + [InlineData("2001-01-01", "2001-01-09", ComparisonOperator.LessThan, "2001-01-09")] + [InlineData("2001-01-01", "2001-01-09", ComparisonOperator.LessOrEqual, "2001-01-05")] + [InlineData("2001-01-01", "2001-01-09", ComparisonOperator.LessOrEqual, "2001-01-01")] + [InlineData("2001-01-09", "2001-01-01", ComparisonOperator.GreaterThan, "2001-01-05")] + [InlineData("2001-01-09", "2001-01-01", ComparisonOperator.GreaterThan, "2001-01-01")] + [InlineData("2001-01-09", "2001-01-01", ComparisonOperator.GreaterOrEqual, "2001-01-05")] + [InlineData("2001-01-09", "2001-01-01", ComparisonOperator.GreaterOrEqual, "2001-01-09")] + public async Task Can_filter_comparison_on_DateTime(string matchingDateTime, string nonMatchingDateTime, ComparisonOperator filterOperator, string filterDateTime) + { + // Arrange + var resource = new FilterableResource + { + SomeDateTime = DateTime.ParseExact(matchingDateTime, "yyyy-MM-dd", null) + }; + + var otherResource = new FilterableResource + { + SomeDateTime = DateTime.ParseExact(nonMatchingDateTime, "yyyy-MM-dd", null) + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.RemoveRange(dbContext.FilterableResources); + dbContext.FilterableResources.AddRange(resource, otherResource); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/filterableResources?filter={filterOperator.ToString().Camelize()}(someDateTime,'{DateTime.ParseExact(filterDateTime, "yyyy-MM-dd", null)}')"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Attributes["someDateTime"].Should().Be(resource.SomeDateTime); + } + + [Theory] + [InlineData("The fox jumped over the lazy dog", "Other", TextMatchKind.Contains, "jumped")] + [InlineData("The fox jumped over the lazy dog", "the fox...", TextMatchKind.Contains, "The")] + [InlineData("The fox jumped over the lazy dog", "The fox jumped", TextMatchKind.Contains, "dog")] + [InlineData("The fox jumped over the lazy dog", "Yesterday The fox...", TextMatchKind.StartsWith, "The")] + [InlineData("The fox jumped over the lazy dog", "over the lazy dog earlier", TextMatchKind.EndsWith, "dog")] + public async Task Can_filter_text_match(string matchingText, string nonMatchingText, TextMatchKind matchKind, string filterText) + { + // Arrange + var resource = new FilterableResource + { + SomeString = matchingText + }; + + var otherResource = new FilterableResource + { + SomeString = nonMatchingText + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.RemoveRange(dbContext.FilterableResources); + dbContext.FilterableResources.AddRange(resource, otherResource); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/filterableResources?filter={matchKind.ToString().Camelize()}(someString,'{filterText}')"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Attributes["someString"].Should().Be(resource.SomeString); + } + + [Theory] + [InlineData("two", "one two", "'one','two','three'")] + [InlineData("two", "nine", "'one','two','three','four','five'")] + public async Task Can_filter_in_set(string matchingText, string nonMatchingText, string filterText) + { + // Arrange + var resource = new FilterableResource + { + SomeString = matchingText + }; + + var otherResource = new FilterableResource + { + SomeString = nonMatchingText + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.RemoveRange(dbContext.FilterableResources); + dbContext.FilterableResources.AddRange(resource, otherResource); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/filterableResources?filter=any(someString,{filterText})"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Attributes["someString"].Should().Be(resource.SomeString); + } + + [Fact] + public async Task Can_filter_on_has() + { + // Arrange + var resource = new FilterableResource + { + Children = new List + { + new FilterableResource() + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.RemoveRange(dbContext.FilterableResources); + dbContext.FilterableResources.AddRange(resource, new FilterableResource()); + + await dbContext.SaveChangesAsync(); + }); + + var route = "/filterableResources?filter=has(children)"; + + // 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(resource.StringId); + } + + [Fact] + public async Task Can_filter_on_count() + { + // Arrange + var resource = new FilterableResource + { + Children = new List + { + new FilterableResource(), + new FilterableResource() + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.RemoveRange(dbContext.FilterableResources); + dbContext.FilterableResources.AddRange(resource, new FilterableResource()); + + await dbContext.SaveChangesAsync(); + }); + + var route = "/filterableResources?filter=equals(count(children),'2')"; + + // 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(resource.StringId); + } + + [Theory] + [InlineData("and(equals(someString,'ABC'),equals(someInt32,'11'))")] + [InlineData("and(equals(someString,'ABC'),equals(someInt32,'11'),equals(someEnum,'Tuesday'))")] + [InlineData("or(equals(someString,'---'),lessThan(someInt32,'33'))")] + [InlineData("not(equals(someEnum,'Saturday'))")] + public async Task Can_filter_on_logical_functions(string filterExpression) + { + // Arrange + var resource1 = new FilterableResource + { + SomeString = "ABC", + SomeInt32 = 11, + SomeEnum = DayOfWeek.Tuesday + }; + + var resource2 = new FilterableResource + { + SomeString = "XYZ", + SomeInt32 = 99, + SomeEnum = DayOfWeek.Saturday + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.RemoveRange(dbContext.FilterableResources); + dbContext.FilterableResources.AddRange(resource1, resource2); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/filterableResources?filter={filterExpression}"; + + // 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(resource1.StringId); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterTests.cs new file mode 100644 index 0000000000..64c85131d1 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterTests.cs @@ -0,0 +1,197 @@ +using System.Collections.Generic; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCoreExample; +using JsonApiDotNetCoreExample.Data; +using JsonApiDotNetCoreExample.Models; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Filtering +{ + public sealed class FilterTests : IClassFixture> + { + private readonly IntegrationTestContext _testContext; + + public FilterTests(IntegrationTestContext testContext) + { + _testContext = testContext; + + var options = (JsonApiOptions) testContext.Factory.Services.GetRequiredService(); + options.EnableLegacyFilterNotation = false; + } + + [Fact] + public async Task Cannot_filter_in_unknown_scope() + { + // Arrange + var route = "/api/v1/people?filter[doesNotExist]=equals(title,null)"; + + // 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("The specified filter is invalid."); + responseDocument.Errors[0].Detail.Should().Be("Relationship 'doesNotExist' does not exist on resource 'people'."); + responseDocument.Errors[0].Source.Parameter.Should().Be("filter[doesNotExist]"); + } + + [Fact] + public async Task Cannot_filter_in_unknown_nested_scope() + { + // Arrange + var route = "/api/v1/people?filter[todoItems.doesNotExist]=equals(title,null)"; + + // 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("The specified filter is invalid."); + responseDocument.Errors[0].Detail.Should().Be("Relationship 'doesNotExist' in 'todoItems.doesNotExist' does not exist on resource 'todoItems'."); + responseDocument.Errors[0].Source.Parameter.Should().Be("filter[todoItems.doesNotExist]"); + } + + [Fact] + public async Task Cannot_filter_on_blocked_attribute() + { + // Arrange + var route = "/api/v1/todoItems?filter=equals(achievedDate,null)"; + + // 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("Filtering on the requested attribute is not allowed."); + responseDocument.Errors[0].Detail.Should().Be("Filtering on attribute 'achievedDate' is not allowed."); + responseDocument.Errors[0].Source.Parameter.Should().Be("filter"); + } + + [Fact] + public async Task Can_filter_on_ID() + { + // Arrange + var person = new Person + { + FirstName = "Jane" + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.People.AddRange(person, new Person()); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/api/v1/people?filter=equals(id,'{person.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(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/Filtering/FilterableResource.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterableResource.cs new file mode 100644 index 0000000000..caeb240fc1 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterableResource.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Filtering +{ + public sealed class FilterableResource : Identifiable + { + [Attr] public string SomeString { get; set; } + + [Attr] public bool SomeBoolean { get; set; } + [Attr] public bool? SomeNullableBoolean { get; set; } + + [Attr] public int SomeInt32 { get; set; } + [Attr] public int? SomeNullableInt32 { get; set; } + + [Attr] public int OtherInt32 { get; set; } + [Attr] public int? OtherNullableInt32 { get; set; } + + [Attr] public ulong SomeUnsignedInt64 { get; set; } + [Attr] public ulong? SomeNullableUnsignedInt64 { get; set; } + + [Attr] public decimal SomeDecimal { get; set; } + [Attr] public decimal? SomeNullableDecimal { get; set; } + + [Attr] public double SomeDouble { get; set; } + [Attr] public double? SomeNullableDouble { get; set; } + + [Attr] public Guid SomeGuid { get; set; } + [Attr] public Guid? SomeNullableGuid { get; set; } + + [Attr] public DateTime SomeDateTime { get; set; } + [Attr] public DateTime? SomeNullableDateTime { get; set; } + + [Attr] public DateTimeOffset SomeDateTimeOffset { get; set; } + [Attr] public DateTimeOffset? SomeNullableDateTimeOffset { get; set; } + + [Attr] public TimeSpan SomeTimeSpan { get; set; } + [Attr] public TimeSpan? SomeNullableTimeSpan { get; set; } + + [Attr] public DayOfWeek SomeEnum { get; set; } + [Attr] public DayOfWeek? SomeNullableEnum { get; set; } + + [HasMany] public ICollection Children { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterableResourcesController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterableResourcesController.cs new file mode 100644 index 0000000000..cc28101ac4 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterableResourcesController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Filtering +{ + public sealed class FilterableResourcesController : JsonApiController + { + public FilterableResourcesController(IJsonApiOptions jsonApiOptions, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(jsonApiOptions, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Includes/IncludeTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Includes/IncludeTests.cs new file mode 100644 index 0000000000..6c360f41bc --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Includes/IncludeTests.cs @@ -0,0 +1,790 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using FluentAssertions.Extensions; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCore.Services; +using JsonApiDotNetCoreExample; +using JsonApiDotNetCoreExample.Data; +using JsonApiDotNetCoreExample.Models; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Includes +{ + public sealed class IncludeTests : IClassFixture> + { + private readonly IntegrationTestContext _testContext; + + public IncludeTests(IntegrationTestContext testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => + { + services.AddScoped, JsonApiResourceService
>(); + }); + } + + [Fact] + public async Task Can_include_in_primary_resources() + { + // Arrange + var article = new Article + { + Caption = "One", + Author = new Author + { + LastName = "Smith" + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync
(); + dbContext.Articles.Add(article); + + await dbContext.SaveChangesAsync(); + }); + + var route = "/api/v1/articles?include=author"; + + // 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(article.StringId); + responseDocument.ManyData[0].Attributes["caption"].Should().Be(article.Caption); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Type.Should().Be("authors"); + responseDocument.Included[0].Id.Should().Be(article.Author.StringId); + responseDocument.Included[0].Attributes["lastName"].Should().Be(article.Author.LastName); + } + + [Fact] + public async Task Can_include_in_primary_resource_by_ID() + { + // Arrange + var article = new Article + { + Caption = "One", + Author = new Author + { + LastName = "Smith" + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Articles.Add(article); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/api/v1/articles/{article.StringId}?include=author"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Id.Should().Be(article.StringId); + responseDocument.SingleData.Attributes["caption"].Should().Be(article.Caption); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Type.Should().Be("authors"); + responseDocument.Included[0].Id.Should().Be(article.Author.StringId); + responseDocument.Included[0].Attributes["lastName"].Should().Be(article.Author.LastName); + } + + [Fact] + public async Task Can_include_in_secondary_resource() + { + // Arrange + var blog = new Blog + { + Owner = new Author + { + LastName = "Smith", + Articles = new List
+ { + new Article + { + Caption = "One" + } + } + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Blogs.Add(blog); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/api/v1/blogs/{blog.StringId}/owner?include=articles"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Id.Should().Be(blog.Owner.StringId); + responseDocument.SingleData.Attributes["lastName"].Should().Be(blog.Owner.LastName); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Type.Should().Be("articles"); + responseDocument.Included[0].Id.Should().Be(blog.Owner.Articles[0].StringId); + responseDocument.Included[0].Attributes["caption"].Should().Be(blog.Owner.Articles[0].Caption); + } + + [Fact] + public async Task Can_include_in_secondary_resources() + { + // Arrange + var blog = new Blog + { + Articles = new List
+ { + new Article + { + Caption = "One", + Author = new Author + { + LastName = "Smith" + } + } + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Blogs.Add(blog); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/api/v1/blogs/{blog.StringId}/articles?include=author"; + + // 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(blog.Articles[0].StringId); + responseDocument.ManyData[0].Attributes["caption"].Should().Be(blog.Articles[0].Caption); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Type.Should().Be("authors"); + responseDocument.Included[0].Id.Should().Be(blog.Articles[0].Author.StringId); + responseDocument.Included[0].Attributes["lastName"].Should().Be(blog.Articles[0].Author.LastName); + } + + [Fact] + public async Task Can_include_HasOne_relationships() + { + // Arrange + var todoItem = new TodoItem + { + Description = "Work", + Owner = new Person + { + FirstName = "Joel" + }, + Assignee = new Person + { + FirstName = "James" + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TodoItems.Add(todoItem); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/api/v1/todoItems/{todoItem.StringId}?include=owner,assignee"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Id.Should().Be(todoItem.StringId); + responseDocument.SingleData.Attributes["description"].Should().Be(todoItem.Description); + + responseDocument.Included.Should().HaveCount(2); + + responseDocument.Included[0].Type.Should().Be("people"); + responseDocument.Included[0].Id.Should().Be(todoItem.Owner.StringId); + responseDocument.Included[0].Attributes["firstName"].Should().Be(todoItem.Owner.FirstName); + + responseDocument.Included[1].Type.Should().Be("people"); + responseDocument.Included[1].Id.Should().Be(todoItem.Assignee.StringId); + responseDocument.Included[1].Attributes["firstName"].Should().Be(todoItem.Assignee.FirstName); + } + + [Fact] + public async Task Can_include_HasMany_relationship() + { + // Arrange + var article = new Article + { + Caption = "One", + Revisions = new List + { + new Revision + { + PublishTime = 24.July(2019) + } + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Articles.Add(article); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/api/v1/articles/{article.StringId}?include=revisions"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Id.Should().Be(article.StringId); + responseDocument.SingleData.Attributes["caption"].Should().Be(article.Caption); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Type.Should().Be("revisions"); + responseDocument.Included[0].Id.Should().Be(article.Revisions.Single().StringId); + responseDocument.Included[0].Attributes["publishTime"].Should().Be(article.Revisions.Single().PublishTime); + } + + [Fact] + public async Task Can_include_HasManyThrough_relationship() + { + // Arrange + var article = new Article + { + Caption = "One", + ArticleTags = new HashSet + { + new ArticleTag + { + Tag = new Tag + { + Name = "Hot" + } + } + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Articles.Add(article); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/api/v1/articles/{article.StringId}?include=tags"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Id.Should().Be(article.StringId); + responseDocument.SingleData.Attributes["caption"].Should().Be(article.Caption); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Type.Should().Be("tags"); + responseDocument.Included[0].Id.Should().Be(article.ArticleTags.Single().Tag.StringId); + responseDocument.Included[0].Attributes["name"].Should().Be(article.ArticleTags.Single().Tag.Name); + } + + [Fact] + public async Task Can_include_chain_of_HasOne_relationships() + { + // Arrange + var article = new Article + { + Caption = "One", + Author = new Author + { + LastName = "Smith", + LivingAddress = new Address + { + Street = "Main Road", + Country = new Country + { + Name = "United States of America" + } + } + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Articles.Add(article); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/api/v1/articles/{article.StringId}?include=author.livingAddress.country"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Id.Should().Be(article.StringId); + responseDocument.SingleData.Attributes["caption"].Should().Be(article.Caption); + + responseDocument.Included.Should().HaveCount(3); + + responseDocument.Included[0].Type.Should().Be("authors"); + responseDocument.Included[0].Id.Should().Be(article.Author.StringId); + responseDocument.Included[0].Attributes["lastName"].Should().Be(article.Author.LastName); + + responseDocument.Included[1].Type.Should().Be("addresses"); + responseDocument.Included[1].Id.Should().Be(article.Author.LivingAddress.StringId); + responseDocument.Included[1].Attributes["street"].Should().Be(article.Author.LivingAddress.Street); + + responseDocument.Included[2].Type.Should().Be("countries"); + responseDocument.Included[2].Id.Should().Be(article.Author.LivingAddress.Country.StringId); + responseDocument.Included[2].Attributes["name"].Should().Be(article.Author.LivingAddress.Country.Name); + } + + [Fact] + public async Task Can_include_chain_of_HasMany_relationships() + { + // Arrange + var blog = new Blog + { + Title = "Some", + Articles = new List
+ { + new Article + { + Caption = "One", + Revisions = new List + { + new Revision + { + PublishTime = 24.July(2019) + } + } + } + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Blogs.Add(blog); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/api/v1/blogs/{blog.StringId}?include=articles.revisions"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Id.Should().Be(blog.StringId); + responseDocument.SingleData.Attributes["title"].Should().Be(blog.Title); + + responseDocument.Included.Should().HaveCount(2); + + responseDocument.Included[0].Type.Should().Be("articles"); + responseDocument.Included[0].Id.Should().Be(blog.Articles[0].StringId); + responseDocument.Included[0].Attributes["caption"].Should().Be(blog.Articles[0].Caption); + + responseDocument.Included[1].Type.Should().Be("revisions"); + responseDocument.Included[1].Id.Should().Be(blog.Articles[0].Revisions.Single().StringId); + responseDocument.Included[1].Attributes["publishTime"].Should().Be(blog.Articles[0].Revisions.Single().PublishTime); + } + + [Fact] + public async Task Can_include_chain_of_recursive_relationships() + { + // Arrange + var todoItem = new TodoItem + { + Description = "Root", + Collection = new TodoItemCollection + { + Name = "Primary", + Owner = new Person + { + FirstName = "Jack" + }, + TodoItems = new HashSet + { + new TodoItem + { + Description = "This is nested.", + Owner = new Person + { + FirstName = "Jill" + } + }, + new TodoItem + { + Description = "This is nested too." + } + } + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TodoItems.Add(todoItem); + + await dbContext.SaveChangesAsync(); + }); + + string route = $"/api/v1/todoItems/{todoItem.StringId}?include=collection.todoItems.owner"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Id.Should().Be(todoItem.StringId); + responseDocument.SingleData.Attributes["description"].Should().Be(todoItem.Description); + + responseDocument.Included.Should().HaveCount(5); + + responseDocument.Included[0].Type.Should().Be("todoCollections"); + responseDocument.Included[0].Id.Should().Be(todoItem.Collection.StringId); + responseDocument.Included[0].Attributes["name"].Should().Be(todoItem.Collection.Name); + + responseDocument.Included[1].Type.Should().Be("todoItems"); + responseDocument.Included[1].Id.Should().Be(todoItem.StringId); + responseDocument.Included[1].Attributes["description"].Should().Be(todoItem.Description); + + responseDocument.Included[2].Type.Should().Be("todoItems"); + responseDocument.Included[2].Id.Should().Be(todoItem.Collection.TodoItems.First().StringId); + responseDocument.Included[2].Attributes["description"].Should().Be(todoItem.Collection.TodoItems.First().Description); + + responseDocument.Included[3].Type.Should().Be("people"); + responseDocument.Included[3].Id.Should().Be(todoItem.Collection.TodoItems.First().Owner.StringId); + responseDocument.Included[3].Attributes["firstName"].Should().Be(todoItem.Collection.TodoItems.First().Owner.FirstName); + + responseDocument.Included[4].Type.Should().Be("todoItems"); + responseDocument.Included[4].Id.Should().Be(todoItem.Collection.TodoItems.Skip(1).First().StringId); + responseDocument.Included[4].Attributes["description"].Should().Be(todoItem.Collection.TodoItems.Skip(1).First().Description); + } + + [Fact] + public async Task Can_include_chain_of_relationships_with_multiple_paths() + { + // Arrange + var todoItem = new TodoItem + { + Description = "Root", + Collection = new TodoItemCollection + { + Name = "Primary", + Owner = new Person + { + FirstName = "Jack", + Role = new PersonRole() + }, + TodoItems = new HashSet + { + new TodoItem + { + Description = "This is nested.", + Owner = new Person + { + FirstName = "Jill" + } + }, + new TodoItem + { + Description = "This is nested too." + } + } + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TodoItems.Add(todoItem); + + await dbContext.SaveChangesAsync(); + }); + + string route = $"/api/v1/todoItems/{todoItem.StringId}?include=collection.owner.role,collection.todoItems.owner"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Id.Should().Be(todoItem.StringId); + responseDocument.SingleData.Attributes["description"].Should().Be(todoItem.Description); + + responseDocument.Included.Should().HaveCount(7); + + responseDocument.Included[0].Type.Should().Be("todoCollections"); + responseDocument.Included[0].Id.Should().Be(todoItem.Collection.StringId); + responseDocument.Included[0].Attributes["name"].Should().Be(todoItem.Collection.Name); + + responseDocument.Included[1].Type.Should().Be("people"); + responseDocument.Included[1].Id.Should().Be(todoItem.Collection.Owner.StringId); + responseDocument.Included[1].Attributes["firstName"].Should().Be(todoItem.Collection.Owner.FirstName); + + responseDocument.Included[2].Type.Should().Be("personRoles"); + responseDocument.Included[2].Id.Should().Be(todoItem.Collection.Owner.Role.StringId); + + responseDocument.Included[3].Type.Should().Be("todoItems"); + responseDocument.Included[3].Id.Should().Be(todoItem.StringId); + responseDocument.Included[3].Attributes["description"].Should().Be(todoItem.Description); + + responseDocument.Included[4].Type.Should().Be("todoItems"); + responseDocument.Included[4].Id.Should().Be(todoItem.Collection.TodoItems.First().StringId); + responseDocument.Included[4].Attributes["description"].Should().Be(todoItem.Collection.TodoItems.First().Description); + + responseDocument.Included[5].Type.Should().Be("people"); + responseDocument.Included[5].Id.Should().Be(todoItem.Collection.TodoItems.First().Owner.StringId); + responseDocument.Included[5].Attributes["firstName"].Should().Be(todoItem.Collection.TodoItems.First().Owner.FirstName); + + responseDocument.Included[6].Type.Should().Be("todoItems"); + responseDocument.Included[6].Id.Should().Be(todoItem.Collection.TodoItems.Skip(1).First().StringId); + responseDocument.Included[6].Attributes["description"].Should().Be(todoItem.Collection.TodoItems.Skip(1).First().Description); + } + + [Fact] + public async Task Prevents_duplicate_includes_over_single_resource() + { + // Arrange + var person = new Person + { + FirstName = "Janice" + }; + + var todoItem = new TodoItem + { + Description = "Root", + Owner = person, + Assignee = person + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TodoItems.Add(todoItem); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/api/v1/todoItems/{todoItem.StringId}?include=owner&include=assignee"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Id.Should().Be(todoItem.StringId); + responseDocument.SingleData.Attributes["description"].Should().Be(todoItem.Description); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Type.Should().Be("people"); + responseDocument.Included[0].Id.Should().Be(person.StringId); + responseDocument.Included[0].Attributes["firstName"].Should().Be(person.FirstName); + } + + [Fact] + public async Task Prevents_duplicate_includes_over_multiple_resources() + { + // Arrange + var person = new Person + { + FirstName = "Janice" + }; + + var todoItems = new List + { + new TodoItem + { + Description = "First", + Owner = person + }, + new TodoItem + { + Description = "Second", + Owner = person + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.TodoItems.AddRange(todoItems); + + await dbContext.SaveChangesAsync(); + }); + + var route = "/api/v1/todoItems?include=owner"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(2); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Type.Should().Be("people"); + responseDocument.Included[0].Id.Should().Be(person.StringId); + responseDocument.Included[0].Attributes["firstName"].Should().Be(person.FirstName); + } + + [Fact] + public async Task Cannot_include_unknown_relationship() + { + // Arrange + var route = "/api/v1/people?include=doesNotExist"; + + // 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("The specified include is invalid."); + responseDocument.Errors[0].Detail.Should().Be("Relationship 'doesNotExist' does not exist on resource 'people'."); + responseDocument.Errors[0].Source.Parameter.Should().Be("include"); + } + + [Fact] + public async Task Cannot_include_unknown_nested_relationship() + { + // Arrange + var route = "/api/v1/people?include=todoItems.doesNotExist"; + + // 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("The specified include is invalid."); + responseDocument.Errors[0].Detail.Should().Be("Relationship 'doesNotExist' in 'todoItems.doesNotExist' does not exist on resource 'todoItems'."); + responseDocument.Errors[0].Source.Parameter.Should().Be("include"); + } + + [Fact] + public async Task Cannot_include_blocked_relationship() + { + // Arrange + var route = "/api/v1/people?include=unIncludeableItem"; + + // 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("Including the requested relationship is not allowed."); + responseDocument.Errors[0].Detail.Should().Be("Including the relationship 'unIncludeableItem' on 'people' is not allowed."); + responseDocument.Errors[0].Source.Parameter.Should().Be("include"); + } + + [Fact] + public async Task Ignores_null_parent_in_nested_include() + { + // Arrange + var todoItems = new List + { + new TodoItem + { + Description = "Owned", + Owner = new Person + { + FirstName = "Julian" + } + }, + new TodoItem + { + Description = "Unowned" + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.TodoItems.AddRange(todoItems); + + await dbContext.SaveChangesAsync(); + }); + + var route = "/api/v1/todoItems?include=owner.role"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(2); + + var resourcesWithOwner = responseDocument.ManyData.Where(resource => resource.Relationships.First(pair => pair.Key == "owner").Value.SingleData != null).ToArray(); + resourcesWithOwner.Should().HaveCount(1); + resourcesWithOwner[0].Attributes["description"].Should().Be(todoItems[0].Description); + + var resourcesWithoutOwner = responseDocument.ManyData.Where(resource => resource.Relationships.First(pair => pair.Key == "owner").Value.SingleData == null).ToArray(); + resourcesWithoutOwner.Should().HaveCount(1); + resourcesWithoutOwner[0].Attributes["description"].Should().Be(todoItems[1].Description); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Type.Should().Be("people"); + responseDocument.Included[0].Id.Should().Be(todoItems[0].Owner.StringId); + responseDocument.Included[0].Attributes["firstName"].Should().Be(todoItems[0].Owner.FirstName); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationRangeTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationRangeTests.cs new file mode 100644 index 0000000000..7ac1706401 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationRangeTests.cs @@ -0,0 +1,155 @@ +using System.Net; +using System.Threading.Tasks; +using Bogus; +using FluentAssertions; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCoreExample; +using JsonApiDotNetCoreExample.Data; +using JsonApiDotNetCoreExample.Models; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Pagination +{ + public sealed class PaginationRangeTests : IClassFixture> + { + private readonly IntegrationTestContext _testContext; + private readonly Faker _todoItemFaker = new Faker(); + + private const int _defaultPageSize = 5; + + public PaginationRangeTests(IntegrationTestContext testContext) + { + _testContext = testContext; + + var options = (JsonApiOptions) testContext.Factory.Services.GetRequiredService(); + options.DefaultPageSize = new PageSize(_defaultPageSize); + options.MaximumPageSize = null; + options.MaximumPageNumber = null; + } + + [Fact] + public async Task When_page_number_is_negative_it_must_fail() + { + // Arrange + var route = "/api/v1/todoItems?page[number]=-1"; + + // 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("The specified paging is invalid."); + responseDocument.Errors[0].Detail.Should().Be("Page number cannot be negative or zero."); + responseDocument.Errors[0].Source.Parameter.Should().Be("page[number]"); + } + + [Fact] + public async Task When_page_number_is_zero_it_must_fail() + { + // Arrange + var route = "/api/v1/todoItems?page[number]=0"; + + // 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("The specified paging is invalid."); + responseDocument.Errors[0].Detail.Should().Be("Page number cannot be negative or zero."); + responseDocument.Errors[0].Source.Parameter.Should().Be("page[number]"); + } + + [Fact] + public async Task When_page_number_is_positive_it_must_succeed() + { + // Arrange + var route = "/api/v1/todoItems?page[number]=20"; + + // Act + var (httpResponse, _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + } + + [Fact] + public async Task When_page_number_is_too_high_it_must_return_empty_set_of_resources() + { + // Arrange + var todoItems = _todoItemFaker.Generate(3); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.TodoItems.AddRange(todoItems); + + await dbContext.SaveChangesAsync(); + }); + + var route = "/api/v1/todoItems?sort=id&page[size]=3&page[number]=2"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().BeEmpty(); + } + + [Fact] + public async Task When_page_size_is_negative_it_must_fail() + { + // Arrange + var route = "/api/v1/todoItems?page[size]=-1"; + + // 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("The specified paging is invalid."); + responseDocument.Errors[0].Detail.Should().Be("Page size cannot be negative."); + responseDocument.Errors[0].Source.Parameter.Should().Be("page[size]"); + } + + [Fact] + public async Task When_page_size_is_zero_it_must_succeed() + { + // Arrange + var route = "/api/v1/todoItems?page[size]=0"; + + // Act + var (httpResponse, _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + } + + [Fact] + public async Task When_page_size_is_positive_it_must_succeed() + { + // Arrange + var route = "/api/v1/todoItems?page[size]=50"; + + // Act + var (httpResponse, _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationRangeWithMaximumTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationRangeWithMaximumTests.cs new file mode 100644 index 0000000000..3d391c5b89 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationRangeWithMaximumTests.cs @@ -0,0 +1,147 @@ +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCoreExample; +using JsonApiDotNetCoreExample.Data; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Pagination +{ + public sealed class PaginationRangeWithMaximumTests : IClassFixture> + { + private readonly IntegrationTestContext _testContext; + + private const int _maximumPageSize = 15; + private const int _maximumPageNumber = 20; + + public PaginationRangeWithMaximumTests(IntegrationTestContext testContext) + { + _testContext = testContext; + + var options = (JsonApiOptions) testContext.Factory.Services.GetRequiredService(); + options.DefaultPageSize = new PageSize(5); + options.MaximumPageSize = new PageSize(_maximumPageSize); + options.MaximumPageNumber = new PageNumber(_maximumPageNumber); + } + + [Fact] + public async Task When_page_number_is_below_maximum_it_must_succeed() + { + // Arrange + const int pageNumber = _maximumPageNumber - 1; + var route = "/api/v1/todoItems?page[number]=" + pageNumber; + + // Act + var (httpResponse, _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + } + + [Fact] + public async Task When_page_number_equals_maximum_it_must_succeed() + { + // Arrange + const int pageNumber = _maximumPageNumber; + var route = "/api/v1/todoItems?page[number]=" + pageNumber; + + // Act + var (httpResponse, _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + } + + [Fact] + public async Task When_page_number_is_over_maximum_it_must_fail() + { + // Arrange + const int pageNumber = _maximumPageNumber + 1; + var route = "/api/v1/todoItems?page[number]=" + pageNumber; + + // 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("The specified paging is invalid."); + responseDocument.Errors[0].Detail.Should().Be($"Page number cannot be higher than {_maximumPageNumber}."); + responseDocument.Errors[0].Source.Parameter.Should().Be("page[number]"); + } + + [Fact] + public async Task When_page_size_equals_zero_it_must_fail() + { + // Arrange + var route = "/api/v1/todoItems?page[size]=0"; + + // 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("The specified paging is invalid."); + responseDocument.Errors[0].Detail.Should().Be("Page size cannot be unconstrained."); + responseDocument.Errors[0].Source.Parameter.Should().Be("page[size]"); + } + + [Fact] + public async Task When_page_size_is_below_maximum_it_must_succeed() + { + // Arrange + const int pageSize = _maximumPageSize - 1; + var route = "/api/v1/todoItems?page[size]=" + pageSize; + + // Act + var (httpResponse, _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + } + + [Fact] + public async Task When_page_size_equals_maximum_it_must_succeed() + { + // Arrange + const int pageSize = _maximumPageSize; + var route = "/api/v1/todoItems?page[size]=" + pageSize; + + // Act + var (httpResponse, _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + } + + [Fact] + public async Task When_page_size_is_over_maximum_it_must_fail() + { + // Arrange + const int pageSize = _maximumPageSize + 1; + var route = "/api/v1/todoItems?page[size]=" + pageSize; + + // 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("The specified paging is invalid."); + responseDocument.Errors[0].Detail.Should().Be($"Page size cannot be higher than {_maximumPageSize}."); + responseDocument.Errors[0].Source.Parameter.Should().Be("page[size]"); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationTests.cs new file mode 100644 index 0000000000..988be680d0 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationTests.cs @@ -0,0 +1,505 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using FluentAssertions.Extensions; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCoreExample; +using JsonApiDotNetCoreExample.Data; +using JsonApiDotNetCoreExample.Models; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Pagination +{ + public sealed class PaginationTests : IClassFixture> + { + private readonly IntegrationTestContext _testContext; + + public PaginationTests(IntegrationTestContext testContext) + { + _testContext = testContext; + + var options = (JsonApiOptions) testContext.Factory.Services.GetRequiredService(); + options.DefaultPageSize = new PageSize(5); + options.MaximumPageSize = null; + options.MaximumPageNumber = null; + + options.DisableTopPagination = false; + options.DisableChildrenPagination = false; + } + + [Fact] + public async Task Can_paginate_in_primary_resources() + { + // Arrange + var articles = new List
+ { + new Article + { + Caption = "One" + }, + new Article + { + Caption = "Two" + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync
(); + dbContext.Articles.AddRange(articles); + + await dbContext.SaveChangesAsync(); + }); + + var route = "/api/v1/articles?page[number]=2&page[size]=1"; + + // 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(articles[1].StringId); + } + + [Fact] + public async Task Cannot_paginate_in_single_primary_resource() + { + // Arrange + var article = new Article + { + Caption = "X" + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Articles.Add(article); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/api/v1/articles/{article.StringId}?page[number]=2"; + + // 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("The specified paging is invalid."); + responseDocument.Errors[0].Detail.Should().Be("This query string parameter can only be used on a collection of resources (not on a single resource)."); + responseDocument.Errors[0].Source.Parameter.Should().Be("page[number]"); + } + + [Fact] + public async Task Can_paginate_in_secondary_resources() + { + // Arrange + var blog = new Blog + { + Articles = new List
+ { + new Article + { + Caption = "One" + }, + new Article + { + Caption = "Two" + } + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Blogs.Add(blog); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/api/v1/blogs/{blog.StringId}/articles?page[number]=2&page[size]=1"; + + // 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(blog.Articles[1].StringId); + } + + [Fact] + public async Task Cannot_paginate_in_single_secondary_resource() + { + // Arrange + var article = new Article + { + Caption = "X" + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Articles.Add(article); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/api/v1/articles/{article.StringId}/author?page[size]=5"; + + // 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("The specified paging is invalid."); + responseDocument.Errors[0].Detail.Should().Be("This query string parameter can only be used on a collection of resources (not on a single resource)."); + responseDocument.Errors[0].Source.Parameter.Should().Be("page[size]"); + } + + [Fact] + public async Task Can_paginate_in_scope_of_HasMany_relationship() + { + // Arrange + var blogs = new List + { + new Blog + { + Articles = new List
+ { + new Article + { + Caption = "One" + }, + new Article + { + Caption = "Two" + } + } + }, + new Blog + { + Articles = new List
+ { + new Article + { + Caption = "First" + }, + new Article + { + Caption = "Second" + } + } + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Blogs.AddRange(blogs); + + await dbContext.SaveChangesAsync(); + }); + + var route = "/api/v1/blogs?include=articles&page[number]=articles:2&page[size]=articles:1"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(2); + responseDocument.Included.Should().HaveCount(2); + + responseDocument.Included[0].Id.Should().Be(blogs[0].Articles[1].StringId); + responseDocument.Included[1].Id.Should().Be(blogs[1].Articles[1].StringId); + } + + [Fact] + public async Task Can_paginate_in_scope_of_HasMany_relationship_on_secondary_resource() + { + // Arrange + var blog = new Blog + { + Owner = new Author + { + LastName = "Smith", + Articles = new List
+ { + new Article + { + Caption = "One" + }, + new Article + { + Caption = "Two" + } + } + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Blogs.Add(blog); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/api/v1/blogs/{blog.StringId}/owner?include=articles&page[number]=articles:2&page[size]=articles:1"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Id.Should().Be(blog.Owner.Articles[1].StringId); + } + + [Fact] + public async Task Can_paginate_in_scope_of_HasManyThrough_relationship() + { + // Arrange + var articles = new List
+ { + new Article + { + Caption = "X", + ArticleTags = new HashSet + { + new ArticleTag + { + Tag = new Tag + { + Name = "Cold" + } + }, + new ArticleTag + { + Tag = new Tag + { + Name = "Hot" + } + } + } + }, + new Article + { + Caption = "X", + ArticleTags = new HashSet + { + new ArticleTag + { + Tag = new Tag + { + Name = "Wet" + } + }, + new ArticleTag + { + Tag = new Tag + { + Name = "Dry" + } + } + } + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync
(); + dbContext.Articles.AddRange(articles); + + await dbContext.SaveChangesAsync(); + }); + + // Workaround for https://github.com/dotnet/efcore/issues/21026 + var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); + options.DisableTopPagination = true; + options.DisableChildrenPagination = false; + + var route = "/api/v1/articles?include=tags&page[number]=tags:2&page[size]=tags:1"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(2); + responseDocument.Included.Should().HaveCount(2); + + responseDocument.Included[0].Id.Should().Be(articles[0].ArticleTags.Skip(1).First().Tag.StringId); + responseDocument.Included[1].Id.Should().Be(articles[1].ArticleTags.Skip(1).First().Tag.StringId); + } + + [Fact] + public async Task Can_paginate_in_multiple_scopes() + { + // Arrange + var blogs = new List + { + new Blog + { + Title = "Cooking" + }, + new Blog + { + Title = "Technology", + Owner = new Author + { + LastName = "Smith", + Articles = new List
+ { + new Article + { + Caption = "One" + }, + new Article + { + Caption = "Two", + Revisions = new List + { + new Revision + { + PublishTime = 1.January(2000) + }, + new Revision + { + PublishTime = 10.January(2010) + } + } + } + } + } + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Blogs.AddRange(blogs); + + await dbContext.SaveChangesAsync(); + }); + + var route = "/api/v1/blogs?include=owner.articles.revisions&" + + "page[size]=1,owner.articles:1,owner.articles.revisions:1&" + + "page[number]=2,owner.articles:2,owner.articles.revisions:2"; + + // 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(blogs[1].StringId); + + responseDocument.Included.Should().HaveCount(3); + responseDocument.Included[0].Id.Should().Be(blogs[1].Owner.StringId); + responseDocument.Included[1].Id.Should().Be(blogs[1].Owner.Articles[1].StringId); + responseDocument.Included[2].Id.Should().Be(blogs[1].Owner.Articles[1].Revisions.Skip(1).First().StringId); + } + + [Fact] + public async Task Cannot_paginate_in_unknown_scope() + { + // Arrange + var route = "/api/v1/people?page[number]=doesNotExist:1"; + + // 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("The specified paging is invalid."); + responseDocument.Errors[0].Detail.Should().Be("Relationship 'doesNotExist' does not exist on resource 'people'."); + responseDocument.Errors[0].Source.Parameter.Should().Be("page[number]"); + } + + [Fact] + public async Task Cannot_paginate_in_unknown_nested_scope() + { + // Arrange + var route = "/api/v1/people?page[size]=todoItems.doesNotExist:1"; + + // 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("The specified paging is invalid."); + responseDocument.Errors[0].Detail.Should().Be("Relationship 'doesNotExist' in 'todoItems.doesNotExist' does not exist on resource 'todoItems'."); + responseDocument.Errors[0].Source.Parameter.Should().Be("page[size]"); + } + + [Fact] + public async Task Uses_default_page_number_and_size() + { + // Arrange + var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); + options.DefaultPageSize = new PageSize(2); + + var blog = new Blog + { + Articles = new List
+ { + new Article + { + Caption = "One" + }, + new Article + { + Caption = "Two" + }, + new Article + { + Caption = "Three" + } + } + }; + + 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(2); + responseDocument.ManyData[0].Id.Should().Be(blog.Articles[0].StringId); + responseDocument.ManyData[1].Id.Should().Be(blog.Articles[1].StringId); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/QueryStringTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/QueryStringTests.cs new file mode 100644 index 0000000000..d523435fcc --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/QueryStringTests.cs @@ -0,0 +1,90 @@ +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCoreExample; +using JsonApiDotNetCoreExample.Data; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.QueryStrings +{ + public sealed class QueryStringTests : IClassFixture> + { + private readonly IntegrationTestContext _testContext; + + public QueryStringTests(IntegrationTestContext testContext) + { + _testContext = testContext; + } + + [Fact] + public async Task Cannot_use_unknown_query_string_parameter() + { + // Arrange + var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); + options.AllowUnknownQueryStringParameters = false; + + var route = "/api/v1/articles?foo=bar"; + + // 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("Unknown query string parameter."); + responseDocument.Errors[0].Detail.Should().Be("Query string parameter 'foo' is unknown. Set 'AllowUnknownQueryStringParameters' to 'true' in options to ignore unknown parameters."); + responseDocument.Errors[0].Source.Parameter.Should().Be("foo"); + } + + [Fact] + public async Task Can_use_unknown_query_string_parameter() + { + // Arrange + var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); + options.AllowUnknownQueryStringParameters = true; + + var route = "/api/v1/articles?foo=bar"; + + // Act + var (httpResponse, _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + } + + [Theory] + [InlineData("include")] + [InlineData("filter")] + [InlineData("sort")] + [InlineData("page")] + [InlineData("fields")] + [InlineData("defaults")] + [InlineData("nulls")] + public async Task Cannot_use_empty_query_string_parameter_value(string parameterName) + { + // Arrange + var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); + options.AllowUnknownQueryStringParameters = false; + + var route = "/api/v1/articles?" + parameterName + "="; + + // 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("Missing query string parameter value."); + responseDocument.Errors[0].Detail.Should().Be($"Missing value for '{parameterName}' query string parameter."); + responseDocument.Errors[0].Source.Parameter.Should().Be(parameterName); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableDbContext.cs new file mode 100644 index 0000000000..395d356313 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableDbContext.cs @@ -0,0 +1,13 @@ +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions +{ + public sealed class CallableDbContext : DbContext + { + public DbSet CallableResources { get; set; } + + public CallableDbContext(DbContextOptions options) : base(options) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableResource.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableResource.cs new file mode 100644 index 0000000000..d80f501a83 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableResource.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions +{ + public sealed class CallableResource : Identifiable + { + [Attr] + public string Label { get; set; } + + [Attr] + public int PercentageComplete { get; set; } + + [Attr] + public int RiskLevel { get; set; } + + [Attr(AttrCapabilities.AllowView | AttrCapabilities.AllowSort)] + public DateTime CreatedAt { get; set; } + + [Attr(AttrCapabilities.AllowView | AttrCapabilities.AllowSort)] + public DateTime ModifiedAt { get; set; } + + [Attr(AttrCapabilities.None)] + public bool IsDeleted { get; set; } + + [HasMany] + public ICollection Children { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableResourceDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableResourceDefinition.cs new file mode 100644 index 0000000000..e0355a4010 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableResourceDefinition.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Linq.Expressions; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Internal.Contracts; +using JsonApiDotNetCore.Internal.Queries.Expressions; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; +using Microsoft.Extensions.Primitives; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions +{ + public sealed class CallableResourceDefinition : ResourceDefinition + { + private static readonly PageSize _maxPageSize = new PageSize(5); + + public CallableResourceDefinition(IResourceGraph resourceGraph) : base(resourceGraph) + { + // This constructor will be resolved from the container, which means + // you can take on any dependency that is also defined in the container. + } + + public override FilterExpression OnApplyFilter(FilterExpression existingFilter) + { + // Use case: automatically exclude deleted resources for all requests. + + var resourceContext = ResourceGraph.GetResourceContext(); + var isDeletedAttribute = resourceContext.Attributes.Single(a => a.Property.Name == nameof(CallableResource.IsDeleted)); + + var isNotDeleted = new ComparisonExpression(ComparisonOperator.Equals, + new ResourceFieldChainExpression(isDeletedAttribute), new LiteralConstantExpression(bool.FalseString)); + + return existingFilter == null + ? (FilterExpression) isNotDeleted + : new LogicalExpression(LogicalOperator.And, new[] {isNotDeleted, existingFilter}); + } + + public override SortExpression OnApplySort(SortExpression existingSort) + { + // Use case: set a default sort order when none was specified in query string. + + if (existingSort != null) + { + return existingSort; + } + + return CreateSortExpressionFromLambda(new PropertySortOrder + { + (resource => resource.Label, ListSortDirection.Ascending), + (resource => resource.ModifiedAt, ListSortDirection.Descending) + }); + } + + public override PaginationExpression OnApplyPagination(PaginationExpression existingPagination) + { + // Use case: enforce a page size of 5 or less for this resource type. + + if (existingPagination != null) + { + var pageSize = existingPagination.PageSize?.Value <= _maxPageSize.Value ? existingPagination.PageSize : _maxPageSize; + return new PaginationExpression(existingPagination.PageNumber, pageSize); + } + + return new PaginationExpression(PageNumber.ValueOne, _maxPageSize); + } + + public override SparseFieldSetExpression OnApplySparseFieldSet(SparseFieldSetExpression existingSparseFieldSet) + { + // Use case: always include percentageComplete and never include riskLevel in responses. + + return existingSparseFieldSet + .Including(resource => resource.PercentageComplete, ResourceGraph) + .Excluding(resource => resource.RiskLevel, ResourceGraph); + } + + protected override QueryStringParameterHandlers OnRegisterQueryableHandlersForQueryStringParameters() + { + // Use case: 'isHighRisk' query string parameter can be used to add extra filter on IQueryable. + + return new QueryStringParameterHandlers + { + ["isHighRisk"] = FilterByHighRisk + }; + } + + private static IQueryable FilterByHighRisk(IQueryable source, StringValues parameterValue) + { + bool isFilterOnHighRisk = bool.Parse(parameterValue); + return isFilterOnHighRisk ? source.Where(resource => resource.RiskLevel >= 5) : source.Where(resource => resource.RiskLevel < 5); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableResourcesController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableResourcesController.cs new file mode 100644 index 0000000000..128bf81d06 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableResourcesController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions +{ + public sealed class CallableResourcesController : JsonApiController + { + public CallableResourcesController(IJsonApiOptions jsonApiOptions, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(jsonApiOptions, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/ResourceDefinitionQueryCallbackTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/ResourceDefinitionQueryCallbackTests.cs new file mode 100644 index 0000000000..5103263f24 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/ResourceDefinitionQueryCallbackTests.cs @@ -0,0 +1,513 @@ +using System.Collections.Generic; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using FluentAssertions.Extensions; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.JsonApiDocuments; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions +{ + public sealed class ResourceDefinitionQueryCallbackTests : IClassFixture, CallableDbContext>> + { + private readonly IntegrationTestContext, CallableDbContext> _testContext; + + public ResourceDefinitionQueryCallbackTests(IntegrationTestContext, CallableDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => + { + services.AddScoped, CallableResourceDefinition>(); + }); + } + + [Fact] + public async Task Filter_from_resource_definition_is_applied() + { + // Arrange + var resources = new List + { + new CallableResource + { + Label = "A", + IsDeleted = true + }, + new CallableResource + { + Label = "A", + IsDeleted = false + }, + new CallableResource + { + Label = "B", + IsDeleted = true + }, + new CallableResource + { + Label = "B", + IsDeleted = false + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.RemoveRange(dbContext.CallableResources); + dbContext.CallableResources.AddRange(resources); + + await dbContext.SaveChangesAsync(); + }); + + var route = "/callableResources"; + + // 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(resources[1].StringId); + responseDocument.ManyData[1].Id.Should().Be(resources[3].StringId); + } + + [Fact] + public async Task Filter_from_resource_definition_and_query_string_are_applied() + { + // Arrange + var resources = new List + { + new CallableResource + { + Label = "A", + IsDeleted = true + }, + new CallableResource + { + Label = "A", + IsDeleted = false + }, + new CallableResource + { + Label = "B", + IsDeleted = true + }, + new CallableResource + { + Label = "B", + IsDeleted = false + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.RemoveRange(dbContext.CallableResources); + dbContext.CallableResources.AddRange(resources); + + await dbContext.SaveChangesAsync(); + }); + + var route = "/callableResources?filter=equals(label,'B')"; + + // 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(resources[3].StringId); + } + + [Fact] + public async Task Sort_from_resource_definition_is_applied() + { + // Arrange + var resources = new List + { + new CallableResource + { + Label = "A", + CreatedAt = 1.January(2001), + ModifiedAt = 15.January(2001) + }, + new CallableResource + { + Label = "A", + CreatedAt = 1.January(2001), + ModifiedAt = 15.December(2001) + }, + new CallableResource + { + Label = "B", + CreatedAt = 1.February(2001), + ModifiedAt = 15.January(2001) + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.RemoveRange(dbContext.CallableResources); + dbContext.CallableResources.AddRange(resources); + + await dbContext.SaveChangesAsync(); + }); + + var route = "/callableResources"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(3); + responseDocument.ManyData[0].Id.Should().Be(resources[1].StringId); + responseDocument.ManyData[1].Id.Should().Be(resources[0].StringId); + responseDocument.ManyData[2].Id.Should().Be(resources[2].StringId); + } + + [Fact] + public async Task Sort_from_query_string_is_applied() + { + // Arrange + var resources = new List + { + new CallableResource + { + Label = "A", + CreatedAt = 1.January(2001), + ModifiedAt = 15.January(2001) + }, + new CallableResource + { + Label = "A", + CreatedAt = 1.January(2001), + ModifiedAt = 15.December(2001) + }, + new CallableResource + { + Label = "B", + CreatedAt = 1.February(2001), + ModifiedAt = 15.January(2001) + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.RemoveRange(dbContext.CallableResources); + dbContext.CallableResources.AddRange(resources); + + await dbContext.SaveChangesAsync(); + }); + + var route = "/callableResources?sort=-createdAt,modifiedAt"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(3); + responseDocument.ManyData[0].Id.Should().Be(resources[2].StringId); + responseDocument.ManyData[1].Id.Should().Be(resources[0].StringId); + responseDocument.ManyData[2].Id.Should().Be(resources[1].StringId); + } + + [Fact] + public async Task Page_size_from_resource_definition_is_applied() + { + // Arrange + var resources = new List(); + + for (int index = 0; index < 10; index++) + { + resources.Add(new CallableResource()); + } + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.RemoveRange(dbContext.CallableResources); + dbContext.CallableResources.AddRange(resources); + + await dbContext.SaveChangesAsync(); + }); + + var route = "/callableResources?page[size]=8"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(5); + } + + [Fact] + public async Task Attribute_inclusion_from_resource_definition_is_applied_for_empty_query_string() + { + // Arrange + var resource = new CallableResource + { + Label = "X", + PercentageComplete = 5 + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.CallableResources.Add(resource); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/callableResources/{resource.StringId}"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Id.Should().Be(resource.StringId); + responseDocument.SingleData.Attributes["label"].Should().Be(resource.Label); + responseDocument.SingleData.Attributes["percentageComplete"].Should().Be(resource.PercentageComplete); + } + + [Fact] + public async Task Attribute_inclusion_from_resource_definition_is_applied_for_non_empty_query_string() + { + // Arrange + var resource = new CallableResource + { + Label = "X", + PercentageComplete = 5 + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.CallableResources.Add(resource); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/callableResources/{resource.StringId}?fields=label"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Id.Should().Be(resource.StringId); + responseDocument.SingleData.Attributes["label"].Should().Be(resource.Label); + responseDocument.SingleData.Attributes["percentageComplete"].Should().Be(resource.PercentageComplete); + } + + [Fact] + public async Task Attribute_exclusion_from_resource_definition_is_applied_for_empty_query_string() + { + // Arrange + var resource = new CallableResource + { + Label = "X", + RiskLevel = 3 + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.CallableResources.Add(resource); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/callableResources/{resource.StringId}"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Id.Should().Be(resource.StringId); + responseDocument.SingleData.Attributes["label"].Should().Be(resource.Label); + responseDocument.SingleData.Attributes.Should().NotContainKey("riskLevel"); + } + + [Fact] + public async Task Attribute_exclusion_from_resource_definition_is_applied_for_non_empty_query_string() + { + // Arrange + var resource = new CallableResource + { + Label = "X", + RiskLevel = 3 + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.CallableResources.Add(resource); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/callableResources/{resource.StringId}?fields=label,riskLevel"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Id.Should().Be(resource.StringId); + responseDocument.SingleData.Attributes["label"].Should().Be(resource.Label); + responseDocument.SingleData.Attributes.Should().NotContainKey("riskLevel"); + } + + [Fact] + public async Task Queryable_parameter_handler_from_resource_definition_is_applied() + { + // Arrange + var resources = new List + { + new CallableResource + { + Label = "A", + RiskLevel = 3 + }, + new CallableResource + { + Label = "A", + RiskLevel = 8 + }, + new CallableResource + { + Label = "B", + RiskLevel = 3 + }, + new CallableResource + { + Label = "B", + RiskLevel = 8 + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.RemoveRange(dbContext.CallableResources); + dbContext.CallableResources.AddRange(resources); + + await dbContext.SaveChangesAsync(); + }); + + var route = "/callableResources?isHighRisk=true"; + + // 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(resources[1].StringId); + responseDocument.ManyData[1].Id.Should().Be(resources[3].StringId); + } + + [Fact] + public async Task Queryable_parameter_handler_from_resource_definition_and_query_string_filter_are_applied() + { + // Arrange + var resources = new List + { + new CallableResource + { + Label = "A", + RiskLevel = 3 + }, + new CallableResource + { + Label = "A", + RiskLevel = 8 + }, + new CallableResource + { + Label = "B", + RiskLevel = 3 + }, + new CallableResource + { + Label = "B", + RiskLevel = 8 + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.RemoveRange(dbContext.CallableResources); + dbContext.CallableResources.AddRange(resources); + + await dbContext.SaveChangesAsync(); + }); + + var route = "/callableResources?isHighRisk=false&filter=equals(label,'B')"; + + // 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(resources[2].StringId); + } + + [Fact] + public async Task Queryable_parameter_handler_from_resource_definition_is_not_applied_on_secondary_request() + { + // Arrange + var resource = new CallableResource + { + RiskLevel = 3, + Children = new List + { + new CallableResource + { + RiskLevel = 3 + }, + new CallableResource + { + RiskLevel = 8 + } + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.CallableResources.Add(resource); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/callableResources/{resource.StringId}/children?isHighRisk=true"; + + // 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("Custom query string parameters cannot be used on nested resource endpoints."); + responseDocument.Errors[0].Detail.Should().Be("Query string parameter 'isHighRisk' cannot be used on a nested resource endpoint."); + responseDocument.Errors[0].Source.Parameter.Should().Be("isHighRisk"); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Sorting/SortTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Sorting/SortTests.cs new file mode 100644 index 0000000000..52aa6e31ee --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Sorting/SortTests.cs @@ -0,0 +1,753 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using Bogus; +using FluentAssertions; +using FluentAssertions.Extensions; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCoreExample; +using JsonApiDotNetCoreExample.Data; +using JsonApiDotNetCoreExample.Models; +using Xunit; +using Person = JsonApiDotNetCoreExample.Models.Person; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Sorting +{ + public sealed class SortTests : IClassFixture> + { + private readonly IntegrationTestContext _testContext; + private readonly Faker
_articleFaker; + private readonly Faker _authorFaker; + + public SortTests(IntegrationTestContext testContext) + { + _testContext = testContext; + + _articleFaker = new Faker
() + .RuleFor(a => a.Caption, f => f.Random.AlphaNumeric(10)); + + _authorFaker = new Faker() + .RuleFor(a => a.LastName, f => f.Random.Words(2)); + } + + [Fact] + public async Task Can_sort_in_primary_resources() + { + // Arrange + var articles = new List
+ { + new Article {Caption = "B"}, + new Article {Caption = "A"}, + new Article {Caption = "C"} + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync
(); + dbContext.Articles.AddRange(articles); + + await dbContext.SaveChangesAsync(); + }); + + var route = "/api/v1/articles?sort=caption"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(3); + responseDocument.ManyData[0].Id.Should().Be(articles[1].StringId); + responseDocument.ManyData[1].Id.Should().Be(articles[0].StringId); + responseDocument.ManyData[2].Id.Should().Be(articles[2].StringId); + } + + [Fact] + public async Task Cannot_sort_in_single_primary_resource() + { + // Arrange + var article = new Article + { + Caption = "X" + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Articles.Add(article); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/api/v1/articles/{article.StringId}?sort=id"; + + // 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("The specified sort is invalid."); + responseDocument.Errors[0].Detail.Should().Be("This query string parameter can only be used on a collection of resources (not on a single resource)."); + responseDocument.Errors[0].Source.Parameter.Should().Be("sort"); + } + + [Fact] + public async Task Can_sort_in_secondary_resources() + { + // Arrange + var blog = new Blog + { + Articles = new List
+ { + new Article {Caption = "B"}, + new Article {Caption = "A"}, + new Article {Caption = "C"} + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Blogs.Add(blog); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/api/v1/blogs/{blog.StringId}/articles?sort=caption"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(3); + responseDocument.ManyData[0].Id.Should().Be(blog.Articles[1].StringId); + responseDocument.ManyData[1].Id.Should().Be(blog.Articles[0].StringId); + responseDocument.ManyData[2].Id.Should().Be(blog.Articles[2].StringId); + } + + [Fact] + public async Task Cannot_sort_in_single_secondary_resource() + { + // Arrange + var article = new Article + { + Caption = "X" + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Articles.Add(article); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/api/v1/articles/{article.StringId}/author?sort=id"; + + // 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("The specified sort is invalid."); + responseDocument.Errors[0].Detail.Should().Be("This query string parameter can only be used on a collection of resources (not on a single resource)."); + responseDocument.Errors[0].Source.Parameter.Should().Be("sort"); + } + + [Fact] + public async Task Can_sort_on_HasMany_relationship() + { + // Arrange + var blogs = new List + { + new Blog + { + Articles = new List
+ { + new Article + { + Caption = "A" + }, + new Article + { + Caption = "B" + } + } + }, + new Blog + { + Articles = new List
+ { + new Article + { + Caption = "C" + } + } + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Blogs.AddRange(blogs); + + await dbContext.SaveChangesAsync(); + }); + + var route = "/api/v1/blogs?sort=count(articles)"; + + // 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(blogs[1].StringId); + responseDocument.ManyData[1].Id.Should().Be(blogs[0].StringId); + } + + [Fact] + public async Task Can_sort_on_HasManyThrough_relationship() + { + // Arrange + var articles = new List
+ { + new Article + { + Caption = "First", + ArticleTags = new HashSet + { + new ArticleTag + { + Tag = new Tag + { + Name = "A" + } + } + } + }, + new Article + { + Caption = "Second", + ArticleTags = new HashSet + { + new ArticleTag + { + Tag = new Tag + { + Name = "B" + } + }, + new ArticleTag + { + Tag = new Tag + { + Name = "C" + } + } + } + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync
(); + dbContext.Articles.AddRange(articles); + + await dbContext.SaveChangesAsync(); + }); + + var route = "/api/v1/articles?sort=-count(tags)"; + + // 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(articles[1].StringId); + responseDocument.ManyData[1].Id.Should().Be(articles[0].StringId); + } + + [Fact] + public async Task Can_sort_in_scope_of_HasMany_relationship() + { + // Arrange + var author = _authorFaker.Generate(); + author.Articles = new List
+ { + new Article {Caption = "B"}, + new Article {Caption = "A"}, + new Article {Caption = "C"} + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AuthorDifferentDbContextName.Add(author); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/api/v1/authors/{author.StringId}?include=articles&sort[articles]=caption"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Id.Should().Be(author.StringId); + + responseDocument.Included.Should().HaveCount(3); + responseDocument.Included[0].Id.Should().Be(author.Articles[1].StringId); + responseDocument.Included[1].Id.Should().Be(author.Articles[0].StringId); + responseDocument.Included[2].Id.Should().Be(author.Articles[2].StringId); + } + + [Fact] + public async Task Can_sort_in_scope_of_HasMany_relationship_on_secondary_resource() + { + // Arrange + var blog = new Blog + { + Owner = new Author + { + LastName = "Smith", + Articles = new List
+ { + new Article {Caption = "B"}, + new Article {Caption = "A"}, + new Article {Caption = "C"} + } + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Blogs.Add(blog); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/api/v1/blogs/{blog.StringId}/owner?include=articles&sort[articles]=caption"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Id.Should().Be(blog.Owner.StringId); + + responseDocument.Included.Should().HaveCount(3); + responseDocument.Included[0].Id.Should().Be(blog.Owner.Articles[1].StringId); + responseDocument.Included[1].Id.Should().Be(blog.Owner.Articles[0].StringId); + responseDocument.Included[2].Id.Should().Be(blog.Owner.Articles[2].StringId); + } + + [Fact] + public async Task Can_sort_in_scope_of_HasManyThrough_relationship() + { + // Arrange + var article = _articleFaker.Generate(); + article.ArticleTags = new HashSet + { + new ArticleTag + { + Tag = new Tag + { + Name = "B" + } + }, + new ArticleTag + { + Tag = new Tag + { + Name = "A" + } + }, + new ArticleTag + { + Tag = new Tag + { + Name = "C" + } + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Articles.Add(article); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/api/v1/articles/{article.StringId}?include=tags&sort[tags]=name"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Id.Should().Be(article.StringId); + + responseDocument.Included.Should().HaveCount(3); + responseDocument.Included[0].Id.Should().Be(article.ArticleTags.Skip(1).First().Tag.StringId); + responseDocument.Included[1].Id.Should().Be(article.ArticleTags.Skip(0).First().Tag.StringId); + responseDocument.Included[2].Id.Should().Be(article.ArticleTags.Skip(2).First().Tag.StringId); + } + + [Fact] + public async Task Can_sort_on_multiple_fields_in_multiple_scopes() + { + // Arrange + var blogs = new List + { + new Blog + { + Title = "Z", + Articles = new List
+ { + new Article + { + Caption = "B", + Revisions = new List + { + new Revision + { + PublishTime = 1.January(2015) + }, + new Revision + { + PublishTime = 1.January(2014) + }, + new Revision + { + PublishTime = 1.January(2016) + } + } + }, + new Article + { + Caption = "A", + Url = "www.some2.com" + }, + new Article + { + Caption = "A", + Url = "www.some1.com" + }, + new Article + { + Caption = "C" + } + } + }, + new Blog + { + Title = "Y" + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Blogs.AddRange(blogs); + + await dbContext.SaveChangesAsync(); + }); + + var route = "/api/v1/blogs?include=articles.revisions&sort=title&sort[articles]=caption,url&sort[articles.revisions]=-publishTime"; + + // 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(blogs[1].StringId); + responseDocument.ManyData[1].Id.Should().Be(blogs[0].StringId); + + responseDocument.Included.Should().HaveCount(7); + + responseDocument.Included[0].Type.Should().Be("articles"); + responseDocument.Included[0].Id.Should().Be(blogs[0].Articles[2].StringId); + + responseDocument.Included[1].Type.Should().Be("articles"); + responseDocument.Included[1].Id.Should().Be(blogs[0].Articles[1].StringId); + + responseDocument.Included[2].Type.Should().Be("articles"); + responseDocument.Included[2].Id.Should().Be(blogs[0].Articles[0].StringId); + + responseDocument.Included[3].Type.Should().Be("revisions"); + responseDocument.Included[3].Id.Should().Be(blogs[0].Articles[0].Revisions.Skip(2).First().StringId); + + responseDocument.Included[4].Type.Should().Be("revisions"); + responseDocument.Included[4].Id.Should().Be(blogs[0].Articles[0].Revisions.Skip(0).First().StringId); + + responseDocument.Included[5].Type.Should().Be("revisions"); + responseDocument.Included[5].Id.Should().Be(blogs[0].Articles[0].Revisions.Skip(1).First().StringId); + + responseDocument.Included[6].Type.Should().Be("articles"); + responseDocument.Included[6].Id.Should().Be(blogs[0].Articles[3].StringId); + } + + [Fact] + public async Task Can_sort_on_HasOne_relationship() + { + // Arrange + var articles = new List
+ { + new Article + { + Caption = "X", + Author = new Author + { + LastName = "Conner" + } + }, + new Article + { + Caption = "X", + Author = new Author + { + LastName = "Smith" + } + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync
(); + dbContext.Articles.AddRange(articles); + + await dbContext.SaveChangesAsync(); + }); + + var route = "/api/v1/articles?sort=-author.lastName"; + + // 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(articles[1].StringId); + responseDocument.ManyData[1].Id.Should().Be(articles[0].StringId); + } + + [Fact] + public async Task Can_sort_in_multiple_scopes() + { + // Arrange + var blogs = new List + { + new Blog + { + Title = "Cooking" + }, + new Blog + { + Title = "Technology", + Owner = new Author + { + LastName = "Smith", + Articles = new List
+ { + new Article + { + Caption = "One" + }, + new Article + { + Caption = "Two", + Revisions = new List + { + new Revision + { + PublishTime = 1.January(2000) + }, + new Revision + { + PublishTime = 10.January(2010) + } + } + } + } + } + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Blogs.AddRange(blogs); + + await dbContext.SaveChangesAsync(); + }); + + var route = "/api/v1/blogs?include=owner.articles.revisions&" + + "sort=-title&" + + "sort[owner.articles]=-caption&" + + "sort[owner.articles.revisions]=-publishTime"; + + // 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(blogs[1].StringId); + responseDocument.ManyData[1].Id.Should().Be(blogs[0].StringId); + + responseDocument.Included.Should().HaveCount(5); + responseDocument.Included[0].Id.Should().Be(blogs[1].Owner.StringId); + responseDocument.Included[1].Id.Should().Be(blogs[1].Owner.Articles[1].StringId); + responseDocument.Included[2].Id.Should().Be(blogs[1].Owner.Articles[1].Revisions.Skip(1).First().StringId); + responseDocument.Included[3].Id.Should().Be(blogs[1].Owner.Articles[1].Revisions.Skip(0).First().StringId); + responseDocument.Included[4].Id.Should().Be(blogs[1].Owner.Articles[0].StringId); + } + + [Fact] + public async Task Cannot_sort_in_unknown_scope() + { + // Arrange + var route = "/api/v1/people?sort[doesNotExist]=id"; + + // 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("The specified sort is invalid."); + responseDocument.Errors[0].Detail.Should().Be("Relationship 'doesNotExist' does not exist on resource 'people'."); + responseDocument.Errors[0].Source.Parameter.Should().Be("sort[doesNotExist]"); + } + + [Fact] + public async Task Cannot_sort_in_unknown_nested_scope() + { + // Arrange + var route = "/api/v1/people?sort[todoItems.doesNotExist]=id"; + + // 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("The specified sort is invalid."); + responseDocument.Errors[0].Detail.Should().Be("Relationship 'doesNotExist' in 'todoItems.doesNotExist' does not exist on resource 'todoItems'."); + responseDocument.Errors[0].Source.Parameter.Should().Be("sort[todoItems.doesNotExist]"); + } + + [Fact] + public async Task Cannot_sort_on_blocked_attribute() + { + // Arrange + var route = "/api/v1/todoItems?sort=achievedDate"; + + // 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("Sorting on the requested attribute is not allowed."); + responseDocument.Errors[0].Detail.Should().Be("Sorting on attribute 'achievedDate' is not allowed."); + responseDocument.Errors[0].Source.Parameter.Should().Be("sort"); + } + + [Fact] + public async Task Can_sort_descending_by_ID() + { + // Arrange + var persons = new List + { + new Person {Id = 3, LastName = "B"}, + new Person {Id = 2, LastName = "A"}, + new Person {Id = 1, LastName = "A"} + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.People.AddRange(persons); + + await dbContext.SaveChangesAsync(); + }); + + var route = "/api/v1/people?sort=lastName,-id"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(3); + responseDocument.ManyData[0].Id.Should().Be(persons[1].StringId); + responseDocument.ManyData[1].Id.Should().Be(persons[2].StringId); + responseDocument.ManyData[2].Id.Should().Be(persons[0].StringId); + } + + [Fact] + public async Task Sorts_by_ID_if_none_specified() + { + // Arrange + var persons = new List + { + new Person {Id = 3}, + new Person {Id = 2}, + new Person {Id = 1}, + new Person {Id = 4} + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.People.AddRange(persons); + + await dbContext.SaveChangesAsync(); + }); + + var route = "/api/v1/people"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(4); + responseDocument.ManyData[0].Id.Should().Be(persons[2].StringId); + responseDocument.ManyData[1].Id.Should().Be(persons[1].StringId); + responseDocument.ManyData[2].Id.Should().Be(persons[0].StringId); + responseDocument.ManyData[3].Id.Should().Be(persons[3].StringId); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/ResourceCaptureStore.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/ResourceCaptureStore.cs new file mode 100644 index 0000000000..dd026f6f86 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/ResourceCaptureStore.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Models; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.SparseFieldSets +{ + public sealed class ResourceCaptureStore + { + public List Resources { get; } = new List(); + + public void Add(IEnumerable resources) + { + Resources.AddRange(resources); + } + + public void Clear() + { + Resources.Clear(); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/ResultCapturingRepository.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/ResultCapturingRepository.cs new file mode 100644 index 0000000000..e87c90d4e5 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/ResultCapturingRepository.cs @@ -0,0 +1,46 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using JsonApiDotNetCore.Data; +using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Internal.Contracts; +using JsonApiDotNetCore.Internal.Generics; +using JsonApiDotNetCore.Internal.Queries; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Serialization; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.SparseFieldSets +{ + /// + /// Enables sparse fieldset tests to verify which fields were (not) retrieved from the database. + /// + public sealed class ResultCapturingRepository : EntityFrameworkCoreRepository + where TResource : class, IIdentifiable + { + private readonly ResourceCaptureStore _captureStore; + + 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) + { + _captureStore = captureStore; + } + + public override async Task> GetAsync(QueryLayer layer) + { + var resources = await base.GetAsync(layer); + + _captureStore.Add(resources); + + return resources; + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/SparseFieldSetTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/SparseFieldSetTests.cs new file mode 100644 index 0000000000..aa161ccb29 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/SparseFieldSetTests.cs @@ -0,0 +1,664 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using Bogus; +using FluentAssertions; +using FluentAssertions.Extensions; +using JsonApiDotNetCore.Data; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCore.Services; +using JsonApiDotNetCoreExample; +using JsonApiDotNetCoreExample.Data; +using JsonApiDotNetCoreExample.Models; +using Microsoft.AspNetCore.Authentication; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.SparseFieldSets +{ + public sealed class SparseFieldSetTests : IClassFixture> + { + private readonly IntegrationTestContext _testContext; + private readonly Faker
_articleFaker; + private readonly Faker _authorFaker; + private readonly Faker _userFaker; + + public SparseFieldSetTests(IntegrationTestContext testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => + { + services.AddSingleton(); + + services.AddScoped, ResultCapturingRepository>(); + services.AddScoped, ResultCapturingRepository
>(); + services.AddScoped, ResultCapturingRepository>(); + services.AddScoped, ResultCapturingRepository>(); + + services.AddScoped, JsonApiResourceService
>(); + }); + + _articleFaker = new Faker
() + .RuleFor(a => a.Caption, f => f.Random.AlphaNumeric(10)); + + _authorFaker = new Faker() + .RuleFor(a => a.LastName, f => f.Random.Words(2)); + + var systemClock = testContext.Factory.Services.GetRequiredService(); + var options = testContext.Factory.Services.GetRequiredService>(); + var tempDbContext = new AppDbContext(options, systemClock); + + _userFaker = new Faker() + .CustomInstantiator(f => new User(tempDbContext)); + } + + [Fact] + public async Task Can_select_fields_in_primary_resources() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + var article = new Article + { + Caption = "One", + Url = "https://one.domain.com" + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync
(); + dbContext.Articles.Add(article); + + await dbContext.SaveChangesAsync(); + }); + + var route = "/api/v1/articles?fields=caption"; + + // 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(article.StringId); + 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); + articleCaptured.Url.Should().BeNull(); + } + + [Fact] + public async Task Can_select_fields_in_primary_resource_by_ID() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + var article = new Article + { + Caption = "One", + Url = "https://one.domain.com" + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Articles.Add(article); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/api/v1/articles/{article.StringId}?fields=url"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Id.Should().Be(article.StringId); + 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); + articleCaptured.Caption.Should().BeNull(); + } + + [Fact] + public async Task Can_select_fields_in_secondary_resources() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + var blog = new Blog + { + Title = "Some", + Articles = new List
+ { + new Article + { + Caption = "One", + Url = "https://one.domain.com" + } + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Blogs.Add(blog); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/api/v1/blogs/{blog.StringId}/articles?fields=caption"; + + // 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(blog.Articles[0].StringId); + 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); + blogCaptured.Title.Should().BeNull(); + + blogCaptured.Articles.Should().HaveCount(1); + blogCaptured.Articles[0].Caption.Should().Be(blog.Articles[0].Caption); + blogCaptured.Articles[0].Url.Should().BeNull(); + } + + [Fact] + public async Task Can_select_fields_in_scope_of_HasOne_relationship() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + var article = _articleFaker.Generate(); + article.Caption = "Some"; + article.Author = new Author + { + FirstName = "Joe", + LastName = "Smith", + BusinessEmail = "nospam@email.com" + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Articles.Add(article); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/api/v1/articles/{article.StringId}?include=author&fields[author]=lastName,businessEmail"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Id.Should().Be(article.StringId); + responseDocument.SingleData.Attributes["caption"].Should().Be(article.Caption); + + responseDocument.Included.Should().HaveCount(1); + 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); + articleCaptured.Caption.Should().Be(article.Caption); + + articleCaptured.Author.LastName.Should().Be(article.Author.LastName); + articleCaptured.Author.BusinessEmail.Should().Be(article.Author.BusinessEmail); + articleCaptured.Author.FirstName.Should().BeNull(); + } + + [Fact] + public async Task Can_select_fields_in_scope_of_HasMany_relationship() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + var author = _authorFaker.Generate(); + author.LastName = "Smith"; + author.Articles = new List
+ { + new Article + { + Caption = "One", + Url = "https://one.domain.com" + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AuthorDifferentDbContextName.Add(author); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/api/v1/authors/{author.StringId}?include=articles&fields[articles]=caption"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Id.Should().Be(author.StringId); + responseDocument.SingleData.Attributes["lastName"].Should().Be(author.LastName); + + responseDocument.Included.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); + authorCaptured.LastName.Should().Be(author.LastName); + + authorCaptured.Articles.Should().HaveCount(1); + authorCaptured.Articles[0].Caption.Should().Be(author.Articles[0].Caption); + authorCaptured.Articles[0].Url.Should().BeNull(); + } + + [Fact] + public async Task Can_select_fields_in_scope_of_HasMany_relationship_on_secondary_resource() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + var blog = new Blog + { + Owner = new Author + { + LastName = "Smith", + Articles = new List
+ { + new Article + { + Caption = "One", + Url = "https://one.domain.com" + } + } + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Blogs.Add(blog); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/api/v1/blogs/{blog.StringId}/owner?include=articles&fields[articles]=caption"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Id.Should().Be(blog.Owner.StringId); + responseDocument.SingleData.Attributes["lastName"].Should().Be(blog.Owner.LastName); + + responseDocument.Included.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); + blogCaptured.Owner.Should().NotBeNull(); + blogCaptured.Owner.LastName.Should().Be(blog.Owner.LastName); + + blogCaptured.Owner.Articles.Should().HaveCount(1); + blogCaptured.Owner.Articles[0].Caption.Should().Be(blog.Owner.Articles[0].Caption); + blogCaptured.Owner.Articles[0].Url.Should().BeNull(); + } + + [Fact] + public async Task Can_select_fields_in_scope_of_HasManyThrough_relationship() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + var article = _articleFaker.Generate(); + article.Caption = "Some"; + article.ArticleTags = new HashSet + { + new ArticleTag + { + Tag = new Tag + { + Name = "Hot" + } + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Articles.Add(article); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/api/v1/articles/{article.StringId}?include=tags&fields[tags]=color"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Id.Should().Be(article.StringId); + responseDocument.SingleData.Attributes["caption"].Should().Be(article.Caption); + + responseDocument.Included.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); + articleCaptured.Caption.Should().Be(article.Caption); + + articleCaptured.ArticleTags.Should().HaveCount(1); + articleCaptured.ArticleTags.Single().Tag.Color.Should().Be(article.ArticleTags.Single().Tag.Color); + articleCaptured.ArticleTags.Single().Tag.Name.Should().BeNull(); + } + + [Fact] + public async Task Can_select_fields_in_multiple_scopes() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + var blog = new Blog + { + Title = "Technology", + CompanyName = "Contoso", + Owner = new Author + { + FirstName = "Jason", + LastName = "Smith", + DateOfBirth = 21.November(1999), + Articles = new List
+ { + new Article + { + Caption = "One", + Url = "www.one.com" + } + } + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Blogs.Add(blog); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/api/v1/blogs/{blog.StringId}?include=owner.articles&fields=title&fields[owner]=firstName,lastName&fields[owner.articles]=caption"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Id.Should().Be(blog.StringId); + 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["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["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); + blogCaptured.Title.Should().Be(blog.Title); + blogCaptured.CompanyName.Should().BeNull(); + + blogCaptured.Owner.FirstName.Should().Be(blog.Owner.FirstName); + blogCaptured.Owner.LastName.Should().Be(blog.Owner.LastName); + blogCaptured.Owner.DateOfBirth.Should().BeNull(); + + blogCaptured.Owner.Articles.Should().HaveCount(1); + blogCaptured.Owner.Articles[0].Caption.Should().Be(blog.Owner.Articles[0].Caption); + blogCaptured.Owner.Articles[0].Url.Should().BeNull(); + } + + [Fact] + public async Task Can_select_only_top_level_fields_with_multiple_includes() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + var blog = new Blog + { + Title = "Technology", + CompanyName = "Contoso", + Owner = new Author + { + FirstName = "Jason", + LastName = "Smith", + DateOfBirth = 21.November(1999), + Articles = new List
+ { + new Article + { + Caption = "One", + Url = "www.one.com" + } + } + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Blogs.Add(blog); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/api/v1/blogs/{blog.StringId}?include=owner.articles&fields=title"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Id.Should().Be(blog.StringId); + 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["firstName"].Should().Be(blog.Owner.FirstName); + responseDocument.Included[0].Attributes["lastName"].Should().Be(blog.Owner.LastName); + responseDocument.Included[0].Attributes["dateOfBirth"].Should().Be(blog.Owner.DateOfBirth); + + responseDocument.Included[1].Id.Should().Be(blog.Owner.Articles[0].StringId); + responseDocument.Included[1].Attributes["caption"].Should().Be(blog.Owner.Articles[0].Caption); + responseDocument.Included[1].Attributes["url"].Should().Be(blog.Owner.Articles[0].Url); + + var blogCaptured = (Blog) store.Resources.Should().ContainSingle(x => x is Blog).And.Subject.Single(); + blogCaptured.Id.Should().Be(blog.Id); + blogCaptured.Title.Should().Be(blog.Title); + blogCaptured.CompanyName.Should().BeNull(); + } + + [Fact] + public async Task Can_select_ID() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + var article = new Article + { + Caption = "One", + Url = "https://one.domain.com" + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync
(); + dbContext.Articles.Add(article); + + await dbContext.SaveChangesAsync(); + }); + + var route = "/api/v1/articles?fields=id,caption"; + + // 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(article.StringId); + 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); + articleCaptured.Caption.Should().Be(article.Caption); + articleCaptured.Url.Should().BeNull(); + } + + [Fact] + public async Task Cannot_select_in_unknown_scope() + { + // Arrange + var route = "/api/v1/people?fields[doesNotExist]=id"; + + // 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("The specified fieldset is invalid."); + responseDocument.Errors[0].Detail.Should().Be("Relationship 'doesNotExist' does not exist on resource 'people'."); + responseDocument.Errors[0].Source.Parameter.Should().Be("fields[doesNotExist]"); + } + + [Fact] + public async Task Cannot_select_in_unknown_nested_scope() + { + // Arrange + var route = "/api/v1/people?fields[todoItems.doesNotExist]=id"; + + // 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("The specified fieldset is invalid."); + responseDocument.Errors[0].Detail.Should().Be("Relationship 'doesNotExist' in 'todoItems.doesNotExist' does not exist on resource 'todoItems'."); + responseDocument.Errors[0].Source.Parameter.Should().Be("fields[todoItems.doesNotExist]"); + } + + [Fact] + public async Task Cannot_select_blocked_attribute() + { + // Arrange + var user = _userFaker.Generate(); + + var route = $"/api/v1/users/{user.Id}?fields=password"; + + // 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("Retrieving the requested attribute is not allowed."); + responseDocument.Errors[0].Detail.Should().Be("Retrieving the attribute 'password' is not allowed."); + responseDocument.Errors[0].Source.Parameter.Should().Be("fields"); + } + + [Fact] + public async Task Retrieves_all_properties_when_fieldset_contains_readonly_attribute() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + var todoItem = new TodoItem + { + Description = "Pending work..." + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TodoItems.Add(todoItem); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/api/v1/todoItems/{todoItem.StringId}?fields=calculatedValue"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Id.Should().Be(todoItem.StringId); + 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); + todoItemCaptured.Description.Should().Be(todoItem.Description); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/TestableStartup.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/TestableStartup.cs new file mode 100644 index 0000000000..aac419c83a --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/TestableStartup.cs @@ -0,0 +1,37 @@ +using JsonApiDotNetCore; +using JsonApiDotNetCoreExample; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests +{ + public sealed class TestableStartup : EmptyStartup + where TDbContext : DbContext + { + public TestableStartup(IConfiguration configuration) : base(configuration) + { + } + + public override void ConfigureServices(IServiceCollection services) + { + services.AddJsonApi(options => + { + options.IncludeExceptionStackTraceInErrors = true; + options.SerializerSettings.Formatting = Formatting.Indented; + options.SerializerSettings.Converters.Add(new StringEnumConverter()); + }); + } + + public override void Configure(IApplicationBuilder app, IWebHostEnvironment environment) + { + app.UseRouting(); + app.UseJsonApi(); + app.UseEndpoints(endpoints => endpoints.MapControllers()); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/JsonApiDotNetCoreExampleTests.csproj b/test/JsonApiDotNetCoreExampleTests/JsonApiDotNetCoreExampleTests.csproj index 574e486669..e03f87d3e5 100644 --- a/test/JsonApiDotNetCoreExampleTests/JsonApiDotNetCoreExampleTests.csproj +++ b/test/JsonApiDotNetCoreExampleTests/JsonApiDotNetCoreExampleTests.csproj @@ -15,6 +15,7 @@ + diff --git a/test/UnitTests/Builders/LinkBuilderTests.cs b/test/UnitTests/Builders/LinkBuilderTests.cs index b36af50d12..3791d832bd 100644 --- a/test/UnitTests/Builders/LinkBuilderTests.cs +++ b/test/UnitTests/Builders/LinkBuilderTests.cs @@ -1,14 +1,14 @@ +using JsonApiDotNetCore; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Managers.Contracts; +using JsonApiDotNetCore.Internal.QueryStrings; using JsonApiDotNetCore.Models.Annotation; -using JsonApiDotNetCore.Models.Links; +using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCore.RequestServices.Contracts; using JsonApiDotNetCoreExample.Models; using Moq; using Xunit; -using JsonApiDotNetCore.Query; -using JsonApiDotNetCore.QueryParameterServices.Common; using JsonApiDotNetCore.Serialization.Server.Builders; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.WebUtilities; @@ -17,11 +17,11 @@ namespace UnitTests { public sealed class LinkBuilderTests { - private readonly IPageService _pageService; + private readonly IPaginationContext _paginationContext; private readonly Mock _provider = new Mock(); private readonly IRequestQueryStringAccessor _queryStringAccessor = new FakeRequestQueryStringAccessor("?foo=bar"); private const string _host = "http://www.example.com"; - private const int _baseId = 123; + private const int _primaryId = 123; private const string _relationshipName = "author"; private const string _topSelf = "http://www.example.com/articles?foo=bar"; private const string _topResourceSelf = "http://www.example.com/articles/123?foo=bar"; @@ -32,20 +32,20 @@ public sealed class LinkBuilderTests public LinkBuilderTests() { - _pageService = GetPageManager(); + _paginationContext = GetPaginationContext(); } [Theory] - [InlineData(Link.All, Link.NotConfigured, _resourceSelf)] - [InlineData(Link.Self, Link.NotConfigured, _resourceSelf)] - [InlineData(Link.None, Link.NotConfigured, null)] - [InlineData(Link.All, Link.Self, _resourceSelf)] - [InlineData(Link.Self, Link.Self, _resourceSelf)] - [InlineData(Link.None, Link.Self, _resourceSelf)] - [InlineData(Link.All, Link.None, null)] - [InlineData(Link.Self, Link.None, null)] - [InlineData(Link.None, Link.None, null)] - public void BuildResourceLinks_GlobalAndResourceConfiguration_ExpectedResult(Link global, Link resource, object expectedResult) + [InlineData(Links.All, Links.NotConfigured, _resourceSelf)] + [InlineData(Links.Self, Links.NotConfigured, _resourceSelf)] + [InlineData(Links.None, Links.NotConfigured, null)] + [InlineData(Links.All, Links.Self, _resourceSelf)] + [InlineData(Links.Self, Links.Self, _resourceSelf)] + [InlineData(Links.None, Links.Self, _resourceSelf)] + [InlineData(Links.All, Links.None, null)] + [InlineData(Links.Self, Links.None, null)] + [InlineData(Links.None, Links.None, null)] + public void BuildResourceLinks_GlobalAndResourceConfiguration_ExpectedResult(Links global, Links resource, object expectedResult) { // Arrange var config = GetConfiguration(resourceLinks: global); @@ -54,7 +54,7 @@ public void BuildResourceLinks_GlobalAndResourceConfiguration_ExpectedResult(Lin var builder = new LinkBuilder(config, GetRequestManager(), null, _provider.Object, _queryStringAccessor); // Act - var links = builder.GetResourceLinks("articles", _baseId.ToString()); + var links = builder.GetResourceLinks("articles", _primaryId.ToString()); // Assert if (expectedResult == null) @@ -64,36 +64,33 @@ public void BuildResourceLinks_GlobalAndResourceConfiguration_ExpectedResult(Lin } [Theory] - [InlineData(Link.All, Link.NotConfigured, Link.NotConfigured, _relSelf, _relRelated)] - [InlineData(Link.All, Link.NotConfigured, Link.All, _relSelf, _relRelated)] - [InlineData(Link.All, Link.NotConfigured, Link.Self, _relSelf, null)] - [InlineData(Link.All, Link.NotConfigured, Link.Related, null, _relRelated)] - [InlineData(Link.All, Link.NotConfigured, Link.None, null, null)] - [InlineData(Link.All, Link.All, Link.NotConfigured, _relSelf, _relRelated)] - [InlineData(Link.All, Link.All, Link.All, _relSelf, _relRelated)] - [InlineData(Link.All, Link.All, Link.Self, _relSelf, null)] - [InlineData(Link.All, Link.All, Link.Related, null, _relRelated)] - [InlineData(Link.All, Link.All, Link.None, null, null)] - [InlineData(Link.All, Link.Self, Link.NotConfigured, _relSelf, null)] - [InlineData(Link.All, Link.Self, Link.All, _relSelf, _relRelated)] - [InlineData(Link.All, Link.Self, Link.Self, _relSelf, null)] - [InlineData(Link.All, Link.Self, Link.Related, null, _relRelated)] - [InlineData(Link.All, Link.Self, Link.None, null, null)] - [InlineData(Link.All, Link.Related, Link.NotConfigured, null, _relRelated)] - [InlineData(Link.All, Link.Related, Link.All, _relSelf, _relRelated)] - [InlineData(Link.All, Link.Related, Link.Self, _relSelf, null)] - [InlineData(Link.All, Link.Related, Link.Related, null, _relRelated)] - [InlineData(Link.All, Link.Related, Link.None, null, null)] - [InlineData(Link.All, Link.None, Link.NotConfigured, null, null)] - [InlineData(Link.All, Link.None, Link.All, _relSelf, _relRelated)] - [InlineData(Link.All, Link.None, Link.Self, _relSelf, null)] - [InlineData(Link.All, Link.None, Link.Related, null, _relRelated)] - [InlineData(Link.All, Link.None, Link.None, null, null)] - public void BuildRelationshipLinks_GlobalResourceAndAttrConfiguration_ExpectedLinks(Link global, - Link resource, - Link relationship, - object expectedSelfLink, - object expectedRelatedLink) + [InlineData(Links.All, Links.NotConfigured, Links.NotConfigured, _relSelf, _relRelated)] + [InlineData(Links.All, Links.NotConfigured, Links.All, _relSelf, _relRelated)] + [InlineData(Links.All, Links.NotConfigured, Links.Self, _relSelf, null)] + [InlineData(Links.All, Links.NotConfigured, Links.Related, null, _relRelated)] + [InlineData(Links.All, Links.NotConfigured, Links.None, null, null)] + [InlineData(Links.All, Links.All, Links.NotConfigured, _relSelf, _relRelated)] + [InlineData(Links.All, Links.All, Links.All, _relSelf, _relRelated)] + [InlineData(Links.All, Links.All, Links.Self, _relSelf, null)] + [InlineData(Links.All, Links.All, Links.Related, null, _relRelated)] + [InlineData(Links.All, Links.All, Links.None, null, null)] + [InlineData(Links.All, Links.Self, Links.NotConfigured, _relSelf, null)] + [InlineData(Links.All, Links.Self, Links.All, _relSelf, _relRelated)] + [InlineData(Links.All, Links.Self, Links.Self, _relSelf, null)] + [InlineData(Links.All, Links.Self, Links.Related, null, _relRelated)] + [InlineData(Links.All, Links.Self, Links.None, null, null)] + [InlineData(Links.All, Links.Related, Links.NotConfigured, null, _relRelated)] + [InlineData(Links.All, Links.Related, Links.All, _relSelf, _relRelated)] + [InlineData(Links.All, Links.Related, Links.Self, _relSelf, null)] + [InlineData(Links.All, Links.Related, Links.Related, null, _relRelated)] + [InlineData(Links.All, Links.Related, Links.None, null, null)] + [InlineData(Links.All, Links.None, Links.NotConfigured, null, null)] + [InlineData(Links.All, Links.None, Links.All, _relSelf, _relRelated)] + [InlineData(Links.All, Links.None, Links.Self, _relSelf, null)] + [InlineData(Links.All, Links.None, Links.Related, null, _relRelated)] + [InlineData(Links.All, Links.None, Links.None, null, null)] + public void BuildRelationshipLinks_GlobalResourceAndAttrConfiguration_ExpectedLinks( + Links global, Links resource, Links relationship, object expectedSelfLink, object expectedRelatedLink) { // Arrange var config = GetConfiguration(relationshipLinks: global); @@ -103,7 +100,7 @@ public void BuildRelationshipLinks_GlobalResourceAndAttrConfiguration_ExpectedLi var attr = new HasOneAttribute(links: relationship) { RightType = typeof(Author), PublicName = "author" }; // Act - var links = builder.GetRelationshipLinks(attr, new Article { Id = _baseId }); + var links = builder.GetRelationshipLinks(attr, new Article { Id = _primaryId }); // Assert if (expectedSelfLink == null && expectedRelatedLink == null) @@ -118,49 +115,47 @@ public void BuildRelationshipLinks_GlobalResourceAndAttrConfiguration_ExpectedLi } [Theory] - [InlineData(Link.All, Link.NotConfigured, _topSelf, true)] - [InlineData(Link.All, Link.All, _topSelf, true)] - [InlineData(Link.All, Link.Self, _topSelf, false)] - [InlineData(Link.All, Link.Paging, null, true)] - [InlineData(Link.All, Link.None, null, false)] - [InlineData(Link.Self, Link.NotConfigured, _topSelf, false)] - [InlineData(Link.Self, Link.All, _topSelf, true)] - [InlineData(Link.Self, Link.Self, _topSelf, false)] - [InlineData(Link.Self, Link.Paging, null, true)] - [InlineData(Link.Self, Link.None, null, false)] - [InlineData(Link.Paging, Link.NotConfigured, null, true)] - [InlineData(Link.Paging, Link.All, _topSelf, true)] - [InlineData(Link.Paging, Link.Self, _topSelf, false)] - [InlineData(Link.Paging, Link.Paging, null, true)] - [InlineData(Link.Paging, Link.None, null, false)] - [InlineData(Link.None, Link.NotConfigured, null, false)] - [InlineData(Link.None, Link.All, _topSelf, true)] - [InlineData(Link.None, Link.Self, _topSelf, false)] - [InlineData(Link.None, Link.Paging, null, true)] - [InlineData(Link.None, Link.None, null, false)] - [InlineData(Link.All, Link.Self, _topResourceSelf, false)] - [InlineData(Link.Self, Link.Self, _topResourceSelf, false)] - [InlineData(Link.Paging, Link.Self, _topResourceSelf, false)] - [InlineData(Link.None, Link.Self, _topResourceSelf, false)] - [InlineData(Link.All, Link.Self, _topRelatedSelf, false)] - [InlineData(Link.Self, Link.Self, _topRelatedSelf, false)] - [InlineData(Link.Paging, Link.Self, _topRelatedSelf, false)] - [InlineData(Link.None, Link.Self, _topRelatedSelf, false)] - public void BuildTopLevelLinks_GlobalAndResourceConfiguration_ExpectedLinks(Link global, - Link resource, - string expectedSelfLink, - bool pages) + [InlineData(Links.All, Links.NotConfigured, _topSelf, true)] + [InlineData(Links.All, Links.All, _topSelf, true)] + [InlineData(Links.All, Links.Self, _topSelf, false)] + [InlineData(Links.All, Links.Paging, null, true)] + [InlineData(Links.All, Links.None, null, false)] + [InlineData(Links.Self, Links.NotConfigured, _topSelf, false)] + [InlineData(Links.Self, Links.All, _topSelf, true)] + [InlineData(Links.Self, Links.Self, _topSelf, false)] + [InlineData(Links.Self, Links.Paging, null, true)] + [InlineData(Links.Self, Links.None, null, false)] + [InlineData(Links.Paging, Links.NotConfigured, null, true)] + [InlineData(Links.Paging, Links.All, _topSelf, true)] + [InlineData(Links.Paging, Links.Self, _topSelf, false)] + [InlineData(Links.Paging, Links.Paging, null, true)] + [InlineData(Links.Paging, Links.None, null, false)] + [InlineData(Links.None, Links.NotConfigured, null, false)] + [InlineData(Links.None, Links.All, _topSelf, true)] + [InlineData(Links.None, Links.Self, _topSelf, false)] + [InlineData(Links.None, Links.Paging, null, true)] + [InlineData(Links.None, Links.None, null, false)] + [InlineData(Links.All, Links.Self, _topResourceSelf, false)] + [InlineData(Links.Self, Links.Self, _topResourceSelf, false)] + [InlineData(Links.Paging, Links.Self, _topResourceSelf, false)] + [InlineData(Links.None, Links.Self, _topResourceSelf, false)] + [InlineData(Links.All, Links.Self, _topRelatedSelf, false)] + [InlineData(Links.Self, Links.Self, _topRelatedSelf, false)] + [InlineData(Links.Paging, Links.Self, _topRelatedSelf, false)] + [InlineData(Links.None, Links.Self, _topRelatedSelf, false)] + public void BuildTopLevelLinks_GlobalAndResourceConfiguration_ExpectedLinks( + Links global, Links resource, string expectedSelfLink, bool pages) { // Arrange var config = GetConfiguration(topLevelLinks: global); var primaryResource = GetArticleResourceContext(topLevelLinks: resource); _provider.Setup(m => m.GetResourceContext
()).Returns(primaryResource); - bool useBaseId = expectedSelfLink != _topSelf; + bool usePrimaryId = expectedSelfLink != _topSelf; string relationshipName = expectedSelfLink == _topRelatedSelf ? _relationshipName : null; - ICurrentRequest currentRequest = GetRequestManager(primaryResource, useBaseId, relationshipName); + ICurrentRequest currentRequest = GetRequestManager(primaryResource, usePrimaryId, relationshipName); - var builder = new LinkBuilder(config, currentRequest, _pageService, _provider.Object, _queryStringAccessor); + var builder = new LinkBuilder(config, currentRequest, _paginationContext, _provider.Object, _queryStringAccessor); // Act var links = builder.GetTopLevelLinks(); @@ -172,58 +167,58 @@ public void BuildTopLevelLinks_GlobalAndResourceConfiguration_ExpectedLinks(Link } else { - Assert.True(CheckLinks(links, pages, expectedSelfLink)); + if (pages) + { + Assert.Equal($"{_host}/articles?foo=bar&page[size]=10&page[number]=2", links.Self); + Assert.Equal($"{_host}/articles?foo=bar&page[size]=10&page[number]=1", links.First); + Assert.Equal($"{_host}/articles?foo=bar&page[size]=10&page[number]=1", links.Prev); + Assert.Equal($"{_host}/articles?foo=bar&page[size]=10&page[number]=3", links.Next); + Assert.Equal($"{_host}/articles?foo=bar&page[size]=10&page[number]=3", links.Last); + } + else + { + Assert.Equal(links.Self , expectedSelfLink); + Assert.Null(links.First); + Assert.Null(links.Prev); + Assert.Null(links.Next); + Assert.Null(links.Last); + } } } - private bool CheckLinks(TopLevelLinks links, bool pages, string expectedSelfLink) - { - if (pages) - { - return links.Self == $"{_host}/articles?foo=bar&page[size]=10&page[number]=2" - && links.First == $"{_host}/articles?foo=bar&page[size]=10&page[number]=1" - && links.Prev == $"{_host}/articles?foo=bar&page[size]=10&page[number]=1" - && links.Next == $"{_host}/articles?foo=bar&page[size]=10&page[number]=3" - && links.Last == $"{_host}/articles?foo=bar&page[size]=10&page[number]=3"; - } - - return links.Self == expectedSelfLink && links.First == null && links.Prev == null && links.Next == null && links.Last == null; - } - - private ICurrentRequest GetRequestManager(ResourceContext resourceContext = null, bool useBaseId = false, string relationshipName = null) + private ICurrentRequest GetRequestManager(ResourceContext resourceContext = null, bool usePrimaryId = false, string relationshipName = null) { var mock = new Mock(); mock.Setup(m => m.BasePath).Returns(_host); - mock.Setup(m => m.BaseId).Returns(useBaseId ? _baseId.ToString() : null); - mock.Setup(m => m.RequestRelationship).Returns(relationshipName != null ? new HasOneAttribute(relationshipName) : null); - mock.Setup(m => m.GetRequestResource()).Returns(resourceContext); + mock.Setup(m => m.PrimaryId).Returns(usePrimaryId ? _primaryId.ToString() : null); + mock.Setup(m => m.Relationship).Returns(relationshipName != null ? new HasOneAttribute(relationshipName) : null); + mock.Setup(m => m.PrimaryResource).Returns(resourceContext); return mock.Object; } - private ILinksConfiguration GetConfiguration(Link resourceLinks = Link.All, - Link topLevelLinks = Link.All, - Link relationshipLinks = Link.All) + private IJsonApiOptions GetConfiguration(Links resourceLinks = Links.All, Links topLevelLinks = Links.All, Links relationshipLinks = Links.All) { - var config = new Mock(); + var config = new Mock(); config.Setup(m => m.TopLevelLinks).Returns(topLevelLinks); config.Setup(m => m.ResourceLinks).Returns(resourceLinks); config.Setup(m => m.RelationshipLinks).Returns(relationshipLinks); + config.Setup(m => m.DefaultPageSize).Returns(new PageSize(25)); return config.Object; } - private IPageService GetPageManager() + private IPaginationContext GetPaginationContext() { - var mock = new Mock(); - mock.Setup(m => m.CanPaginate).Returns(true); - mock.Setup(m => m.CurrentPage).Returns(2); - mock.Setup(m => m.TotalPages).Returns(3); - mock.Setup(m => m.PageSize).Returns(10); + var mock = new Mock(); + mock.Setup(x => x.PageNumber).Returns(new PageNumber(2)); + mock.Setup(x => x.PageSize).Returns(new PageSize(10)); + mock.Setup(x => x.TotalPageCount).Returns(3); + return mock.Object; } - private ResourceContext GetArticleResourceContext(Link resourceLinks = Link.NotConfigured, - Link topLevelLinks = Link.NotConfigured, - Link relationshipLinks = Link.NotConfigured) + private ResourceContext GetArticleResourceContext(Links resourceLinks = Links.NotConfigured, + Links topLevelLinks = Links.NotConfigured, + Links relationshipLinks = Links.NotConfigured) { return new ResourceContext { diff --git a/test/UnitTests/Builders/LinkTests.cs b/test/UnitTests/Builders/LinkTests.cs index df1504930c..1489074277 100644 --- a/test/UnitTests/Builders/LinkTests.cs +++ b/test/UnitTests/Builders/LinkTests.cs @@ -1,4 +1,4 @@ -using JsonApiDotNetCore.Models.Links; +using JsonApiDotNetCore.Models.JsonApiDocuments; using Xunit; namespace UnitTests.Builders @@ -6,31 +6,31 @@ namespace UnitTests.Builders public sealed class LinkTests { [Theory] - [InlineData(Link.All, Link.Self, true)] - [InlineData(Link.All, Link.Related, true)] - [InlineData(Link.All, Link.Paging, true)] - [InlineData(Link.None, Link.Self, false)] - [InlineData(Link.None, Link.Related, false)] - [InlineData(Link.None, Link.Paging, false)] - [InlineData(Link.NotConfigured, Link.Self, false)] - [InlineData(Link.NotConfigured, Link.Related, false)] - [InlineData(Link.NotConfigured, Link.Paging, false)] - [InlineData(Link.Self, Link.Self, true)] - [InlineData(Link.Self, Link.Related, false)] - [InlineData(Link.Self, Link.Paging, false)] - [InlineData(Link.Self, Link.None, false)] - [InlineData(Link.Self, Link.NotConfigured, false)] - [InlineData(Link.Related, Link.Self, false)] - [InlineData(Link.Related, Link.Related, true)] - [InlineData(Link.Related, Link.Paging, false)] - [InlineData(Link.Related, Link.None, false)] - [InlineData(Link.Related, Link.NotConfigured, false)] - [InlineData(Link.Paging, Link.Self, false)] - [InlineData(Link.Paging, Link.Related, false)] - [InlineData(Link.Paging, Link.Paging, true)] - [InlineData(Link.Paging, Link.None, false)] - [InlineData(Link.Paging, Link.NotConfigured, false)] - public void LinkHasFlag_BaseLinkAndCheckLink_ExpectedResult(Link baseLink, Link checkLink, bool equal) + [InlineData(Links.All, Links.Self, true)] + [InlineData(Links.All, Links.Related, true)] + [InlineData(Links.All, Links.Paging, true)] + [InlineData(Links.None, Links.Self, false)] + [InlineData(Links.None, Links.Related, false)] + [InlineData(Links.None, Links.Paging, false)] + [InlineData(Links.NotConfigured, Links.Self, false)] + [InlineData(Links.NotConfigured, Links.Related, false)] + [InlineData(Links.NotConfigured, Links.Paging, false)] + [InlineData(Links.Self, Links.Self, true)] + [InlineData(Links.Self, Links.Related, false)] + [InlineData(Links.Self, Links.Paging, false)] + [InlineData(Links.Self, Links.None, false)] + [InlineData(Links.Self, Links.NotConfigured, false)] + [InlineData(Links.Related, Links.Self, false)] + [InlineData(Links.Related, Links.Related, true)] + [InlineData(Links.Related, Links.Paging, false)] + [InlineData(Links.Related, Links.None, false)] + [InlineData(Links.Related, Links.NotConfigured, false)] + [InlineData(Links.Paging, Links.Self, false)] + [InlineData(Links.Paging, Links.Related, false)] + [InlineData(Links.Paging, Links.Paging, true)] + [InlineData(Links.Paging, Links.None, false)] + [InlineData(Links.Paging, Links.NotConfigured, false)] + public void LinkHasFlag_BaseLinkAndCheckLink_ExpectedResult(Links baseLink, Links checkLink, bool equal) { Assert.Equal(equal, baseLink.HasFlag(checkLink)); } diff --git a/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs b/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs index 64157e3fae..1015a4f889 100644 --- a/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs +++ b/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs @@ -37,13 +37,13 @@ public ResourceController( ILoggerFactory loggerFactory, IGetAllService getAll = null, IGetByIdService getById = null, + IGetSecondaryService getSecondary = null, IGetRelationshipService getRelationship = null, - IGetRelationshipsService getRelationships = null, ICreateService create = null, IUpdateService update = null, IUpdateRelationshipService updateRelationships = null, IDeleteService delete = null) - : base(jsonApiOptions, loggerFactory, getAll, getById, getRelationship, getRelationships, create, + : base(jsonApiOptions, loggerFactory, getAll, getById, getSecondary, getRelationship, create, update, updateRelationships, delete) { } } @@ -111,14 +111,14 @@ public async Task GetRelationshipsAsync_Calls_Service() { // Arrange const int id = 0; - var serviceMock = new Mock>(); - var controller = new ResourceController(new Mock().Object, NullLoggerFactory.Instance, getRelationships: serviceMock.Object); + var serviceMock = new Mock>(); + var controller = new ResourceController(new Mock().Object, NullLoggerFactory.Instance, getRelationship: serviceMock.Object); // Act - await controller.GetRelationshipsAsync(id, string.Empty); + await controller.GetRelationshipAsync(id, string.Empty); // Assert - serviceMock.Verify(m => m.GetRelationshipsAsync(id, string.Empty), Times.Once); + serviceMock.Verify(m => m.GetRelationshipAsync(id, string.Empty), Times.Once); } [Fact] @@ -129,7 +129,7 @@ public async Task GetRelationshipsAsync_Throws_405_If_No_Service() var controller = new ResourceController(new Mock().Object, NullLoggerFactory.Instance); // Act - var exception = await Assert.ThrowsAsync(() => controller.GetRelationshipsAsync(id, string.Empty)); + var exception = await Assert.ThrowsAsync(() => controller.GetRelationshipAsync(id, string.Empty)); // Assert Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.Error.StatusCode); @@ -141,14 +141,14 @@ public async Task GetRelationshipAsync_Calls_Service() { // Arrange const int id = 0; - var serviceMock = new Mock>(); - var controller = new ResourceController(new Mock().Object, NullLoggerFactory.Instance, getRelationship: serviceMock.Object); + var serviceMock = new Mock>(); + var controller = new ResourceController(new Mock().Object, NullLoggerFactory.Instance, getSecondary: serviceMock.Object); // Act - await controller.GetRelationshipAsync(id, string.Empty); + await controller.GetSecondaryAsync(id, string.Empty); // Assert - serviceMock.Verify(m => m.GetRelationshipAsync(id, string.Empty), Times.Once); + serviceMock.Verify(m => m.GetSecondaryAsync(id, string.Empty), Times.Once); } [Fact] @@ -159,7 +159,7 @@ public async Task GetRelationshipAsync_Throws_405_If_No_Service() var controller = new ResourceController(new Mock().Object, NullLoggerFactory.Instance); // Act - var exception = await Assert.ThrowsAsync(() => controller.GetRelationshipAsync(id, string.Empty)); + var exception = await Assert.ThrowsAsync(() => controller.GetSecondaryAsync(id, string.Empty)); // Assert Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.Error.StatusCode); @@ -225,10 +225,10 @@ public async Task PatchRelationshipsAsync_Calls_Service() var controller = new ResourceController(new Mock().Object, NullLoggerFactory.Instance, updateRelationships: serviceMock.Object); // Act - await controller.PatchRelationshipsAsync(id, string.Empty, null); + await controller.PatchRelationshipAsync(id, string.Empty, null); // Assert - serviceMock.Verify(m => m.UpdateRelationshipsAsync(id, string.Empty, null), Times.Once); + serviceMock.Verify(m => m.UpdateRelationshipAsync(id, string.Empty, null), Times.Once); } [Fact] @@ -239,7 +239,7 @@ public async Task PatchRelationshipsAsync_Throws_405_If_No_Service() var controller = new ResourceController(new Mock().Object, NullLoggerFactory.Instance); // Act - var exception = await Assert.ThrowsAsync(() => controller.PatchRelationshipsAsync(id, string.Empty, null)); + var exception = await Assert.ThrowsAsync(() => controller.PatchRelationshipAsync(id, string.Empty, null)); // Assert Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.Error.StatusCode); diff --git a/test/UnitTests/Controllers/JsonApiControllerMixin_Tests.cs b/test/UnitTests/Controllers/CoreJsonApiControllerTests.cs similarity index 96% rename from test/UnitTests/Controllers/JsonApiControllerMixin_Tests.cs rename to test/UnitTests/Controllers/CoreJsonApiControllerTests.cs index 46d5ecf784..02fd752344 100644 --- a/test/UnitTests/Controllers/JsonApiControllerMixin_Tests.cs +++ b/test/UnitTests/Controllers/CoreJsonApiControllerTests.cs @@ -7,9 +7,8 @@ namespace UnitTests { - public sealed class JsonApiControllerMixin_Tests : JsonApiControllerMixin + public sealed class CoreJsonApiControllerTests : CoreJsonApiController { - [Fact] public void Errors_Correctly_Infers_Status_Code() { diff --git a/test/UnitTests/Data/DefaultEntityRepositoryTest.cs b/test/UnitTests/Data/DefaultEntityRepositoryTest.cs deleted file mode 100644 index 78040a7d98..0000000000 --- a/test/UnitTests/Data/DefaultEntityRepositoryTest.cs +++ /dev/null @@ -1,75 +0,0 @@ -using JsonApiDotNetCore.Data; -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Serialization; -using JsonApiDotNetCoreExample.Models; -using Microsoft.EntityFrameworkCore; -using Moq; -using System.Collections.Generic; -using System.ComponentModel.Design; -using System.Linq; -using System.Threading.Tasks; -using JsonApiDotNetCore.Internal; -using Microsoft.Extensions.Logging.Abstractions; -using Xunit; - -namespace UnitTests.Data -{ - public sealed class DefaultEntityRepositoryTest - { - - [Fact] - public async Task PageAsync_IQueryableIsAListAndPageNumberPositive_CanStillCount() - { - // If IQueryable is actually a list (this can happen after a filter or hook) - // It needs to not do CountAsync, because well.. its not asynchronous. - - // Arrange - var repository = Setup(); - var todoItems = new List - { - new TodoItem{ Id = 1 }, - new TodoItem{ Id = 2 } - }; - - // Act - var result = await repository.PageAsync(todoItems.AsQueryable(), pageSize: 1, pageNumber: 2); - - // Assert - Assert.True(result.ElementAt(0).Id == todoItems[1].Id); - } - - [Fact] - public async Task PageAsync_IQueryableIsAListAndPageNumberNegative_CanStillCount() - { - // If IQueryable is actually a list (this can happen after a filter or hook) - // It needs to not do CountAsync, because well.. its not asynchronous. - - // Arrange - var repository = Setup(); - var todoItems = new List - { - new TodoItem{ Id = 1 }, - new TodoItem{ Id = 2 }, - new TodoItem{ Id = 3 }, - new TodoItem{ Id = 4 } - }; - - // Act - var result = await repository.PageAsync(todoItems.AsQueryable(), pageSize: 1, pageNumber: -2); - - // Assert - Assert.True(result.First().Id == 3); - } - - private DefaultResourceRepository Setup() - { - var contextResolverMock = new Mock(); - contextResolverMock.Setup(m => m.GetContext()).Returns(new Mock().Object); - var resourceGraph = new Mock(); - var targetedFields = new Mock(); - var resourceFactory = new DefaultResourceFactory(new ServiceContainer()); - var repository = new DefaultResourceRepository(targetedFields.Object, contextResolverMock.Object, resourceGraph.Object, null, resourceFactory, NullLoggerFactory.Instance); - return repository; - } - } -} diff --git a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs index 738d4770d6..ff5d77e6eb 100644 --- a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs +++ b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs @@ -15,7 +15,8 @@ using System.Threading.Tasks; using JsonApiDotNetCore; using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Managers.Contracts; +using JsonApiDotNetCore.RequestServices; +using JsonApiDotNetCore.RequestServices.Contracts; using JsonApiDotNetCore.Serialization.Server.Builders; using JsonApiDotNetCore.Serialization.Server; using Microsoft.AspNetCore.Authentication; @@ -31,7 +32,7 @@ public void AddJsonApiInternals_Adds_All_Required_Services() var services = new ServiceCollection(); services.AddLogging(); services.AddSingleton(); - services.AddDbContext(options => options.UseInMemoryDatabase("UnitTestDb"), ServiceLifetime.Transient); + services.AddDbContext(options => options.UseInMemoryDatabase("UnitTestDb")); services.AddJsonApi(); // Act @@ -41,11 +42,11 @@ public void AddJsonApiInternals_Adds_All_Required_Services() var provider = services.BuildServiceProvider(); // Assert - var currentRequest = provider.GetService(); + var currentRequest = provider.GetService() as CurrentRequest; Assert.NotNull(currentRequest); var resourceGraph = provider.GetService(); Assert.NotNull(resourceGraph); - currentRequest.SetRequestResource(resourceGraph.GetResourceContext()); + currentRequest.PrimaryResource = resourceGraph.GetResourceContext(); Assert.NotNull(provider.GetService()); Assert.NotNull(provider.GetService()); Assert.NotNull(provider.GetService(typeof(IResourceRepository))); @@ -67,7 +68,7 @@ public void RegisterResource_DeviatingDbContextPropertyName_RegistersCorrectly() var services = new ServiceCollection(); services.AddLogging(); services.AddSingleton(); - services.AddDbContext(options => options.UseInMemoryDatabase("UnitTestDb"), ServiceLifetime.Transient); + services.AddDbContext(options => options.UseInMemoryDatabase("UnitTestDb")); services.AddJsonApi(); // Act @@ -98,8 +99,8 @@ public void AddResourceService_Registers_All_Shorthand_Service_Interfaces() Assert.IsType(provider.GetService(typeof(IResourceQueryService))); Assert.IsType(provider.GetService(typeof(IGetAllService))); Assert.IsType(provider.GetService(typeof(IGetByIdService))); + Assert.IsType(provider.GetService(typeof(IGetSecondaryService))); Assert.IsType(provider.GetService(typeof(IGetRelationshipService))); - Assert.IsType(provider.GetService(typeof(IGetRelationshipsService))); Assert.IsType(provider.GetService(typeof(ICreateService))); Assert.IsType(provider.GetService(typeof(IUpdateService))); Assert.IsType(provider.GetService(typeof(IDeleteService))); @@ -121,8 +122,8 @@ public void AddResourceService_Registers_All_LongForm_Service_Interfaces() Assert.IsType(provider.GetService(typeof(IResourceQueryService))); Assert.IsType(provider.GetService(typeof(IGetAllService))); Assert.IsType(provider.GetService(typeof(IGetByIdService))); + Assert.IsType(provider.GetService(typeof(IGetSecondaryService))); Assert.IsType(provider.GetService(typeof(IGetRelationshipService))); - Assert.IsType(provider.GetService(typeof(IGetRelationshipsService))); Assert.IsType(provider.GetService(typeof(ICreateService))); Assert.IsType(provider.GetService(typeof(IUpdateService))); Assert.IsType(provider.GetService(typeof(IDeleteService))); @@ -163,26 +164,26 @@ public class GuidResource : Identifiable { } private class IntResourceService : IResourceService { - public Task CreateAsync(IntResource entity) => throw new NotImplementedException(); + 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() => throw new NotImplementedException(); public Task GetAsync(int id) => throw new NotImplementedException(); - public Task GetRelationshipAsync(int id, string relationshipName) => throw new NotImplementedException(); - public Task GetRelationshipsAsync(int id, string relationshipName) => throw new NotImplementedException(); - public Task UpdateAsync(int id, IntResource entity) => throw new NotImplementedException(); - public Task UpdateRelationshipsAsync(int id, string relationshipName, object relationships) => 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(); } private class GuidResourceService : IResourceService { - public Task CreateAsync(GuidResource entity) => throw new NotImplementedException(); + 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() => throw new NotImplementedException(); public Task GetAsync(Guid id) => throw new NotImplementedException(); - public Task GetRelationshipAsync(Guid id, string relationshipName) => throw new NotImplementedException(); - public Task GetRelationshipsAsync(Guid id, string relationshipName) => throw new NotImplementedException(); - public Task UpdateAsync(Guid id, GuidResource entity) => throw new NotImplementedException(); - public Task UpdateRelationshipsAsync(Guid id, string relationshipName, object relationships) => 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(); } diff --git a/test/UnitTests/Internal/ResourceGraphBuilder_Tests.cs b/test/UnitTests/Internal/ResourceGraphBuilderTests.cs similarity index 91% rename from test/UnitTests/Internal/ResourceGraphBuilder_Tests.cs rename to test/UnitTests/Internal/ResourceGraphBuilderTests.cs index 1ceb41f2aa..5c734a0750 100644 --- a/test/UnitTests/Internal/ResourceGraphBuilder_Tests.cs +++ b/test/UnitTests/Internal/ResourceGraphBuilderTests.cs @@ -8,7 +8,7 @@ namespace UnitTests.Internal { - public sealed class ResourceGraphBuilder_Tests + public sealed class ResourceGraphBuilderTests { [Fact] public void AddDbContext_Does_Not_Throw_If_Context_Contains_Members_That_Do_Not_Implement_IIdentifiable() @@ -38,7 +38,7 @@ public void Adding_DbContext_Members_That_Do_Not_Implement_IIdentifiable_Logs_Wa // Assert Assert.Single(loggerFactory.Logger.Messages); Assert.Equal(LogLevel.Warning, loggerFactory.Logger.Messages[0].LogLevel); - Assert.Equal("Entity 'UnitTests.Internal.ResourceGraphBuilder_Tests+TestContext' does not implement 'IIdentifiable'.", loggerFactory.Logger.Messages[0].Text); + Assert.Equal("Entity 'UnitTests.Internal.ResourceGraphBuilderTests+TestContext' does not implement 'IIdentifiable'.", loggerFactory.Logger.Messages[0].Text); } private class Foo { } diff --git a/test/UnitTests/Middleware/JsonApiMiddlewareTests.cs b/test/UnitTests/Middleware/JsonApiMiddlewareTests.cs index 851450d390..af2e9ecaf2 100644 --- a/test/UnitTests/Middleware/JsonApiMiddlewareTests.cs +++ b/test/UnitTests/Middleware/JsonApiMiddlewareTests.cs @@ -1,7 +1,6 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Managers; using JsonApiDotNetCore.Middleware; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; @@ -11,6 +10,7 @@ using System.Linq; using System.Threading.Tasks; using JsonApiDotNetCore.Models.Annotation; +using JsonApiDotNetCore.RequestServices; using Xunit; namespace UnitTests.Middleware @@ -29,11 +29,11 @@ public async Task ParseUrlBase_ObfuscatedIdClass_ShouldSetIdCorrectly() await RunMiddlewareTask(configuration); // Assert - Assert.Equal(id, currentRequest.BaseId); - + Assert.Equal(id, currentRequest.PrimaryId); } + [Fact] - public async Task ParseUrlBase_UrlHasBaseIdSet_ShouldSetCurrentRequestWithSaidId() + public async Task ParseUrlBase_UrlHasPrimaryIdSet_ShouldSetCurrentRequestWithSaidId() { // Arrange var id = "123"; @@ -44,11 +44,11 @@ public async Task ParseUrlBase_UrlHasBaseIdSet_ShouldSetCurrentRequestWithSaidId await RunMiddlewareTask(configuration); // Assert - Assert.Equal(id, currentRequest.BaseId); + Assert.Equal(id, currentRequest.PrimaryId); } [Fact] - public async Task ParseUrlBase_UrlHasNoBaseIdSet_ShouldHaveBaseIdSetToNull() + public async Task ParseUrlBase_UrlHasNoPrimaryIdSet_ShouldHaveBaseIdSetToNull() { // Arrange var configuration = GetConfiguration("/users"); @@ -58,11 +58,11 @@ public async Task ParseUrlBase_UrlHasNoBaseIdSet_ShouldHaveBaseIdSetToNull() await RunMiddlewareTask(configuration); // Assert - Assert.Null(currentRequest.BaseId); + Assert.Null(currentRequest.PrimaryId); } [Fact] - public async Task ParseUrlBase_UrlHasNegativeBaseIdAndTypeIsInt_ShouldNotThrowJAException() + public async Task ParseUrlBase_UrlHasNegativePrimaryIdAndTypeIsInt_ShouldNotThrowJAException() { // Arrange var configuration = GetConfiguration("/users/-5/"); @@ -106,7 +106,7 @@ private InvokeConfiguration GetConfiguration(string path, string resourceName = var currentRequest = new CurrentRequest(); if (relType != null) { - currentRequest.RequestRelationship = new HasManyAttribute + currentRequest.Relationship = new HasManyAttribute { RightType = relType }; diff --git a/test/UnitTests/Models/LinkTests.cs b/test/UnitTests/Models/LinkTests.cs index 69ef23d577..71ce2fef97 100644 --- a/test/UnitTests/Models/LinkTests.cs +++ b/test/UnitTests/Models/LinkTests.cs @@ -1,4 +1,4 @@ -using JsonApiDotNetCore.Models.Links; +using JsonApiDotNetCore.Models.JsonApiDocuments; using Xunit; namespace UnitTests.Models @@ -9,70 +9,70 @@ public sealed class LinkTests public void All_Contains_All_Flags_Except_None() { // Arrange - var e = Link.All; + var e = Links.All; // Assert - Assert.True(e.HasFlag(Link.Self)); - Assert.True(e.HasFlag(Link.Paging)); - Assert.True(e.HasFlag(Link.Related)); - Assert.True(e.HasFlag(Link.All)); - Assert.False(e.HasFlag(Link.None)); + Assert.True(e.HasFlag(Links.Self)); + Assert.True(e.HasFlag(Links.Paging)); + Assert.True(e.HasFlag(Links.Related)); + Assert.True(e.HasFlag(Links.All)); + Assert.False(e.HasFlag(Links.None)); } [Fact] public void None_Contains_Only_None() { // Arrange - var e = Link.None; + var e = Links.None; // Assert - Assert.False(e.HasFlag(Link.Self)); - Assert.False(e.HasFlag(Link.Paging)); - Assert.False(e.HasFlag(Link.Related)); - Assert.False(e.HasFlag(Link.All)); - Assert.True(e.HasFlag(Link.None)); + Assert.False(e.HasFlag(Links.Self)); + Assert.False(e.HasFlag(Links.Paging)); + Assert.False(e.HasFlag(Links.Related)); + Assert.False(e.HasFlag(Links.All)); + Assert.True(e.HasFlag(Links.None)); } [Fact] public void Self() { // Arrange - var e = Link.Self; + var e = Links.Self; // Assert - Assert.True(e.HasFlag(Link.Self)); - Assert.False(e.HasFlag(Link.Paging)); - Assert.False(e.HasFlag(Link.Related)); - Assert.False(e.HasFlag(Link.All)); - Assert.False(e.HasFlag(Link.None)); + Assert.True(e.HasFlag(Links.Self)); + Assert.False(e.HasFlag(Links.Paging)); + Assert.False(e.HasFlag(Links.Related)); + Assert.False(e.HasFlag(Links.All)); + Assert.False(e.HasFlag(Links.None)); } [Fact] public void Paging() { // Arrange - var e = Link.Paging; + var e = Links.Paging; // Assert - Assert.False(e.HasFlag(Link.Self)); - Assert.True(e.HasFlag(Link.Paging)); - Assert.False(e.HasFlag(Link.Related)); - Assert.False(e.HasFlag(Link.All)); - Assert.False(e.HasFlag(Link.None)); + Assert.False(e.HasFlag(Links.Self)); + Assert.True(e.HasFlag(Links.Paging)); + Assert.False(e.HasFlag(Links.Related)); + Assert.False(e.HasFlag(Links.All)); + Assert.False(e.HasFlag(Links.None)); } [Fact] public void Related() { // Arrange - var e = Link.Related; + var e = Links.Related; // Assert - Assert.False(e.HasFlag(Link.Self)); - Assert.False(e.HasFlag(Link.Paging)); - Assert.True(e.HasFlag(Link.Related)); - Assert.False(e.HasFlag(Link.All)); - Assert.False(e.HasFlag(Link.None)); + Assert.False(e.HasFlag(Links.Self)); + Assert.False(e.HasFlag(Links.Paging)); + Assert.True(e.HasFlag(Links.Related)); + Assert.False(e.HasFlag(Links.All)); + Assert.False(e.HasFlag(Links.None)); } } } diff --git a/test/UnitTests/Models/ResourceConstructionExpressionTests.cs b/test/UnitTests/Models/ResourceConstructionExpressionTests.cs index 90ac696958..8c93b34999 100644 --- a/test/UnitTests/Models/ResourceConstructionExpressionTests.cs +++ b/test/UnitTests/Models/ResourceConstructionExpressionTests.cs @@ -15,7 +15,7 @@ public sealed class ResourceConstructionExpressionTests public void When_resource_has_default_constructor_it_must_succeed() { // Arrange - var factory = new DefaultResourceFactory(new ServiceContainer()); + var factory = new ResourceFactory(new ServiceContainer()); // Act NewExpression newExpression = factory.CreateNewExpression(typeof(ResourceWithoutConstructor)); @@ -42,7 +42,7 @@ public void When_resource_has_constructor_with_injectable_parameter_it_must_succ serviceContainer.AddService(typeof(ISystemClock), systemClock); serviceContainer.AddService(typeof(AppDbContext), appDbContext); - var factory = new DefaultResourceFactory(serviceContainer); + var factory = new ResourceFactory(serviceContainer); // Act NewExpression newExpression = factory.CreateNewExpression(typeof(ResourceWithDbContextConstructor)); @@ -61,7 +61,7 @@ public void When_resource_has_constructor_with_injectable_parameter_it_must_succ public void When_resource_has_constructor_with_string_parameter_it_must_fail() { // Arrange - var factory = new DefaultResourceFactory(new ServiceContainer()); + var factory = new ResourceFactory(new ServiceContainer()); // Act Action action = () => factory.CreateNewExpression(typeof(ResourceWithStringConstructor)); diff --git a/test/UnitTests/Models/ResourceConstructionTests.cs b/test/UnitTests/Models/ResourceConstructionTests.cs index 044fe6300f..dd2f59eb81 100644 --- a/test/UnitTests/Models/ResourceConstructionTests.cs +++ b/test/UnitTests/Models/ResourceConstructionTests.cs @@ -34,7 +34,7 @@ public void When_resource_has_default_constructor_it_must_succeed() .AddResource() .Build(); - var serializer = new RequestDeserializer(graph, new DefaultResourceFactory(new ServiceContainer()), new TargetedFields(), _mockHttpContextAccessor.Object); + var serializer = new RequestDeserializer(graph, new ResourceFactory(new ServiceContainer()), new TargetedFields(), _mockHttpContextAccessor.Object); var body = new { @@ -63,7 +63,7 @@ public void When_resource_has_default_constructor_that_throws_it_must_fail() .AddResource() .Build(); - var serializer = new RequestDeserializer(graph, new DefaultResourceFactory(new ServiceContainer()), new TargetedFields(), _mockHttpContextAccessor.Object); + var serializer = new RequestDeserializer(graph, new ResourceFactory(new ServiceContainer()), new TargetedFields(), _mockHttpContextAccessor.Object); var body = new { @@ -99,7 +99,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 DefaultResourceFactory(serviceContainer), new TargetedFields(), _mockHttpContextAccessor.Object); + var serializer = new RequestDeserializer(graph, new ResourceFactory(serviceContainer), new TargetedFields(), _mockHttpContextAccessor.Object); var body = new { @@ -129,7 +129,7 @@ public void When_resource_has_constructor_with_string_parameter_it_must_fail() .AddResource() .Build(); - var serializer = new RequestDeserializer(graph, new DefaultResourceFactory(new ServiceContainer()), new TargetedFields(), _mockHttpContextAccessor.Object); + var serializer = new RequestDeserializer(graph, new ResourceFactory(new ServiceContainer()), new TargetedFields(), _mockHttpContextAccessor.Object); var body = new { diff --git a/test/UnitTests/Models/ResourceDefinitionTests.cs b/test/UnitTests/Models/ResourceDefinitionTests.cs deleted file mode 100644 index e40142774c..0000000000 --- a/test/UnitTests/Models/ResourceDefinitionTests.cs +++ /dev/null @@ -1,93 +0,0 @@ -using System; -using JsonApiDotNetCore.Builders; -using JsonApiDotNetCore.Internal.Query; -using JsonApiDotNetCore.Models; -using System.Linq; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Models.Annotation; -using Microsoft.Extensions.Logging.Abstractions; -using Xunit; - -namespace UnitTests.Models -{ - public sealed class ResourceDefinition_Scenario_Tests - { - [Fact] - public void Property_Sort_Order_Uses_NewExpression() - { - // Arrange - var resource = new RequestFilteredResource(isAdmin: false); - - // Act - var sorts = resource.DefaultSort(); - - // Assert - Assert.Equal(2, sorts.Count); - - Assert.Equal(nameof(Model.CreatedAt), sorts[0].Attribute.Property.Name); - Assert.Equal(SortDirection.Ascending, sorts[0].SortDirection); - - Assert.Equal(nameof(Model.Password), sorts[1].Attribute.Property.Name); - Assert.Equal(SortDirection.Descending, sorts[1].SortDirection); - } - - [Fact] - public void Request_Filter_Uses_Member_Expression() - { - // Arrange - var resource = new RequestFilteredResource(isAdmin: true); - - // Act - var attrs = resource.GetAllowedAttributes(); - - // Assert - Assert.DoesNotContain(attrs, a => a.Property.Name == nameof(Model.AlwaysExcluded)); - } - - [Fact] - public void Request_Filter_Uses_NewExpression() - { - // Arrange - var resource = new RequestFilteredResource(isAdmin: false); - - // Act - var attrs = resource.GetAllowedAttributes(); - - // Assert - Assert.DoesNotContain(attrs, a => a.Property.Name == nameof(Model.AlwaysExcluded)); - Assert.DoesNotContain(attrs, a => a.Property.Name == nameof(Model.Password)); - } - } - - public class Model : Identifiable - { - [Attr] public string AlwaysExcluded { get; set; } - [Attr] public string Password { get; set; } - [Attr] public DateTime CreatedAt { get; set; } - } - - public sealed class RequestFilteredResource : ResourceDefinition - { - // this constructor will be resolved from the container - // that means you can take on any dependency that is also defined in the container - public RequestFilteredResource(bool isAdmin) : base(new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance).AddResource().Build()) - { - if (isAdmin) - HideFields(model => model.AlwaysExcluded); - else - HideFields(model => new { model.AlwaysExcluded, model.Password }); - } - - public override QueryFilters GetQueryFilters() - => new QueryFilters { - { "is-active", (query, value) => query.Select(x => x) } - }; - - public override PropertySortOrder GetDefaultSortOrder() - => new PropertySortOrder - { - (model => model.CreatedAt, SortDirection.Ascending), - (model => model.Password, SortDirection.Descending) - }; - } -} diff --git a/test/UnitTests/QueryParameters/DefaultsServiceTests.cs b/test/UnitTests/QueryParameters/DefaultsServiceTests.cs deleted file mode 100644 index a324427fa9..0000000000 --- a/test/UnitTests/QueryParameters/DefaultsServiceTests.cs +++ /dev/null @@ -1,92 +0,0 @@ -using System.Net; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Exceptions; -using JsonApiDotNetCore.Query; -using Newtonsoft.Json; -using Xunit; - -namespace UnitTests.QueryParameters -{ - public sealed class DefaultsServiceTests : QueryParametersUnitTestCollection - { - public DefaultsService GetService(DefaultValueHandling defaultValue, bool allowOverride) - { - var options = new JsonApiOptions - { - SerializerSettings = { DefaultValueHandling = defaultValue }, - AllowQueryStringOverrideForSerializerDefaultValueHandling = allowOverride - }; - - return new DefaultsService(options); - } - - [Fact] - public void CanParse_DefaultsService_SucceedOnMatch() - { - // Arrange - var service = GetService(DefaultValueHandling.Include, true); - - // Act - bool result = service.CanParse("defaults"); - - // Assert - Assert.True(result); - } - - [Fact] - public void CanParse_DefaultsService_FailOnMismatch() - { - // Arrange - var service = GetService(DefaultValueHandling.Include, true); - - // Act - bool result = service.CanParse("defaultsettings"); - - // Assert - Assert.False(result); - } - - [Theory] - [InlineData("false", DefaultValueHandling.Ignore, false, DefaultValueHandling.Ignore)] - [InlineData("true", DefaultValueHandling.Ignore, false, DefaultValueHandling.Ignore)] - [InlineData("false", DefaultValueHandling.Include, false, DefaultValueHandling.Include)] - [InlineData("true", DefaultValueHandling.Include, false, DefaultValueHandling.Include)] - [InlineData("false", DefaultValueHandling.Ignore, true, DefaultValueHandling.Ignore)] - [InlineData("true", DefaultValueHandling.Ignore, true, DefaultValueHandling.Include)] - [InlineData("false", DefaultValueHandling.Include, true, DefaultValueHandling.Ignore)] - [InlineData("true", DefaultValueHandling.Include, true, DefaultValueHandling.Include)] - public void Parse_QueryConfigWithApiSettings_Succeeds(string queryValue, DefaultValueHandling defaultValue, bool allowOverride, DefaultValueHandling expected) - { - // Arrange - const string parameterName = "defaults"; - var service = GetService(defaultValue, allowOverride); - - // Act - if (service.CanParse(parameterName) && service.IsEnabled(DisableQueryAttribute.Empty)) - { - service.Parse(parameterName, queryValue); - } - - // Assert - Assert.Equal(expected, service.SerializerDefaultValueHandling); - } - - [Fact] - public void Parse_DefaultsService_FailOnNonBooleanValue() - { - // Arrange - const string parameterName = "defaults"; - var service = GetService(DefaultValueHandling.Include, true); - - // Act, assert - var exception = Assert.Throws(() => service.Parse(parameterName, "some")); - - Assert.Equal(parameterName, exception.QueryParameterName); - Assert.Equal(HttpStatusCode.BadRequest, exception.Error.StatusCode); - Assert.Equal("The specified query string value must be 'true' or 'false'.", exception.Error.Title); - Assert.Equal($"The value 'some' for parameter '{parameterName}' is not a valid boolean value.", exception.Error.Detail); - Assert.Equal(parameterName, exception.Error.Source.Parameter); - } - } -} diff --git a/test/UnitTests/QueryParameters/FilterServiceTests.cs b/test/UnitTests/QueryParameters/FilterServiceTests.cs deleted file mode 100644 index ce62ac9ef2..0000000000 --- a/test/UnitTests/QueryParameters/FilterServiceTests.cs +++ /dev/null @@ -1,79 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using JsonApiDotNetCore.Internal.Query; -using JsonApiDotNetCore.Query; -using Microsoft.Extensions.Primitives; -using Xunit; - -namespace UnitTests.QueryParameters -{ - public sealed class FilterServiceTests : QueryParametersUnitTestCollection - { - public FilterService GetService() - { - return new FilterService(MockResourceDefinitionProvider(), _resourceGraph, MockCurrentRequest(_articleResourceContext)); - } - - [Fact] - public void CanParse_FilterService_SucceedOnMatch() - { - // Arrange - var filterService = GetService(); - - // Act - bool result = filterService.CanParse("filter[age]"); - - // Assert - Assert.True(result); - } - - [Fact] - public void CanParse_FilterService_FailOnMismatch() - { - // Arrange - var filterService = GetService(); - - // Act - bool result = filterService.CanParse("other"); - - // Assert - Assert.False(result); - } - - [Theory] - [InlineData("title", "", "value")] - [InlineData("title", "eq:", "value")] - [InlineData("title", "lt:", "value")] - [InlineData("title", "gt:", "value")] - [InlineData("title", "le:", "value")] - [InlineData("title", "ge:", "value")] - [InlineData("title", "like:", "value")] - [InlineData("title", "ne:", "value")] - [InlineData("title", "in:", "value")] - [InlineData("title", "nin:", "value")] - [InlineData("title", "isnull:", "")] - [InlineData("title", "isnotnull:", "")] - [InlineData("title", "", "2017-08-15T22:43:47.0156350-05:00")] - [InlineData("title", "le:", "2017-08-15T22:43:47.0156350-05:00")] - public void Parse_ValidFilters_CanParse(string key, string @operator, string value) - { - // Arrange - var queryValue = @operator + value; - var query = new KeyValuePair($"filter[{key}]", queryValue); - var filterService = GetService(); - - // Act - filterService.Parse(query.Key, query.Value); - var filter = filterService.Get().Single(); - - // Assert - if (!string.IsNullOrEmpty(@operator)) - Assert.Equal(@operator.Replace(":", ""), filter.Operation.ToString("G")); - else - Assert.Equal(FilterOperation.eq, filter.Operation); - - if (!string.IsNullOrEmpty(value)) - Assert.Equal(value, filter.Value); - } - } -} diff --git a/test/UnitTests/QueryParameters/IncludeServiceTests.cs b/test/UnitTests/QueryParameters/IncludeServiceTests.cs deleted file mode 100644 index 9183ed394a..0000000000 --- a/test/UnitTests/QueryParameters/IncludeServiceTests.cs +++ /dev/null @@ -1,122 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Net; -using JsonApiDotNetCore.Exceptions; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Query; -using Microsoft.Extensions.Primitives; -using UnitTests.TestModels; -using Xunit; - -namespace UnitTests.QueryParameters -{ - public sealed class IncludeServiceTests : QueryParametersUnitTestCollection - { - public IncludeService GetService(ResourceContext resourceContext = null) - { - return new IncludeService(_resourceGraph, MockCurrentRequest(resourceContext ?? _articleResourceContext)); - } - - [Fact] - public void CanParse_IncludeService_SucceedOnMatch() - { - // Arrange - var service = GetService(); - - // Act - bool result = service.CanParse("include"); - - // Assert - Assert.True(result); - } - - [Fact] - public void CanParse_IncludeService_FailOnMismatch() - { - // Arrange - var service = GetService(); - - // Act - bool result = service.CanParse("includes"); - - // Assert - Assert.False(result); - } - - [Fact] - public void Parse_MultipleNestedChains_CanParse() - { - // Arrange - const string chain = "author.blogs.reviewer.favoriteFood,reviewer.blogs.author.favoriteSong"; - var query = new KeyValuePair("include", chain); - var service = GetService(); - - // Act - service.Parse(query.Key, query.Value); - - // Assert - var chains = service.Get(); - Assert.Equal(2, chains.Count); - var firstChain = chains[0]; - Assert.Equal("author", firstChain.First().PublicName); - Assert.Equal("favoriteFood", firstChain.Last().PublicName); - var secondChain = chains[1]; - Assert.Equal("reviewer", secondChain.First().PublicName); - Assert.Equal("favoriteSong", secondChain.Last().PublicName); - } - - [Fact] - public void Parse_ChainsOnWrongMainResource_ThrowsJsonApiException() - { - // Arrange - const string chain = "author.blogs.reviewer.favoriteFood,reviewer.blogs.author.favoriteSong"; - var query = new KeyValuePair("include", chain); - var service = GetService(_resourceGraph.GetResourceContext()); - - // Act, assert - var exception = Assert.Throws(() => service.Parse(query.Key, query.Value)); - - Assert.Equal("include", exception.QueryParameterName); - Assert.Equal(HttpStatusCode.BadRequest, exception.Error.StatusCode); - Assert.Equal("The requested relationship to include does not exist.", exception.Error.Title); - Assert.Equal("The relationship 'author' on 'foods' does not exist.", exception.Error.Detail); - Assert.Equal("include", exception.Error.Source.Parameter); - } - - [Fact] - public void Parse_NotIncludable_ThrowsJsonApiException() - { - // Arrange - const string chain = "cannotInclude"; - var query = new KeyValuePair("include", chain); - var service = GetService(); - - // Act, assert - var exception = Assert.Throws(() => service.Parse(query.Key, query.Value)); - - Assert.Equal("include", exception.QueryParameterName); - Assert.Equal(HttpStatusCode.BadRequest, exception.Error.StatusCode); - Assert.Equal("Including the requested relationship is not allowed.", exception.Error.Title); - Assert.Equal("Including the relationship 'cannotInclude' on 'articles' is not allowed.", exception.Error.Detail); - Assert.Equal("include", exception.Error.Source.Parameter); - } - - [Fact] - public void Parse_NonExistingRelationship_ThrowsJsonApiException() - { - // Arrange - const string chain = "nonsense"; - var query = new KeyValuePair("include", chain); - var service = GetService(); - - // Act, assert - var exception = Assert.Throws(() => service.Parse(query.Key, query.Value)); - - Assert.Equal("include", exception.QueryParameterName); - Assert.Equal(HttpStatusCode.BadRequest, exception.Error.StatusCode); - Assert.Equal("The requested relationship to include does not exist.", exception.Error.Title); - Assert.Equal("The relationship 'nonsense' on 'articles' does not exist.", exception.Error.Detail); - Assert.Equal("include", exception.Error.Source.Parameter); - } - } -} diff --git a/test/UnitTests/QueryParameters/NullsServiceTests.cs b/test/UnitTests/QueryParameters/NullsServiceTests.cs deleted file mode 100644 index 8b65752918..0000000000 --- a/test/UnitTests/QueryParameters/NullsServiceTests.cs +++ /dev/null @@ -1,92 +0,0 @@ -using System.Net; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Exceptions; -using JsonApiDotNetCore.Query; -using Newtonsoft.Json; -using Xunit; - -namespace UnitTests.QueryParameters -{ - public sealed class NullsServiceTests : QueryParametersUnitTestCollection - { - public NullsService GetService(NullValueHandling defaultValue, bool allowOverride) - { - var options = new JsonApiOptions - { - SerializerSettings = { NullValueHandling = defaultValue }, - AllowQueryStringOverrideForSerializerNullValueHandling = allowOverride - }; - - return new NullsService(options); - } - - [Fact] - public void CanParse_NullsService_SucceedOnMatch() - { - // Arrange - var service = GetService(NullValueHandling.Include, true); - - // Act - bool result = service.CanParse("nulls"); - - // Assert - Assert.True(result); - } - - [Fact] - public void CanParse_NullsService_FailOnMismatch() - { - // Arrange - var service = GetService(NullValueHandling.Include, true); - - // Act - bool result = service.CanParse("nullsettings"); - - // Assert - Assert.False(result); - } - - [Theory] - [InlineData("false", NullValueHandling.Ignore, false, NullValueHandling.Ignore)] - [InlineData("true", NullValueHandling.Ignore, false, NullValueHandling.Ignore)] - [InlineData("false", NullValueHandling.Include, false, NullValueHandling.Include)] - [InlineData("true", NullValueHandling.Include, false, NullValueHandling.Include)] - [InlineData("false", NullValueHandling.Ignore, true, NullValueHandling.Ignore)] - [InlineData("true", NullValueHandling.Ignore, true, NullValueHandling.Include)] - [InlineData("false", NullValueHandling.Include, true, NullValueHandling.Ignore)] - [InlineData("true", NullValueHandling.Include, true, NullValueHandling.Include)] - public void Parse_QueryConfigWithApiSettings_Succeeds(string queryValue, NullValueHandling defaultValue, bool allowOverride, NullValueHandling expected) - { - // Arrange - const string parameterName = "nulls"; - var service = GetService(defaultValue, allowOverride); - - // Act - if (service.CanParse(parameterName) && service.IsEnabled(DisableQueryAttribute.Empty)) - { - service.Parse(parameterName, queryValue); - } - - // Assert - Assert.Equal(expected, service.SerializerNullValueHandling); - } - - [Fact] - public void Parse_NullsService_FailOnNonBooleanValue() - { - // Arrange - const string parameterName = "nulls"; - var service = GetService(NullValueHandling.Include, true); - - // Act, assert - var exception = Assert.Throws(() => service.Parse(parameterName, "some")); - - Assert.Equal(parameterName, exception.QueryParameterName); - Assert.Equal(HttpStatusCode.BadRequest, exception.Error.StatusCode); - Assert.Equal("The specified query string value must be 'true' or 'false'.", exception.Error.Title); - Assert.Equal($"The value 'some' for parameter '{parameterName}' is not a valid boolean value.", exception.Error.Detail); - Assert.Equal(parameterName, exception.Error.Source.Parameter); - } - } -} diff --git a/test/UnitTests/QueryParameters/PageServiceTests.cs b/test/UnitTests/QueryParameters/PageServiceTests.cs deleted file mode 100644 index ee9a078d19..0000000000 --- a/test/UnitTests/QueryParameters/PageServiceTests.cs +++ /dev/null @@ -1,110 +0,0 @@ -using System.Collections.Generic; -using System.Net; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Exceptions; -using JsonApiDotNetCore.Query; -using Microsoft.Extensions.Primitives; -using Xunit; - -namespace UnitTests.QueryParameters -{ - public sealed class PageServiceTests : QueryParametersUnitTestCollection - { - public PageService GetService(int? maximumPageSize = null, int? maximumPageNumber = null) - { - return new PageService(new JsonApiOptions - { - MaximumPageSize = maximumPageSize, - MaximumPageNumber = maximumPageNumber - }); - } - - [Fact] - public void CanParse_PageService_SucceedOnMatch() - { - // Arrange - var service = GetService(); - - // Act - bool result = service.CanParse("page[size]"); - - // Assert - Assert.True(result); - } - - [Fact] - public void CanParse_PageService_FailOnMismatch() - { - // Arrange - var service = GetService(); - - // Act - bool result = service.CanParse("page[some]"); - - // Assert - Assert.False(result); - } - - [Theory] - [InlineData("0", 0, null, false)] - [InlineData("0", 0, 50, true)] - [InlineData("1", 1, null, false)] - [InlineData("abcde", 0, null, true)] - [InlineData("", 0, null, true)] - [InlineData("5", 5, 10, false)] - [InlineData("5", 5, 3, true)] - public void Parse_PageSize_CanParse(string value, int expectedValue, int? maximumPageSize, bool shouldThrow) - { - // Arrange - var query = new KeyValuePair("page[size]", value); - var service = GetService(maximumPageSize: maximumPageSize); - - // Act - if (shouldThrow) - { - var exception = Assert.Throws(() => service.Parse(query.Key, query.Value)); - - Assert.Equal("page[size]", exception.QueryParameterName); - Assert.Equal(HttpStatusCode.BadRequest, exception.Error.StatusCode); - Assert.Equal("The specified value is not in the range of valid values.", exception.Error.Title); - Assert.StartsWith($"Value '{value}' is invalid, because it must be a whole number that is", exception.Error.Detail); - Assert.Equal("page[size]", exception.Error.Source.Parameter); - } - else - { - service.Parse(query.Key, query.Value); - Assert.Equal(expectedValue, service.PageSize); - } - } - - [Theory] - [InlineData("1", 1, null, false)] - [InlineData("abcde", 0, null, true)] - [InlineData("", 0, null, true)] - [InlineData("5", 5, 10, false)] - [InlineData("5", 5, 3, true)] - public void Parse_PageNumber_CanParse(string value, int expectedValue, int? maximumPageNumber, bool shouldThrow) - { - // Arrange - var query = new KeyValuePair("page[number]", value); - var service = GetService(maximumPageNumber: maximumPageNumber); - - // Act - if (shouldThrow) - { - var exception = Assert.Throws(() => service.Parse(query.Key, query.Value)); - - Assert.Equal("page[number]", exception.QueryParameterName); - Assert.Equal(HttpStatusCode.BadRequest, exception.Error.StatusCode); - Assert.Equal("The specified value is not in the range of valid values.", exception.Error.Title); - Assert.StartsWith($"Value '{value}' is invalid, because it must be a whole number that is non-zero", exception.Error.Detail); - Assert.Equal("page[number]", exception.Error.Source.Parameter); - } - else - { - service.Parse(query.Key, query.Value); - Assert.Equal(expectedValue, service.CurrentPage); - } - } - } -} diff --git a/test/UnitTests/QueryParameters/QueryParametersUnitTestCollection.cs b/test/UnitTests/QueryParameters/QueryParametersUnitTestCollection.cs deleted file mode 100644 index 8fb916a2a7..0000000000 --- a/test/UnitTests/QueryParameters/QueryParametersUnitTestCollection.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System; -using JsonApiDotNetCore.Builders; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Managers.Contracts; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Query; -using Microsoft.Extensions.Logging.Abstractions; -using Moq; -using UnitTests.TestModels; - -namespace UnitTests.QueryParameters -{ - public class QueryParametersUnitTestCollection - { - protected readonly ResourceContext _articleResourceContext; - protected readonly IResourceGraph _resourceGraph; - - public QueryParametersUnitTestCollection() - { - var builder = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance); - builder.AddResource
(); - builder.AddResource(); - builder.AddResource(); - builder.AddResource(); - builder.AddResource(); - _resourceGraph = builder.Build(); - _articleResourceContext = _resourceGraph.GetResourceContext
(); - } - - public ICurrentRequest MockCurrentRequest(ResourceContext requestResource = null) - { - var mock = new Mock(); - - if (requestResource != null) - mock.Setup(m => m.GetRequestResource()).Returns(requestResource); - - return mock.Object; - } - - public IResourceDefinitionProvider MockResourceDefinitionProvider(params (Type, IResourceDefinition)[] rds) - { - var mock = new Mock(); - - foreach (var (type, resourceDefinition) in rds) - mock.Setup(m => m.Get(type)).Returns(resourceDefinition); - - return mock.Object; - } - } -} diff --git a/test/UnitTests/QueryParameters/SortServiceTests.cs b/test/UnitTests/QueryParameters/SortServiceTests.cs deleted file mode 100644 index 60a471982a..0000000000 --- a/test/UnitTests/QueryParameters/SortServiceTests.cs +++ /dev/null @@ -1,63 +0,0 @@ -using System.Collections.Generic; -using System.Net; -using JsonApiDotNetCore.Exceptions; -using JsonApiDotNetCore.Query; -using Microsoft.Extensions.Primitives; -using Xunit; - -namespace UnitTests.QueryParameters -{ - public sealed class SortServiceTests : QueryParametersUnitTestCollection - { - public SortService GetService() - { - return new SortService(MockResourceDefinitionProvider(), _resourceGraph, MockCurrentRequest(_articleResourceContext)); - } - - [Fact] - public void CanParse_SortService_SucceedOnMatch() - { - // Arrange - var service = GetService(); - - // Act - bool result = service.CanParse("sort"); - - // Assert - Assert.True(result); - } - - [Fact] - public void CanParse_SortService_FailOnMismatch() - { - // Arrange - var service = GetService(); - - // Act - bool result = service.CanParse("sorting"); - - // Assert - Assert.False(result); - } - - [Theory] - [InlineData("text,,1")] - [InlineData("text,hello,,5")] - [InlineData(",,2")] - public void Parse_InvalidSortQuery_ThrowsJsonApiException(string stringSortQuery) - { - // Arrange - var query = new KeyValuePair("sort", stringSortQuery); - var sortService = GetService(); - - // Act, assert - var exception = Assert.Throws(() => sortService.Parse(query.Key, query.Value)); - - Assert.Equal("sort", exception.QueryParameterName); - Assert.Equal(HttpStatusCode.BadRequest, exception.Error.StatusCode); - Assert.Equal("The list of fields to sort on contains empty elements.", exception.Error.Title); - Assert.Null(exception.Error.Detail); - Assert.Equal("sort", exception.Error.Source.Parameter); - } - } -} diff --git a/test/UnitTests/QueryParameters/SparseFieldsServiceTests.cs b/test/UnitTests/QueryParameters/SparseFieldsServiceTests.cs deleted file mode 100644 index 59967e2901..0000000000 --- a/test/UnitTests/QueryParameters/SparseFieldsServiceTests.cs +++ /dev/null @@ -1,229 +0,0 @@ -using System.Collections.Generic; -using System.Net; -using JsonApiDotNetCore.Exceptions; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Models.Annotation; -using JsonApiDotNetCore.Query; -using JsonApiDotNetCoreExample.Models; -using Microsoft.Extensions.Primitives; -using Xunit; -using Person = UnitTests.TestModels.Person; - -namespace UnitTests.QueryParameters -{ - public sealed class SparseFieldsServiceTests : QueryParametersUnitTestCollection - { - public SparseFieldsService GetService(ResourceContext resourceContext = null) - { - return new SparseFieldsService(_resourceGraph, MockCurrentRequest(resourceContext ?? _articleResourceContext)); - } - - [Fact] - public void CanParse_SparseFieldsService_SucceedOnMatch() - { - // Arrange - var service = GetService(); - - // Act - bool result = service.CanParse("fields[customer]"); - - // Assert - Assert.True(result); - } - - [Fact] - public void CanParse_SparseFieldsService_FailOnMismatch() - { - // Arrange - var service = GetService(); - - // Act - bool result = service.CanParse("fieldset"); - - // Assert - Assert.False(result); - } - - [Fact] - public void Parse_ValidSelection_CanParse() - { - // Arrange - const string type = "articles"; - const string attrName = "name"; - var attribute = new AttrAttribute(attrName) {Property = typeof(Article).GetProperty(nameof(Article.Name))}; - var idAttribute = new AttrAttribute("id") {Property = typeof(Article).GetProperty(nameof(Article.Id))}; - - var query = new KeyValuePair("fields", attrName); - - var resourceContext = new ResourceContext - { - ResourceName = type, - Attributes = new List { attribute, idAttribute }, - Relationships = new List() - }; - var service = GetService(resourceContext); - - // Act - service.Parse(query.Key, query.Value); - var result = service.Get(); - - // Assert - Assert.NotEmpty(result); - Assert.Contains(idAttribute, result); - Assert.Contains(attribute, result); - } - - [Fact] - public void Parse_InvalidRelationship_ThrowsJsonApiException() - { - // Arrange - const string type = "articles"; - var attrName = "someField"; - var attribute = new AttrAttribute(attrName); - var idAttribute = new AttrAttribute("id"); - var queryParameterName = "fields[missing]"; - - var query = new KeyValuePair(queryParameterName, attrName); - - var resourceContext = new ResourceContext - { - ResourceName = type, - Attributes = new List { attribute, idAttribute }, - Relationships = new List() - }; - var service = GetService(resourceContext); - - // Act, assert - var exception = Assert.Throws(() => service.Parse(query.Key, query.Value)); - - Assert.Equal(queryParameterName, exception.QueryParameterName); - Assert.Equal(HttpStatusCode.BadRequest, exception.Error.StatusCode); - Assert.Equal("Sparse field navigation path refers to an invalid relationship.", exception.Error.Title); - Assert.Equal("'missing' in 'fields[missing]' is not a valid relationship of articles.", exception.Error.Detail); - Assert.Equal(queryParameterName, exception.Error.Source.Parameter); - } - - [Fact] - public void Parse_DeeplyNestedSelection_ThrowsJsonApiException() - { - // Arrange - const string type = "articles"; - const string relationship = "author.employer"; - const string attrName = "someField"; - var attribute = new AttrAttribute(attrName); - var idAttribute = new AttrAttribute("id"); - var queryParameterName = $"fields[{relationship}]"; - - var query = new KeyValuePair(queryParameterName, attrName); - - var resourceContext = new ResourceContext - { - ResourceName = type, - Attributes = new List { attribute, idAttribute }, - Relationships = new List() - }; - var service = GetService(resourceContext); - - // Act, assert - var exception = Assert.Throws(() => service.Parse(query.Key, query.Value)); - - Assert.Equal(queryParameterName, exception.QueryParameterName); - Assert.Equal(HttpStatusCode.BadRequest, exception.Error.StatusCode); - Assert.Equal("Deeply nested sparse field selection is currently not supported.", exception.Error.Title); - Assert.Equal($"Parameter fields[{relationship}] is currently not supported.", exception.Error.Detail); - Assert.Equal(queryParameterName, exception.Error.Source.Parameter); - } - - [Fact] - public void Parse_InvalidField_ThrowsJsonApiException() - { - // Arrange - const string type = "articles"; - const string attrName = "dne"; - var idAttribute = new AttrAttribute("id") {Property = typeof(Article).GetProperty(nameof(Article.Id))}; - - var query = new KeyValuePair("fields", attrName); - - var resourceContext = new ResourceContext - { - ResourceName = type, - Attributes = new List {idAttribute}, - Relationships = new List() - }; - - var service = GetService(resourceContext); - - // Act, assert - var exception = Assert.Throws(() => service.Parse(query.Key, query.Value)); - - Assert.Equal("fields", exception.QueryParameterName); - Assert.Equal(HttpStatusCode.BadRequest, exception.Error.StatusCode); - Assert.Equal("The specified field does not exist on the requested resource.", exception.Error.Title); - Assert.Equal($"The field '{attrName}' does not exist on resource '{type}'.", exception.Error.Detail); - Assert.Equal("fields", exception.Error.Source.Parameter); - } - - [Fact] - public void Parse_InvalidRelatedField_ThrowsJsonApiException() - { - // Arrange - var idAttribute = new AttrAttribute("id") {Property = typeof(Article).GetProperty(nameof(Article.Id))}; - - var query = new KeyValuePair("fields[author]", "invalid"); - - var resourceContext = new ResourceContext - { - ResourceName = "articles", - Attributes = new List {idAttribute}, - Relationships = new List - { - new HasOneAttribute("author") - { - Property = typeof(Article).GetProperty(nameof(Article.Author)), - RightType = typeof(Person) - } - } - }; - - var service = GetService(resourceContext); - - // Act, assert - var exception = Assert.Throws(() => service.Parse(query.Key, query.Value)); - - Assert.Equal("fields[author]", exception.QueryParameterName); - Assert.Equal(HttpStatusCode.BadRequest, exception.Error.StatusCode); - Assert.Equal("The specified field does not exist on the requested related resource.", exception.Error.Title); - Assert.Equal("The field 'invalid' does not exist on related resource 'author' of type 'people'.", exception.Error.Detail); - Assert.Equal("fields[author]", exception.Error.Source.Parameter); - } - - [Fact] - public void Parse_LegacyNotation_ThrowsJsonApiException() - { - // Arrange - const string type = "articles"; - const string attrName = "dne"; - var queryParameterName = $"fields[{type}]"; - - var query = new KeyValuePair(queryParameterName, attrName); - - var resourceContext = new ResourceContext - { - ResourceName = type, - Attributes = new List(), - Relationships = new List() - }; - - var service = GetService(resourceContext); - - // Act, assert - var exception = Assert.Throws(() => service.Parse(query.Key, query.Value)); - - Assert.Equal(queryParameterName, exception.QueryParameterName); - Assert.Equal(HttpStatusCode.BadRequest, exception.Error.StatusCode); - Assert.StartsWith("Square bracket notation in 'filter' is now reserved for relationships only", exception.Error.Title); - Assert.Equal($"Use '?fields=...' instead of '?fields[{type}]=...'.", exception.Error.Detail); - Assert.Equal(queryParameterName, exception.Error.Source.Parameter); - } - } -} diff --git a/test/UnitTests/QueryStringParameters/DefaultsParseTests.cs b/test/UnitTests/QueryStringParameters/DefaultsParseTests.cs new file mode 100644 index 0000000000..13cf147bcc --- /dev/null +++ b/test/UnitTests/QueryStringParameters/DefaultsParseTests.cs @@ -0,0 +1,129 @@ +using System; +using System.Net; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Exceptions; +using JsonApiDotNetCore.Internal.QueryStrings; +using Newtonsoft.Json; +using Xunit; + +namespace UnitTests.QueryStringParameters +{ + public sealed class DefaultsParseTests + { + private readonly IDefaultsQueryStringParameterReader _reader; + + public DefaultsParseTests() + { + _reader = new DefaultsQueryStringParameterReader(new JsonApiOptions()); + } + + [Theory] + [InlineData("defaults", true)] + [InlineData("default", false)] + [InlineData("defaultsettings", false)] + public void Reader_Supports_Parameter_Name(string parameterName, bool expectCanParse) + { + // Act + var canParse = _reader.CanRead(parameterName); + + // Assert + canParse.Should().Be(expectCanParse); + } + + [Theory] + [InlineData(StandardQueryStringParameters.Defaults, false, false)] + [InlineData(StandardQueryStringParameters.Defaults, true, false)] + [InlineData(StandardQueryStringParameters.All, false, false)] + [InlineData(StandardQueryStringParameters.All, true, false)] + [InlineData(StandardQueryStringParameters.None, false, false)] + [InlineData(StandardQueryStringParameters.None, true, true)] + [InlineData(StandardQueryStringParameters.Filter, false, false)] + [InlineData(StandardQueryStringParameters.Filter, true, true)] + public void Reader_Is_Enabled(StandardQueryStringParameters parametersDisabled, bool allowOverride, bool expectIsEnabled) + { + // Arrange + var options = new JsonApiOptions + { + AllowQueryStringOverrideForSerializerDefaultValueHandling = allowOverride + }; + + var reader = new DefaultsQueryStringParameterReader(options); + + // Act + var isEnabled = reader.IsEnabled(new DisableQueryAttribute(parametersDisabled)); + + // Assert + isEnabled.Should().Be(allowOverride && expectIsEnabled); + } + + [Theory] + [InlineData("defaults", "", "The value '' must be 'true' or 'false'.")] + [InlineData("defaults", " ", "The value ' ' must be 'true' or 'false'.")] + [InlineData("defaults", "null", "The value 'null' must be 'true' or 'false'.")] + [InlineData("defaults", "0", "The value '0' must be 'true' or 'false'.")] + [InlineData("defaults", "1", "The value '1' must be 'true' or 'false'.")] + [InlineData("defaults", "-1", "The value '-1' must be 'true' or 'false'.")] + public void Reader_Read_Fails(string parameterName, string parameterValue, string errorMessage) + { + // Act + Action action = () => _reader.Read(parameterName, parameterValue); + + // Assert + var exception = action.Should().ThrowExactly().And; + + exception.QueryParameterName.Should().Be(parameterName); + exception.Error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + exception.Error.Title.Should().Be("The specified defaults is invalid."); + exception.Error.Detail.Should().Be(errorMessage); + exception.Error.Source.Parameter.Should().Be(parameterName); + } + + [Theory] + [InlineData("defaults", "true", DefaultValueHandling.Include)] + [InlineData("defaults", "True", DefaultValueHandling.Include)] + [InlineData("defaults", "false", DefaultValueHandling.Ignore)] + [InlineData("defaults", "False", DefaultValueHandling.Ignore)] + public void Reader_Read_Succeeds(string parameterName, string parameterValue, DefaultValueHandling expectedValue) + { + // Act + _reader.Read(parameterName, parameterValue); + + DefaultValueHandling handling = _reader.SerializerDefaultValueHandling; + + // Assert + handling.Should().Be(expectedValue); + } + + [Theory] + [InlineData("false", DefaultValueHandling.Ignore, false, DefaultValueHandling.Ignore)] + [InlineData("true", DefaultValueHandling.Ignore, false, DefaultValueHandling.Ignore)] + [InlineData("false", DefaultValueHandling.Include, false, DefaultValueHandling.Include)] + [InlineData("true", DefaultValueHandling.Include, false, DefaultValueHandling.Include)] + [InlineData("false", DefaultValueHandling.Ignore, true, DefaultValueHandling.Ignore)] + [InlineData("true", DefaultValueHandling.Ignore, true, DefaultValueHandling.Include)] + [InlineData("false", DefaultValueHandling.Include, true, DefaultValueHandling.Ignore)] + [InlineData("true", DefaultValueHandling.Include, true, DefaultValueHandling.Include)] + public void Reader_Outcome(string queryStringParameterValue, DefaultValueHandling optionsDefaultValue, bool optionsAllowOverride, DefaultValueHandling expected) + { + // Arrange + var options = new JsonApiOptions + { + SerializerSettings = {DefaultValueHandling = optionsDefaultValue}, + AllowQueryStringOverrideForSerializerDefaultValueHandling = optionsAllowOverride + }; + + var reader = new DefaultsQueryStringParameterReader(options); + + // Act + if (reader.IsEnabled(DisableQueryAttribute.Empty)) + { + reader.Read("defaults", queryStringParameterValue); + } + + // Assert + reader.SerializerDefaultValueHandling.Should().Be(expected); + } + } +} diff --git a/test/UnitTests/QueryStringParameters/FilterParseTests.cs b/test/UnitTests/QueryStringParameters/FilterParseTests.cs new file mode 100644 index 0000000000..4ababfd6bd --- /dev/null +++ b/test/UnitTests/QueryStringParameters/FilterParseTests.cs @@ -0,0 +1,141 @@ +using System; +using System.ComponentModel.Design; +using System.Linq; +using System.Net; +using FluentAssertions; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Exceptions; +using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Internal.QueryStrings; +using Xunit; + +namespace UnitTests.QueryStringParameters +{ + public sealed class FilterParseTests : ParseTestsBase + { + private readonly FilterQueryStringParameterReader _reader; + + public FilterParseTests() + { + Options.EnableLegacyFilterNotation = false; + + var resourceFactory = new ResourceFactory(new ServiceContainer()); + _reader = new FilterQueryStringParameterReader(CurrentRequest, ResourceGraph, resourceFactory, Options); + } + + [Theory] + [InlineData("filter", true)] + [InlineData("filter[title]", true)] + [InlineData("filters", false)] + [InlineData("filter[", false)] + [InlineData("filter]", false)] + public void Reader_Supports_Parameter_Name(string parameterName, bool expectCanParse) + { + // Act + var canParse = _reader.CanRead(parameterName); + + // Assert + canParse.Should().Be(expectCanParse); + } + + [Theory] + [InlineData(StandardQueryStringParameters.Filter, false)] + [InlineData(StandardQueryStringParameters.All, false)] + [InlineData(StandardQueryStringParameters.None, true)] + [InlineData(StandardQueryStringParameters.Page, true)] + public void Reader_Is_Enabled(StandardQueryStringParameters parametersDisabled, bool expectIsEnabled) + { + // Act + var isEnabled = _reader.IsEnabled(new DisableQueryAttribute(parametersDisabled)); + + // Assert + isEnabled.Should().Be(expectIsEnabled); + } + + [Theory] + [InlineData("filter[", "equals(caption,'some')", "Field name expected.")] + [InlineData("filter[caption]", "equals(url,'some')", "Relationship 'caption' does not exist on resource 'blogs'.")] + [InlineData("filter[articles.caption]", "equals(firstName,'some')", "Relationship 'caption' in 'articles.caption' does not exist on resource 'articles'.")] + [InlineData("filter[articles.author]", "equals(firstName,'some')", "Relationship 'author' in 'articles.author' must be a to-many relationship on resource 'articles'.")] + [InlineData("filter[articles.revisions.author]", "equals(firstName,'some')", "Relationship 'author' in 'articles.revisions.author' must be a to-many relationship on resource 'revisions'.")] + [InlineData("filter[articles]", "equals(author,'some')", "Attribute 'author' does not exist on resource 'articles'.")] + [InlineData("filter[articles]", "lessThan(author,null)", "Attribute 'author' does not exist on resource 'articles'.")] + [InlineData("filter", " ", "Unexpected whitespace.")] + [InlineData("filter", "some", "Filter function expected.")] + [InlineData("filter", "equals", "( expected.")] + [InlineData("filter", "equals'", "Unexpected ' outside text.")] + [InlineData("filter", "equals(", "Count function or field name expected.")] + [InlineData("filter", "equals('1'", "Count function or field name expected.")] + [InlineData("filter", "equals(count(articles),", "Count function, value between quotes, null or field name expected.")] + [InlineData("filter", "equals(title,')", "' expected.")] + [InlineData("filter", "equals(title,null", ") expected.")] + [InlineData("filter", "equals(null", "Field 'null' does not exist on resource 'blogs'.")] + [InlineData("filter", "equals(title,(", "Count function, value between quotes, null or field name expected.")] + [InlineData("filter", "equals(has(articles),'true')", "Field 'has' does not exist on resource 'blogs'.")] + [InlineData("filter", "contains)", "( expected.")] + [InlineData("filter", "contains(title,'a','b')", ") expected.")] + [InlineData("filter", "contains(title,null)", "Value between quotes expected.")] + [InlineData("filter[articles]", "contains(author,null)", "Attribute 'author' does not exist on resource 'articles'.")] + [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[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.")] + [InlineData("filter", "or(equals(title,'some'),equals(title,'other')))", "End of expression expected.")] + [InlineData("filter", "and(equals(title,'some')", ", expected.")] + [InlineData("filter", "and(null", "Filter function expected.")] + [InlineData("filter", "expr:equals(caption,'some')", "Filter function expected.")] + [InlineData("filter", "expr:Equals(caption,'some')", "Filter function expected.")] + public void Reader_Read_Fails(string parameterName, string parameterValue, string errorMessage) + { + // Act + Action action = () => _reader.Read(parameterName, parameterValue); + + // Assert + var exception = action.Should().ThrowExactly().And; + + exception.QueryParameterName.Should().Be(parameterName); + exception.Error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + exception.Error.Title.Should().Be("The specified filter is invalid."); + exception.Error.Detail.Should().Be(errorMessage); + exception.Error.Source.Parameter.Should().Be(parameterName); + } + + [Theory] + [InlineData("filter", "equals(title,'Brian O''Quote')", null, "equals(title,'Brian O''Quote')")] + [InlineData("filter", "equals(title,'')", null, "equals(title,'')")] + [InlineData("filter[articles]", "equals(caption,'this, that & more')", "articles", "equals(caption,'this, that & more')")] + [InlineData("filter[owner.articles]", "equals(caption,'some')", "owner.articles", "equals(caption,'some')")] + [InlineData("filter[articles.revisions]", "equals(publishTime,'2000-01-01')", "articles.revisions", "equals(publishTime,'2000-01-01')")] + [InlineData("filter", "equals(count(articles),'1')", null, "equals(count(articles),'1')")] + [InlineData("filter[articles]", "equals(caption,null)", "articles", "equals(caption,null)")] + [InlineData("filter[articles]", "equals(author,null)", "articles", "equals(author,null)")] + [InlineData("filter[articles]", "equals(author.firstName,author.lastName)", "articles", "equals(author.firstName,author.lastName)")] + [InlineData("filter[articles.revisions]", "lessThan(publishTime,'2000-01-01')", "articles.revisions", "lessThan(publishTime,'2000-01-01')")] + [InlineData("filter[articles.revisions]", "lessOrEqual(publishTime,'2000-01-01')", "articles.revisions", "lessOrEqual(publishTime,'2000-01-01')")] + [InlineData("filter[articles.revisions]", "greaterThan(publishTime,'2000-01-01')", "articles.revisions", "greaterThan(publishTime,'2000-01-01')")] + [InlineData("filter[articles.revisions]", "greaterOrEqual(publishTime,'2000-01-01')", "articles.revisions", "greaterOrEqual(publishTime,'2000-01-01')")] + [InlineData("filter", "has(articles)", null, "has(articles)")] + [InlineData("filter", "contains(title,'this')", null, "contains(title,'this')")] + [InlineData("filter", "startsWith(title,'this')", null, "startsWith(title,'this')")] + [InlineData("filter", "endsWith(title,'this')", null, "endsWith(title,'this')")] + [InlineData("filter", "any(title,'this','that','there')", null, "any(title,'this','that','there')")] + [InlineData("filter", "and(contains(title,'sales'),contains(title,'marketing'),contains(title,'advertising'))", null, "and(contains(title,'sales'),contains(title,'marketing'),contains(title,'advertising'))")] + [InlineData("filter[articles]", "or(and(not(equals(author.firstName,null)),not(equals(author.lastName,null))),not(has(revisions)))", "articles", "or(and(not(equals(author.firstName,null)),not(equals(author.lastName,null))),not(has(revisions)))")] + public void Reader_Read_Succeeds(string parameterName, string parameterValue, string scopeExpected, string valueExpected) + { + // Act + _reader.Read(parameterName, parameterValue); + + var constraints = _reader.GetConstraints(); + + // Assert + var scope = constraints.Select(x => x.Scope).Single(); + scope?.ToString().Should().Be(scopeExpected); + + var value = constraints.Select(x => x.Expression).Single(); + value.ToString().Should().Be(valueExpected); + } + } +} diff --git a/test/UnitTests/QueryStringParameters/IncludeParseTests.cs b/test/UnitTests/QueryStringParameters/IncludeParseTests.cs new file mode 100644 index 0000000000..06cad458e6 --- /dev/null +++ b/test/UnitTests/QueryStringParameters/IncludeParseTests.cs @@ -0,0 +1,94 @@ +using System; +using System.Linq; +using System.Net; +using FluentAssertions; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Exceptions; +using JsonApiDotNetCore.Internal.QueryStrings; +using Xunit; + +namespace UnitTests.QueryStringParameters +{ + public sealed class IncludeParseTests : ParseTestsBase + { + private readonly IncludeQueryStringParameterReader _reader; + + public IncludeParseTests() + { + _reader = new IncludeQueryStringParameterReader(CurrentRequest, ResourceGraph); + } + + [Theory] + [InlineData("include", true)] + [InlineData("include[some]", false)] + [InlineData("includes", false)] + public void Reader_Supports_Parameter_Name(string parameterName, bool expectCanParse) + { + // Act + var canParse = _reader.CanRead(parameterName); + + // Assert + canParse.Should().Be(expectCanParse); + } + + [Theory] + [InlineData(StandardQueryStringParameters.Include, false)] + [InlineData(StandardQueryStringParameters.All, false)] + [InlineData(StandardQueryStringParameters.None, true)] + [InlineData(StandardQueryStringParameters.Filter, true)] + public void Reader_Is_Enabled(StandardQueryStringParameters parametersDisabled, bool expectIsEnabled) + { + // Act + var isEnabled = _reader.IsEnabled(new DisableQueryAttribute(parametersDisabled)); + + // Assert + isEnabled.Should().Be(expectIsEnabled); + } + + [Theory] + [InlineData("includes", "", "Relationship name expected.")] + [InlineData("includes", " ", "Unexpected whitespace.")] + [InlineData("includes", ",", "Relationship name expected.")] + [InlineData("includes", "articles,", "Relationship name expected.")] + [InlineData("includes", "articles[", ", expected.")] + [InlineData("includes", "title", "Relationship 'title' does not exist on resource 'blogs'.")] + [InlineData("includes", "articles.revisions.publishTime,", "Relationship 'publishTime' in 'articles.revisions.publishTime' does not exist on resource 'revisions'.")] + public void Reader_Read_Fails(string parameterName, string parameterValue, string errorMessage) + { + // Act + Action action = () => _reader.Read(parameterName, parameterValue); + + // Assert + var exception = action.Should().ThrowExactly().And; + + exception.QueryParameterName.Should().Be(parameterName); + exception.Error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + exception.Error.Title.Should().Be("The specified include is invalid."); + exception.Error.Detail.Should().Be(errorMessage); + exception.Error.Source.Parameter.Should().Be(parameterName); + } + + [Theory] + [InlineData("includes", "owner", "owner")] + [InlineData("includes", "articles", "articles")] + [InlineData("includes", "owner.articles", "owner.articles")] + [InlineData("includes", "articles.author", "articles.author")] + [InlineData("includes", "articles.revisions", "articles.revisions")] + [InlineData("includes", "articles,articles.revisions", "articles,articles.revisions")] + [InlineData("includes", "articles,articles.revisions,articles.tags", "articles,articles.revisions,articles.tags")] + public void Reader_Read_Succeeds(string parameterName, string parameterValue, string valueExpected) + { + // Act + _reader.Read(parameterName, parameterValue); + + var constraints = _reader.GetConstraints(); + + // Assert + var scope = constraints.Select(x => x.Scope).Single(); + scope.Should().BeNull(); + + var value = constraints.Select(x => x.Expression).Single(); + value.ToString().Should().Be(valueExpected); + } + } +} diff --git a/test/UnitTests/QueryStringParameters/LegacyFilterParseTests.cs b/test/UnitTests/QueryStringParameters/LegacyFilterParseTests.cs new file mode 100644 index 0000000000..523b47a78b --- /dev/null +++ b/test/UnitTests/QueryStringParameters/LegacyFilterParseTests.cs @@ -0,0 +1,95 @@ +using System; +using System.ComponentModel.Design; +using System.Linq; +using System.Net; +using FluentAssertions; +using JsonApiDotNetCore.Exceptions; +using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Internal.QueryStrings; +using JsonApiDotNetCoreExample.Models; +using Xunit; + +namespace UnitTests.QueryStringParameters +{ + public sealed class LegacyFilterParseTests : ParseTestsBase + { + private readonly FilterQueryStringParameterReader _reader; + + public LegacyFilterParseTests() + { + Options.EnableLegacyFilterNotation = true; + + CurrentRequest.PrimaryResource = ResourceGraph.GetResourceContext
(); + + var resourceFactory = new ResourceFactory(new ServiceContainer()); + _reader = new FilterQueryStringParameterReader(CurrentRequest, ResourceGraph, resourceFactory, Options); + } + + [Theory] + [InlineData("filter", "some", "Expected field name between brackets in filter parameter name.")] + [InlineData("filter[", "some", "Expected field name between brackets in filter parameter name.")] + [InlineData("filter[]", "some", "Expected field name between brackets in filter parameter name.")] + [InlineData("filter[.]", "some", "Relationship '' in '.' does not exist on resource 'articles'.")] + [InlineData("filter[some]", "other", "Field 'some' does not exist on resource 'articles'.")] + [InlineData("filter[author]", "some", "Attribute 'author' does not exist on resource 'articles'.")] + [InlineData("filter[author.articles]", "some", "Field 'articles' in 'author.articles' must be an attribute or a to-one relationship on resource 'authors'.")] + [InlineData("filter[unknown.id]", "some", "Relationship 'unknown' in 'unknown.id' does not exist on resource 'articles'.")] + [InlineData("filter[author]", " ", "Unexpected whitespace.")] + [InlineData("filter", "expr:equals(some,'other')", "Field 'some' does not exist on resource 'articles'.")] + [InlineData("filter", "expr:equals(author,'Joe')", "Attribute 'author' does not exist on resource 'articles'.")] + [InlineData("filter", "expr:has(author)", "Relationship 'author' must be a to-many relationship on resource 'articles'.")] + [InlineData("filter", "expr:equals(count(author),'1')", "Relationship 'author' must be a to-many relationship on resource 'articles'.")] + public void Reader_Read_Fails(string parameterName, string parameterValue, string errorMessage) + { + // Act + Action action = () => _reader.Read(parameterName, parameterValue); + + // Assert + var exception = action.Should().ThrowExactly().And; + + exception.QueryParameterName.Should().Be(parameterName); + exception.Error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + exception.Error.Title.Should().Be("The specified filter is invalid."); + exception.Error.Detail.Should().Be(errorMessage); + exception.Error.Source.Parameter.Should().Be(parameterName); + } + + [Theory] + [InlineData("filter[caption]", "Brian O'Quote", "equals(caption,'Brian O''Quote')")] + [InlineData("filter[caption]", "using,comma", "equals(caption,'using,comma')")] + [InlineData("filter[caption]", "am&per-sand", "equals(caption,'am&per-sand')")] + [InlineData("filter[caption]", "2017-08-15T22:43:47.0156350-05:00", "equals(caption,'2017-08-15T22:43:47.0156350-05:00')")] + [InlineData("filter[caption]", "eq:1", "equals(caption,'1')")] + [InlineData("filter[caption]", "lt:2", "lessThan(caption,'2')")] + [InlineData("filter[caption]", "gt:3", "greaterThan(caption,'3')")] + [InlineData("filter[caption]", "le:4", "lessOrEqual(caption,'4')")] + [InlineData("filter[caption]", "le:2017-08-15T22:43:47.0156350-05:00", "lessOrEqual(caption,'2017-08-15T22:43:47.0156350-05:00')")] + [InlineData("filter[caption]", "ge:5", "greaterOrEqual(caption,'5')")] + [InlineData("filter[caption]", "like:that", "contains(caption,'that')")] + [InlineData("filter[caption]", "ne:1", "not(equals(caption,'1'))")] + [InlineData("filter[caption]", "in:first,second", "any(caption,'first','second')")] + [InlineData("filter[caption]", "nin:first,last", "not(any(caption,'first','last'))")] + [InlineData("filter[caption]", "isnull:", "equals(caption,null)")] + [InlineData("filter[caption]", "isnotnull:", "not(equals(caption,null))")] + [InlineData("filter[caption]", "unknown:some", "equals(caption,'unknown:some')")] + [InlineData("filter[author.firstName]", "Jack", "equals(author.firstName,'Jack')")] + [InlineData("filter", "expr:equals(caption,'some')", "equals(caption,'some')")] + [InlineData("filter", "expr:equals(author,null)", "equals(author,null)")] + [InlineData("filter", "expr:has(author.articles)", "has(author.articles)")] + [InlineData("filter", "expr:equals(count(author.articles),'1')", "equals(count(author.articles),'1')")] + public void Reader_Read_Succeeds(string parameterName, string parameterValue, string expressionExpected) + { + // Act + _reader.Read(parameterName, parameterValue); + + var constraints = _reader.GetConstraints(); + + // Assert + var scope = constraints.Select(x => x.Scope).Single(); + scope.Should().BeNull(); + + var value = constraints.Select(x => x.Expression).Single(); + value.ToString().Should().Be(expressionExpected); + } + } +} diff --git a/test/UnitTests/QueryStringParameters/NullsParseTests.cs b/test/UnitTests/QueryStringParameters/NullsParseTests.cs new file mode 100644 index 0000000000..4f9e9109c3 --- /dev/null +++ b/test/UnitTests/QueryStringParameters/NullsParseTests.cs @@ -0,0 +1,129 @@ +using System; +using System.Net; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Exceptions; +using JsonApiDotNetCore.Internal.QueryStrings; +using Newtonsoft.Json; +using Xunit; + +namespace UnitTests.QueryStringParameters +{ + public sealed class NullsParseTests + { + private readonly INullsQueryStringParameterReader _reader; + + public NullsParseTests() + { + _reader = new NullsQueryStringParameterReader(new JsonApiOptions()); + } + + [Theory] + [InlineData("nulls", true)] + [InlineData("null", false)] + [InlineData("nullsettings", false)] + public void Reader_Supports_Parameter_Name(string parameterName, bool expectCanParse) + { + // Act + var canParse = _reader.CanRead(parameterName); + + // Assert + canParse.Should().Be(expectCanParse); + } + + [Theory] + [InlineData(StandardQueryStringParameters.Nulls, false, false)] + [InlineData(StandardQueryStringParameters.Nulls, true, false)] + [InlineData(StandardQueryStringParameters.All, false, false)] + [InlineData(StandardQueryStringParameters.All, true, false)] + [InlineData(StandardQueryStringParameters.None, false, false)] + [InlineData(StandardQueryStringParameters.None, true, true)] + [InlineData(StandardQueryStringParameters.Filter, false, false)] + [InlineData(StandardQueryStringParameters.Filter, true, true)] + public void Reader_Is_Enabled(StandardQueryStringParameters parametersDisabled, bool allowOverride, bool expectIsEnabled) + { + // Arrange + var options = new JsonApiOptions + { + AllowQueryStringOverrideForSerializerNullValueHandling = allowOverride + }; + + var reader = new NullsQueryStringParameterReader(options); + + // Act + var isEnabled = reader.IsEnabled(new DisableQueryAttribute(parametersDisabled)); + + // Assert + isEnabled.Should().Be(allowOverride && expectIsEnabled); + } + + [Theory] + [InlineData("nulls", "", "The value '' must be 'true' or 'false'.")] + [InlineData("nulls", " ", "The value ' ' must be 'true' or 'false'.")] + [InlineData("nulls", "null", "The value 'null' must be 'true' or 'false'.")] + [InlineData("nulls", "0", "The value '0' must be 'true' or 'false'.")] + [InlineData("nulls", "1", "The value '1' must be 'true' or 'false'.")] + [InlineData("nulls", "-1", "The value '-1' must be 'true' or 'false'.")] + public void Reader_Read_Fails(string parameterName, string parameterValue, string errorMessage) + { + // Act + Action action = () => _reader.Read(parameterName, parameterValue); + + // Assert + var exception = action.Should().ThrowExactly().And; + + exception.QueryParameterName.Should().Be(parameterName); + exception.Error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + exception.Error.Title.Should().Be("The specified nulls is invalid."); + exception.Error.Detail.Should().Be(errorMessage); + exception.Error.Source.Parameter.Should().Be(parameterName); + } + + [Theory] + [InlineData("nulls", "true", NullValueHandling.Include)] + [InlineData("nulls", "True", NullValueHandling.Include)] + [InlineData("nulls", "false", NullValueHandling.Ignore)] + [InlineData("nulls", "False", NullValueHandling.Ignore)] + public void Reader_Read_Succeeds(string parameterName, string parameterValue, NullValueHandling expectedValue) + { + // Act + _reader.Read(parameterName, parameterValue); + + NullValueHandling handling = _reader.SerializerNullValueHandling; + + // Assert + handling.Should().Be(expectedValue); + } + + [Theory] + [InlineData("false", NullValueHandling.Ignore, false, NullValueHandling.Ignore)] + [InlineData("true", NullValueHandling.Ignore, false, NullValueHandling.Ignore)] + [InlineData("false", NullValueHandling.Include, false, NullValueHandling.Include)] + [InlineData("true", NullValueHandling.Include, false, NullValueHandling.Include)] + [InlineData("false", NullValueHandling.Ignore, true, NullValueHandling.Ignore)] + [InlineData("true", NullValueHandling.Ignore, true, NullValueHandling.Include)] + [InlineData("false", NullValueHandling.Include, true, NullValueHandling.Ignore)] + [InlineData("true", NullValueHandling.Include, true, NullValueHandling.Include)] + public void Reader_Outcome(string queryStringParameterValue, NullValueHandling optionsNullValue, bool optionsAllowOverride, NullValueHandling expected) + { + // Arrange + var options = new JsonApiOptions + { + SerializerSettings = {NullValueHandling = optionsNullValue}, + AllowQueryStringOverrideForSerializerNullValueHandling = optionsAllowOverride + }; + + var reader = new NullsQueryStringParameterReader(options); + + // Act + if (reader.IsEnabled(DisableQueryAttribute.Empty)) + { + reader.Read("nulls", queryStringParameterValue); + } + + // Assert + reader.SerializerNullValueHandling.Should().Be(expected); + } + } +} diff --git a/test/UnitTests/QueryStringParameters/PaginationParseTests.cs b/test/UnitTests/QueryStringParameters/PaginationParseTests.cs new file mode 100644 index 0000000000..76f6a29f1f --- /dev/null +++ b/test/UnitTests/QueryStringParameters/PaginationParseTests.cs @@ -0,0 +1,155 @@ +using System; +using System.Linq; +using System.Net; +using FluentAssertions; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Exceptions; +using JsonApiDotNetCore.Internal.QueryStrings; +using Xunit; + +namespace UnitTests.QueryStringParameters +{ + public sealed class PaginationParseTests : ParseTestsBase + { + private readonly IPaginationQueryStringParameterReader _reader; + + public PaginationParseTests() + { + Options.DefaultPageSize = new PageSize(25); + _reader = new PaginationQueryStringParameterReader(CurrentRequest, ResourceGraph, Options); + } + + [Theory] + [InlineData("page[size]", true)] + [InlineData("page[number]", true)] + [InlineData("page", false)] + [InlineData("page[", false)] + [InlineData("page[some]", false)] + public void Reader_Supports_Parameter_Name(string parameterName, bool expectCanParse) + { + // Act + var canParse = _reader.CanRead(parameterName); + + // Assert + canParse.Should().Be(expectCanParse); + } + + [Theory] + [InlineData(StandardQueryStringParameters.Page, false)] + [InlineData(StandardQueryStringParameters.All, false)] + [InlineData(StandardQueryStringParameters.None, true)] + [InlineData(StandardQueryStringParameters.Sort, true)] + public void Reader_Is_Enabled(StandardQueryStringParameters parametersDisabled, bool expectIsEnabled) + { + // Act + var isEnabled = _reader.IsEnabled(new DisableQueryAttribute(parametersDisabled)); + + // Assert + isEnabled.Should().Be(expectIsEnabled); + } + + [Theory] + [InlineData("", "Number or relationship name expected.")] + [InlineData("1,", "Number or relationship name expected.")] + [InlineData("(", "Number or relationship name expected.")] + [InlineData(" ", "Unexpected whitespace.")] + [InlineData("-", "Digits expected.")] + [InlineData("-1", "Page number cannot be negative or zero.")] + [InlineData("articles", ": expected.")] + [InlineData("articles:", "Number expected.")] + [InlineData("articles:abc", "Number expected.")] + [InlineData("1(", ", expected.")] + [InlineData("articles:-abc", "Digits expected.")] + [InlineData("articles:-1", "Page number cannot be negative or zero.")] + [InlineData("articles.id", "Relationship 'id' in 'articles.id' does not exist on resource 'articles'.")] + [InlineData("articles.tags.id", "Relationship 'id' in 'articles.tags.id' does not exist on resource 'tags'.")] + [InlineData("articles.author", "Relationship 'author' in 'articles.author' must be a to-many relationship on resource 'articles'.")] + [InlineData("something", "Relationship 'something' does not exist on resource 'blogs'.")] + public void Reader_Read_Page_Number_Fails(string parameterValue, string errorMessage) + { + // Act + Action action = () => _reader.Read("page[number]", parameterValue); + + // Assert + var exception = action.Should().ThrowExactly().And; + + exception.QueryParameterName.Should().Be("page[number]"); + exception.Error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + exception.Error.Title.Should().Be("The specified paging is invalid."); + exception.Error.Detail.Should().Be(errorMessage); + exception.Error.Source.Parameter.Should().Be("page[number]"); + } + + [Theory] + [InlineData("", "Number or relationship name expected.")] + [InlineData("1,", "Number or relationship name expected.")] + [InlineData("(", "Number or relationship name expected.")] + [InlineData(" ", "Unexpected whitespace.")] + [InlineData("-", "Digits expected.")] + [InlineData("-1", "Page size cannot be negative.")] + [InlineData("articles", ": expected.")] + [InlineData("articles:", "Number expected.")] + [InlineData("articles:abc", "Number expected.")] + [InlineData("1(", ", expected.")] + [InlineData("articles:-abc", "Digits expected.")] + [InlineData("articles:-1", "Page size cannot be negative.")] + [InlineData("articles.id", "Relationship 'id' in 'articles.id' does not exist on resource 'articles'.")] + [InlineData("articles.tags.id", "Relationship 'id' in 'articles.tags.id' does not exist on resource 'tags'.")] + [InlineData("articles.author", "Relationship 'author' in 'articles.author' must be a to-many relationship on resource 'articles'.")] + [InlineData("something", "Relationship 'something' does not exist on resource 'blogs'.")] + public void Reader_Read_Page_Size_Fails(string parameterValue, string errorMessage) + { + // Act + Action action = () => _reader.Read("page[size]", parameterValue); + + // Assert + var exception = action.Should().ThrowExactly().And; + + exception.QueryParameterName.Should().Be("page[size]"); + exception.Error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + exception.Error.Title.Should().Be("The specified paging is invalid."); + exception.Error.Detail.Should().Be(errorMessage); + exception.Error.Source.Parameter.Should().Be("page[size]"); + } + + [Theory] + [InlineData(null, "5", "", "Page number: 1, size: 5")] + [InlineData("2", null, "", "Page number: 2, size: 25")] + [InlineData("2", "5", "", "Page number: 2, size: 5")] + [InlineData("articles:4", "articles:2", "|articles", "Page number: 1, size: 25|Page number: 4, size: 2")] + [InlineData("articles:4", "5", "|articles", "Page number: 1, size: 5|Page number: 4, size: 25")] + [InlineData("4", "articles:5", "|articles", "Page number: 4, size: 25|Page number: 1, size: 5")] + [InlineData("3,owner.articles:4", "20,owner.articles:10", "|owner.articles", "Page number: 3, size: 20|Page number: 4, size: 10")] + [InlineData("articles:4,3", "articles:10,20", "|articles", "Page number: 3, size: 20|Page number: 4, size: 10")] + [InlineData("articles:4,articles.revisions:5,3", "articles:10,articles.revisions:15,20", "|articles|articles.revisions", "Page number: 3, size: 20|Page number: 4, size: 10|Page number: 5, size: 15")] + public void Reader_Read_Pagination_Succeeds(string pageNumber, string pageSize, string scopeTreesExpected, string valueTreesExpected) + { + // Act + if (pageNumber != null) + { + _reader.Read("page[number]", pageNumber); + } + + if (pageSize != null) + { + _reader.Read("page[size]", pageSize); + } + + var constraints = _reader.GetConstraints(); + + // Assert + var scopeTreesExpectedArray = scopeTreesExpected.Split("|"); + var scopeTrees = constraints.Select(x => x.Scope).ToArray(); + + scopeTrees.Should().HaveSameCount(scopeTreesExpectedArray); + scopeTrees.Select(tree => tree?.ToString() ?? "").Should().BeEquivalentTo(scopeTreesExpectedArray, options => options.WithStrictOrdering()); + + var valueTreesExpectedArray = valueTreesExpected.Split("|"); + var valueTrees = constraints.Select(x => x.Expression).ToArray(); + + valueTrees.Should().HaveSameCount(valueTreesExpectedArray); + valueTrees.Select(tree => tree.ToString()).Should().BeEquivalentTo(valueTreesExpectedArray, options => options.WithStrictOrdering()); + } + } +} diff --git a/test/UnitTests/QueryStringParameters/ParseTestsBase.cs b/test/UnitTests/QueryStringParameters/ParseTestsBase.cs new file mode 100644 index 0000000000..4717c2c11f --- /dev/null +++ b/test/UnitTests/QueryStringParameters/ParseTestsBase.cs @@ -0,0 +1,37 @@ +using JsonApiDotNetCore.Builders; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Internal.Contracts; +using JsonApiDotNetCore.RequestServices; +using JsonApiDotNetCoreExample.Models; +using Microsoft.Extensions.Logging.Abstractions; + +namespace UnitTests.QueryStringParameters +{ + public abstract class ParseTestsBase + { + protected JsonApiOptions Options { get; } + protected IResourceGraph ResourceGraph { get; } + protected CurrentRequest CurrentRequest { get; } + + protected ParseTestsBase() + { + Options = new JsonApiOptions(); + + ResourceGraph = new ResourceGraphBuilder(Options, NullLoggerFactory.Instance) + .AddResource() + .AddResource
() + .AddResource() + .AddResource
() + .AddResource() + .AddResource() + .AddResource() + .Build(); + + CurrentRequest = new CurrentRequest + { + PrimaryResource = ResourceGraph.GetResourceContext(), + IsCollection = true + }; + } + } +} diff --git a/test/UnitTests/QueryStringParameters/SortParseTests.cs b/test/UnitTests/QueryStringParameters/SortParseTests.cs new file mode 100644 index 0000000000..fdae623781 --- /dev/null +++ b/test/UnitTests/QueryStringParameters/SortParseTests.cs @@ -0,0 +1,113 @@ +using System; +using System.Linq; +using System.Net; +using FluentAssertions; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Exceptions; +using JsonApiDotNetCore.Internal.QueryStrings; +using Xunit; + +namespace UnitTests.QueryStringParameters +{ + public sealed class SortParseTests : ParseTestsBase + { + private readonly SortQueryStringParameterReader _reader; + + public SortParseTests() + { + _reader = new SortQueryStringParameterReader(CurrentRequest, ResourceGraph); + } + + [Theory] + [InlineData("sort", true)] + [InlineData("sort[articles]", true)] + [InlineData("sort[articles.revisions]", true)] + [InlineData("sorting", false)] + [InlineData("sort[", false)] + [InlineData("sort]", false)] + public void Reader_Supports_Parameter_Name(string parameterName, bool expectCanParse) + { + // Act + var canParse = _reader.CanRead(parameterName); + + // Assert + canParse.Should().Be(expectCanParse); + } + + [Theory] + [InlineData(StandardQueryStringParameters.Sort, false)] + [InlineData(StandardQueryStringParameters.All, false)] + [InlineData(StandardQueryStringParameters.None, true)] + [InlineData(StandardQueryStringParameters.Filter, true)] + public void Reader_Is_Enabled(StandardQueryStringParameters parametersDisabled, bool expectIsEnabled) + { + // Act + var isEnabled = _reader.IsEnabled(new DisableQueryAttribute(parametersDisabled)); + + // Assert + isEnabled.Should().Be(expectIsEnabled); + } + + [Theory] + [InlineData("sort[", "id", "Field name expected.")] + [InlineData("sort[abc.def]", "id", "Relationship 'abc' in 'abc.def' does not exist on resource 'blogs'.")] + [InlineData("sort[articles.author]", "id", "Relationship 'author' in 'articles.author' must be a to-many relationship on resource 'articles'.")] + [InlineData("sort", "", "-, count function or field name expected.")] + [InlineData("sort", " ", "Unexpected whitespace.")] + [InlineData("sort", "-", "Count function or field name expected.")] + [InlineData("sort", "abc", "Attribute 'abc' does not exist on resource 'blogs'.")] + [InlineData("sort[articles]", "author", "Attribute 'author' does not exist on resource 'articles'.")] + [InlineData("sort[articles]", "author.livingAddress", "Attribute 'livingAddress' in 'author.livingAddress' does not exist on resource 'authors'.")] + [InlineData("sort", "-count", "( expected.")] + [InlineData("sort", "count", "( expected.")] + [InlineData("sort", "count(articles", ") expected.")] + [InlineData("sort", "count(", "Field name expected.")] + [InlineData("sort", "count(-abc)", "Field name expected.")] + [InlineData("sort", "count(abc)", "Relationship 'abc' does not exist on resource 'blogs'.")] + [InlineData("sort", "count(id)", "Relationship 'id' does not exist on resource 'blogs'.")] + [InlineData("sort[articles]", "count(author)", "Relationship 'author' must be a to-many relationship on resource 'articles'.")] + [InlineData("sort[articles]", "caption,", "-, count function or field name expected.")] + [InlineData("sort[articles]", "caption:", ", expected.")] + [InlineData("sort[articles]", "caption,-", "Count function or field name expected.")] + public void Reader_Read_Fails(string parameterName, string parameterValue, string errorMessage) + { + // Act + Action action = () => _reader.Read(parameterName, parameterValue); + + // Assert + var exception = action.Should().ThrowExactly().And; + + exception.QueryParameterName.Should().Be(parameterName); + exception.Error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + exception.Error.Title.Should().Be("The specified sort is invalid."); + exception.Error.Detail.Should().Be(errorMessage); + exception.Error.Source.Parameter.Should().Be(parameterName); + } + + [Theory] + [InlineData("sort", "id", null, "id")] + [InlineData("sort", "count(articles),-id", null, "count(articles),-id")] + [InlineData("sort", "-count(articles),id", null, "-count(articles),id")] + [InlineData("sort[articles]", "count(revisions),-id", "articles", "count(revisions),-id")] + [InlineData("sort[owner.articles]", "-caption", "owner.articles", "-caption")] + [InlineData("sort[articles]", "author.firstName", "articles", "author.firstName")] + [InlineData("sort[articles]", "-caption,-author.firstName", "articles", "-caption,-author.firstName")] + [InlineData("sort[articles]", "caption,author.firstName,-id", "articles", "caption,author.firstName,-id")] + [InlineData("sort[articles.tags]", "id,name", "articles.tags", "id,name")] + [InlineData("sort[articles.revisions]", "-publishTime,author.lastName,author.livingAddress.country.name", "articles.revisions", "-publishTime,author.lastName,author.livingAddress.country.name")] + public void Reader_Read_Succeeds(string parameterName, string parameterValue, string scopeExpected, string valueExpected) + { + // Act + _reader.Read(parameterName, parameterValue); + + var constraints = _reader.GetConstraints(); + + // Assert + var scope = constraints.Select(x => x.Scope).Single(); + scope?.ToString().Should().Be(scopeExpected); + + var value = constraints.Select(x => x.Expression).Single(); + value.ToString().Should().Be(valueExpected); + } + } +} diff --git a/test/UnitTests/QueryStringParameters/SparseFieldSetParseTests.cs b/test/UnitTests/QueryStringParameters/SparseFieldSetParseTests.cs new file mode 100644 index 0000000000..b6083150d2 --- /dev/null +++ b/test/UnitTests/QueryStringParameters/SparseFieldSetParseTests.cs @@ -0,0 +1,100 @@ +using System; +using System.Linq; +using System.Net; +using FluentAssertions; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Exceptions; +using JsonApiDotNetCore.Internal.QueryStrings; +using Xunit; + +namespace UnitTests.QueryStringParameters +{ + public sealed class SparseFieldSetParseTests : ParseTestsBase + { + private readonly SparseFieldSetQueryStringParameterReader _reader; + + public SparseFieldSetParseTests() + { + _reader = new SparseFieldSetQueryStringParameterReader(CurrentRequest, ResourceGraph); + } + + [Theory] + [InlineData("fields", true)] + [InlineData("fields[articles]", true)] + [InlineData("fields[articles.revisions]", true)] + [InlineData("fieldset", false)] + [InlineData("fields[", false)] + [InlineData("fields]", false)] + public void Reader_Supports_Parameter_Name(string parameterName, bool expectCanParse) + { + // Act + var canParse = _reader.CanRead(parameterName); + + // Assert + canParse.Should().Be(expectCanParse); + } + + [Theory] + [InlineData(StandardQueryStringParameters.Fields, false)] + [InlineData(StandardQueryStringParameters.All, false)] + [InlineData(StandardQueryStringParameters.None, true)] + [InlineData(StandardQueryStringParameters.Filter, true)] + public void Reader_Is_Enabled(StandardQueryStringParameters parametersDisabled, bool expectIsEnabled) + { + // Act + var isEnabled = _reader.IsEnabled(new DisableQueryAttribute(parametersDisabled)); + + // Assert + isEnabled.Should().Be(expectIsEnabled); + } + + [Theory] + [InlineData("fields[", "id", "Field name expected.")] + [InlineData("fields[id]", "id", "Relationship 'id' does not exist on resource 'blogs'.")] + [InlineData("fields[articles.id]", "id", "Relationship 'id' in 'articles.id' does not exist on resource 'articles'.")] + [InlineData("fields", "", "Attribute name expected.")] + [InlineData("fields", " ", "Unexpected whitespace.")] + [InlineData("fields", "id,articles", "Attribute 'articles' does not exist on resource 'blogs'.")] + [InlineData("fields", "id,articles.name", "Attribute 'articles.name' does not exist on resource 'blogs'.")] + [InlineData("fields[articles]", "id,tags", "Attribute 'tags' does not exist on resource 'articles'.")] + [InlineData("fields[articles.author.livingAddress]", "street,some", "Attribute 'some' does not exist on resource 'addresses'.")] + [InlineData("fields", "id(", ", expected.")] + [InlineData("fields", "id,", "Attribute name expected.")] + public void Reader_Read_Fails(string parameterName, string parameterValue, string errorMessage) + { + // Act + Action action = () => _reader.Read(parameterName, parameterValue); + + // Assert + var exception = action.Should().ThrowExactly().And; + + exception.QueryParameterName.Should().Be(parameterName); + exception.Error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + exception.Error.Title.Should().Be("The specified fieldset is invalid."); + exception.Error.Detail.Should().Be(errorMessage); + exception.Error.Source.Parameter.Should().Be(parameterName); + } + + [Theory] + [InlineData("fields", "id", null, "id")] + [InlineData("fields[articles]", "caption,url", "articles", "caption,url")] + [InlineData("fields[owner.articles]", "caption", "owner.articles", "caption")] + [InlineData("fields[articles.author]", "firstName,id", "articles.author", "firstName,id")] + [InlineData("fields[articles.author.livingAddress]", "street,zipCode", "articles.author.livingAddress", "street,zipCode")] + [InlineData("fields[articles.tags]", "name,id", "articles.tags", "name,id")] + public void Reader_Read_Succeeds(string parameterName, string parameterValue, string scopeExpected, string valueExpected) + { + // Act + _reader.Read(parameterName, parameterValue); + + var constraints = _reader.GetConstraints(); + + // Assert + var scope = constraints.Select(x => x.Scope).Single(); + scope?.ToString().Should().Be(scopeExpected); + + var value = constraints.Select(x => x.Expression).Single(); + value.ToString().Should().Be(valueExpected); + } + } +} diff --git a/test/UnitTests/ResourceHooks/DiscoveryTests.cs b/test/UnitTests/ResourceHooks/DiscoveryTests.cs index 493d61e4f8..cf658034f2 100644 --- a/test/UnitTests/ResourceHooks/DiscoveryTests.cs +++ b/test/UnitTests/ResourceHooks/DiscoveryTests.cs @@ -19,8 +19,8 @@ public sealed class DummyResourceDefinition : ResourceDefinition { public DummyResourceDefinition() : base(new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance).AddResource().Build()) { } - public override IEnumerable BeforeDelete(IEntityHashSet affected, ResourcePipeline pipeline) { return affected; } - public override void AfterDelete(HashSet entities, ResourcePipeline pipeline, bool succeeded) { } + public override IEnumerable BeforeDelete(IResourceHashSet affected, ResourcePipeline pipeline) { return affected; } + public override void AfterDelete(HashSet resources, ResourcePipeline pipeline, bool succeeded) { } } private IServiceProvider MockProvider(object service) where TResource : class, IIdentifiable @@ -44,8 +44,8 @@ public class AnotherDummy : Identifiable { } public abstract class ResourceDefinitionBase : ResourceDefinition where T : class, IIdentifiable { protected ResourceDefinitionBase(IResourceGraph resourceGraph) : base(resourceGraph) { } - public override IEnumerable BeforeDelete(IEntityHashSet entities, ResourcePipeline pipeline) { return entities; } - public override void AfterDelete(HashSet entities, ResourcePipeline pipeline, bool succeeded) { } + public override IEnumerable BeforeDelete(IResourceHashSet resources, ResourcePipeline pipeline) { return resources; } + public override void AfterDelete(HashSet resources, ResourcePipeline pipeline, bool succeeded) { } } public sealed class AnotherDummyResourceDefinition : ResourceDefinitionBase @@ -68,10 +68,10 @@ public sealed class YetAnotherDummyResourceDefinition : ResourceDefinition().Build()) { } - public override IEnumerable BeforeDelete(IEntityHashSet affected, ResourcePipeline pipeline) { return affected; } + public override IEnumerable BeforeDelete(IResourceHashSet affected, ResourcePipeline pipeline) { return affected; } [LoadDatabaseValues(false)] - public override void AfterDelete(HashSet entities, ResourcePipeline pipeline, bool succeeded) { } + public override void AfterDelete(HashSet resources, ResourcePipeline pipeline, bool succeeded) { } } [Fact] @@ -100,8 +100,8 @@ public sealed class GenericDummyResourceDefinition : ResourceDefiniti { public GenericDummyResourceDefinition() : base(new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance).AddResource().Build()) { } - public override IEnumerable BeforeDelete(IEntityHashSet entities, ResourcePipeline pipeline) { return entities; } - public override void AfterDelete(HashSet entities, ResourcePipeline pipeline, bool succeeded) { } + public override IEnumerable BeforeDelete(IResourceHashSet resources, ResourcePipeline pipeline) { return resources; } + public override void AfterDelete(HashSet resources, ResourcePipeline pipeline, bool succeeded) { } } } } diff --git a/test/UnitTests/ResourceHooks/AffectedEntitiesHelperTests.cs b/test/UnitTests/ResourceHooks/RelationshipDictionaryTests.cs similarity index 57% rename from test/UnitTests/ResourceHooks/AffectedEntitiesHelperTests.cs rename to test/UnitTests/ResourceHooks/RelationshipDictionaryTests.cs index 8e0b95c29e..59ae2a621c 100644 --- a/test/UnitTests/ResourceHooks/AffectedEntitiesHelperTests.cs +++ b/test/UnitTests/ResourceHooks/RelationshipDictionaryTests.cs @@ -1,12 +1,12 @@ -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Hooks; using System.Collections.Generic; -using Xunit; using System.Linq; using System.Reflection; +using JsonApiDotNetCore.Hooks; +using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Models.Annotation; +using Xunit; -namespace UnitTests.ResourceHooks.AffectedEntities +namespace UnitTests.ResourceHooks { public sealed class Dummy : Identifiable { @@ -32,11 +32,11 @@ public sealed class RelationshipDictionaryTests public readonly HasManyAttribute ToManyAttr; public readonly Dictionary> Relationships = new Dictionary>(); - public readonly HashSet FirstToOnesEntities = new HashSet { new Dummy { Id = 1 }, new Dummy { Id = 2 }, new Dummy { Id = 3 } }; - public readonly HashSet SecondToOnesEntities = new HashSet { new Dummy { Id = 4 }, new Dummy { Id = 5 }, new Dummy { Id = 6 } }; - public readonly HashSet ToManiesEntities = new HashSet { new Dummy { Id = 7 }, new Dummy { Id = 8 }, new Dummy { Id = 9 } }; - public readonly HashSet NoRelationshipsEntities = new HashSet { new Dummy { Id = 10 }, new Dummy { Id = 11 }, new Dummy { Id = 12 } }; - public readonly HashSet AllEntities; + public readonly HashSet FirstToOnesResources = new HashSet { new Dummy { Id = 1 }, new Dummy { Id = 2 }, new Dummy { Id = 3 } }; + public readonly HashSet SecondToOnesResources = new HashSet { new Dummy { Id = 4 }, new Dummy { Id = 5 }, new Dummy { Id = 6 } }; + public readonly HashSet ToManiesResources = new HashSet { new Dummy { Id = 7 }, new Dummy { Id = 8 }, new Dummy { Id = 9 } }; + public readonly HashSet NoRelationshipsResources = new HashSet { new Dummy { Id = 10 }, new Dummy { Id = 11 }, new Dummy { Id = 12 } }; + public readonly HashSet AllResources; public RelationshipDictionaryTests() { FirstToOneAttr = new HasOneAttribute("firstToOne") @@ -57,10 +57,10 @@ public RelationshipDictionaryTests() RightType = typeof(ToMany), Property = typeof(Dummy).GetProperty(nameof(Dummy.ToManies)) }; - Relationships.Add(FirstToOneAttr, FirstToOnesEntities); - Relationships.Add(SecondToOneAttr, SecondToOnesEntities); - Relationships.Add(ToManyAttr, ToManiesEntities); - AllEntities = new HashSet(FirstToOnesEntities.Union(SecondToOnesEntities).Union(ToManiesEntities).Union(NoRelationshipsEntities)); + Relationships.Add(FirstToOneAttr, FirstToOnesResources); + Relationships.Add(SecondToOneAttr, SecondToOnesResources); + Relationships.Add(ToManyAttr, ToManiesResources); + AllResources = new HashSet(FirstToOnesResources.Union(SecondToOnesResources).Union(ToManiesResources).Union(NoRelationshipsResources)); } [Fact] @@ -90,38 +90,38 @@ public void RelationshipsDictionary_GetAffected() var affectedThroughToMany = relationshipsDictionary.GetAffected(d => d.ToManies).ToList(); // Assert - affectedThroughFirstToOne.ForEach(entity => Assert.Contains(entity, FirstToOnesEntities)); - affectedThroughSecondToOne.ForEach(entity => Assert.Contains(entity, SecondToOnesEntities)); - affectedThroughToMany.ForEach(entity => Assert.Contains(entity, ToManiesEntities)); + affectedThroughFirstToOne.ForEach(resource => Assert.Contains(resource, FirstToOnesResources)); + affectedThroughSecondToOne.ForEach(resource => Assert.Contains(resource, SecondToOnesResources)); + affectedThroughToMany.ForEach(resource => Assert.Contains(resource, ToManiesResources)); } [Fact] - public void EntityHashSet_GetByRelationships() + public void ResourceHashSet_GetByRelationships() { // Arrange - EntityHashSet entities = new EntityHashSet(AllEntities, Relationships); + ResourceHashSet resources = new ResourceHashSet(AllResources, Relationships); // Act - Dictionary> toOnes = entities.GetByRelationship(); - Dictionary> toManies = entities.GetByRelationship(); - Dictionary> notTargeted = entities.GetByRelationship(); - Dictionary> allRelationships = entities.AffectedRelationships; + Dictionary> toOnes = resources.GetByRelationship(); + Dictionary> toManies = resources.GetByRelationship(); + Dictionary> notTargeted = resources.GetByRelationship(); + Dictionary> allRelationships = resources.AffectedRelationships; // Assert AssertRelationshipDictionaryGetters(allRelationships, toOnes, toManies, notTargeted); - var allEntitiesWithAffectedRelationships = allRelationships.SelectMany(kvp => kvp.Value).ToList(); - NoRelationshipsEntities.ToList().ForEach(e => + var allResourcesWithAffectedRelationships = allRelationships.SelectMany(kvp => kvp.Value).ToList(); + NoRelationshipsResources.ToList().ForEach(e => { - Assert.DoesNotContain(e, allEntitiesWithAffectedRelationships); + Assert.DoesNotContain(e, allResourcesWithAffectedRelationships); }); } [Fact] - public void EntityDiff_GetByRelationships() + public void ResourceDiff_GetByRelationships() { // Arrange - var dbEntities = new HashSet(AllEntities.Select(e => new Dummy { Id = e.Id }).ToList()); - DiffableEntityHashSet diffs = new DiffableEntityHashSet(AllEntities, dbEntities, Relationships, null); + var dbResources = new HashSet(AllResources.Select(e => new Dummy { Id = e.Id }).ToList()); + DiffableResourceHashSet diffs = new DiffableResourceHashSet(AllResources, dbResources, Relationships, null); // Act Dictionary> toOnes = diffs.GetByRelationship(); @@ -131,47 +131,47 @@ public void EntityDiff_GetByRelationships() // Assert AssertRelationshipDictionaryGetters(allRelationships, toOnes, toManies, notTargeted); - var allEntitiesWithAffectedRelationships = allRelationships.SelectMany(kvp => kvp.Value).ToList(); - NoRelationshipsEntities.ToList().ForEach(e => + var allResourcesWithAffectedRelationships = allRelationships.SelectMany(kvp => kvp.Value).ToList(); + NoRelationshipsResources.ToList().ForEach(e => { - Assert.DoesNotContain(e, allEntitiesWithAffectedRelationships); + Assert.DoesNotContain(e, allResourcesWithAffectedRelationships); }); - var requestEntitiesFromDiff = diffs; - requestEntitiesFromDiff.ToList().ForEach(e => + var requestResourcesFromDiff = diffs; + requestResourcesFromDiff.ToList().ForEach(e => { - Assert.Contains(e, AllEntities); + Assert.Contains(e, AllResources); }); - var databaseEntitiesFromDiff = diffs.GetDiffs().Select(d => d.DatabaseValue); - databaseEntitiesFromDiff.ToList().ForEach(e => + var databaseResourcesFromDiff = diffs.GetDiffs().Select(d => d.DatabaseValue); + databaseResourcesFromDiff.ToList().ForEach(e => { - Assert.Contains(e, dbEntities); + Assert.Contains(e, dbResources); }); } [Fact] - public void EntityDiff_Loops_Over_Diffs() + public void ResourceDiff_Loops_Over_Diffs() { // Arrange - var dbEntities = new HashSet(AllEntities.Select(e => new Dummy { Id = e.Id })); - DiffableEntityHashSet diffs = new DiffableEntityHashSet(AllEntities, dbEntities, Relationships, null); + var dbResources = new HashSet(AllResources.Select(e => new Dummy { Id = e.Id })); + DiffableResourceHashSet diffs = new DiffableResourceHashSet(AllResources, dbResources, Relationships, null); // Assert & act - foreach (EntityDiffPair diff in diffs.GetDiffs()) + foreach (ResourceDiffPair diff in diffs.GetDiffs()) { - Assert.Equal(diff.Entity.Id, diff.DatabaseValue.Id); - Assert.NotEqual(diff.Entity, diff.DatabaseValue); - Assert.Contains(diff.Entity, AllEntities); - Assert.Contains(diff.DatabaseValue, dbEntities); + Assert.Equal(diff.Resource.Id, diff.DatabaseValue.Id); + Assert.NotEqual(diff.Resource, diff.DatabaseValue); + Assert.Contains(diff.Resource, AllResources); + Assert.Contains(diff.DatabaseValue, dbResources); } } [Fact] - public void EntityDiff_GetAffected_Relationships() + public void ResourceDiff_GetAffected_Relationships() { // Arrange - var dbEntities = new HashSet(AllEntities.Select(e => new Dummy { Id = e.Id })); - DiffableEntityHashSet diffs = new DiffableEntityHashSet(AllEntities, dbEntities, Relationships, null); + var dbResources = new HashSet(AllResources.Select(e => new Dummy { Id = e.Id })); + DiffableResourceHashSet diffs = new DiffableResourceHashSet(AllResources, dbResources, Relationships, null); // Act var affectedThroughFirstToOne = diffs.GetAffected(d => d.FirstToOne).ToList(); @@ -179,21 +179,21 @@ public void EntityDiff_GetAffected_Relationships() var affectedThroughToMany = diffs.GetAffected(d => d.ToManies).ToList(); // Assert - affectedThroughFirstToOne.ForEach(entity => Assert.Contains(entity, FirstToOnesEntities)); - affectedThroughSecondToOne.ForEach(entity => Assert.Contains(entity, SecondToOnesEntities)); - affectedThroughToMany.ForEach(entity => Assert.Contains(entity, ToManiesEntities)); + affectedThroughFirstToOne.ForEach(resource => Assert.Contains(resource, FirstToOnesResources)); + affectedThroughSecondToOne.ForEach(resource => Assert.Contains(resource, SecondToOnesResources)); + affectedThroughToMany.ForEach(resource => Assert.Contains(resource, ToManiesResources)); } [Fact] - public void EntityDiff_GetAffected_Attributes() + public void ResourceDiff_GetAffected_Attributes() { // Arrange - var dbEntities = new HashSet(AllEntities.Select(e => new Dummy { Id = e.Id })); + var dbResources = new HashSet(AllResources.Select(e => new Dummy { Id = e.Id })); var updatedAttributes = new Dictionary> { - { typeof(Dummy).GetProperty("SomeUpdatedProperty"), AllEntities } + { typeof(Dummy).GetProperty("SomeUpdatedProperty"), AllResources } }; - DiffableEntityHashSet diffs = new DiffableEntityHashSet(AllEntities, dbEntities, Relationships, updatedAttributes); + DiffableResourceHashSet diffs = new DiffableResourceHashSet(AllResources, dbResources, Relationships, updatedAttributes); // Act var affectedThroughSomeUpdatedProperty = diffs.GetAffected(d => d.SomeUpdatedProperty); @@ -214,19 +214,19 @@ private void AssertRelationshipDictionaryGetters(Dictionary + toOnes[FirstToOneAttr].ToList().ForEach(resource => { - Assert.Contains(entity, FirstToOnesEntities); + Assert.Contains(resource, FirstToOnesResources); }); - toOnes[SecondToOneAttr].ToList().ForEach(entity => + toOnes[SecondToOneAttr].ToList().ForEach(resource => { - Assert.Contains(entity, SecondToOnesEntities); + Assert.Contains(resource, SecondToOnesResources); }); - toManies[ToManyAttr].ToList().ForEach(entity => + toManies[ToManyAttr].ToList().ForEach(resource => { - Assert.Contains(entity, ToManiesEntities); + Assert.Contains(resource, ToManiesResources); }); Assert.Empty(notTargeted); } diff --git a/test/UnitTests/ResourceHooks/ResourceHookExecutor/Create/BeforeCreateTests.cs b/test/UnitTests/ResourceHooks/ResourceHookExecutor/Create/BeforeCreateTests.cs index 64c2e24e9b..413ef00338 100644 --- a/test/UnitTests/ResourceHooks/ResourceHookExecutor/Create/BeforeCreateTests.cs +++ b/test/UnitTests/ResourceHooks/ResourceHookExecutor/Create/BeforeCreateTests.cs @@ -23,7 +23,7 @@ public void BeforeCreate() // Act hookExecutor.BeforeCreate(todoList, ResourcePipeline.Post); // Assert - todoResourceMock.Verify(rd => rd.BeforeCreate(It.IsAny>(), ResourcePipeline.Post), Times.Once()); + todoResourceMock.Verify(rd => rd.BeforeCreate(It.IsAny>(), ResourcePipeline.Post), Times.Once()); ownerResourceMock.Verify(rd => rd.BeforeUpdateRelationship(It.IsAny>(), It.IsAny>(), ResourcePipeline.Post), Times.Once()); VerifyNoOtherCalls(todoResourceMock, ownerResourceMock); } @@ -41,7 +41,7 @@ public void BeforeCreate_Without_Parent_Hook_Implemented() // Act hookExecutor.BeforeCreate(todoList, ResourcePipeline.Post); // Assert - todoResourceMock.Verify(rd => rd.BeforeCreate(It.IsAny>(), ResourcePipeline.Post), Times.Never()); + todoResourceMock.Verify(rd => rd.BeforeCreate(It.IsAny>(), ResourcePipeline.Post), Times.Never()); ownerResourceMock.Verify(rd => rd.BeforeUpdateRelationship(It.IsAny>(), It.IsAny>(), ResourcePipeline.Post), Times.Once()); VerifyNoOtherCalls(todoResourceMock, ownerResourceMock); } @@ -58,7 +58,7 @@ public void BeforeCreate_Without_Child_Hook_Implemented() // Act hookExecutor.BeforeCreate(todoList, ResourcePipeline.Post); // Assert - todoResourceMock.Verify(rd => rd.BeforeCreate(It.IsAny>(), ResourcePipeline.Post), Times.Once()); + todoResourceMock.Verify(rd => rd.BeforeCreate(It.IsAny>(), ResourcePipeline.Post), Times.Once()); VerifyNoOtherCalls(todoResourceMock, ownerResourceMock); } [Fact] diff --git a/test/UnitTests/ResourceHooks/ResourceHookExecutor/Create/BeforeCreate_WithDbValues_Tests.cs b/test/UnitTests/ResourceHooks/ResourceHookExecutor/Create/BeforeCreate_WithDbValues_Tests.cs index c5ca7ac86f..aa6acff4d1 100644 --- a/test/UnitTests/ResourceHooks/ResourceHookExecutor/Create/BeforeCreate_WithDbValues_Tests.cs +++ b/test/UnitTests/ResourceHooks/ResourceHookExecutor/Create/BeforeCreate_WithDbValues_Tests.cs @@ -53,7 +53,7 @@ public void BeforeCreate() hookExecutor.BeforeCreate(todoList, ResourcePipeline.Post); // Assert - todoResourceMock.Verify(rd => rd.BeforeCreate(It.Is>((entities) => TodoCheck(entities, 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.IsAny>(), @@ -98,7 +98,7 @@ public void BeforeCreate_Without_Child_Hook_Implemented() hookExecutor.BeforeCreate(todoList, ResourcePipeline.Post); // Assert - todoResourceMock.Verify(rd => rd.BeforeCreate(It.Is>((entities) => TodoCheck(entities, 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)), ResourcePipeline.Post), @@ -118,7 +118,7 @@ public void BeforeCreate_NoImplicit() hookExecutor.BeforeCreate(todoList, ResourcePipeline.Post); // Assert - todoResourceMock.Verify(rd => rd.BeforeCreate(It.Is>((entities) => TodoCheck(entities, 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.IsAny>(), @@ -159,13 +159,13 @@ public void BeforeCreate_NoImplicit_Without_Child_Hook_Implemented() hookExecutor.BeforeCreate(todoList, ResourcePipeline.Post); // Assert - todoResourceMock.Verify(rd => rd.BeforeCreate(It.Is>((entities) => TodoCheck(entities, description)), ResourcePipeline.Post), Times.Once()); + todoResourceMock.Verify(rd => rd.BeforeCreate(It.Is>((resources) => TodoCheck(resources, description)), ResourcePipeline.Post), Times.Once()); VerifyNoOtherCalls(todoResourceMock, ownerResourceMock); } - private bool TodoCheck(IEnumerable entities, string checksum) + private bool TodoCheck(IEnumerable resources, string checksum) { - return entities.Single().Description == checksum; + return resources.Single().Description == checksum; } private bool TodoCheckRelationships(IRelationshipsDictionary rh, string checksum) diff --git a/test/UnitTests/ResourceHooks/ResourceHookExecutor/Delete/BeforeDeleteTests.cs b/test/UnitTests/ResourceHooks/ResourceHookExecutor/Delete/BeforeDeleteTests.cs index d6493c2f61..9dfbe89174 100644 --- a/test/UnitTests/ResourceHooks/ResourceHookExecutor/Delete/BeforeDeleteTests.cs +++ b/test/UnitTests/ResourceHooks/ResourceHookExecutor/Delete/BeforeDeleteTests.cs @@ -21,7 +21,7 @@ public void BeforeDelete() hookExecutor.BeforeDelete(todoList, ResourcePipeline.Delete); // Assert - resourceDefinitionMock.Verify(rd => rd.BeforeDelete(It.IsAny>(), It.IsAny()), Times.Once()); + resourceDefinitionMock.Verify(rd => rd.BeforeDelete(It.IsAny>(), It.IsAny()), Times.Once()); resourceDefinitionMock.VerifyNoOtherCalls(); } diff --git a/test/UnitTests/ResourceHooks/ResourceHookExecutor/Delete/BeforeDelete_WithDbValue_Tests.cs b/test/UnitTests/ResourceHooks/ResourceHookExecutor/Delete/BeforeDelete_WithDbValue_Tests.cs index 53c1ba7d4f..4a9a8c1198 100644 --- a/test/UnitTests/ResourceHooks/ResourceHookExecutor/Delete/BeforeDelete_WithDbValue_Tests.cs +++ b/test/UnitTests/ResourceHooks/ResourceHookExecutor/Delete/BeforeDelete_WithDbValue_Tests.cs @@ -45,7 +45,7 @@ public void BeforeDelete() hookExecutor.BeforeDelete(new List { person }, ResourcePipeline.Delete); // Assert - personResourceMock.Verify(rd => rd.BeforeDelete(It.IsAny>(), It.IsAny()), Times.Once()); + personResourceMock.Verify(rd => rd.BeforeDelete(It.IsAny>(), It.IsAny()), Times.Once()); todoResourceMock.Verify(rd => rd.BeforeImplicitUpdateRelationship(It.Is>(rh => CheckImplicitTodos(rh)), ResourcePipeline.Delete), Times.Once()); passportResourceMock.Verify(rd => rd.BeforeImplicitUpdateRelationship(It.Is>(rh => CheckImplicitPassports(rh)), ResourcePipeline.Delete), Times.Once()); VerifyNoOtherCalls(personResourceMock, todoResourceMock, passportResourceMock); @@ -82,7 +82,7 @@ public void BeforeDelete_No_Children_Hooks() hookExecutor.BeforeDelete(new List { person }, ResourcePipeline.Delete); // Assert - personResourceMock.Verify(rd => rd.BeforeDelete(It.IsAny>(), It.IsAny()), Times.Once()); + personResourceMock.Verify(rd => rd.BeforeDelete(It.IsAny>(), It.IsAny()), Times.Once()); VerifyNoOtherCalls(personResourceMock, todoResourceMock, passportResourceMock); } diff --git a/test/UnitTests/ResourceHooks/ResourceHookExecutor/IdentifiableManyToMany_OnReturnTests.cs b/test/UnitTests/ResourceHooks/ResourceHookExecutor/IdentifiableManyToMany_OnReturnTests.cs index d14dcdc3f5..93332ce0ca 100644 --- a/test/UnitTests/ResourceHooks/ResourceHookExecutor/IdentifiableManyToMany_OnReturnTests.cs +++ b/test/UnitTests/ResourceHooks/ResourceHookExecutor/IdentifiableManyToMany_OnReturnTests.cs @@ -144,4 +144,3 @@ public void OnReturn_Without_Any_Hook_Implemented() } } } - diff --git a/test/UnitTests/ResourceHooks/ResourceHookExecutor/Read/BeforeReadTests.cs b/test/UnitTests/ResourceHooks/ResourceHookExecutor/Read/BeforeReadTests.cs index 9c1248453b..b3da136f62 100644 --- a/test/UnitTests/ResourceHooks/ResourceHookExecutor/Read/BeforeReadTests.cs +++ b/test/UnitTests/ResourceHooks/ResourceHookExecutor/Read/BeforeReadTests.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; using JsonApiDotNetCore.Hooks; -using JsonApiDotNetCore.Models.Annotation; +using JsonApiDotNetCore.Internal.Queries; using JsonApiDotNetCoreExample.Models; using Moq; using Xunit; @@ -16,9 +16,8 @@ public void BeforeRead() { // Arrange var todoDiscovery = SetDiscoverableHooks(targetHooks, DisableDbValues); - var (iqMock, hookExecutor, todoResourceMock) = CreateTestObjects(todoDiscovery); + var (_, hookExecutor, todoResourceMock) = CreateTestObjects(todoDiscovery); - iqMock.Setup(c => c.Get()).Returns(new List>()); // Act hookExecutor.BeforeRead(ResourcePipeline.Get); // Assert @@ -34,10 +33,11 @@ public void BeforeReadWithInclusion() var todoDiscovery = SetDiscoverableHooks(targetHooks, DisableDbValues); var personDiscovery = SetDiscoverableHooks(targetHooks, DisableDbValues); - var (iqMock, _, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery); + var (constraintsMock, _, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery); // eg a call on api/todoItems?include=owner,assignee,stakeHolders - iqMock.Setup(c => c.Get()).Returns(GetIncludedRelationshipsChains("owner", "assignee", "stakeHolders")); + var relationshipsChains = GetIncludedRelationshipsChains("owner", "assignee", "stakeHolders"); + constraintsMock.Setup(x => x.GetEnumerator()).Returns(new List(ConvertInclusionChains(relationshipsChains)).GetEnumerator()); // Act hookExecutor.BeforeRead(ResourcePipeline.Get); @@ -55,10 +55,11 @@ public void BeforeReadWithNestedInclusion() var personDiscovery = SetDiscoverableHooks(targetHooks, DisableDbValues); var passportDiscovery = SetDiscoverableHooks(targetHooks, DisableDbValues); - var (iqMock, hookExecutor, todoResourceMock, ownerResourceMock, passportResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, passportDiscovery); + var (constraintsMock, hookExecutor, todoResourceMock, ownerResourceMock, passportResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, passportDiscovery); // eg a call on api/todoItems?include=owner.passport,assignee,stakeHolders - iqMock.Setup(c => c.Get()).Returns(GetIncludedRelationshipsChains("owner.passport", "assignee", "stakeHolders")); + var relationshipsChains = GetIncludedRelationshipsChains("owner.passport", "assignee", "stakeHolders"); + constraintsMock.Setup(x => x.GetEnumerator()).Returns(new List(ConvertInclusionChains(relationshipsChains)).GetEnumerator()); // Act hookExecutor.BeforeRead(ResourcePipeline.Get); @@ -78,10 +79,11 @@ public void BeforeReadWithNestedInclusion_No_Parent_Hook_Implemented() var personDiscovery = SetDiscoverableHooks(targetHooks, DisableDbValues); var passportDiscovery = SetDiscoverableHooks(targetHooks, DisableDbValues); - var (iqMock, hookExecutor, todoResourceMock, ownerResourceMock, passportResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, passportDiscovery); + var (constraintsMock, hookExecutor, todoResourceMock, ownerResourceMock, passportResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, passportDiscovery); // eg a call on api/todoItems?include=owner.passport,assignee,stakeHolders - iqMock.Setup(c => c.Get()).Returns(GetIncludedRelationshipsChains("owner.passport", "assignee", "stakeHolders")); + var relationshipsChains = GetIncludedRelationshipsChains("owner.passport", "assignee", "stakeHolders"); + constraintsMock.Setup(x => x.GetEnumerator()).Returns(new List(ConvertInclusionChains(relationshipsChains)).GetEnumerator()); // Act hookExecutor.BeforeRead(ResourcePipeline.Get); @@ -99,10 +101,11 @@ public void BeforeReadWithNestedInclusion_No_Child_Hook_Implemented() var personDiscovery = SetDiscoverableHooks(NoHooks, DisableDbValues); var passportDiscovery = SetDiscoverableHooks(targetHooks, DisableDbValues); - var (iqMock, hookExecutor, todoResourceMock, ownerResourceMock, passportResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, passportDiscovery); + var (constraintsMock, hookExecutor, todoResourceMock, ownerResourceMock, passportResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, passportDiscovery); // eg a call on api/todoItems?include=owner.passport,assignee,stakeHolders - iqMock.Setup(c => c.Get()).Returns(GetIncludedRelationshipsChains("owner.passport", "assignee", "stakeHolders")); + var relationshipsChains = GetIncludedRelationshipsChains("owner.passport", "assignee", "stakeHolders"); + constraintsMock.Setup(x => x.GetEnumerator()).Returns(new List(ConvertInclusionChains(relationshipsChains)).GetEnumerator()); // Act hookExecutor.BeforeRead(ResourcePipeline.Get); @@ -120,10 +123,11 @@ public void BeforeReadWithNestedInclusion_No_Grandchild_Hook_Implemented() var personDiscovery = SetDiscoverableHooks(targetHooks, DisableDbValues); var passportDiscovery = SetDiscoverableHooks(NoHooks, DisableDbValues); - var (iqMock, hookExecutor, todoResourceMock, ownerResourceMock, passportResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, passportDiscovery); + var (constraintsMock, hookExecutor, todoResourceMock, ownerResourceMock, passportResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, passportDiscovery); // eg a call on api/todoItems?include=owner.passport,assignee,stakeHolders - iqMock.Setup(c => c.Get()).Returns(GetIncludedRelationshipsChains("owner.passport", "assignee", "stakeHolders")); + var relationshipsChains = GetIncludedRelationshipsChains("owner.passport", "assignee", "stakeHolders"); + constraintsMock.Setup(x => x.GetEnumerator()).Returns(new List(ConvertInclusionChains(relationshipsChains)).GetEnumerator()); // Act hookExecutor.BeforeRead(ResourcePipeline.Get); @@ -142,10 +146,11 @@ public void BeforeReadWithNestedInclusion_Without_Any_Hook_Implemented() var personDiscovery = SetDiscoverableHooks(NoHooks, DisableDbValues); var passportDiscovery = SetDiscoverableHooks(NoHooks, DisableDbValues); - var (iqMock, hookExecutor, todoResourceMock, ownerResourceMock, passportResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, passportDiscovery); + var (constraintsMock, hookExecutor, todoResourceMock, ownerResourceMock, passportResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, passportDiscovery); // eg a call on api/todoItems?include=owner.passport,assignee,stakeHolders - iqMock.Setup(c => c.Get()).Returns(GetIncludedRelationshipsChains("owner.passport", "assignee", "stakeHolders")); + var relationshipsChains = GetIncludedRelationshipsChains("owner.passport", "assignee", "stakeHolders"); + constraintsMock.Setup(x => x.GetEnumerator()).Returns(new List(ConvertInclusionChains(relationshipsChains)).GetEnumerator()); // Act hookExecutor.BeforeRead(ResourcePipeline.Get); diff --git a/test/UnitTests/ResourceHooks/ResourceHookExecutor/ScenarioTests.cs b/test/UnitTests/ResourceHooks/ResourceHookExecutor/ScenarioTests.cs index c528e86929..cf05ddfae1 100644 --- a/test/UnitTests/ResourceHooks/ResourceHookExecutor/ScenarioTests.cs +++ b/test/UnitTests/ResourceHooks/ResourceHookExecutor/ScenarioTests.cs @@ -6,17 +6,17 @@ namespace UnitTests.ResourceHooks { - public sealed class SameEntityTypeTests : HooksTestsSetup + public sealed class SameResourceTypeTests : HooksTestsSetup { private readonly ResourceHook[] targetHooks = { ResourceHook.OnReturn }; [Fact] - public void Entity_Has_Multiple_Relations_To_Same_Type() + public void Resource_Has_Multiple_Relations_To_Same_Type() { // Arrange var todoDiscovery = SetDiscoverableHooks(targetHooks, DisableDbValues); var personDiscovery = SetDiscoverableHooks(targetHooks, DisableDbValues); -var (_, _, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery); + var (_, _, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery); var person1 = new Person(); var todo = new TodoItem { Owner = person1 }; var person2 = new Person { AssignedTodoItems = new HashSet { todo } }; @@ -35,7 +35,7 @@ public void Entity_Has_Multiple_Relations_To_Same_Type() } [Fact] - public void Entity_Has_Cyclic_Relations() + public void Resource_Has_Cyclic_Relations() { // Arrange var todoDiscovery = SetDiscoverableHooks(targetHooks, DisableDbValues); @@ -54,7 +54,7 @@ public void Entity_Has_Cyclic_Relations() } [Fact] - public void Entity_Has_Nested_Cyclic_Relations() + public void Resource_Has_Nested_Cyclic_Relations() { // Arrange var todoDiscovery = SetDiscoverableHooks(targetHooks, DisableDbValues); diff --git a/test/UnitTests/ResourceHooks/ResourceHookExecutor/Update/BeforeUpdateTests.cs b/test/UnitTests/ResourceHooks/ResourceHookExecutor/Update/BeforeUpdateTests.cs index a4fcdd22e2..5c9d876058 100644 --- a/test/UnitTests/ResourceHooks/ResourceHookExecutor/Update/BeforeUpdateTests.cs +++ b/test/UnitTests/ResourceHooks/ResourceHookExecutor/Update/BeforeUpdateTests.cs @@ -23,7 +23,7 @@ public void BeforeUpdate() hookExecutor.BeforeUpdate(todoList, ResourcePipeline.Patch); // Assert - todoResourceMock.Verify(rd => rd.BeforeUpdate(It.IsAny>(), ResourcePipeline.Patch), Times.Once()); + todoResourceMock.Verify(rd => rd.BeforeUpdate(It.IsAny>(), ResourcePipeline.Patch), Times.Once()); ownerResourceMock.Verify(rd => rd.BeforeUpdateRelationship(It.IsAny>(), It.IsAny>(), ResourcePipeline.Patch), Times.Once()); VerifyNoOtherCalls(todoResourceMock, ownerResourceMock); } @@ -59,7 +59,7 @@ public void BeforeUpdate_Without_Child_Hook_Implemented() hookExecutor.BeforeUpdate(todoList, ResourcePipeline.Patch); // Assert - todoResourceMock.Verify(rd => rd.BeforeUpdate(It.IsAny>(), ResourcePipeline.Patch), Times.Once()); + todoResourceMock.Verify(rd => rd.BeforeUpdate(It.IsAny>(), ResourcePipeline.Patch), 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 8cf140f55f..67b0aec733 100644 --- a/test/UnitTests/ResourceHooks/ResourceHookExecutor/Update/BeforeUpdate_WithDbValues_Tests.cs +++ b/test/UnitTests/ResourceHooks/ResourceHookExecutor/Update/BeforeUpdate_WithDbValues_Tests.cs @@ -56,7 +56,7 @@ public void BeforeUpdate() 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)), @@ -85,11 +85,11 @@ public void BeforeUpdate_Deleting_Relationship() ufMock.Setup(c => c.Relationships).Returns(_resourceGraph.GetRelationships((TodoItem t) => t.OneToOnePerson)); // Act - var _todoList = new List { new TodoItem { Id = this.todoList[0].Id } }; + 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)), ResourcePipeline.Patch), @@ -134,7 +134,7 @@ public void BeforeUpdate_Without_Child_Hook_Implemented() 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)), ResourcePipeline.Patch), @@ -154,7 +154,7 @@ public void BeforeUpdate_NoImplicit() 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.IsAny>(), @@ -195,20 +195,20 @@ public void BeforeUpdate_NoImplicit_Without_Child_Hook_Implemented() 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); } - private bool TodoCheckDiff(IDiffableEntityHashSet entities, string checksum) + private bool TodoCheckDiff(IDiffableResourceHashSet resources, string checksum) { - var diffPair = entities.GetDiffs().Single(); + var diffPair = resources.GetDiffs().Single(); var dbCheck = diffPair.DatabaseValue.Description == checksum; - var reqCheck = diffPair.Entity.Description == null; + var reqCheck = diffPair.Resource.Description == null; - var updatedRelationship = entities.GetByRelationship().Single(); + var updatedRelationship = resources.GetByRelationship().Single(); var diffCheck = updatedRelationship.Key.PublicName == "oneToOnePerson"; - var getAffectedCheck = entities.GetAffected(e => e.OneToOnePerson).Any(); + var getAffectedCheck = resources.GetAffected(e => e.OneToOnePerson).Any(); return (dbCheck && reqCheck && diffCheck && getAffectedCheck); } diff --git a/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs b/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs index 5801812f6e..1dcfe3823e 100644 --- a/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs +++ b/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs @@ -15,10 +15,10 @@ using System.Linq; using Person = JsonApiDotNetCoreExample.Models.Person; using JsonApiDotNetCore.Internal.Contracts; +using JsonApiDotNetCore.Internal.Queries; +using JsonApiDotNetCore.Internal.Queries.Expressions; using JsonApiDotNetCore.Serialization; -using JsonApiDotNetCore.Internal.Query; using JsonApiDotNetCore.Models.Annotation; -using JsonApiDotNetCore.Query; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.Extensions.Logging.Abstractions; @@ -56,10 +56,10 @@ public HooksDummyData() _personFaker = new Faker().Rules((f, i) => i.Id = f.UniqueIndex + 1); _articleFaker = new Faker
().Rules((f, i) => i.Id = f.UniqueIndex + 1); - _articleTagFaker = new Faker().CustomInstantiator(f => new ArticleTag(appDbContext)); + _articleTagFaker = new Faker().CustomInstantiator(f => new ArticleTag()); _identifiableArticleTagFaker = new Faker().Rules((f, i) => i.Id = f.UniqueIndex + 1); _tagFaker = new Faker() - .CustomInstantiator(f => new Tag(appDbContext)) + .CustomInstantiator(f => new Tag()) .Rules((f, i) => i.Id = f.UniqueIndex + 1); _passportFaker = new Faker() @@ -148,102 +148,105 @@ protected HashSet CreateTodoWithOwner() public class HooksTestsSetup : HooksDummyData { - private (Mock, Mock, Mock, IJsonApiOptions) CreateMocks() + private (Mock, Mock>, Mock, IJsonApiOptions) CreateMocks() { var pfMock = new Mock(); var ufMock = new Mock(); - var iqsMock = new Mock(); + + var constraintsMock = new Mock>(); + constraintsMock.Setup(x => x.GetEnumerator()).Returns(new List(new IQueryConstraintProvider[0]).GetEnumerator()); + var optionsMock = new JsonApiOptions { LoadDatabaseValues = false }; - return (ufMock, iqsMock, pfMock, optionsMock); + return (ufMock, constraintsMock, pfMock, optionsMock); } - internal (Mock, ResourceHookExecutor, Mock>) CreateTestObjects(IHooksDiscovery mainDiscovery = null) - where TMain : class, IIdentifiable + internal (Mock>, ResourceHookExecutor, Mock>) CreateTestObjects(IHooksDiscovery primaryDiscovery = null) + where TPrimary : class, IIdentifiable { // creates the resource definition mock and corresponding ImplementedHooks discovery instance - var mainResource = CreateResourceDefinition(mainDiscovery); + var primaryResource = CreateResourceDefinition(primaryDiscovery); // mocking the genericServiceFactory and JsonApiContext and wiring them up. - var (ufMock, iqMock, gpfMock, options) = CreateMocks(); + var (ufMock, constraintsMock, gpfMock, options) = CreateMocks(); - SetupProcessorFactoryForResourceDefinition(gpfMock, mainResource.Object, mainDiscovery); + SetupProcessorFactoryForResourceDefinition(gpfMock, primaryResource.Object, primaryDiscovery); - var execHelper = new HookExecutorHelper(gpfMock.Object, options); + var execHelper = new HookExecutorHelper(gpfMock.Object, _resourceGraph, options); var traversalHelper = new TraversalHelper(_resourceGraph, ufMock.Object); - var hookExecutor = new ResourceHookExecutor(execHelper, traversalHelper, ufMock.Object, iqMock.Object, _resourceGraph, null); + var hookExecutor = new ResourceHookExecutor(execHelper, traversalHelper, ufMock.Object, constraintsMock.Object, _resourceGraph, null); - return (iqMock, hookExecutor, mainResource); + return (constraintsMock, hookExecutor, primaryResource); } - protected (Mock, Mock, IResourceHookExecutor, Mock>, Mock>) - CreateTestObjects( - IHooksDiscovery mainDiscovery = null, - IHooksDiscovery nestedDiscovery = null, + protected (Mock>, Mock, IResourceHookExecutor, Mock>, Mock>) + CreateTestObjects( + IHooksDiscovery primaryDiscovery = null, + IHooksDiscovery secondaryDiscovery = null, DbContextOptions repoDbContextOptions = null ) - where TMain : class, IIdentifiable - where TNested : class, IIdentifiable + where TPrimary : class, IIdentifiable + where TSecondary : class, IIdentifiable { // creates the resource definition mock and corresponding for a given set of discoverable hooks - var mainResource = CreateResourceDefinition(mainDiscovery); - var nestedResource = CreateResourceDefinition(nestedDiscovery); + var primaryResource = CreateResourceDefinition(primaryDiscovery); + var secondaryResource = CreateResourceDefinition(secondaryDiscovery); // mocking the genericServiceFactory and JsonApiContext and wiring them up. - var (ufMock, iqMock, gpfMock, options) = CreateMocks(); + var (ufMock, constraintsMock, gpfMock, options) = CreateMocks(); var dbContext = repoDbContextOptions != null ? new AppDbContext(repoDbContextOptions, new FrozenSystemClock()) : null; var resourceGraph = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance) - .AddResource() - .AddResource() + .AddResource() + .AddResource() .Build(); - SetupProcessorFactoryForResourceDefinition(gpfMock, mainResource.Object, mainDiscovery, dbContext, resourceGraph); - SetupProcessorFactoryForResourceDefinition(gpfMock, nestedResource.Object, nestedDiscovery, dbContext, resourceGraph); + SetupProcessorFactoryForResourceDefinition(gpfMock, primaryResource.Object, primaryDiscovery, dbContext, resourceGraph); + SetupProcessorFactoryForResourceDefinition(gpfMock, secondaryResource.Object, secondaryDiscovery, dbContext, resourceGraph); - var execHelper = new HookExecutorHelper(gpfMock.Object, options); + var execHelper = new HookExecutorHelper(gpfMock.Object, _resourceGraph, options); var traversalHelper = new TraversalHelper(_resourceGraph, ufMock.Object); - var hookExecutor = new ResourceHookExecutor(execHelper, traversalHelper, ufMock.Object, iqMock.Object, _resourceGraph, null); + var hookExecutor = new ResourceHookExecutor(execHelper, traversalHelper, ufMock.Object, constraintsMock.Object, _resourceGraph, null); - return (iqMock, ufMock, hookExecutor, mainResource, nestedResource); + return (constraintsMock, ufMock, hookExecutor, primaryResource, secondaryResource); } - protected (Mock, IResourceHookExecutor, Mock>, Mock>, Mock>) - CreateTestObjects( - IHooksDiscovery mainDiscovery = null, - IHooksDiscovery firstNestedDiscovery = null, - IHooksDiscovery secondNestedDiscovery = null, + protected (Mock>, IResourceHookExecutor, Mock>, Mock>, Mock>) + CreateTestObjects( + IHooksDiscovery primaryDiscovery = null, + IHooksDiscovery firstSecondaryDiscovery = null, + IHooksDiscovery secondSecondaryDiscovery = null, DbContextOptions repoDbContextOptions = null ) - where TMain : class, IIdentifiable - where TFirstNested : class, IIdentifiable - where TSecondNested : class, IIdentifiable + where TPrimary : class, IIdentifiable + where TFirstSecondary : class, IIdentifiable + where TSecondSecondary : class, IIdentifiable { // creates the resource definition mock and corresponding for a given set of discoverable hooks - var mainResource = CreateResourceDefinition(mainDiscovery); - var firstNestedResource = CreateResourceDefinition(firstNestedDiscovery); - var secondNestedResource = CreateResourceDefinition(secondNestedDiscovery); + var primaryResource = CreateResourceDefinition(primaryDiscovery); + var firstSecondaryResource = CreateResourceDefinition(firstSecondaryDiscovery); + var secondSecondaryResource = CreateResourceDefinition(secondSecondaryDiscovery); // mocking the genericServiceFactory and JsonApiContext and wiring them up. - var (ufMock, iqMock, gpfMock, options) = CreateMocks(); + var (ufMock, constraintsMock, gpfMock, options) = CreateMocks(); var dbContext = repoDbContextOptions != null ? new AppDbContext(repoDbContextOptions, new FrozenSystemClock()) : null; var resourceGraph = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance) - .AddResource() - .AddResource() - .AddResource() + .AddResource() + .AddResource() + .AddResource() .Build(); - SetupProcessorFactoryForResourceDefinition(gpfMock, mainResource.Object, mainDiscovery, dbContext, resourceGraph); - SetupProcessorFactoryForResourceDefinition(gpfMock, firstNestedResource.Object, firstNestedDiscovery, dbContext, resourceGraph); - SetupProcessorFactoryForResourceDefinition(gpfMock, secondNestedResource.Object, secondNestedDiscovery, dbContext, resourceGraph); + SetupProcessorFactoryForResourceDefinition(gpfMock, primaryResource.Object, primaryDiscovery, dbContext, resourceGraph); + SetupProcessorFactoryForResourceDefinition(gpfMock, firstSecondaryResource.Object, firstSecondaryDiscovery, dbContext, resourceGraph); + SetupProcessorFactoryForResourceDefinition(gpfMock, secondSecondaryResource.Object, secondSecondaryDiscovery, dbContext, resourceGraph); - var execHelper = new HookExecutorHelper(gpfMock.Object, options); + var execHelper = new HookExecutorHelper(gpfMock.Object, _resourceGraph, options); var traversalHelper = new TraversalHelper(_resourceGraph, ufMock.Object); - var hookExecutor = new ResourceHookExecutor(execHelper, traversalHelper, ufMock.Object, iqMock.Object, _resourceGraph, null); + var hookExecutor = new ResourceHookExecutor(execHelper, traversalHelper, ufMock.Object, constraintsMock.Object, _resourceGraph, null); - return (iqMock, hookExecutor, mainResource, firstNestedResource, secondNestedResource); + return (constraintsMock, hookExecutor, primaryResource, firstSecondaryResource, secondSecondaryResource); } protected IHooksDiscovery SetDiscoverableHooks(ResourceHook[] implementedHooks, params ResourceHook[] enableDbValuesHooks) @@ -289,19 +292,19 @@ protected DbContextOptions InitInMemoryDb(Action seeder private void MockHooks(Mock> resourceDefinition) where TModel : class, IIdentifiable { resourceDefinition - .Setup(rd => rd.BeforeCreate(It.IsAny>(), It.IsAny())) - .Returns, ResourcePipeline>((entities, context) => entities) + .Setup(rd => rd.BeforeCreate(It.IsAny>(), It.IsAny())) + .Returns, ResourcePipeline>((resources, context) => resources) .Verifiable(); resourceDefinition .Setup(rd => rd.BeforeRead(It.IsAny(), It.IsAny(), It.IsAny())) .Verifiable(); resourceDefinition - .Setup(rd => rd.BeforeUpdate(It.IsAny>(), It.IsAny())) - .Returns, ResourcePipeline>((entities, context) => entities) + .Setup(rd => rd.BeforeUpdate(It.IsAny>(), It.IsAny())) + .Returns, ResourcePipeline>((resources, context) => resources) .Verifiable(); resourceDefinition - .Setup(rd => rd.BeforeDelete(It.IsAny>(), It.IsAny())) - .Returns, ResourcePipeline>((entities, context) => entities) + .Setup(rd => rd.BeforeDelete(It.IsAny>(), It.IsAny())) + .Returns, ResourcePipeline>((resources, context) => resources) .Verifiable(); resourceDefinition .Setup(rd => rd.BeforeUpdateRelationship(It.IsAny>(), It.IsAny>(), It.IsAny())) @@ -312,7 +315,7 @@ private void MockHooks(Mock> resourceDefi .Verifiable(); resourceDefinition .Setup(rd => rd.OnReturn(It.IsAny>(), It.IsAny())) - .Returns, ResourcePipeline>((entities, context) => entities) + .Returns, ResourcePipeline>((resources, context) => resources) .Verifiable(); resourceDefinition .Setup(rd => rd.AfterCreate(It.IsAny>(), It.IsAny())) @@ -363,9 +366,9 @@ private IResourceReadRepository CreateTestRepository(AppDbC where TModel : class, IIdentifiable { var serviceProvider = ((IInfrastructure) dbContext).Instance; - var resourceFactory = new DefaultResourceFactory(serviceProvider); + var resourceFactory = new ResourceFactory(serviceProvider); IDbContextResolver resolver = CreateTestDbResolver(dbContext); - return new DefaultResourceRepository(null, resolver, resourceGraph, null, resourceFactory, NullLoggerFactory.Instance); + return new EntityFrameworkCoreRepository(null, resolver, resourceGraph, null, resourceFactory, new List(), NullLoggerFactory.Instance); } private IDbContextResolver CreateTestDbResolver(AppDbContext dbContext) where TModel : class, IIdentifiable @@ -404,7 +407,7 @@ protected List GetIncludedRelationshipsChain(string chain { var parsedChain = new List(); var resourceContext = _resourceGraph.GetResourceContext(); - var splitPath = chain.Split(QueryConstants.DOT); + var splitPath = chain.Split('.'); foreach (var requestedRelationship in splitPath) { var relationship = resourceContext.Relationships.Single(r => r.PublicName == requestedRelationship); @@ -413,5 +416,24 @@ protected List GetIncludedRelationshipsChain(string chain } return parsedChain; } + + protected IEnumerable ConvertInclusionChains(List> inclusionChains) + { + var expressionsInScope = new List(); + + if (inclusionChains != null) + { + var chains = inclusionChains.Select(relationships => new ResourceFieldChainExpression(relationships)).ToList(); + var includeExpression = new IncludeExpression(chains); + expressionsInScope.Add(new ExpressionInScope(null, includeExpression)); + } + + var mock = new Mock(); + mock.Setup(x => x.GetConstraints()).Returns(expressionsInScope); + + IQueryConstraintProvider includeConstraintProvider = mock.Object; + return new List {includeConstraintProvider}; + } + } } diff --git a/test/UnitTests/Serialization/Client/RequestSerializerTests.cs b/test/UnitTests/Serialization/Client/RequestSerializerTests.cs index c81f9faff8..261aaa4b61 100644 --- a/test/UnitTests/Serialization/Client/RequestSerializerTests.cs +++ b/test/UnitTests/Serialization/Client/RequestSerializerTests.cs @@ -22,10 +22,10 @@ public RequestSerializerTests() public void SerializeSingle_ResourceWithDefaultTargetFields_CanBuild() { // Arrange - var entity = new TestResource { Id = 1, StringField = "value", NullableIntField = 123 }; + var resource = new TestResource { Id = 1, StringField = "value", NullableIntField = 123 }; // Act - string serialized = _serializer.Serialize(entity); + string serialized = _serializer.Serialize(resource); // Assert var expectedFormatted = @@ -53,11 +53,11 @@ public void SerializeSingle_ResourceWithDefaultTargetFields_CanBuild() public void SerializeSingle_ResourceWithTargetedSetAttributes_CanBuild() { // Arrange - var entity = new TestResource { Id = 1, StringField = "value", NullableIntField = 123 }; + var resource = new TestResource { Id = 1, StringField = "value", NullableIntField = 123 }; _serializer.AttributesToSerialize = _resourceGraph.GetAttributes(tr => tr.StringField); // Act - string serialized = _serializer.Serialize(entity); + string serialized = _serializer.Serialize(resource); // Assert var expectedFormatted = @@ -78,11 +78,11 @@ public void SerializeSingle_ResourceWithTargetedSetAttributes_CanBuild() public void SerializeSingle_NoIdWithTargetedSetAttributes_CanBuild() { // Arrange - var entityNoId = new TestResource { Id = 0, StringField = "value", NullableIntField = 123 }; + var resourceNoId = new TestResource { Id = 0, StringField = "value", NullableIntField = 123 }; _serializer.AttributesToSerialize = _resourceGraph.GetAttributes(tr => tr.StringField); // Act - string serialized = _serializer.Serialize(entityNoId); + string serialized = _serializer.Serialize(resourceNoId); // Assert var expectedFormatted = @@ -103,11 +103,11 @@ public void SerializeSingle_NoIdWithTargetedSetAttributes_CanBuild() public void SerializeSingle_ResourceWithoutTargetedAttributes_CanBuild() { // Arrange - var entity = new TestResource { Id = 1, StringField = "value", NullableIntField = 123 }; + var resource = new TestResource { Id = 1, StringField = "value", NullableIntField = 123 }; _serializer.AttributesToSerialize = _resourceGraph.GetAttributes(tr => new { }); // Act - string serialized = _serializer.Serialize(entity); + string serialized = _serializer.Serialize(resource); // Assert var expectedFormatted = @@ -126,7 +126,7 @@ public void SerializeSingle_ResourceWithoutTargetedAttributes_CanBuild() public void SerializeSingle_ResourceWithTargetedRelationships_CanBuild() { // Arrange - var entityWithRelationships = new MultipleRelationshipsPrincipalPart + var resourceWithRelationships = new MultipleRelationshipsPrincipalPart { PopulatedToOne = new OneToOneDependent { Id = 10 }, PopulatedToManies = new HashSet { new OneToManyDependent { Id = 20 } } @@ -134,7 +134,7 @@ public void SerializeSingle_ResourceWithTargetedRelationships_CanBuild() _serializer.RelationshipsToSerialize = _resourceGraph.GetRelationships(tr => new { tr.EmptyToOne, tr.EmptyToManies, tr.PopulatedToOne, tr.PopulatedToManies }); // Act - string serialized = _serializer.Serialize(entityWithRelationships); + string serialized = _serializer.Serialize(resourceWithRelationships); // Assert var expectedFormatted = @"{ @@ -175,7 +175,7 @@ public void SerializeSingle_ResourceWithTargetedRelationships_CanBuild() public void SerializeMany_ResourcesWithTargetedAttributes_CanBuild() { // Arrange - var entities = new List + var resources = new List { new TestResource { Id = 1, StringField = "value1", NullableIntField = 123 }, new TestResource { Id = 2, StringField = "value2", NullableIntField = 123 } @@ -183,7 +183,7 @@ public void SerializeMany_ResourcesWithTargetedAttributes_CanBuild() _serializer.AttributesToSerialize = _resourceGraph.GetAttributes(tr => tr.StringField); // Act - string serialized = _serializer.Serialize(entities); + string serialized = _serializer.Serialize(resources); // Assert var expectedFormatted = @@ -231,11 +231,11 @@ public void SerializeSingle_Null_CanBuild() public void SerializeMany_EmptyList_CanBuild() { // Arrange - var entities = new List(); + var resources = new List(); _serializer.AttributesToSerialize = _resourceGraph.GetAttributes(tr => tr.StringField); // Act - string serialized = _serializer.Serialize(entities); + string serialized = _serializer.Serialize(resources); // Assert var expectedFormatted = diff --git a/test/UnitTests/Serialization/Client/ResponseDeserializerTests.cs b/test/UnitTests/Serialization/Client/ResponseDeserializerTests.cs index 7c93a01da9..4138de80c6 100644 --- a/test/UnitTests/Serialization/Client/ResponseDeserializerTests.cs +++ b/test/UnitTests/Serialization/Client/ResponseDeserializerTests.cs @@ -3,7 +3,7 @@ using System.Linq; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Models.Links; +using JsonApiDotNetCore.Models.JsonApiDocuments; using JsonApiDotNetCore.Serialization.Client; using Newtonsoft.Json; using Xunit; @@ -18,10 +18,10 @@ public sealed class ResponseDeserializerTests : DeserializerTestsSetup public ResponseDeserializerTests() { - _deserializer = new ResponseDeserializer(_resourceGraph, new DefaultResourceFactory(new ServiceContainer())); + _deserializer = new ResponseDeserializer(_resourceGraph, new ResourceFactory(new ServiceContainer())); _linkValues.Add("self", "http://example.com/articles"); - _linkValues.Add("next", "http://example.com/articles?page[offset]=2"); - _linkValues.Add("last", "http://example.com/articles?page[offset]=10"); + _linkValues.Add("next", "http://example.com/articles?page[number]=2"); + _linkValues.Add("last", "http://example.com/articles?page[number]=10"); } [Fact] @@ -97,13 +97,13 @@ public void DeserializeSingle_ResourceWithAttributes_CanDeserialize() // Act var result = _deserializer.DeserializeSingle(body); - var entity = result.Data; + var resource = result.Data; // Assert Assert.Null(result.Links); Assert.Null(result.Meta); - Assert.Equal(1, entity.Id); - Assert.Equal(content.SingleData.Attributes["stringField"], entity.StringField); + Assert.Equal(1, resource.Id); + Assert.Equal(content.SingleData.Attributes["stringField"], resource.StringField); } [Fact] @@ -136,17 +136,17 @@ public void DeserializeSingle_MultipleDependentRelationshipsWithIncluded_CanDese // Act var result = _deserializer.DeserializeSingle(body); - var entity = result.Data; + var resource = result.Data; // Assert - Assert.Equal(1, entity.Id); - Assert.NotNull(entity.PopulatedToOne); - Assert.Equal(toOneAttributeValue, entity.PopulatedToOne.AttributeMember); - Assert.Equal(toManyAttributeValue, entity.PopulatedToManies.First().AttributeMember); - Assert.NotNull(entity.PopulatedToManies); - Assert.NotNull(entity.EmptyToManies); - Assert.Empty(entity.EmptyToManies); - Assert.Null(entity.EmptyToOne); + Assert.Equal(1, resource.Id); + Assert.NotNull(resource.PopulatedToOne); + Assert.Equal(toOneAttributeValue, resource.PopulatedToOne.AttributeMember); + Assert.Equal(toManyAttributeValue, resource.PopulatedToManies.First().AttributeMember); + Assert.NotNull(resource.PopulatedToManies); + Assert.NotNull(resource.EmptyToManies); + Assert.Empty(resource.EmptyToManies); + Assert.Null(resource.EmptyToOne); } [Fact] @@ -179,16 +179,16 @@ public void DeserializeSingle_MultiplePrincipalRelationshipsWithIncluded_CanDese // Act var result = _deserializer.DeserializeSingle(body); - var entity = result.Data; + var resource = result.Data; // Assert - Assert.Equal(1, entity.Id); - Assert.NotNull(entity.PopulatedToOne); - Assert.Equal(toOneAttributeValue, entity.PopulatedToOne.AttributeMember); - Assert.Equal(toManyAttributeValue, entity.PopulatedToMany.AttributeMember); - Assert.NotNull(entity.PopulatedToMany); - Assert.Null(entity.EmptyToMany); - Assert.Null(entity.EmptyToOne); + Assert.Equal(1, resource.Id); + Assert.NotNull(resource.PopulatedToOne); + Assert.Equal(toOneAttributeValue, resource.PopulatedToOne.AttributeMember); + Assert.Equal(toManyAttributeValue, resource.PopulatedToMany.AttributeMember); + Assert.NotNull(resource.PopulatedToMany); + Assert.Null(resource.EmptyToMany); + Assert.Null(resource.EmptyToOne); } [Fact] @@ -219,18 +219,18 @@ public void DeserializeSingle_NestedIncluded_CanDeserialize() // Act var result = _deserializer.DeserializeSingle(body); - var entity = result.Data; + var resource = result.Data; // Assert - Assert.Equal(1, entity.Id); - Assert.Null(entity.PopulatedToOne); - Assert.Null(entity.EmptyToManies); - Assert.Null(entity.EmptyToOne); - Assert.NotNull(entity.PopulatedToManies); - var includedEntity = entity.PopulatedToManies.First(); - Assert.Equal(toManyAttributeValue, includedEntity.AttributeMember); - var nestedIncludedEntity = includedEntity.Principal; - Assert.Equal(nestedIncludeAttributeValue, nestedIncludedEntity.AttributeMember); + Assert.Equal(1, resource.Id); + Assert.Null(resource.PopulatedToOne); + Assert.Null(resource.EmptyToManies); + Assert.Null(resource.EmptyToOne); + Assert.NotNull(resource.PopulatedToManies); + var includedResource = resource.PopulatedToManies.First(); + Assert.Equal(toManyAttributeValue, includedResource.AttributeMember); + var nestedIncludedResource = includedResource.Principal; + Assert.Equal(nestedIncludeAttributeValue, nestedIncludedResource.AttributeMember); } @@ -270,11 +270,11 @@ public void DeserializeSingle_DeeplyNestedIncluded_CanDeserialize() // Act var result = _deserializer.DeserializeSingle(body); - var entity = result.Data; + var resource = result.Data; // Assert - Assert.Equal(1, entity.Id); - var included = entity.Multi; + Assert.Equal(1, resource.Id); + var included = resource.Multi; Assert.Equal(10, included.Id); Assert.Equal(includedAttributeValue, included.AttributeMember); var nestedIncluded = included.PopulatedToManies.First(); @@ -322,11 +322,11 @@ public void DeserializeList_DeeplyNestedIncluded_CanDeserialize() // Act var result = _deserializer.DeserializeList(body); - var entity = result.Data.First(); + var resource = result.Data.First(); // Assert - Assert.Equal(1, entity.Id); - var included = entity.Multi; + Assert.Equal(1, resource.Id); + var included = resource.Multi; Assert.Equal(10, included.Id); Assert.Equal(includedAttributeValue, included.AttributeMember); var nestedIncluded = included.PopulatedToManies.First(); diff --git a/test/UnitTests/Serialization/Common/DocumentBuilderTests.cs b/test/UnitTests/Serialization/Common/DocumentBuilderTests.cs index 07ee1e9c9b..857d0d200c 100644 --- a/test/UnitTests/Serialization/Common/DocumentBuilderTests.cs +++ b/test/UnitTests/Serialization/Common/DocumentBuilderTests.cs @@ -21,7 +21,7 @@ public BaseDocumentBuilderTests() [Fact] - public void EntityToDocument_NullEntity_CanBuild() + public void ResourceToDocument_NullResource_CanBuild() { // Act var document = _builder.Build((TestResource) null); @@ -33,13 +33,13 @@ public void EntityToDocument_NullEntity_CanBuild() [Fact] - public void EntityToDocument_EmptyList_CanBuild() + public void ResourceToDocument_EmptyList_CanBuild() { // Arrange - var entities = new List(); + var resources = new List(); // Act - var document = _builder.Build(entities); + var document = _builder.Build(resources); // Assert Assert.NotNull(document.Data); @@ -48,7 +48,7 @@ public void EntityToDocument_EmptyList_CanBuild() [Fact] - public void EntityToDocument_SingleEntity_CanBuild() + public void ResourceToDocument_SingleResource_CanBuild() { // Arrange IIdentifiable dummy = new DummyResource(); @@ -62,13 +62,13 @@ public void EntityToDocument_SingleEntity_CanBuild() } [Fact] - public void EntityToDocument_EntityList_CanBuild() + public void ResourceToDocument_ResourceList_CanBuild() { // Arrange - var entities = new List { new DummyResource(), new DummyResource() }; + var resources = new List { new DummyResource(), new DummyResource() }; // Act - var document = _builder.Build(entities); + var document = _builder.Build(resources); var data = (List)document.Data; // Assert diff --git a/test/UnitTests/Serialization/Common/DocumentParserTests.cs b/test/UnitTests/Serialization/Common/DocumentParserTests.cs index 058c9ba9af..1628dea0b7 100644 --- a/test/UnitTests/Serialization/Common/DocumentParserTests.cs +++ b/test/UnitTests/Serialization/Common/DocumentParserTests.cs @@ -5,7 +5,6 @@ using System.Linq; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Models.Annotation; using Newtonsoft.Json; using Xunit; using UnitTests.TestModels; @@ -18,7 +17,7 @@ public sealed class BaseDocumentParserTests : DeserializerTestsSetup public BaseDocumentParserTests() { - _deserializer = new TestDocumentParser(_resourceGraph, new DefaultResourceFactory(new ServiceContainer())); + _deserializer = new TestDocumentParser(_resourceGraph, new ResourceFactory(new ServiceContainer())); } [Fact] @@ -131,11 +130,11 @@ public void DeserializeAttributes_VariousDataTypes_CanDeserialize(string member, } // Act - var entity = (TestResource)_deserializer.Deserialize(body); + var resource = (TestResource)_deserializer.Deserialize(body); // Assert var pi = _resourceGraph.GetResourceContext("testResource").Attributes.Single(attr => attr.PublicName == member).Property; - var deserializedValue = pi.GetValue(entity); + var deserializedValue = pi.GetValue(resource); if (member == "intField") { diff --git a/test/UnitTests/Serialization/Common/ResourceObjectBuilderTests.cs b/test/UnitTests/Serialization/Common/ResourceObjectBuilderTests.cs index 5a1f26e6f9..3d413ac962 100644 --- a/test/UnitTests/Serialization/Common/ResourceObjectBuilderTests.cs +++ b/test/UnitTests/Serialization/Common/ResourceObjectBuilderTests.cs @@ -19,13 +19,13 @@ public ResourceObjectBuilderTests() } [Fact] - public void EntityToResourceObject_EmptyResource_CanBuild() + public void ResourceToResourceObject_EmptyResource_CanBuild() { // Arrange - var entity = new TestResource(); + var resource = new TestResource(); // Act - var resourceObject = _builder.Build(entity); + var resourceObject = _builder.Build(resource); // Assert Assert.Null(resourceObject.Attributes); @@ -35,13 +35,13 @@ public void EntityToResourceObject_EmptyResource_CanBuild() } [Fact] - public void EntityToResourceObject_ResourceWithId_CanBuild() + public void ResourceToResourceObject_ResourceWithId_CanBuild() { // Arrange - var entity = new TestResource { Id = 1 }; + var resource = new TestResource { Id = 1 }; // Act - var resourceObject = _builder.Build(entity); + var resourceObject = _builder.Build(resource); // Assert Assert.Equal("1", resourceObject.Id); @@ -53,14 +53,14 @@ public void EntityToResourceObject_ResourceWithId_CanBuild() [Theory] [InlineData(null, null)] [InlineData("string field", 1)] - public void EntityToResourceObject_ResourceWithIncludedAttrs_CanBuild(string stringFieldValue, int? intFieldValue) + public void ResourceToResourceObject_ResourceWithIncludedAttrs_CanBuild(string stringFieldValue, int? intFieldValue) { // Arrange - var entity = new TestResource { StringField = stringFieldValue, NullableIntField = intFieldValue }; + var resource = new TestResource { StringField = stringFieldValue, NullableIntField = intFieldValue }; var attrs = _resourceGraph.GetAttributes(tr => new { tr.StringField, tr.NullableIntField }); // Act - var resourceObject = _builder.Build(entity, attrs); + var resourceObject = _builder.Build(resource, attrs); // Assert Assert.NotNull(resourceObject.Attributes); @@ -70,13 +70,13 @@ public void EntityToResourceObject_ResourceWithIncludedAttrs_CanBuild(string str } [Fact] - public void EntityWithRelationshipsToResourceObject_EmptyResource_CanBuild() + public void ResourceWithRelationshipsToResourceObject_EmptyResource_CanBuild() { // Arrange - var entity = new MultipleRelationshipsPrincipalPart(); + var resource = new MultipleRelationshipsPrincipalPart(); // Act - var resourceObject = _builder.Build(entity); + var resourceObject = _builder.Build(resource); // Assert Assert.Null(resourceObject.Attributes); @@ -86,16 +86,16 @@ public void EntityWithRelationshipsToResourceObject_EmptyResource_CanBuild() } [Fact] - public void EntityWithRelationshipsToResourceObject_ResourceWithId_CanBuild() + public void ResourceWithRelationshipsToResourceObject_ResourceWithId_CanBuild() { // Arrange - var entity = new MultipleRelationshipsPrincipalPart + var resource = new MultipleRelationshipsPrincipalPart { PopulatedToOne = new OneToOneDependent { Id = 10 }, }; // Act - var resourceObject = _builder.Build(entity); + var resourceObject = _builder.Build(resource); // Assert Assert.Null(resourceObject.Attributes); @@ -105,10 +105,10 @@ public void EntityWithRelationshipsToResourceObject_ResourceWithId_CanBuild() } [Fact] - public void EntityWithRelationshipsToResourceObject_WithIncludedRelationshipsAttributes_CanBuild() + public void ResourceWithRelationshipsToResourceObject_WithIncludedRelationshipsAttributes_CanBuild() { // Arrange - var entity = new MultipleRelationshipsPrincipalPart + var resource = new MultipleRelationshipsPrincipalPart { PopulatedToOne = new OneToOneDependent { Id = 10 }, PopulatedToManies = new HashSet { new OneToManyDependent { Id = 20 } } @@ -116,7 +116,7 @@ public void EntityWithRelationshipsToResourceObject_WithIncludedRelationshipsAtt var relationships = _resourceGraph.GetRelationships(tr => new { tr.PopulatedToManies, tr.PopulatedToOne, tr.EmptyToOne, tr.EmptyToManies }); // Act - var resourceObject = _builder.Build(entity, relationships: relationships); + var resourceObject = _builder.Build(resource, relationships: relationships); // Assert Assert.Equal(4, resourceObject.Relationships.Count); @@ -133,14 +133,14 @@ public void EntityWithRelationshipsToResourceObject_WithIncludedRelationshipsAtt } [Fact] - public void EntityWithRelationshipsToResourceObject_DeviatingForeignKeyWhileRelationshipIncluded_IgnoresForeignKeyDuringBuild() + public void ResourceWithRelationshipsToResourceObject_DeviatingForeignKeyWhileRelationshipIncluded_IgnoresForeignKeyDuringBuild() { // Arrange - var entity = new OneToOneDependent { Principal = new OneToOnePrincipal { Id = 10 }, PrincipalId = 123 }; + var resource = new OneToOneDependent { Principal = new OneToOnePrincipal { Id = 10 }, PrincipalId = 123 }; var relationships = _resourceGraph.GetRelationships(tr => tr.Principal); // Act - var resourceObject = _builder.Build(entity, relationships: relationships); + var resourceObject = _builder.Build(resource, relationships: relationships); // Assert Assert.Single(resourceObject.Relationships); @@ -150,28 +150,28 @@ public void EntityWithRelationshipsToResourceObject_DeviatingForeignKeyWhileRela } [Fact] - public void EntityWithRelationshipsToResourceObject_DeviatingForeignKeyAndNoNavigationWhileRelationshipIncluded_IgnoresForeignKeyDuringBuild() + public void ResourceWithRelationshipsToResourceObject_DeviatingForeignKeyAndNoNavigationWhileRelationshipIncluded_IgnoresForeignKeyDuringBuild() { // Arrange - var entity = new OneToOneDependent { Principal = null, PrincipalId = 123 }; + var resource = new OneToOneDependent { Principal = null, PrincipalId = 123 }; var relationships = _resourceGraph.GetRelationships(tr => tr.Principal); // Act - var resourceObject = _builder.Build(entity, relationships: relationships); + var resourceObject = _builder.Build(resource, relationships: relationships); // Assert Assert.Null(resourceObject.Relationships["principal"].Data); } [Fact] - public void EntityWithRequiredRelationshipsToResourceObject_DeviatingForeignKeyWhileRelationshipIncluded_IgnoresForeignKeyDuringBuild() + public void ResourceWithRequiredRelationshipsToResourceObject_DeviatingForeignKeyWhileRelationshipIncluded_IgnoresForeignKeyDuringBuild() { // Arrange - var entity = new OneToOneRequiredDependent { Principal = new OneToOnePrincipal { Id = 10 }, PrincipalId = 123 }; + var resource = new OneToOneRequiredDependent { Principal = new OneToOnePrincipal { Id = 10 }, PrincipalId = 123 }; var relationships = _resourceGraph.GetRelationships(tr => tr.Principal); // Act - var resourceObject = _builder.Build(entity, relationships: relationships); + var resourceObject = _builder.Build(resource, relationships: relationships); // Assert Assert.Single(resourceObject.Relationships); @@ -181,25 +181,25 @@ public void EntityWithRequiredRelationshipsToResourceObject_DeviatingForeignKeyW } [Fact] - public void EntityWithRequiredRelationshipsToResourceObject_DeviatingForeignKeyAndNoNavigationWhileRelationshipIncluded_ThrowsNotSupportedException() + public void ResourceWithRequiredRelationshipsToResourceObject_DeviatingForeignKeyAndNoNavigationWhileRelationshipIncluded_ThrowsNotSupportedException() { // Arrange - var entity = new OneToOneRequiredDependent { Principal = null, PrincipalId = 123 }; + var resource = new OneToOneRequiredDependent { Principal = null, PrincipalId = 123 }; var relationships = _resourceGraph.GetRelationships(tr => tr.Principal); // Act & assert - Assert.ThrowsAny(() => _builder.Build(entity, relationships: relationships)); + Assert.ThrowsAny(() => _builder.Build(resource, relationships: relationships)); } [Fact] - public void EntityWithRequiredRelationshipsToResourceObject_EmptyResourceWhileRelationshipIncluded_ThrowsNotSupportedException() + public void ResourceWithRequiredRelationshipsToResourceObject_EmptyResourceWhileRelationshipIncluded_ThrowsNotSupportedException() { // Arrange - var entity = new OneToOneRequiredDependent(); + var resource = new OneToOneRequiredDependent(); var relationships = _resourceGraph.GetRelationships(tr => tr.Principal); // Act & assert - Assert.ThrowsAny(() => _builder.Build(entity, relationships: relationships)); + Assert.ThrowsAny(() => _builder.Build(resource, relationships: relationships)); } } } diff --git a/test/UnitTests/Serialization/DeserializerTestsSetup.cs b/test/UnitTests/Serialization/DeserializerTestsSetup.cs index 8e1290da4c..848b7d2546 100644 --- a/test/UnitTests/Serialization/DeserializerTestsSetup.cs +++ b/test/UnitTests/Serialization/DeserializerTestsSetup.cs @@ -27,24 +27,24 @@ public TestDocumentParser(IResourceGraph resourceGraph, IResourceFactory resourc return base.Deserialize(body); } - protected override void AfterProcessField(IIdentifiable entity, ResourceFieldAttribute field, RelationshipEntry data = null) { } + protected override void AfterProcessField(IIdentifiable resource, ResourceFieldAttribute field, RelationshipEntry data = null) { } } - protected Document CreateDocumentWithRelationships(string mainType, string relationshipMemberName, string relatedType = null, bool isToManyData = false) + protected Document CreateDocumentWithRelationships(string primaryType, string relationshipMemberName, string relatedType = null, bool isToManyData = false) { - var content = CreateDocumentWithRelationships(mainType); + var content = CreateDocumentWithRelationships(primaryType); content.SingleData.Relationships.Add(relationshipMemberName, CreateRelationshipData(relatedType, isToManyData)); return content; } - protected Document CreateDocumentWithRelationships(string mainType) + protected Document CreateDocumentWithRelationships(string primaryType) { return new Document { Data = new ResourceObject { Id = "1", - Type = mainType, + Type = primaryType, Relationships = new Dictionary() } }; diff --git a/test/UnitTests/Serialization/SerializerTestsSetup.cs b/test/UnitTests/Serialization/SerializerTestsSetup.cs index 5dec6461f5..7a1589e459 100644 --- a/test/UnitTests/Serialization/SerializerTestsSetup.cs +++ b/test/UnitTests/Serialization/SerializerTestsSetup.cs @@ -1,10 +1,12 @@ using System; using System.Collections.Generic; +using System.Linq; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Internal.Queries; +using JsonApiDotNetCore.Internal.Queries.Expressions; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Models.Annotation; -using JsonApiDotNetCore.Models.Links; -using JsonApiDotNetCore.Query; +using JsonApiDotNetCore.Models.JsonApiDocuments; using JsonApiDotNetCore.Serialization; using JsonApiDotNetCore.Serialization.Server; using JsonApiDotNetCore.Serialization.Server.Builders; @@ -42,19 +44,19 @@ protected ResponseSerializer GetResponseSerializer(List(metaDict); var link = GetLinkBuilder(topLinks, resourceLinks, relationshipLinks); - var included = GetIncludedRelationships(inclusionChains); + var includeConstraints = GetIncludeConstraints(inclusionChains); var includedBuilder = GetIncludedBuilder(); var fieldsToSerialize = GetSerializableFields(); - ResponseResourceObjectBuilder resourceObjectBuilder = new ResponseResourceObjectBuilder(link, includedBuilder, included, _resourceGraph, GetSerializerSettingsProvider()); + ResponseResourceObjectBuilder resourceObjectBuilder = new ResponseResourceObjectBuilder(link, includedBuilder, includeConstraints, _resourceGraph, GetSerializerSettingsProvider()); return new ResponseSerializer(meta, link, includedBuilder, fieldsToSerialize, resourceObjectBuilder, new JsonApiOptions()); } protected ResponseResourceObjectBuilder GetResponseResourceObjectBuilder(List> inclusionChains = null, ResourceLinks resourceLinks = null, RelationshipLinks relationshipLinks = null) { var link = GetLinkBuilder(null, resourceLinks, relationshipLinks); - var included = GetIncludedRelationships(inclusionChains); + var includeConstraints = GetIncludeConstraints(inclusionChains); var includedBuilder = GetIncludedBuilder(); - return new ResponseResourceObjectBuilder(link, includedBuilder, included, _resourceGraph, GetSerializerSettingsProvider()); + return new ResponseResourceObjectBuilder(link, includedBuilder, includeConstraints, _resourceGraph, GetSerializerSettingsProvider()); } private IIncludedResourceObjectBuilder GetIncludedBuilder() @@ -88,18 +90,27 @@ protected ILinkBuilder GetLinkBuilder(TopLevelLinks top = null, ResourceLinks re protected IFieldsToSerialize GetSerializableFields() { var mock = new Mock(); - mock.Setup(m => m.GetAllowedAttributes(It.IsAny(), It.IsAny())).Returns((t, r) => _resourceGraph.GetResourceContext(t).Attributes); - mock.Setup(m => m.GetAllowedRelationships(It.IsAny())).Returns(t => _resourceGraph.GetResourceContext(t).Relationships); + mock.Setup(m => m.GetAttributes(It.IsAny(), It.IsAny())).Returns((t, r) => _resourceGraph.GetResourceContext(t).Attributes); + mock.Setup(m => m.GetRelationships(It.IsAny())).Returns(t => _resourceGraph.GetResourceContext(t).Relationships); return mock.Object; } - protected IIncludeService GetIncludedRelationships(List> inclusionChains = null) + protected IEnumerable GetIncludeConstraints(List> inclusionChains = null) { - var mock = new Mock(); + var expressionsInScope = new List(); + if (inclusionChains != null) - mock.Setup(m => m.Get()).Returns(inclusionChains); + { + var chains = inclusionChains.Select(relationships => new ResourceFieldChainExpression(relationships)).ToList(); + var includeExpression = new IncludeExpression(chains); + expressionsInScope.Add(new ExpressionInScope(null, includeExpression)); + } - return mock.Object; + var mock = new Mock(); + mock.Setup(x => x.GetConstraints()).Returns(expressionsInScope); + + IQueryConstraintProvider includeConstraintProvider = mock.Object; + return new List {includeConstraintProvider}; } /// @@ -110,14 +121,14 @@ protected sealed class TestDocumentBuilder : BaseDocumentBuilder { public TestDocumentBuilder(IResourceObjectBuilder resourceObjectBuilder) : base(resourceObjectBuilder) { } - public new Document Build(IIdentifiable entity, IReadOnlyCollection attributes = null, IReadOnlyCollection relationships = null) + public new Document Build(IIdentifiable resource, IReadOnlyCollection attributes = null, IReadOnlyCollection relationships = null) { - return base.Build(entity, attributes, relationships); + return base.Build(resource, attributes, relationships); } - public new Document Build(IEnumerable entities, IReadOnlyCollection attributes = null, IReadOnlyCollection relationships = null) + public new Document Build(IEnumerable resources, IReadOnlyCollection attributes = null, IReadOnlyCollection relationships = null) { - return base.Build(entities, attributes, relationships); + return base.Build(resources, attributes, relationships); } } } diff --git a/test/UnitTests/Serialization/Server/IncludedResourceObjectBuilderTests.cs b/test/UnitTests/Serialization/Server/IncludedResourceObjectBuilderTests.cs index 7e893ea2ac..5b3286221f 100644 --- a/test/UnitTests/Serialization/Server/IncludedResourceObjectBuilderTests.cs +++ b/test/UnitTests/Serialization/Server/IncludedResourceObjectBuilderTests.cs @@ -1,7 +1,6 @@ using Xunit; using System.Collections.Generic; using System.Linq; -using JsonApiDotNetCore.Internal.Query; using JsonApiDotNetCore.Models.Annotation; using JsonApiDotNetCore.Serialization.Server.Builders; using UnitTests.TestModels; @@ -155,7 +154,7 @@ private List GetIncludedRelationshipsChain(string chain) { var parsedChain = new List(); var resourceContext = _resourceGraph.GetResourceContext
(); - var splitPath = chain.Split(QueryConstants.DOT); + var splitPath = chain.Split('.'); foreach (var requestedRelationship in splitPath) { var relationship = resourceContext.Relationships.Single(r => r.PublicName == requestedRelationship); diff --git a/test/UnitTests/Serialization/Server/RequestDeserializerTests.cs b/test/UnitTests/Serialization/Server/RequestDeserializerTests.cs index 810a80a89c..0712bdb24c 100644 --- a/test/UnitTests/Serialization/Server/RequestDeserializerTests.cs +++ b/test/UnitTests/Serialization/Server/RequestDeserializerTests.cs @@ -7,7 +7,6 @@ using JsonApiDotNetCore.Models.Annotation; using JsonApiDotNetCore.Serialization; using JsonApiDotNetCore.Serialization.Server; -using Microsoft.AspNetCore.Http; using Moq; using Newtonsoft.Json; using Xunit; @@ -21,7 +20,7 @@ public sealed class RequestDeserializerTests : DeserializerTestsSetup private readonly Mock _fieldsManagerMock = new Mock(); public RequestDeserializerTests() { - _deserializer = new RequestDeserializer(_resourceGraph, new DefaultResourceFactory(new ServiceContainer()), _fieldsManagerMock.Object, _mockHttpContextAccessor.Object); + _deserializer = new RequestDeserializer(_resourceGraph, new ResourceFactory(new ServiceContainer()), _fieldsManagerMock.Object, _mockHttpContextAccessor.Object); } [Fact] diff --git a/test/UnitTests/Serialization/Server/ResponseResourceObjectBuilderTests.cs b/test/UnitTests/Serialization/Server/ResponseResourceObjectBuilderTests.cs index d9b5ab527c..eecbad1aec 100644 --- a/test/UnitTests/Serialization/Server/ResponseResourceObjectBuilderTests.cs +++ b/test/UnitTests/Serialization/Server/ResponseResourceObjectBuilderTests.cs @@ -20,11 +20,11 @@ public ResponseResourceObjectBuilderTests() public void Build_RelationshipNotIncludedAndLinksEnabled_RelationshipEntryWithLinks() { // Arrange - var entity = new OneToManyPrincipal { Id = 10 }; + var resource = new OneToManyPrincipal { Id = 10 }; var builder = GetResponseResourceObjectBuilder(relationshipLinks: _dummyRelationshipLinks); // Act - var resourceObject = builder.Build(entity, relationships: _relationshipsForBuild); + var resourceObject = builder.Build(resource, relationships: _relationshipsForBuild); // Assert Assert.True(resourceObject.Relationships.TryGetValue(_relationshipName, out var entry)); @@ -37,11 +37,11 @@ public void Build_RelationshipNotIncludedAndLinksEnabled_RelationshipEntryWithLi public void Build_RelationshipNotIncludedAndLinksDisabled_NoRelationshipObject() { // Arrange - var entity = new OneToManyPrincipal { Id = 10 }; + var resource = new OneToManyPrincipal { Id = 10 }; var builder = GetResponseResourceObjectBuilder(); // Act - var resourceObject = builder.Build(entity, relationships: _relationshipsForBuild); + var resourceObject = builder.Build(resource, relationships: _relationshipsForBuild); // Assert Assert.Null(resourceObject.Relationships); @@ -51,11 +51,11 @@ public void Build_RelationshipNotIncludedAndLinksDisabled_NoRelationshipObject() public void Build_RelationshipIncludedAndLinksDisabled_RelationshipEntryWithData() { // Arrange - var entity = new OneToManyPrincipal { Id = 10, Dependents = new HashSet { new OneToManyDependent { Id = 20 } } }; + var resource = new OneToManyPrincipal { Id = 10, Dependents = new HashSet { new OneToManyDependent { Id = 20 } } }; var builder = GetResponseResourceObjectBuilder(inclusionChains: new List> { _relationshipsForBuild } ); // Act - var resourceObject = builder.Build(entity, relationships: _relationshipsForBuild); + var resourceObject = builder.Build(resource, relationships: _relationshipsForBuild); // Assert Assert.True(resourceObject.Relationships.TryGetValue(_relationshipName, out var entry)); @@ -68,11 +68,11 @@ public void Build_RelationshipIncludedAndLinksDisabled_RelationshipEntryWithData public void Build_RelationshipIncludedAndLinksEnabled_RelationshipEntryWithDataAndLinks() { // Arrange - var entity = new OneToManyPrincipal { Id = 10, Dependents = new HashSet { new OneToManyDependent { Id = 20 } } }; + var resource = new OneToManyPrincipal { Id = 10, Dependents = new HashSet { new OneToManyDependent { Id = 20 } } }; var builder = GetResponseResourceObjectBuilder(inclusionChains: new List> { _relationshipsForBuild }, relationshipLinks: _dummyRelationshipLinks); // Act - var resourceObject = builder.Build(entity, relationships: _relationshipsForBuild); + var resourceObject = builder.Build(resource, relationships: _relationshipsForBuild); // Assert Assert.True(resourceObject.Relationships.TryGetValue(_relationshipName, out var entry)); diff --git a/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs b/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs index 7f5f1b8a36..0bb9459b61 100644 --- a/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs +++ b/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs @@ -16,11 +16,11 @@ public sealed class ResponseSerializerTests : SerializerTestsSetup public void SerializeSingle_ResourceWithDefaultTargetFields_CanSerialize() { // Arrange - var entity = new TestResource { Id = 1, StringField = "value", NullableIntField = 123 }; + var resource = new TestResource { Id = 1, StringField = "value", NullableIntField = 123 }; var serializer = GetResponseSerializer(); // Act - string serialized = serializer.SerializeSingle(entity); + string serialized = serializer.SerializeSingle(resource); // Assert var expectedFormatted = @@ -50,11 +50,11 @@ public void SerializeSingle_ResourceWithDefaultTargetFields_CanSerialize() public void SerializeMany_ResourceWithDefaultTargetFields_CanSerialize() { // Arrange - var entity = new TestResource { Id = 1, StringField = "value", NullableIntField = 123 }; + var resource = new TestResource { Id = 1, StringField = "value", NullableIntField = 123 }; var serializer = GetResponseSerializer(); // Act - string serialized = serializer.SerializeMany(new List { entity }); + string serialized = serializer.SerializeMany(new List { resource }); // Assert var expectedFormatted = @@ -83,7 +83,7 @@ public void SerializeMany_ResourceWithDefaultTargetFields_CanSerialize() public void SerializeSingle_ResourceWithIncludedRelationships_CanSerialize() { // Arrange - var entity = new MultipleRelationshipsPrincipalPart + var resource = new MultipleRelationshipsPrincipalPart { Id = 1, PopulatedToOne = new OneToOneDependent { Id = 10 }, @@ -93,7 +93,7 @@ public void SerializeSingle_ResourceWithIncludedRelationships_CanSerialize() var serializer = GetResponseSerializer(inclusionChains: chain); // Act - string serialized = serializer.SerializeSingle(entity); + string serialized = serializer.SerializeSingle(resource); // Assert var expectedFormatted = @@ -144,12 +144,12 @@ public void SerializeSingle_ResourceWithIncludedRelationships_CanSerialize() public void SerializeSingle_ResourceWithDeeplyIncludedRelationships_CanSerialize() { // Arrange - var deeplyIncludedEntity = new OneToManyPrincipal { Id = 30, AttributeMember = "deep" }; - var includedEntity = new OneToManyDependent { Id = 20, Principal = deeplyIncludedEntity }; - var entity = new MultipleRelationshipsPrincipalPart + var deeplyIncludedResource = new OneToManyPrincipal { Id = 30, AttributeMember = "deep" }; + var includedResource = new OneToManyDependent { Id = 20, Principal = deeplyIncludedResource }; + var resource = new MultipleRelationshipsPrincipalPart { Id = 10, - PopulatedToManies = new HashSet { includedEntity } + PopulatedToManies = new HashSet { includedResource } }; var chains = _resourceGraph.GetRelationships() @@ -165,7 +165,7 @@ public void SerializeSingle_ResourceWithDeeplyIncludedRelationships_CanSerialize var serializer = GetResponseSerializer(inclusionChains: chains); // Act - string serialized = serializer.SerializeSingle(entity); + string serialized = serializer.SerializeSingle(resource); // Assert var expectedFormatted = @@ -262,11 +262,11 @@ public void SerializeList_EmptyList_CanSerialize() public void SerializeSingle_ResourceWithLinksEnabled_CanSerialize() { // Arrange - var entity = new OneToManyPrincipal { Id = 10 }; + var resource = new OneToManyPrincipal { Id = 10 }; var serializer = GetResponseSerializer(topLinks: _dummyTopLevelLinks, relationshipLinks: _dummyRelationshipLinks, resourceLinks: _dummyResourceLinks); // Act - string serialized = serializer.SerializeSingle(entity); + string serialized = serializer.SerializeSingle(resource); // Assert var expectedFormatted = @@ -307,11 +307,11 @@ public void SerializeSingle_ResourceWithMeta_IncludesMetaInResult() { // Arrange var meta = new Dictionary { { "test", "meta" } }; - var entity = new OneToManyPrincipal { Id = 10 }; + var resource = new OneToManyPrincipal { Id = 10 }; var serializer = GetResponseSerializer(metaDict: meta); // Act - string serialized = serializer.SerializeSingle(entity); + string serialized = serializer.SerializeSingle(resource); // Assert var expectedFormatted = @@ -362,13 +362,13 @@ public void SerializeSingle_NullWithLinksAndMeta_StillShowsLinksAndMeta() public void SerializeSingleWithRequestRelationship_NullToOneRelationship_CanSerialize() { // Arrange - var entity = new OneToOnePrincipal { Id = 2, Dependent = null }; + 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(entity); + string serialized = serializer.SerializeSingle(resource); // Assert var expectedFormatted = @"{ ""data"": null}"; @@ -380,14 +380,14 @@ public void SerializeSingleWithRequestRelationship_NullToOneRelationship_CanSeri public void SerializeSingleWithRequestRelationship_PopulatedToOneRelationship_CanSerialize() { // Arrange - var entity = new OneToOnePrincipal { Id = 2, Dependent = new OneToOneDependent { Id = 1 } }; + 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(entity); + string serialized = serializer.SerializeSingle(resource); // Assert var expectedFormatted = @@ -407,14 +407,14 @@ public void SerializeSingleWithRequestRelationship_PopulatedToOneRelationship_Ca public void SerializeSingleWithRequestRelationship_EmptyToManyRelationship_CanSerialize() { // Arrange - var entity = new OneToManyPrincipal { Id = 2, Dependents = new HashSet() }; + 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(entity); + string serialized = serializer.SerializeSingle(resource); // Assert var expectedFormatted = @"{ ""data"": [] }"; @@ -426,14 +426,14 @@ public void SerializeSingleWithRequestRelationship_EmptyToManyRelationship_CanSe public void SerializeSingleWithRequestRelationship_PopulatedToManyRelationship_CanSerialize() { // Arrange - var entity = new OneToManyPrincipal { Id = 2, Dependents = new HashSet { new OneToManyDependent { Id = 1 } } }; + 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(entity); + string serialized = serializer.SerializeSingle(resource); // Assert var expectedFormatted = diff --git a/test/UnitTests/Services/DefaultResourceService_Tests.cs b/test/UnitTests/Services/DefaultResourceService_Tests.cs new file mode 100644 index 0000000000..f10235b6ad --- /dev/null +++ b/test/UnitTests/Services/DefaultResourceService_Tests.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.Design; +using System.Linq; +using System.Threading.Tasks; +using JsonApiDotNetCore.Builders; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Data; +using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Internal.Contracts; +using JsonApiDotNetCore.Internal.Queries; +using JsonApiDotNetCore.Query; +using JsonApiDotNetCore.RequestServices; +using JsonApiDotNetCore.Serialization; +using JsonApiDotNetCore.Services; +using JsonApiDotNetCoreExample.Models; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace UnitTests.Services +{ + public sealed class JsonApiResourceServiceTests + { + private readonly Mock> _repositoryMock = new Mock>(); + private readonly IResourceGraph _resourceGraph; + + public JsonApiResourceServiceTests() + { + _resourceGraph = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance) + .AddResource() + .AddResource() + .Build(); + } + + [Fact] + public async Task GetRelationshipAsync_Passes_Public_ResourceName_To_Repository() + { + // Arrange + var todoItem = new TodoItem(); + + _repositoryMock.Setup(m => m.GetAsync(It.IsAny())).ReturnsAsync(new[] {todoItem}); + var service = GetService(); + + // Act + await service.GetSecondaryAsync(1, "collection"); + + // Assert + _repositoryMock.Verify(m => m.GetAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task GetRelationshipAsync_Returns_Relationship_Value() + { + // Arrange + var todoItem = new TodoItem + { + Id = 1, + Collection = new TodoItemCollection { Id = Guid.NewGuid() } + }; + + _repositoryMock.Setup(m => m.GetAsync(It.IsAny())).ReturnsAsync(new[] {todoItem}); + var service = GetService(); + + // Act + var result = await service.GetSecondaryAsync(1, "collection"); + + // Assert + Assert.NotNull(result); + var collection = Assert.IsType(result); + Assert.Equal(todoItem.Collection.Id, collection.Id); + } + + private JsonApiResourceService GetService() + { + var options = new JsonApiOptions(); + var changeTracker = new ResourceChangeTracker(options, _resourceGraph, new TargetedFields()); + var serviceProvider = new ServiceContainer(); + var resourceFactory = new ResourceFactory(serviceProvider); + var resourceDefinitionProvider = new ResourceDefinitionProvider(_resourceGraph, new TestScopedServiceProvider(serviceProvider)); + var paginationContext = new PaginationContext(); + var composer = new QueryLayerComposer(new List(), _resourceGraph, resourceDefinitionProvider, options, paginationContext); + var currentRequest = new CurrentRequest + { + PrimaryResource = _resourceGraph.GetResourceContext(), + SecondaryResource = _resourceGraph.GetResourceContext(), + Relationship = _resourceGraph.GetRelationships(typeof(TodoItem)) + .Single(x => x.PublicName == "collection") + }; + + return new JsonApiResourceService(_repositoryMock.Object, composer, paginationContext, options, + NullLoggerFactory.Instance, currentRequest, changeTracker, resourceFactory, null); + } + } +} diff --git a/test/UnitTests/Services/EntityResourceService_Tests.cs b/test/UnitTests/Services/EntityResourceService_Tests.cs deleted file mode 100644 index 0b9b4bc5a4..0000000000 --- a/test/UnitTests/Services/EntityResourceService_Tests.cs +++ /dev/null @@ -1,132 +0,0 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel.Design; -using System.Linq; -using System.Threading.Tasks; -using JsonApiDotNetCore.Builders; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Data; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Models.Annotation; -using JsonApiDotNetCore.Query; -using JsonApiDotNetCore.RequestServices; -using JsonApiDotNetCore.Serialization; -using JsonApiDotNetCore.Services; -using JsonApiDotNetCoreExample.Models; -using Microsoft.Extensions.Logging.Abstractions; -using Moq; -using Xunit; - -namespace UnitTests.Services -{ - public sealed class EntityResourceService_Tests - { - private readonly Mock> _repositoryMock = new Mock>(); - private readonly IResourceGraph _resourceGraph; - private readonly Mock _includeService; - private readonly Mock _sparseFieldsService; - private readonly Mock _pageService; - - public Mock _sortService { get; } - public Mock _filterService { get; } - - public EntityResourceService_Tests() - { - _includeService = new Mock(); - _includeService.Setup(m => m.Get()).Returns(new List>()); - _sparseFieldsService = new Mock(); - _pageService = new Mock(); - _sortService = new Mock(); - _filterService = new Mock(); - _resourceGraph = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance) - .AddResource() - .AddResource() - .Build(); - } - - [Fact] - public async Task GetRelationshipAsync_Passes_Public_ResourceName_To_Repository() - { - // Arrange - const int id = 1; - const string relationshipName = "collection"; - var relationship = new RelationshipAttribute[] - { - new HasOneAttribute(relationshipName) - { - LeftType = typeof(TodoItem), - RightType = typeof(TodoItemCollection) - } - }; - - var todoItem = new TodoItem(); - var query = new List { todoItem }.AsQueryable(); - - _repositoryMock.Setup(m => m.Get(id)).Returns(query); - _repositoryMock.Setup(m => m.Include(query, relationship)).Returns(query); - _repositoryMock.Setup(m => m.FirstOrDefaultAsync(query)).ReturnsAsync(todoItem); - - var service = GetService(); - - // Act - await service.GetRelationshipAsync(id, relationshipName); - - // Assert - _repositoryMock.Verify(m => m.Get(id), Times.Once); - _repositoryMock.Verify(m => m.Include(query, relationship), Times.Once); - _repositoryMock.Verify(m => m.FirstOrDefaultAsync(query), Times.Once); - } - - [Fact] - public async Task GetRelationshipAsync_Returns_Relationship_Value() - { - // Arrange - const int id = 1; - const string relationshipName = "collection"; - var relationships = new RelationshipAttribute[] - { - new HasOneAttribute(relationshipName) - { - LeftType = typeof(TodoItem), - RightType = typeof(TodoItemCollection) - } - }; - - var todoItem = new TodoItem - { - Collection = new TodoItemCollection { Id = Guid.NewGuid() } - }; - - var query = new List { todoItem }.AsQueryable(); - - _repositoryMock.Setup(m => m.Get(id)).Returns(query); - _repositoryMock.Setup(m => m.Include(query, relationships)).Returns(query); - _repositoryMock.Setup(m => m.FirstOrDefaultAsync(query)).ReturnsAsync(todoItem); - - var service = GetService(); - - // Act - var result = await service.GetRelationshipAsync(id, relationshipName); - - // Assert - Assert.NotNull(result); - var collection = Assert.IsType(result); - Assert.Equal(todoItem.Collection.Id, collection.Id); - } - - private DefaultResourceService GetService() - { - var queryParamServices = new List - { - _includeService.Object, _pageService.Object, _filterService.Object, - _sortService.Object, _sparseFieldsService.Object - }; - - var options = new JsonApiOptions(); - var changeTracker = new DefaultResourceChangeTracker(options, _resourceGraph, new TargetedFields()); - - return new DefaultResourceService(queryParamServices, options, NullLoggerFactory.Instance, _repositoryMock.Object, _resourceGraph, changeTracker, new DefaultResourceFactory(new ServiceContainer())); - } - } -} diff --git a/test/UnitTests/TestModels.cs b/test/UnitTests/TestModels.cs index 5fc9eb5027..93bf2a08e2 100644 --- a/test/UnitTests/TestModels.cs +++ b/test/UnitTests/TestModels.cs @@ -14,7 +14,7 @@ public sealed class TestResource : Identifiable [Attr] public int? NullableIntField { get; set; } [Attr] public Guid GuidField { get; set; } [Attr] public ComplexType ComplexField { get; set; } - [Attr(AttrCapabilities.All & ~AttrCapabilities.AllowMutate)] public string Immutable { get; set; } + [Attr(AttrCapabilities.All & ~AttrCapabilities.AllowChange)] public string Immutable { get; set; } } public class TestResourceWithList : Identifiable diff --git a/test/UnitTests/UnitTests.csproj b/test/UnitTests/UnitTests.csproj index f9c5e4b14a..aece5f0576 100644 --- a/test/UnitTests/UnitTests.csproj +++ b/test/UnitTests/UnitTests.csproj @@ -15,6 +15,7 @@ + From b3f8deca480d67702a02d736f4890284bfcca04d Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 17 Jul 2020 14:06:33 +0200 Subject: [PATCH 03/13] Fix cibuild --- .../Acceptance/SerializationTests.cs | 15 +++++---------- .../Spec/FetchingRelationshipsTests.cs | 19 +++++++++++++------ .../Helpers/Extensions/StringExtensions.cs | 10 ++++++++++ 3 files changed, 28 insertions(+), 16 deletions(-) create mode 100644 test/JsonApiDotNetCoreExampleTests/Helpers/Extensions/StringExtensions.cs diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/SerializationTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/SerializationTests.cs index 521108669c..882251b145 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/SerializationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/SerializationTests.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using JsonApiDotNetCoreExample.Models; using JsonApiDotNetCoreExampleTests.Acceptance.Spec; +using JsonApiDotNetCoreExampleTests.Helpers.Extensions; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Xunit; @@ -43,10 +44,9 @@ public async Task When_getting_person_it_must_match_JSON_text() Assert.Equal(HttpStatusCode.OK, response.StatusCode); var bodyText = await response.Content.ReadAsStringAsync(); - var token = JsonConvert.DeserializeObject(bodyText); - var bodyFormatted = NormalizeLineEndings(token.ToString()); + var json = JsonConvert.DeserializeObject(bodyText).ToString(); - var expectedText = NormalizeLineEndings(@"{ + var expected = @"{ ""meta"": { ""copyright"": ""Copyright 2015 Example Corp."", ""authors"": [ @@ -123,13 +123,8 @@ public async Task When_getting_person_it_must_match_JSON_text() ""self"": ""http://localhost/api/v1/people/123"" } } -}"); - Assert.Equal(expectedText, bodyFormatted); - } - - private static string NormalizeLineEndings(string text) - { - return text.Replace("\r\n", "\n").Replace("\r", "\n"); +}"; + Assert.Equal(expected.NormalizeLineEndings(), json.NormalizeLineEndings()); } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs index f815423007..93d860d03a 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs @@ -8,6 +8,7 @@ using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; +using JsonApiDotNetCoreExampleTests.Helpers.Extensions; using Microsoft.AspNetCore; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; @@ -60,7 +61,7 @@ public async Task When_getting_existing_ToOne_relationship_it_should_succeed() var json = JsonConvert.DeserializeObject(body).ToString(); - Assert.Equal(@"{ + string expected = @"{ ""links"": { ""self"": ""http://localhost/api/v1/todoItems/" + todoItem.StringId + @"/relationships/owner"", ""related"": ""http://localhost/api/v1/todoItems/" + todoItem.StringId + @"/owner"" @@ -69,7 +70,8 @@ public async Task When_getting_existing_ToOne_relationship_it_should_succeed() ""type"": ""people"", ""id"": """ + todoItem.Owner.StringId + @""" } -}", json); +}"; + Assert.Equal(expected.NormalizeLineEndings(), json.NormalizeLineEndings()); } [Fact] @@ -112,7 +114,7 @@ public async Task When_getting_existing_ToMany_relationship_it_should_succeed() var json = JsonConvert.DeserializeObject(body).ToString(); - Assert.Equal(@"{ + var expected = @"{ ""links"": { ""self"": ""http://localhost/api/v1/authors/" + author.StringId + @"/relationships/articles"", ""related"": ""http://localhost/api/v1/authors/" + author.StringId + @"/articles"" @@ -127,7 +129,9 @@ public async Task When_getting_existing_ToMany_relationship_it_should_succeed() ""id"": """ + author.Articles[1].StringId + @""" } ] -}", json); +}"; + + Assert.Equal(expected.NormalizeLineEndings(), json.NormalizeLineEndings()); } [Fact] @@ -156,7 +160,8 @@ public async Task When_getting_related_missing_to_one_resource_it_should_succeed Assert.Equal(HttpStatusCode.OK, response.StatusCode); var json = JsonConvert.DeserializeObject(body).ToString(); - Assert.Equal(@"{ + + var expected = @"{ ""meta"": { ""copyright"": ""Copyright 2015 Example Corp."", ""authors"": [ @@ -169,7 +174,9 @@ public async Task When_getting_related_missing_to_one_resource_it_should_succeed ""self"": ""http://localhost/api/v1/todoItems/" + todoItem.StringId + @"/owner"" }, ""data"": null -}", json); +}"; + + Assert.Equal(expected.NormalizeLineEndings(), json.NormalizeLineEndings()); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/Helpers/Extensions/StringExtensions.cs b/test/JsonApiDotNetCoreExampleTests/Helpers/Extensions/StringExtensions.cs new file mode 100644 index 0000000000..8cccc0c8e2 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Helpers/Extensions/StringExtensions.cs @@ -0,0 +1,10 @@ +namespace JsonApiDotNetCoreExampleTests.Helpers.Extensions +{ + public static class StringExtensions + { + public static string NormalizeLineEndings(this string text) + { + return text.Replace("\r\n", "\n").Replace("\r", "\n"); + } + } +} From a6ce7ff92f0620e4b5c33f53d66bf447cd5ccb97 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 10 Aug 2020 15:32:18 +0200 Subject: [PATCH 04/13] Fixed: use intersection on serialize sparse fieldset from ResourceDefinition. This hides forced included fields from output, but still fetches them. --- .../Models/ResourceDefinition.cs | 12 ++++++-- .../Server/Contracts/IFieldsToSerialize.cs | 4 +-- .../Serialization/Server/FieldsToSerialize.cs | 28 +++++++++++++------ .../ResourceDefinitions/CallableResource.cs | 3 ++ .../CallableResourceDefinition.cs | 2 +- .../ResourceDefinitionQueryCallbackTests.cs | 5 ++-- 6 files changed, 38 insertions(+), 16 deletions(-) diff --git a/src/JsonApiDotNetCore/Models/ResourceDefinition.cs b/src/JsonApiDotNetCore/Models/ResourceDefinition.cs index d18fc360a5..0ac842a0ed 100644 --- a/src/JsonApiDotNetCore/Models/ResourceDefinition.cs +++ b/src/JsonApiDotNetCore/Models/ResourceDefinition.cs @@ -142,11 +142,17 @@ public virtual PaginationExpression OnApplyPagination(PaginationExpression exist /// Tip: Use and /// to safely change the fieldset without worrying about nulls. ///
- /// - /// An optional existing sparse fieldset, coming from query string. Can be null. + /// + /// This method executes twice for a single request: first to select which fields to retrieve from the data store and then to + /// select which fields to serialize. Including extra fields from this method will retrieve them, but not include them in the json output. + /// This enables you to expose calculated properties whose value depends on a field that is not in the sparse fieldset. + /// + /// The incoming sparse fieldset from query string. + /// At query execution time, this is null if the query string contains no sparse fieldset. + /// At serialization time, this contains all viewable fields if the query string contains no sparse fieldset. /// /// - /// The new sparse fieldset, or null to disable the existing sparse fieldset and select all fields. + /// The new sparse fieldset, or null to discard the existing sparse fieldset and select all viewable fields. /// public virtual SparseFieldSetExpression OnApplySparseFieldSet(SparseFieldSetExpression existingSparseFieldSet) { diff --git a/src/JsonApiDotNetCore/Serialization/Server/Contracts/IFieldsToSerialize.cs b/src/JsonApiDotNetCore/Serialization/Server/Contracts/IFieldsToSerialize.cs index 1a4478b795..490732bbc6 100644 --- a/src/JsonApiDotNetCore/Serialization/Server/Contracts/IFieldsToSerialize.cs +++ b/src/JsonApiDotNetCore/Serialization/Server/Contracts/IFieldsToSerialize.cs @@ -15,11 +15,11 @@ namespace JsonApiDotNetCore.Serialization.Server public interface IFieldsToSerialize { /// - /// Gets the list of attributes that are to be serialized for resource of type . + /// Gets the list of attributes that are to be serialized for resource of type . /// If is non-null, it will consider the allowed list of attributes /// as an included relationship. /// - IReadOnlyCollection GetAttributes(Type type, RelationshipAttribute relationship = null); + IReadOnlyCollection GetAttributes(Type resourceType, RelationshipAttribute relationship = null); /// /// Gets the list of relationships that are to be serialized for resource of type . diff --git a/src/JsonApiDotNetCore/Serialization/Server/FieldsToSerialize.cs b/src/JsonApiDotNetCore/Serialization/Server/FieldsToSerialize.cs index 3c52371700..794ae7234c 100644 --- a/src/JsonApiDotNetCore/Serialization/Server/FieldsToSerialize.cs +++ b/src/JsonApiDotNetCore/Serialization/Server/FieldsToSerialize.cs @@ -28,7 +28,7 @@ public FieldsToSerialize( } /// - public IReadOnlyCollection GetAttributes(Type type, RelationshipAttribute relationship = null) + public IReadOnlyCollection GetAttributes(Type resourceType, RelationshipAttribute relationship = null) { var sparseFieldSetAttributes = _constraintProviders .SelectMany(p => p.GetConstraints()) @@ -42,23 +42,35 @@ public IReadOnlyCollection GetAttributes(Type type, RelationshipA if (!sparseFieldSetAttributes.Any()) { - sparseFieldSetAttributes = _resourceGraph.GetAttributes(type).ToHashSet(); + sparseFieldSetAttributes = GetViewableAttributes(resourceType); } - sparseFieldSetAttributes.RemoveWhere(attr => !attr.Capabilities.HasFlag(AttrCapabilities.AllowView)); - - var resourceDefinition = _resourceDefinitionProvider.Get(type); + var resourceDefinition = _resourceDefinitionProvider.Get(resourceType); if (resourceDefinition != null) { - var tempExpression = sparseFieldSetAttributes.Any() ? new SparseFieldSetExpression(sparseFieldSetAttributes) : null; - tempExpression = resourceDefinition.OnApplySparseFieldSet(tempExpression); + var inputExpression = sparseFieldSetAttributes.Any() ? new SparseFieldSetExpression(sparseFieldSetAttributes) : null; + var outputExpression = resourceDefinition.OnApplySparseFieldSet(inputExpression); - sparseFieldSetAttributes = tempExpression == null ? new HashSet() : tempExpression.Attributes.ToHashSet(); + if (outputExpression == null) + { + sparseFieldSetAttributes = GetViewableAttributes(resourceType); + } + else + { + sparseFieldSetAttributes.IntersectWith(outputExpression.Attributes); + } } return sparseFieldSetAttributes; } + private HashSet GetViewableAttributes(Type resourceType) + { + return _resourceGraph.GetAttributes(resourceType) + .Where(attr => attr.Capabilities.HasFlag(AttrCapabilities.AllowView)) + .ToHashSet(); + } + /// /// /// Note: this method does NOT check if a relationship is included to determine diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableResource.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableResource.cs index d80f501a83..168c3ea5b8 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableResource.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableResource.cs @@ -13,6 +13,9 @@ public sealed class CallableResource : Identifiable [Attr] public int PercentageComplete { get; set; } + [Attr] + public string Status => $"{PercentageComplete}% completed."; + [Attr] public int RiskLevel { get; set; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableResourceDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableResourceDefinition.cs index e0355a4010..9fa1139dcf 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableResourceDefinition.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableResourceDefinition.cs @@ -68,7 +68,7 @@ public override PaginationExpression OnApplyPagination(PaginationExpression exis public override SparseFieldSetExpression OnApplySparseFieldSet(SparseFieldSetExpression existingSparseFieldSet) { - // Use case: always include percentageComplete and never include riskLevel in responses. + // Use case: always retrieve percentageComplete and never include riskLevel in responses. return existingSparseFieldSet .Including(resource => resource.PercentageComplete, ResourceGraph) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/ResourceDefinitionQueryCallbackTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/ResourceDefinitionQueryCallbackTests.cs index 5103263f24..49dbb4f098 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/ResourceDefinitionQueryCallbackTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/ResourceDefinitionQueryCallbackTests.cs @@ -295,7 +295,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var route = $"/callableResources/{resource.StringId}?fields=label"; + var route = $"/callableResources/{resource.StringId}?fields=label,status"; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -306,7 +306,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Should().NotBeNull(); responseDocument.SingleData.Id.Should().Be(resource.StringId); responseDocument.SingleData.Attributes["label"].Should().Be(resource.Label); - responseDocument.SingleData.Attributes["percentageComplete"].Should().Be(resource.PercentageComplete); + responseDocument.SingleData.Attributes.Should().NotContainKey("percentageComplete"); + responseDocument.SingleData.Attributes["status"].Should().Be("5% completed."); } [Fact] From 41df4121b744666a53e4f48ae2abb131b26c3100 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Wed, 12 Aug 2020 16:07:17 +0200 Subject: [PATCH 05/13] Refactored include chains into a tree, so it can be used from `ResourceDefinition`s --- docs/usage/resources/resource-definitions.md | 20 +++ .../Hooks/Execution/HookExecutorHelper.cs | 2 +- .../Hooks/ResourceHookExecutor.cs | 2 +- .../Expressions/IncludeChainConverter.cs | 156 ++++++++++++++++++ .../Expressions/IncludeElementExpression.cs | 45 +++++ .../Queries/Expressions/IncludeExpression.cs | 17 +- .../Expressions/QueryExpressionVisitor.cs | 5 + .../Internal/Queries/Parsing/IncludeParser.cs | 2 +- .../Internal/Queries/QueryLayerComposer.cs | 116 +++++++++---- .../QueryableBuilding/IncludeClauseBuilder.cs | 2 +- .../Models/ResourceDefinition.cs | 17 +- .../Builders/ResponseResourceObjectBuilder.cs | 2 +- .../Services/JsonApiResourceService.cs | 19 +-- .../ResourceDefinitions/CallableResource.cs | 3 + .../CallableResourceDefinition.cs | 32 +++- .../ResourceDefinitionQueryCallbackTests.cs | 33 ++++ .../IncludeParseTests.cs | 4 +- .../ResourceHooks/ResourceHooksTestsSetup.cs | 2 +- .../Serialization/SerializerTestsSetup.cs | 2 +- 19 files changed, 408 insertions(+), 73 deletions(-) create mode 100644 src/JsonApiDotNetCore/Internal/Queries/Expressions/IncludeChainConverter.cs create mode 100644 src/JsonApiDotNetCore/Internal/Queries/Expressions/IncludeElementExpression.cs diff --git a/docs/usage/resources/resource-definitions.md b/docs/usage/resources/resource-definitions.md index 586484b813..d35f2e301f 100644 --- a/docs/usage/resources/resource-definitions.md +++ b/docs/usage/resources/resource-definitions.md @@ -144,6 +144,26 @@ public class AccountDefinition : ResourceDefinition } ``` +## Block including related resources + +```c# +public class EmployeeDefinition : ResourceDefinition +{ + public override IReadOnlyCollection OnApplyIncludes(IReadOnlyCollection existingIncludes) + { + if (existingIncludes.Any(include => include.Relationship.Property.Name == nameof(Employee.Manager))) + { + throw new JsonApiException(new Error(HttpStatusCode.BadRequest) + { + Title = "Including the manager of employees is not permitted." + }); + } + + return existingIncludes; + } +} +``` + ## Custom query string parameters _since v3.0.0_ diff --git a/src/JsonApiDotNetCore/Hooks/Execution/HookExecutorHelper.cs b/src/JsonApiDotNetCore/Hooks/Execution/HookExecutorHelper.cs index 2977742e72..bc6e7d80d0 100644 --- a/src/JsonApiDotNetCore/Hooks/Execution/HookExecutorHelper.cs +++ b/src/JsonApiDotNetCore/Hooks/Execution/HookExecutorHelper.cs @@ -147,7 +147,7 @@ private IEnumerable GetWhereAndInclude(IEnumerable new ResourceFieldChainExpression(relationship)).ToList(); if (chains.Any()) { - queryLayer.Include = new IncludeExpression(chains); + queryLayer.Include = IncludeChainConverter.FromRelationshipChains(chains); } var repository = GetRepository(); diff --git a/src/JsonApiDotNetCore/Hooks/ResourceHookExecutor.cs b/src/JsonApiDotNetCore/Hooks/ResourceHookExecutor.cs index a1ea87b999..bddbe04f07 100644 --- a/src/JsonApiDotNetCore/Hooks/ResourceHookExecutor.cs +++ b/src/JsonApiDotNetCore/Hooks/ResourceHookExecutor.cs @@ -55,7 +55,7 @@ public void BeforeRead(ResourcePipeline pipeline, string stringId = n .OfType() .ToArray(); - foreach (var chain in includes.SelectMany(include => include.Chains)) + foreach (var chain in includes.SelectMany(IncludeChainConverter.GetRelationshipChains)) { RecursiveBeforeRead(chain.Fields.Cast().ToList(), pipeline, calledContainers); } diff --git a/src/JsonApiDotNetCore/Internal/Queries/Expressions/IncludeChainConverter.cs b/src/JsonApiDotNetCore/Internal/Queries/Expressions/IncludeChainConverter.cs new file mode 100644 index 0000000000..05dd0bf4ba --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Queries/Expressions/IncludeChainConverter.cs @@ -0,0 +1,156 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using JsonApiDotNetCore.Models.Annotation; + +namespace JsonApiDotNetCore.Internal.Queries.Expressions +{ + public static class IncludeChainConverter + { + /// + /// Converts a tree of inclusions into a set of relationship chains. + /// + /// + /// Input tree: + /// Article + /// { + /// Blog, + /// Revisions + /// { + /// Author + /// } + /// } + /// + /// Output chains: + /// Article -> Blog, + /// Article -> Revisions -> Author + /// + public static IReadOnlyCollection GetRelationshipChains(IncludeExpression include) + { + if (include == null) + { + throw new ArgumentNullException(nameof(include)); + } + + IncludeToChainsConverter converter = new IncludeToChainsConverter(); + converter.Visit(include, null); + + return converter.Chains.AsReadOnly(); + } + + /// + /// Converts a set of relationship chains into a tree of inclusions. + /// + /// + /// Input chains: + /// Article -> Blog, + /// Article -> Revisions -> Author + /// + /// Output tree: + /// Article + /// { + /// Blog, + /// Revisions + /// { + /// Author + /// } + /// } + /// + public static IncludeExpression FromRelationshipChains(IEnumerable chains) + { + if (chains == null) + { + throw new ArgumentNullException(nameof(chains)); + } + + var elements = ConvertChainsToElements(chains); + return elements.Any() ? new IncludeExpression(elements) : IncludeExpression.Empty; + } + + private static IReadOnlyCollection ConvertChainsToElements(IEnumerable chains) + { + var rootNode = new MutableIncludeNode(null); + + foreach (ResourceFieldChainExpression chain in chains) + { + MutableIncludeNode currentNode = rootNode; + + foreach (var relationship in chain.Fields.OfType()) + { + if (!currentNode.Children.ContainsKey(relationship)) + { + currentNode.Children[relationship] = new MutableIncludeNode(relationship); + } + + currentNode = currentNode.Children[relationship]; + } + } + + return rootNode.Children.Values.Select(child => child.ToExpression()).ToList(); + } + + private sealed class IncludeToChainsConverter : QueryExpressionVisitor + { + private readonly Stack _parentRelationshipStack = new Stack(); + + public List Chains { get; } = new List(); + + public override object VisitInclude(IncludeExpression expression, object argument) + { + foreach (IncludeElementExpression element in expression.Elements) + { + Visit(element, null); + } + + return null; + } + + public override object VisitIncludeElement(IncludeElementExpression expression, object argument) + { + if (!expression.Children.Any()) + { + FlushChain(expression); + } + else + { + _parentRelationshipStack.Push(expression.Relationship); + + foreach (IncludeElementExpression child in expression.Children) + { + Visit(child, null); + } + + _parentRelationshipStack.Pop(); + } + + return null; + } + + private void FlushChain(IncludeElementExpression expression) + { + List fieldsInChain = _parentRelationshipStack.Reverse().ToList(); + fieldsInChain.Add(expression.Relationship); + + Chains.Add(new ResourceFieldChainExpression(fieldsInChain)); + } + } + + private sealed class MutableIncludeNode + { + private readonly RelationshipAttribute _relationship; + + public IDictionary Children { get; } = new Dictionary(); + + public MutableIncludeNode(RelationshipAttribute relationship) + { + _relationship = relationship; + } + + public IncludeElementExpression ToExpression() + { + var elementChildren = Children.Values.Select(child => child.ToExpression()).ToList(); + return new IncludeElementExpression(_relationship, elementChildren); + } + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/Queries/Expressions/IncludeElementExpression.cs b/src/JsonApiDotNetCore/Internal/Queries/Expressions/IncludeElementExpression.cs new file mode 100644 index 0000000000..a04e16befd --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/Queries/Expressions/IncludeElementExpression.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using JsonApiDotNetCore.Models.Annotation; + +namespace JsonApiDotNetCore.Internal.Queries.Expressions +{ + public class IncludeElementExpression : QueryExpression + { + public RelationshipAttribute Relationship { get; } + public IReadOnlyCollection Children { get; } + + public IncludeElementExpression(RelationshipAttribute relationship) + : this(relationship, Array.Empty()) + { + } + + public IncludeElementExpression(RelationshipAttribute relationship, IReadOnlyCollection children) + { + Relationship = relationship ?? throw new ArgumentNullException(nameof(relationship)); + Children = children ?? throw new ArgumentNullException(nameof(children)); + } + + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitIncludeElement(this, argument); + } + + public override string ToString() + { + var builder = new StringBuilder(); + builder.Append(Relationship); + + if (Children.Any()) + { + builder.Append('{'); + builder.Append(string.Join(",", Children.Select(child => child.ToString()))); + builder.Append('}'); + } + + return builder.ToString(); + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/Queries/Expressions/IncludeExpression.cs b/src/JsonApiDotNetCore/Internal/Queries/Expressions/IncludeExpression.cs index 135b4daf59..f252cc53c4 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/Expressions/IncludeExpression.cs +++ b/src/JsonApiDotNetCore/Internal/Queries/Expressions/IncludeExpression.cs @@ -6,24 +6,22 @@ namespace JsonApiDotNetCore.Internal.Queries.Expressions { public class IncludeExpression : QueryExpression { - // TODO: Unfold into a tree of child relationships, so it can be used from ResourceDefinitions. - - public IReadOnlyCollection Chains { get; } + public IReadOnlyCollection Elements { get; } public static readonly IncludeExpression Empty = new IncludeExpression(); private IncludeExpression() { - Chains = Array.Empty(); + Elements = Array.Empty(); } - public IncludeExpression(IReadOnlyCollection chains) + public IncludeExpression(IReadOnlyCollection elements) { - Chains = chains ?? throw new ArgumentNullException(nameof(chains)); + Elements = elements ?? throw new ArgumentNullException(nameof(elements)); - if (!chains.Any()) + if (!elements.Any()) { - throw new ArgumentException("Must have one or more chains.", nameof(chains)); + throw new ArgumentException("Must have one or more elements.", nameof(elements)); } } @@ -34,7 +32,8 @@ public override TResult Accept(QueryExpressionVisitor child.ToString())); + var chains = IncludeChainConverter.GetRelationshipChains(this); + return string.Join(",", chains.Select(child => child.ToString())); } } } diff --git a/src/JsonApiDotNetCore/Internal/Queries/Expressions/QueryExpressionVisitor.cs b/src/JsonApiDotNetCore/Internal/Queries/Expressions/QueryExpressionVisitor.cs index 5efab045c1..2c6b3472a5 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/Expressions/QueryExpressionVisitor.cs +++ b/src/JsonApiDotNetCore/Internal/Queries/Expressions/QueryExpressionVisitor.cs @@ -102,6 +102,11 @@ public virtual TResult VisitInclude(IncludeExpression expression, TArgument argu return DefaultVisit(expression, argument); } + public virtual TResult VisitIncludeElement(IncludeElementExpression expression, TArgument argument) + { + return DefaultVisit(expression, argument); + } + public virtual TResult VisitQueryableHandler(QueryableHandlerExpression expression, TArgument argument) { return DefaultVisit(expression, argument); diff --git a/src/JsonApiDotNetCore/Internal/Queries/Parsing/IncludeParser.cs b/src/JsonApiDotNetCore/Internal/Queries/Parsing/IncludeParser.cs index 87c8d77387..6ef2ffbef1 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/Parsing/IncludeParser.cs +++ b/src/JsonApiDotNetCore/Internal/Queries/Parsing/IncludeParser.cs @@ -38,7 +38,7 @@ protected IncludeExpression ParseInclude() chains.Add(nextChain); } - return new IncludeExpression(chains); + return IncludeChainConverter.FromRelationshipChains(chains); } } } diff --git a/src/JsonApiDotNetCore/Internal/Queries/QueryLayerComposer.cs b/src/JsonApiDotNetCore/Internal/Queries/QueryLayerComposer.cs index c69236de90..564c03b00c 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/QueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Internal/Queries/QueryLayerComposer.cs @@ -71,8 +71,7 @@ public QueryLayer Compose(ResourceContext requestResource) var constraints = _constraintProviders.SelectMany(p => p.GetConstraints()).ToArray(); var topLayer = ComposeTopLayer(constraints, requestResource); - - ComposeChildren(topLayer, constraints); + topLayer.Include = ComposeChildren(topLayer, constraints); return topLayer; } @@ -93,7 +92,6 @@ private QueryLayer ComposeTopLayer(IEnumerable constraints, R return new QueryLayer(resourceContext) { - Include = GetIncludes(expressionsInTopScope), Filter = GetFilter(expressionsInTopScope, resourceContext), Sort = GetSort(expressionsInTopScope, resourceContext), Pagination = ((JsonApiOptions)_options).DisableTopPagination ? null : topPagination, @@ -101,51 +99,97 @@ private QueryLayer ComposeTopLayer(IEnumerable constraints, R }; } - private void ComposeChildren(QueryLayer topLayer, ExpressionInScope[] constraints) + private IncludeExpression ComposeChildren(QueryLayer topLayer, ExpressionInScope[] constraints) { - if (topLayer.Include == null) - { - return; - } + var include = constraints + .Where(c => c.Scope == null) + .Select(expressionInScope => expressionInScope.Expression).OfType() + .FirstOrDefault() ?? IncludeExpression.Empty; + + var includeElements = + ProcessIncludeSet(include.Elements, topLayer, new List(), constraints); + + return !ReferenceEquals(includeElements, include.Elements) + ? includeElements.Any() ? new IncludeExpression(includeElements) : IncludeExpression.Empty + : include; + } + + private IReadOnlyCollection ProcessIncludeSet(IReadOnlyCollection includeElements, + QueryLayer parentLayer, IList parentRelationshipChain, ExpressionInScope[] constraints) + { + includeElements = GetIncludeElements(includeElements, parentLayer.ResourceContext) ?? Array.Empty(); - foreach (var includeChain in topLayer.Include.Chains) + var updatesInChildren = new Dictionary>(); + + foreach (var includeElement in includeElements) { - var currentLayer = topLayer; - List currentScope = new List(); + parentLayer.Projection ??= new Dictionary(); - foreach (var relationship in includeChain.Fields.OfType()) + if (!parentLayer.Projection.ContainsKey(includeElement.Relationship)) { - currentScope.Add(relationship); - var currentResourceContext = _resourceContextProvider.GetResourceContext(relationship.RightType); + var relationshipChain = new List(parentRelationshipChain) + { + includeElement.Relationship + }; - currentLayer.Projection ??= new Dictionary(); + var expressionsInCurrentScope = constraints + .Where(c => c.Scope != null && c.Scope.Fields.SequenceEqual(relationshipChain)) + .Select(expressionInScope => expressionInScope.Expression) + .ToArray(); - if (!currentLayer.Projection.ContainsKey(relationship)) + var resourceContext = + _resourceContextProvider.GetResourceContext(includeElement.Relationship.RightType); + + var child = new QueryLayer(resourceContext) { - var expressionsInCurrentScope = constraints - .Where(c => c.Scope != null && c.Scope.Fields.SequenceEqual(currentScope)) - .Select(expressionInScope => expressionInScope.Expression) - .ToArray(); + Filter = GetFilter(expressionsInCurrentScope, resourceContext), + Sort = GetSort(expressionsInCurrentScope, resourceContext), + Pagination = ((JsonApiOptions) _options).DisableChildrenPagination + ? null + : GetPagination(expressionsInCurrentScope, resourceContext), + Projection = GetSparseFieldSetProjection(expressionsInCurrentScope, resourceContext) + }; - var child = new QueryLayer(currentResourceContext) - { - Filter = GetFilter(expressionsInCurrentScope, currentResourceContext), - Sort = GetSort(expressionsInCurrentScope, currentResourceContext), - Pagination = ((JsonApiOptions)_options).DisableChildrenPagination ? null : GetPagination(expressionsInCurrentScope, currentResourceContext), - Projection = GetSparseFieldSetProjection(expressionsInCurrentScope, currentResourceContext) - }; + parentLayer.Projection.Add(includeElement.Relationship, child); - currentLayer.Projection.Add(relationship, child); - } + if (includeElement.Children.Any()) + { + var updatedChildren = ProcessIncludeSet(includeElement.Children, child, relationshipChain, constraints); - currentLayer = currentLayer.Projection[relationship]; + if (!ReferenceEquals(includeElement.Children, updatedChildren)) + { + updatesInChildren.Add(includeElement, updatedChildren); + } + } } } + + return !updatesInChildren.Any() ? includeElements : ApplyIncludeElementUpdates(includeElements, updatesInChildren); + } + + private static IReadOnlyCollection ApplyIncludeElementUpdates(IReadOnlyCollection includeElements, + IDictionary> updatesInChildren) + { + var newIncludeElements = new List(includeElements); + + foreach (var (existingElement, updatedChildren) in updatesInChildren) + { + var existingIndex = newIncludeElements.IndexOf(existingElement); + newIncludeElements[existingIndex] = new IncludeElementExpression(existingElement.Relationship, updatedChildren); + } + + return newIncludeElements; } - protected virtual IncludeExpression GetIncludes(IEnumerable expressionsInScope) + protected virtual IReadOnlyCollection GetIncludeElements(IReadOnlyCollection includeElements, ResourceContext resourceContext) { - return expressionsInScope.OfType().FirstOrDefault(); + var resourceDefinition = _resourceDefinitionProvider.Get(resourceContext.ResourceType); + if (resourceDefinition != null) + { + includeElements = resourceDefinition.OnApplyIncludes(includeElements); + } + + return includeElements; } protected virtual FilterExpression GetFilter(IEnumerable expressionsInScope, ResourceContext resourceContext) @@ -209,12 +253,14 @@ protected virtual IDictionary GetSparseField attributes = tempExpression == null ? new HashSet() : tempExpression.Attributes.ToHashSet(); } - if (attributes.Any()) + if (!attributes.Any()) { - var idAttribute = resourceContext.Attributes.Single(x => x.Property.Name == nameof(Identifiable.Id)); - attributes.Add(idAttribute); + return null; } + var idAttribute = resourceContext.Attributes.Single(x => x.Property.Name == nameof(Identifiable.Id)); + attributes.Add(idAttribute); + return attributes.Cast().ToDictionary(key => key, value => (QueryLayer) null); } } diff --git a/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/IncludeClauseBuilder.cs b/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/IncludeClauseBuilder.cs index e16887c69d..8621b296a1 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/IncludeClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/IncludeClauseBuilder.cs @@ -38,7 +38,7 @@ public override Expression VisitInclude(IncludeExpression expression, object arg { var source = ApplyEagerLoads(_source, _resourceContext.EagerLoads, null); - foreach (ResourceFieldChainExpression chain in expression.Chains) + foreach (ResourceFieldChainExpression chain in IncludeChainConverter.GetRelationshipChains(expression)) { string path = null; diff --git a/src/JsonApiDotNetCore/Models/ResourceDefinition.cs b/src/JsonApiDotNetCore/Models/ResourceDefinition.cs index 0ac842a0ed..0b8f5982e6 100644 --- a/src/JsonApiDotNetCore/Models/ResourceDefinition.cs +++ b/src/JsonApiDotNetCore/Models/ResourceDefinition.cs @@ -12,6 +12,7 @@ namespace JsonApiDotNetCore.Models { public interface IResourceDefinition { + IReadOnlyCollection OnApplyIncludes(IReadOnlyCollection existingIncludes); FilterExpression OnApplyFilter(FilterExpression existingFilter); SortExpression OnApplySort(SortExpression existingSort); PaginationExpression OnApplyPagination(PaginationExpression existingPagination); @@ -20,7 +21,7 @@ public interface IResourceDefinition } /// - /// exposes developer friendly hooks into how their resources are exposed. + /// Exposes developer friendly hooks into how their resources are exposed. /// It is intended to improve the experience and reduce boilerplate for commonly required features. /// The goal of this class is to reduce the frequency with which developers have to override the /// service and repository layers. @@ -60,6 +61,20 @@ public virtual void BeforeImplicitUpdateRelationship(IRelationshipsDictionary public virtual IEnumerable OnReturn(HashSet resources, ResourcePipeline pipeline) { return resources; } + /// + /// Enables to extend, replace or remove includes that are being applied on this resource type. + /// + /// + /// An optional existing set of includes, coming from query string. Never null, but may be empty. + /// + /// + /// The new set of includes. Return an empty collection to remove all inclusions (never return null). + /// + public virtual IReadOnlyCollection OnApplyIncludes(IReadOnlyCollection existingIncludes) + { + return existingIncludes; + } + /// /// Enables to extend, replace or remove a filter that is being applied on a set of this resource type. /// diff --git a/src/JsonApiDotNetCore/Serialization/Server/Builders/ResponseResourceObjectBuilder.cs b/src/JsonApiDotNetCore/Serialization/Server/Builders/ResponseResourceObjectBuilder.cs index 58d2fc5458..cf1e5dad75 100644 --- a/src/JsonApiDotNetCore/Serialization/Server/Builders/ResponseResourceObjectBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Server/Builders/ResponseResourceObjectBuilder.cs @@ -79,7 +79,7 @@ private bool ShouldInclude(RelationshipAttribute relationship, out List>(); - foreach (var chain in includes.SelectMany(x => x.Chains)) + foreach (var chain in includes.SelectMany(IncludeChainConverter.GetRelationshipChains)) { if (chain.Fields.First().Equals(relationship)) { diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 0dee0d2561..1115dd2223 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -260,22 +260,11 @@ private QueryLayer GetPrimaryLayerForSecondaryEndpoint(QueryLayer secondaryLayer private IncludeExpression RewriteIncludeForSecondaryEndpoint(IncludeExpression relativeInclude) { - if (relativeInclude != null && relativeInclude.Chains.Any()) - { - var absoluteChains = new List(); - foreach (ResourceFieldChainExpression relativeChain in relativeInclude.Chains) - { - var absoluteFieldsInChain = new List(relativeChain.Fields); - absoluteFieldsInChain.Insert(0, _currentRequest.Relationship); - - var absoluteChain = new ResourceFieldChainExpression(absoluteFieldsInChain); - absoluteChains.Add(absoluteChain); - } - - return new IncludeExpression(absoluteChains); - } + var parentElement = relativeInclude != null + ? new IncludeElementExpression(_currentRequest.Relationship, relativeInclude.Elements) + : new IncludeElementExpression(_currentRequest.Relationship); - return new IncludeExpression(new[] {new ResourceFieldChainExpression(_currentRequest.Relationship)}); + return new IncludeExpression(new[] {parentElement}); } public virtual async Task UpdateAsync(TId id, TResource requestResource) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableResource.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableResource.cs index 168c3ea5b8..d645448a7d 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableResource.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableResource.cs @@ -30,5 +30,8 @@ public sealed class CallableResource : Identifiable [HasMany] public ICollection Children { get; set; } + + [HasOne] + public CallableResource Owner { get; set; } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableResourceDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableResourceDefinition.cs index 9fa1139dcf..adc7cba090 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableResourceDefinition.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableResourceDefinition.cs @@ -1,25 +1,49 @@ -using System; using System.Collections.Generic; using System.ComponentModel; using System.Linq; -using System.Linq.Expressions; +using System.Net; using JsonApiDotNetCore; +using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Internal.Queries.Expressions; using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Models.Annotation; +using JsonApiDotNetCore.Models.JsonApiDocuments; using Microsoft.Extensions.Primitives; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions { + public interface IUserRolesService + { + bool AllowIncludeOwner { get; } + } + public sealed class CallableResourceDefinition : ResourceDefinition { + private readonly IUserRolesService _userRolesService; private static readonly PageSize _maxPageSize = new PageSize(5); - public CallableResourceDefinition(IResourceGraph resourceGraph) : base(resourceGraph) + public CallableResourceDefinition(IResourceGraph resourceGraph, IUserRolesService userRolesService) : base(resourceGraph) { // This constructor will be resolved from the container, which means // you can take on any dependency that is also defined in the container. + + _userRolesService = userRolesService; + } + + public override IReadOnlyCollection OnApplyIncludes(IReadOnlyCollection existingIncludes) + { + // Use case: prevent including owner if user has insufficient permissions. + + if (!_userRolesService.AllowIncludeOwner && + existingIncludes.Any(x => x.Relationship.Property.Name == nameof(CallableResource.Owner))) + { + throw new JsonApiException(new Error(HttpStatusCode.BadRequest) + { + Title = "Including owner is not permitted." + }); + } + + return existingIncludes; } public override FilterExpression OnApplyFilter(FilterExpression existingFilter) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/ResourceDefinitionQueryCallbackTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/ResourceDefinitionQueryCallbackTests.cs index 49dbb4f098..2bed4e9e7c 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/ResourceDefinitionQueryCallbackTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/ResourceDefinitionQueryCallbackTests.cs @@ -21,9 +21,37 @@ public ResourceDefinitionQueryCallbackTests(IntegrationTestContext { services.AddScoped, CallableResourceDefinition>(); + services.AddSingleton(); }); } + [Fact] + public async Task Include_from_resource_definition_is_blocked() + { + // Arrange + var userRolesService = (FakeUserRolesService) _testContext.Factory.Services.GetRequiredService(); + userRolesService.AllowIncludeOwner = false; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.RemoveRange(dbContext.CallableResources); + + await dbContext.SaveChangesAsync(); + }); + + var route = "/callableResources?include=owner"; + + // 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("Including owner is not permitted."); + } + [Fact] public async Task Filter_from_resource_definition_is_applied() { @@ -510,5 +538,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[0].Detail.Should().Be("Query string parameter 'isHighRisk' cannot be used on a nested resource endpoint."); responseDocument.Errors[0].Source.Parameter.Should().Be("isHighRisk"); } + + private sealed class FakeUserRolesService : IUserRolesService + { + public bool AllowIncludeOwner { get; set; } = true; + } } } diff --git a/test/UnitTests/QueryStringParameters/IncludeParseTests.cs b/test/UnitTests/QueryStringParameters/IncludeParseTests.cs index 06cad458e6..cc54afe207 100644 --- a/test/UnitTests/QueryStringParameters/IncludeParseTests.cs +++ b/test/UnitTests/QueryStringParameters/IncludeParseTests.cs @@ -74,8 +74,8 @@ public void Reader_Read_Fails(string parameterName, string parameterValue, strin [InlineData("includes", "owner.articles", "owner.articles")] [InlineData("includes", "articles.author", "articles.author")] [InlineData("includes", "articles.revisions", "articles.revisions")] - [InlineData("includes", "articles,articles.revisions", "articles,articles.revisions")] - [InlineData("includes", "articles,articles.revisions,articles.tags", "articles,articles.revisions,articles.tags")] + [InlineData("includes", "articles,articles.revisions", "articles.revisions")] + [InlineData("includes", "articles,articles.revisions,articles.tags", "articles.revisions,articles.tags")] public void Reader_Read_Succeeds(string parameterName, string parameterValue, string valueExpected) { // Act diff --git a/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs b/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs index 1dcfe3823e..79cefb40dd 100644 --- a/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs +++ b/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs @@ -424,7 +424,7 @@ protected IEnumerable ConvertInclusionChains(List new ResourceFieldChainExpression(relationships)).ToList(); - var includeExpression = new IncludeExpression(chains); + var includeExpression = IncludeChainConverter.FromRelationshipChains(chains); expressionsInScope.Add(new ExpressionInScope(null, includeExpression)); } diff --git a/test/UnitTests/Serialization/SerializerTestsSetup.cs b/test/UnitTests/Serialization/SerializerTestsSetup.cs index 7a1589e459..d183b9fbe5 100644 --- a/test/UnitTests/Serialization/SerializerTestsSetup.cs +++ b/test/UnitTests/Serialization/SerializerTestsSetup.cs @@ -102,7 +102,7 @@ protected IEnumerable GetIncludeConstraints(List new ResourceFieldChainExpression(relationships)).ToList(); - var includeExpression = new IncludeExpression(chains); + var includeExpression = IncludeChainConverter.FromRelationshipChains(chains); expressionsInScope.Add(new ExpressionInScope(null, includeExpression)); } From d5c57cb62bd01938a6a4bf03cdc74a2aea824397 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Wed, 12 Aug 2020 16:42:24 +0200 Subject: [PATCH 06/13] Added options.MaximumIncludeDepth --- docs/usage/options.md | 12 ++++- .../Configuration/IJsonApiOptions.cs | 7 ++- .../Configuration/JsonApiOptions.cs | 2 + .../IncludeQueryStringParameterReader.cs | 32 +++++++++++- .../IntegrationTests/Includes/IncludeTests.cs | 52 +++++++++++++++++++ .../IncludeParseTests.cs | 3 +- 6 files changed, 103 insertions(+), 5 deletions(-) diff --git a/docs/usage/options.md b/docs/usage/options.md index 63b3fcd3ff..fe5751aa60 100644 --- a/docs/usage/options.md +++ b/docs/usage/options.md @@ -65,12 +65,20 @@ options.UseRelativeLinks = true; ## Unknown Query String Parameters If you would like to use unknown query string parameters (parameters not reserved by the json:api specification or registered using ResourceDefinitions), you can set `AllowUnknownQueryStringParameters = true`. -When set, an HTTP 400 Bad Request for unknown query string parameters. +When set, an HTTP 400 Bad Request is returned for unknown query string parameters. ```c# options.AllowUnknownQueryStringParameters = true; ``` +## Maximum include depth + +To limit the maximum depth of nested includes, use `MaximumIncludeDepth`. This is null by default, which means unconstrained. If set and a request exceeds the limit, an HTTP 400 Bad Request is returned. + +```c# +options.MaximumIncludeDepth = 1; +``` + ## Custom Serializer Settings We use Newtonsoft.Json for all serialization needs. @@ -82,6 +90,8 @@ options.SerializerSettings.Converters.Add(new StringEnumConverter()); options.SerializerSettings.Formatting = Formatting.Indented; ``` +Because we copy resource properties into an intermediate object before serialization, Newtonsoft.Json annotations on properties are ignored. + ## Enable ModelState Validation If you would like to use ASP.NET Core ModelState validation into your controllers when creating / updating resources, set `ValidateModelState = true`. By default, no model validation is performed. diff --git a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs index bdfb67d180..1f3750e38f 100644 --- a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs @@ -163,7 +163,12 @@ public interface IJsonApiOptions /// bool AllowQueryStringOverrideForSerializerDefaultValueHandling { get; } - // TODO: Add MaximumIncludeDepth + /// + /// Controls how many levels deep includes are allowed to be nested. + /// For example, MaximumIncludeDepth=1 would allow ?include=articles but not ?include=articles.revisions. + /// null by default, which means unconstrained. + /// + int? MaximumIncludeDepth { get; } /// /// Specifies the settings that are used by the . diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs index da704d9fab..5588026bff 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs @@ -68,6 +68,8 @@ public class JsonApiOptions : IJsonApiOptions /// public bool AllowQueryStringOverrideForSerializerDefaultValueHandling { get; set; } + public int? MaximumIncludeDepth { get; set; } + /// public JsonSerializerSettings SerializerSettings { get; } = new JsonSerializerSettings { diff --git a/src/JsonApiDotNetCore/Internal/QueryStrings/IncludeQueryStringParameterReader.cs b/src/JsonApiDotNetCore/Internal/QueryStrings/IncludeQueryStringParameterReader.cs index baf01716ef..f8428236c3 100644 --- a/src/JsonApiDotNetCore/Internal/QueryStrings/IncludeQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/Internal/QueryStrings/IncludeQueryStringParameterReader.cs @@ -1,4 +1,6 @@ using System.Collections.Generic; +using System.Linq; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal.Contracts; @@ -20,12 +22,14 @@ public interface IIncludeQueryStringParameterReader : IQueryStringParameterReade public class IncludeQueryStringParameterReader : QueryStringParameterReader, IIncludeQueryStringParameterReader { + private readonly IJsonApiOptions _options; private IncludeExpression _includeExpression; private string _lastParameterName; - public IncludeQueryStringParameterReader(ICurrentRequest currentRequest, IResourceContextProvider resourceContextProvider) + public IncludeQueryStringParameterReader(ICurrentRequest currentRequest, IResourceContextProvider resourceContextProvider, IJsonApiOptions options) : base(currentRequest, resourceContextProvider) { + _options = options; } public bool IsEnabled(DisableQueryAttribute disableQueryAttribute) @@ -58,7 +62,31 @@ private IncludeExpression GetInclude(string parameterValue) var parser = new IncludeParser(parameterValue, (path, _) => ChainResolver.ResolveRelationshipChain(RequestResource, path, ValidateInclude)); - return parser.Parse(); + IncludeExpression include = parser.Parse(); + + ValidateMaximumIncludeDepth(include); + + return include; + } + + private void ValidateMaximumIncludeDepth(IncludeExpression include) + { + if (_options.MaximumIncludeDepth != null) + { + var chains = IncludeChainConverter.GetRelationshipChains(include); + + foreach (var chain in chains) + { + if (chain.Fields.Count > _options.MaximumIncludeDepth) + { + var path = string.Join('.', chain.Fields.Select(field => field.PublicName)); + + throw new InvalidQueryStringParameterException(_lastParameterName, + "Including at the requested depth is not allowed.", + $"Including '{path}' exceeds the maximum inclusion depth of {_options.MaximumIncludeDepth}."); + } + } + } } private void ValidateInclude(RelationshipAttribute relationship, ResourceContext resourceContext, string path) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Includes/IncludeTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Includes/IncludeTests.cs index 6c360f41bc..21839eab5e 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Includes/IncludeTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Includes/IncludeTests.cs @@ -4,6 +4,8 @@ using System.Threading.Tasks; using FluentAssertions; using FluentAssertions.Extensions; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Models.JsonApiDocuments; using JsonApiDotNetCore.Services; @@ -27,6 +29,9 @@ public IncludeTests(IntegrationTestContext testContext) { services.AddScoped, JsonApiResourceService
>(); }); + + var options = (JsonApiOptions) testContext.Factory.Services.GetRequiredService(); + options.MaximumIncludeDepth = null; } [Fact] @@ -786,5 +791,52 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Included[0].Id.Should().Be(todoItems[0].Owner.StringId); responseDocument.Included[0].Attributes["firstName"].Should().Be(todoItems[0].Owner.FirstName); } + + [Fact] + public async Task Can_include_at_configured_maximum_inclusion_depth() + { + // Arrange + var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); + options.MaximumIncludeDepth = 1; + + var blog = new Blog(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Blogs.Add(blog); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/api/v1/blogs/{blog.StringId}/articles?include=author,revisions"; + + // Act + var (httpResponse, _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + } + + [Fact] + public async Task Cannot_exceed_configured_maximum_inclusion_depth() + { + // Arrange + var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); + options.MaximumIncludeDepth = 1; + + var route = "/api/v1/blogs/123/owner?include=articles.revisions"; + + // 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("Including at the requested depth is not allowed."); + responseDocument.Errors[0].Detail.Should().Be("Including 'articles.revisions' exceeds the maximum inclusion depth of 1."); + responseDocument.Errors[0].Source.Parameter.Should().Be("include"); + } } } diff --git a/test/UnitTests/QueryStringParameters/IncludeParseTests.cs b/test/UnitTests/QueryStringParameters/IncludeParseTests.cs index cc54afe207..fba42b0b3a 100644 --- a/test/UnitTests/QueryStringParameters/IncludeParseTests.cs +++ b/test/UnitTests/QueryStringParameters/IncludeParseTests.cs @@ -2,6 +2,7 @@ using System.Linq; using System.Net; using FluentAssertions; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal.QueryStrings; @@ -15,7 +16,7 @@ public sealed class IncludeParseTests : ParseTestsBase public IncludeParseTests() { - _reader = new IncludeQueryStringParameterReader(CurrentRequest, ResourceGraph); + _reader = new IncludeQueryStringParameterReader(CurrentRequest, ResourceGraph, new JsonApiOptions()); } [Theory] From 5d625fa71ec0e4099fb3906a8791c2971d25aeae Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 13 Aug 2020 14:23:55 +0200 Subject: [PATCH 07/13] Moved chain resolver logic into parsers. This is tricky to get right and now that it is integrated parsers can easily be reused from `ResourceDefinition`s without needing to know what type of relationships are allowed at various stages. --- .../Internal/Queries/Parsing/FilterParser.cs | 54 ++++++++++++---- .../Internal/Queries/Parsing/IncludeParser.cs | 22 ++++++- .../Queries/Parsing/PaginationParser.cs | 22 ++++++- .../Internal/Queries/Parsing/QueryParser.cs | 19 ++++-- .../QueryStringParameterScopeParser.cs | 41 +++++++++++-- .../Internal/Queries/Parsing/SortParser.cs | 32 +++++++++- .../Queries/Parsing/SparseFieldSetParser.cs | 24 +++++++- .../FilterQueryStringParameterReader.cs | 61 ++++++------------- .../IncludeQueryStringParameterReader.cs | 35 +++++------ .../PaginationQueryStringParameterReader.cs | 9 ++- .../QueryStringParameterReader.cs | 12 ++-- .../ResourceFieldChainResolver.cs | 2 +- .../SortQueryStringParameterReader.cs | 50 +++++---------- ...parseFieldSetQueryStringParameterReader.cs | 43 +++++-------- .../Acceptance/Spec/EagerLoadTests.cs | 1 - .../IntegrationTests/Includes/IncludeTests.cs | 1 - 16 files changed, 255 insertions(+), 173 deletions(-) diff --git a/src/JsonApiDotNetCore/Internal/Queries/Parsing/FilterParser.cs b/src/JsonApiDotNetCore/Internal/Queries/Parsing/FilterParser.cs index 877433f50f..bbda5918fc 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/Parsing/FilterParser.cs +++ b/src/JsonApiDotNetCore/Internal/Queries/Parsing/FilterParser.cs @@ -3,26 +3,32 @@ using System.Linq; using System.Reflection; using Humanizer; +using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Internal.Queries.Expressions; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Annotation; namespace JsonApiDotNetCore.Internal.Queries.Parsing { - // TODO: Combine callbacks into parsers to make them reusable from ResourceDefinitions. public class FilterParser : QueryParser { - private readonly ResolveFieldChainCallback _resolveFieldChainCallback; - private readonly Func _resolveStringId; + private readonly IResourceFactory _resourceFactory; + private readonly Action _validateSingleFieldCallback; + private ResourceContext _resourceContextInScope; - public FilterParser(string source, ResolveFieldChainCallback resolveFieldChainCallback, Func resolveStringId) - : base(source, resolveFieldChainCallback) + public FilterParser(IResourceContextProvider resourceContextProvider, IResourceFactory resourceFactory, + Action validateSingleFieldCallback = null) + : base(resourceContextProvider) { - _resolveFieldChainCallback = resolveFieldChainCallback; - _resolveStringId = resolveStringId ?? throw new ArgumentNullException(nameof(resolveStringId)); + _resourceFactory = resourceFactory ?? throw new ArgumentNullException(nameof(resourceFactory)); + _validateSingleFieldCallback = validateSingleFieldCallback; } - public FilterExpression Parse() + public FilterExpression Parse(string source, ResourceContext resourceContextInScope) { + Tokenize(source); + _resourceContextInScope = resourceContextInScope ?? throw new ArgumentNullException(nameof(resourceContextInScope)); + var expression = ParseFilter(); AssertTokenStackIsEmpty(); @@ -139,13 +145,13 @@ protected ComparisonExpression ParseComparison(string operatorName) !(rightTerm is NullConstantExpression)) { // Run another pass over left chain to have it fail when chain ends in relationship. - _resolveFieldChainCallback(leftChain.ToString(), FieldChainRequirements.EndsInAttribute); + OnResolveFieldChain(leftChain.ToString(), FieldChainRequirements.EndsInAttribute); } PropertyInfo leftProperty = leftChain.Fields.Last().Property; if (leftProperty.Name == nameof(Identifiable.Id) && rightTerm is LiteralConstantExpression rightConstant) { - string id = _resolveStringId(leftProperty.ReflectedType, rightConstant.Value); + string id = DeObfuscateStringId(leftProperty.ReflectedType, rightConstant.Value); rightTerm = new LiteralConstantExpression(id); } } @@ -205,7 +211,7 @@ protected EqualsAnyOfExpression ParseAny() for (int index = 0; index < constants.Count; index++) { string stringId = constants[index].Value; - string id = _resolveStringId(targetAttributeProperty.ReflectedType, stringId); + string id = DeObfuscateStringId(targetAttributeProperty.ReflectedType, stringId); constants[index] = new LiteralConstantExpression(id); } } @@ -285,5 +291,31 @@ protected LiteralConstantExpression ParseConstant() throw new QueryParseException("Value between quotes expected."); } + + private string DeObfuscateStringId(Type resourceType, string stringId) + { + return TypeHelper.ConvertStringIdToTypedId(resourceType, stringId, _resourceFactory).ToString(); + } + + protected override IReadOnlyCollection OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) + { + if (chainRequirements == FieldChainRequirements.EndsInToMany) + { + return ChainResolver.ResolveToOneChainEndingInToMany(_resourceContextInScope, path, _validateSingleFieldCallback); + } + + if (chainRequirements == FieldChainRequirements.EndsInAttribute) + { + return ChainResolver.ResolveToOneChainEndingInAttribute(_resourceContextInScope, path, _validateSingleFieldCallback); + } + + if (chainRequirements.HasFlag(FieldChainRequirements.EndsInAttribute) && + chainRequirements.HasFlag(FieldChainRequirements.EndsInToOne)) + { + return ChainResolver.ResolveToOneChainEndingInAttributeOrToOne(_resourceContextInScope, path, _validateSingleFieldCallback); + } + + throw new InvalidOperationException($"Unexpected combination of chain requirement flags '{chainRequirements}'."); + } } } diff --git a/src/JsonApiDotNetCore/Internal/Queries/Parsing/IncludeParser.cs b/src/JsonApiDotNetCore/Internal/Queries/Parsing/IncludeParser.cs index 6ef2ffbef1..0bd7090a41 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/Parsing/IncludeParser.cs +++ b/src/JsonApiDotNetCore/Internal/Queries/Parsing/IncludeParser.cs @@ -1,18 +1,29 @@ +using System; using System.Collections.Generic; using System.Linq; +using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Internal.Queries.Expressions; +using JsonApiDotNetCore.Models.Annotation; namespace JsonApiDotNetCore.Internal.Queries.Parsing { public class IncludeParser : QueryParser { - public IncludeParser(string source, ResolveFieldChainCallback resolveFieldChainCallback) - : base(source, resolveFieldChainCallback) + private readonly Action _validateSingleRelationshipCallback; + private ResourceContext _resourceContextInScope; + + public IncludeParser(IResourceContextProvider resourceContextProvider, + Action validateSingleRelationshipCallback = null) + : base(resourceContextProvider) { + _validateSingleRelationshipCallback = validateSingleRelationshipCallback; } - public IncludeExpression Parse() + public IncludeExpression Parse(string source, ResourceContext resourceContextInScope) { + _resourceContextInScope = resourceContextInScope ?? throw new ArgumentNullException(nameof(resourceContextInScope)); + Tokenize(source); + var expression = ParseInclude(); AssertTokenStackIsEmpty(); @@ -40,5 +51,10 @@ protected IncludeExpression ParseInclude() return IncludeChainConverter.FromRelationshipChains(chains); } + + protected override IReadOnlyCollection OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) + { + return ChainResolver.ResolveRelationshipChain(_resourceContextInScope, path, _validateSingleRelationshipCallback); + } } } diff --git a/src/JsonApiDotNetCore/Internal/Queries/Parsing/PaginationParser.cs b/src/JsonApiDotNetCore/Internal/Queries/Parsing/PaginationParser.cs index 0633554fed..ba081a79d2 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/Parsing/PaginationParser.cs +++ b/src/JsonApiDotNetCore/Internal/Queries/Parsing/PaginationParser.cs @@ -1,18 +1,29 @@ +using System; using System.Collections.Generic; using System.Linq; +using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Internal.Queries.Expressions; +using JsonApiDotNetCore.Models.Annotation; namespace JsonApiDotNetCore.Internal.Queries.Parsing { public class PaginationParser : QueryParser { - public PaginationParser(string source, ResolveFieldChainCallback resolveFieldChainCallback) - : base(source, resolveFieldChainCallback) + private readonly Action _validateSingleFieldCallback; + private ResourceContext _resourceContextInScope; + + public PaginationParser(IResourceContextProvider resourceContextProvider, + Action validateSingleFieldCallback = null) + : base(resourceContextProvider) { + _validateSingleFieldCallback = validateSingleFieldCallback; } - public PaginationQueryStringValueExpression Parse() + public PaginationQueryStringValueExpression Parse(string source, ResourceContext resourceContextInScope) { + _resourceContextInScope = resourceContextInScope ?? throw new ArgumentNullException(nameof(resourceContextInScope)); + Tokenize(source); + var expression = ParsePagination(); AssertTokenStackIsEmpty(); @@ -87,5 +98,10 @@ protected PaginationElementQueryStringValueExpression ParsePaginationElement() return null; } + + protected override IReadOnlyCollection OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) + { + return ChainResolver.ResolveToManyChain(_resourceContextInScope, path, _validateSingleFieldCallback); + } } } diff --git a/src/JsonApiDotNetCore/Internal/Queries/Parsing/QueryParser.cs b/src/JsonApiDotNetCore/Internal/Queries/Parsing/QueryParser.cs index 68febbb6b3..6b08b36739 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/Parsing/QueryParser.cs +++ b/src/JsonApiDotNetCore/Internal/Queries/Parsing/QueryParser.cs @@ -1,20 +1,27 @@ -using System; using System.Collections.Generic; using System.Linq; +using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Internal.Queries.Expressions; +using JsonApiDotNetCore.Internal.QueryStrings; +using JsonApiDotNetCore.Models.Annotation; namespace JsonApiDotNetCore.Internal.Queries.Parsing { public abstract class QueryParser { - private readonly ResolveFieldChainCallback _resolveFieldChainCallback; + private protected ResourceFieldChainResolver ChainResolver { get; } - protected Stack TokenStack { get; } + protected Stack TokenStack { get; private set; } - protected QueryParser(string source, ResolveFieldChainCallback resolveFieldChainCallback) + protected QueryParser(IResourceContextProvider resourceContextProvider) { - _resolveFieldChainCallback = resolveFieldChainCallback ?? throw new ArgumentNullException(nameof(resolveFieldChainCallback)); + ChainResolver = new ResourceFieldChainResolver(resourceContextProvider); + } + + protected abstract IReadOnlyCollection OnResolveFieldChain(string path, FieldChainRequirements chainRequirements); + protected virtual void Tokenize(string source) + { var tokenizer = new QueryTokenizer(source); TokenStack = new Stack(tokenizer.EnumerateTokens().Reverse()); } @@ -23,7 +30,7 @@ protected ResourceFieldChainExpression ParseFieldChain(FieldChainRequirements ch { if (TokenStack.TryPop(out Token token) && token.Kind == TokenKind.Text) { - var chain = _resolveFieldChainCallback(token.Value, chainRequirements); + var chain = OnResolveFieldChain(token.Value, chainRequirements); if (chain.Any()) { return new ResourceFieldChainExpression(chain); diff --git a/src/JsonApiDotNetCore/Internal/Queries/Parsing/QueryStringParameterScopeParser.cs b/src/JsonApiDotNetCore/Internal/Queries/Parsing/QueryStringParameterScopeParser.cs index 8dcf2a3d5b..9a53f42ae2 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/Parsing/QueryStringParameterScopeParser.cs +++ b/src/JsonApiDotNetCore/Internal/Queries/Parsing/QueryStringParameterScopeParser.cs @@ -1,24 +1,38 @@ +using System; +using System.Collections.Generic; +using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Internal.Queries.Expressions; +using JsonApiDotNetCore.Models.Annotation; namespace JsonApiDotNetCore.Internal.Queries.Parsing { public class QueryStringParameterScopeParser : QueryParser { - public QueryStringParameterScopeParser(string source, ResolveFieldChainCallback resolveFieldChainCallback) - : base(source, resolveFieldChainCallback) + private readonly FieldChainRequirements _chainRequirements; + private readonly Action _validateSingleFieldCallback; + private ResourceContext _resourceContextInScope; + + public QueryStringParameterScopeParser(IResourceContextProvider resourceContextProvider, FieldChainRequirements chainRequirements, + Action validateSingleFieldCallback = null) + : base(resourceContextProvider) { + _chainRequirements = chainRequirements; + _validateSingleFieldCallback = validateSingleFieldCallback; } - public QueryStringParameterScopeExpression Parse(FieldChainRequirements chainRequirements) + public QueryStringParameterScopeExpression Parse(string source, ResourceContext resourceContextInScope) { - var expression = ParseQueryStringParameterScope(chainRequirements); + _resourceContextInScope = resourceContextInScope ?? throw new ArgumentNullException(nameof(resourceContextInScope)); + Tokenize(source); + + var expression = ParseQueryStringParameterScope(); AssertTokenStackIsEmpty(); return expression; } - protected QueryStringParameterScopeExpression ParseQueryStringParameterScope(FieldChainRequirements chainRequirements) + protected QueryStringParameterScopeExpression ParseQueryStringParameterScope() { if (!TokenStack.TryPop(out Token token) || token.Kind != TokenKind.Text) { @@ -33,12 +47,27 @@ protected QueryStringParameterScopeExpression ParseQueryStringParameterScope(Fie { TokenStack.Pop(); - scope = ParseFieldChain(chainRequirements, null); + scope = ParseFieldChain(_chainRequirements, null); EatSingleCharacterToken(TokenKind.CloseBracket); } return new QueryStringParameterScopeExpression(name, scope); } + + protected override IReadOnlyCollection OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) + { + if (chainRequirements == FieldChainRequirements.EndsInToMany) + { + return ChainResolver.ResolveToManyChain(_resourceContextInScope, path, _validateSingleFieldCallback); + } + + if (chainRequirements == FieldChainRequirements.IsRelationship) + { + return ChainResolver.ResolveRelationshipChain(_resourceContextInScope, path, _validateSingleFieldCallback); + } + + throw new InvalidOperationException($"Unexpected combination of chain requirement flags '{chainRequirements}'."); + } } } diff --git a/src/JsonApiDotNetCore/Internal/Queries/Parsing/SortParser.cs b/src/JsonApiDotNetCore/Internal/Queries/Parsing/SortParser.cs index b5be60059e..274d2928d2 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/Parsing/SortParser.cs +++ b/src/JsonApiDotNetCore/Internal/Queries/Parsing/SortParser.cs @@ -1,18 +1,29 @@ +using System; using System.Collections.Generic; using System.Linq; +using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Internal.Queries.Expressions; +using JsonApiDotNetCore.Models.Annotation; namespace JsonApiDotNetCore.Internal.Queries.Parsing { public class SortParser : QueryParser { - public SortParser(string source, ResolveFieldChainCallback resolveFieldChainCallback) - : base(source, resolveFieldChainCallback) + private readonly Action _validateSingleFieldCallback; + private ResourceContext _resourceContextInScope; + + public SortParser(IResourceContextProvider resourceContextProvider, + Action validateSingleFieldCallback = null) + : base(resourceContextProvider) { + _validateSingleFieldCallback = validateSingleFieldCallback; } - public SortExpression Parse() + public SortExpression Parse(string source, ResourceContext resourceContextInScope) { + _resourceContextInScope = resourceContextInScope ?? throw new ArgumentNullException(nameof(resourceContextInScope)); + Tokenize(source); + SortExpression expression = ParseSort(); AssertTokenStackIsEmpty(); @@ -60,5 +71,20 @@ protected SortElementExpression ParseSortElement() ResourceFieldChainExpression targetAttribute = ParseFieldChain(FieldChainRequirements.EndsInAttribute, errorMessage); return new SortElementExpression(targetAttribute, isAscending); } + + protected override IReadOnlyCollection OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) + { + if (chainRequirements == FieldChainRequirements.EndsInToMany) + { + return ChainResolver.ResolveToOneChainEndingInToMany(_resourceContextInScope, path); + } + + if (chainRequirements == FieldChainRequirements.EndsInAttribute) + { + return ChainResolver.ResolveToOneChainEndingInAttribute(_resourceContextInScope, path, _validateSingleFieldCallback); + } + + throw new InvalidOperationException($"Unexpected combination of chain requirement flags '{chainRequirements}'."); + } } } diff --git a/src/JsonApiDotNetCore/Internal/Queries/Parsing/SparseFieldSetParser.cs b/src/JsonApiDotNetCore/Internal/Queries/Parsing/SparseFieldSetParser.cs index b3fa4d274c..9b89e5c39e 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/Parsing/SparseFieldSetParser.cs +++ b/src/JsonApiDotNetCore/Internal/Queries/Parsing/SparseFieldSetParser.cs @@ -1,5 +1,7 @@ +using System; using System.Collections.Generic; using System.Linq; +using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Internal.Queries.Expressions; using JsonApiDotNetCore.Models.Annotation; @@ -7,13 +9,20 @@ namespace JsonApiDotNetCore.Internal.Queries.Parsing { public class SparseFieldSetParser : QueryParser { - public SparseFieldSetParser(string source, ResolveFieldChainCallback resolveFieldChainCallback) - : base(source, resolveFieldChainCallback) + private readonly Action _validateSingleAttributeCallback; + private ResourceContext _resourceContextInScope; + + public SparseFieldSetParser(IResourceContextProvider resourceContextProvider, Action validateSingleAttributeCallback = null) + : base(resourceContextProvider) { + _validateSingleAttributeCallback = validateSingleAttributeCallback; } - public SparseFieldSetExpression Parse() + public SparseFieldSetExpression Parse(string source, ResourceContext resourceContextInScope) { + _resourceContextInScope = resourceContextInScope ?? throw new ArgumentNullException(nameof(resourceContextInScope)); + Tokenize(source); + var expression = ParseSparseFieldSet(); AssertTokenStackIsEmpty(); @@ -40,5 +49,14 @@ protected SparseFieldSetExpression ParseSparseFieldSet() return new SparseFieldSetExpression(attributes.Values); } + + protected override IReadOnlyCollection OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) + { + var attribute = ChainResolver.GetAttribute(path, _resourceContextInScope, path); + + _validateSingleAttributeCallback?.Invoke(attribute, _resourceContextInScope, path); + + return new[] {attribute}; + } } } diff --git a/src/JsonApiDotNetCore/Internal/QueryStrings/FilterQueryStringParameterReader.cs b/src/JsonApiDotNetCore/Internal/QueryStrings/FilterQueryStringParameterReader.cs index e8deb14f06..6c285b6c34 100644 --- a/src/JsonApiDotNetCore/Internal/QueryStrings/FilterQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/Internal/QueryStrings/FilterQueryStringParameterReader.cs @@ -26,8 +26,10 @@ public class FilterQueryStringParameterReader : QueryStringParameterReader, IFil { private static readonly LegacyFilterNotationConverter _legacyConverter = new LegacyFilterNotationConverter(); - private readonly IResourceFactory _resourceFactory; private readonly IJsonApiOptions _options; + private readonly QueryStringParameterScopeParser _scopeParser; + private readonly FilterParser _filterParser; + private readonly List _filtersInGlobalScope = new List(); private readonly Dictionary> _filtersPerScope = new Dictionary>(); private string _lastParameterName; @@ -36,8 +38,18 @@ public FilterQueryStringParameterReader(ICurrentRequest currentRequest, IResourceContextProvider resourceContextProvider, IResourceFactory resourceFactory, IJsonApiOptions options) : base(currentRequest, resourceContextProvider) { - _resourceFactory = resourceFactory; - _options = options; + _options = options ?? throw new ArgumentNullException(nameof(options)); + _scopeParser = new QueryStringParameterScopeParser(resourceContextProvider, FieldChainRequirements.EndsInToMany); + _filterParser = new FilterParser(resourceContextProvider, resourceFactory, ValidateSingleField); + } + + private void ValidateSingleField(ResourceFieldAttribute field, ResourceContext resourceContext, string path) + { + if (field is AttrAttribute attribute && !attribute.Capabilities.HasFlag(AttrCapabilities.AllowFilter)) + { + throw new InvalidQueryStringParameterException(_lastParameterName, "Filtering on the requested attribute is not allowed.", + $"Filtering on attribute '{attribute.PublicName}' is not allowed."); + } } public bool IsEnabled(DisableQueryAttribute disableQueryAttribute) @@ -83,10 +95,7 @@ private void ReadSingleValue(string parameterName, string parameterValue) private ResourceFieldChainExpression GetScope(string parameterName) { - var parser = new QueryStringParameterScopeParser(parameterName, - (path, _) => ChainResolver.ResolveToManyChain(RequestResource, path)); - - var parameterScope = parser.Parse(FieldChainRequirements.EndsInToMany); + var parameterScope = _scopeParser.Parse(parameterName, RequestResource); if (parameterScope.Scope == null) { @@ -99,43 +108,7 @@ private ResourceFieldChainExpression GetScope(string parameterName) private FilterExpression GetFilter(string parameterValue, ResourceFieldChainExpression scope) { ResourceContext resourceContextInScope = GetResourceContextForScope(scope); - - var parser = new FilterParser(parameterValue, - (path, chainRequirements) => ResolveChainInFilter(chainRequirements, resourceContextInScope, path), - (resourceType, stringId) => TypeHelper.ConvertStringIdToTypedId(resourceType, stringId, _resourceFactory).ToString()); - - return parser.Parse(); - } - - private IReadOnlyCollection ResolveChainInFilter(FieldChainRequirements chainRequirements, - ResourceContext resourceContextInScope, string path) - { - if (chainRequirements == FieldChainRequirements.EndsInToMany) - { - return ChainResolver.ResolveToOneChainEndingInToMany(resourceContextInScope, path); - } - - if (chainRequirements == FieldChainRequirements.EndsInAttribute) - { - return ChainResolver.ResolveToOneChainEndingInAttribute(resourceContextInScope, path, ValidateFilter); - } - - if (chainRequirements.HasFlag(FieldChainRequirements.EndsInAttribute) && - chainRequirements.HasFlag(FieldChainRequirements.EndsInToOne)) - { - return ChainResolver.ResolveToOneChainEndingInAttributeOrToOne(resourceContextInScope, path, ValidateFilter); - } - - throw new InvalidOperationException($"Unexpected combination of chain requirement flags '{chainRequirements}'."); - } - - private void ValidateFilter(ResourceFieldAttribute field, ResourceContext resourceContext, string path) - { - if (field is AttrAttribute attribute && !attribute.Capabilities.HasFlag(AttrCapabilities.AllowFilter)) - { - throw new InvalidQueryStringParameterException(_lastParameterName, "Filtering on the requested attribute is not allowed.", - $"Filtering on attribute '{attribute.PublicName}' is not allowed."); - } + return _filterParser.Parse(parameterValue, resourceContextInScope); } private void StoreFilterInScope(FilterExpression filter, ResourceFieldChainExpression scope) diff --git a/src/JsonApiDotNetCore/Internal/QueryStrings/IncludeQueryStringParameterReader.cs b/src/JsonApiDotNetCore/Internal/QueryStrings/IncludeQueryStringParameterReader.cs index f8428236c3..bfb0f82ff6 100644 --- a/src/JsonApiDotNetCore/Internal/QueryStrings/IncludeQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/Internal/QueryStrings/IncludeQueryStringParameterReader.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using JsonApiDotNetCore.Configuration; @@ -23,13 +24,28 @@ public interface IIncludeQueryStringParameterReader : IQueryStringParameterReade public class IncludeQueryStringParameterReader : QueryStringParameterReader, IIncludeQueryStringParameterReader { private readonly IJsonApiOptions _options; + private readonly IncludeParser _includeParser; + private IncludeExpression _includeExpression; private string _lastParameterName; public IncludeQueryStringParameterReader(ICurrentRequest currentRequest, IResourceContextProvider resourceContextProvider, IJsonApiOptions options) : base(currentRequest, resourceContextProvider) { - _options = options; + _options = options ?? throw new ArgumentNullException(nameof(options)); + _includeParser = new IncludeParser(resourceContextProvider, ValidateSingleRelationship); + } + + private void ValidateSingleRelationship(RelationshipAttribute relationship, ResourceContext resourceContext, string path) + { + if (!relationship.CanInclude) + { + throw new InvalidQueryStringParameterException(_lastParameterName, + "Including the requested relationship is not allowed.", + path == relationship.PublicName + ? $"Including the relationship '{relationship.PublicName}' on '{resourceContext.ResourceName}' is not allowed." + : $"Including the relationship '{relationship.PublicName}' in '{path}' on '{resourceContext.ResourceName}' is not allowed."); + } } public bool IsEnabled(DisableQueryAttribute disableQueryAttribute) @@ -59,10 +75,7 @@ public void Read(string parameterName, StringValues parameterValue) private IncludeExpression GetInclude(string parameterValue) { - var parser = new IncludeParser(parameterValue, - (path, _) => ChainResolver.ResolveRelationshipChain(RequestResource, path, ValidateInclude)); - - IncludeExpression include = parser.Parse(); + IncludeExpression include = _includeParser.Parse(parameterValue, RequestResource); ValidateMaximumIncludeDepth(include); @@ -89,18 +102,6 @@ private void ValidateMaximumIncludeDepth(IncludeExpression include) } } - private void ValidateInclude(RelationshipAttribute relationship, ResourceContext resourceContext, string path) - { - if (!relationship.CanInclude) - { - throw new InvalidQueryStringParameterException(_lastParameterName, - "Including the requested relationship is not allowed.", - path == relationship.PublicName - ? $"Including the relationship '{relationship.PublicName}' on '{resourceContext.ResourceName}' is not allowed." - : $"Including the relationship '{relationship.PublicName}' in '{path}' on '{resourceContext.ResourceName}' is not allowed."); - } - } - public IReadOnlyCollection GetConstraints() { var expressionInScope = _includeExpression != null diff --git a/src/JsonApiDotNetCore/Internal/QueryStrings/PaginationQueryStringParameterReader.cs b/src/JsonApiDotNetCore/Internal/QueryStrings/PaginationQueryStringParameterReader.cs index 9a6d52e9b8..efa7ec8892 100644 --- a/src/JsonApiDotNetCore/Internal/QueryStrings/PaginationQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/Internal/QueryStrings/PaginationQueryStringParameterReader.cs @@ -26,6 +26,7 @@ public class PaginationQueryStringParameterReader : QueryStringParameterReader, private const string _pageNumberParameterName = "page[number]"; private readonly IJsonApiOptions _options; + private readonly PaginationParser _paginationParser; private PaginationQueryStringValueExpression _pageSizeConstraint; private PaginationQueryStringValueExpression _pageNumberConstraint; @@ -33,7 +34,8 @@ public class PaginationQueryStringParameterReader : QueryStringParameterReader, public PaginationQueryStringParameterReader(ICurrentRequest currentRequest, IResourceContextProvider resourceContextProvider, IJsonApiOptions options) : base(currentRequest, resourceContextProvider) { - _options = options; + _options = options ?? throw new ArgumentNullException(nameof(options)); + _paginationParser = new PaginationParser(resourceContextProvider); } public bool IsEnabled(DisableQueryAttribute disableQueryAttribute) @@ -76,10 +78,7 @@ public void Read(string parameterName, StringValues parameterValue) private PaginationQueryStringValueExpression GetPageConstraint(string parameterValue) { - var parser = new PaginationParser(parameterValue, - (path, _) => ChainResolver.ResolveToManyChain(RequestResource, path)); - - return parser.Parse(); + return _paginationParser.Parse(parameterValue, RequestResource); } private void ValidatePageSize(PaginationQueryStringValueExpression constraint) diff --git a/src/JsonApiDotNetCore/Internal/QueryStrings/QueryStringParameterReader.cs b/src/JsonApiDotNetCore/Internal/QueryStrings/QueryStringParameterReader.cs index 1c125afa61..edfdbe159e 100644 --- a/src/JsonApiDotNetCore/Internal/QueryStrings/QueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/Internal/QueryStrings/QueryStringParameterReader.cs @@ -10,11 +10,11 @@ namespace JsonApiDotNetCore.Internal.QueryStrings { public abstract class QueryStringParameterReader { + private readonly IResourceContextProvider _resourceContextProvider; private readonly bool _isCollectionRequest; - protected IResourceContextProvider ResourceContextProvider { get; } - protected ResourceFieldChainResolver ChainResolver { get; } protected ResourceContext RequestResource { get; } + private protected ResourceFieldChainResolver ChainResolver { get; } protected QueryStringParameterReader(ICurrentRequest currentRequest, IResourceContextProvider resourceContextProvider) { @@ -23,10 +23,10 @@ protected QueryStringParameterReader(ICurrentRequest currentRequest, IResourceCo throw new ArgumentNullException(nameof(currentRequest)); } - ResourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); - ChainResolver = new ResourceFieldChainResolver(resourceContextProvider); - RequestResource = currentRequest.SecondaryResource ?? currentRequest.PrimaryResource; + _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); _isCollectionRequest = currentRequest.IsCollection; + RequestResource = currentRequest.SecondaryResource ?? currentRequest.PrimaryResource; + ChainResolver = new ResourceFieldChainResolver(resourceContextProvider); } protected ResourceContext GetResourceContextForScope(ResourceFieldChainExpression scope) @@ -39,7 +39,7 @@ protected ResourceContext GetResourceContextForScope(ResourceFieldChainExpressio var lastField = scope.Fields.Last(); var type = lastField is RelationshipAttribute relationship ? relationship.RightType : lastField.Property.PropertyType; - return ResourceContextProvider.GetResourceContext(type); + return _resourceContextProvider.GetResourceContext(type); } protected void AssertIsCollectionRequest() diff --git a/src/JsonApiDotNetCore/Internal/QueryStrings/ResourceFieldChainResolver.cs b/src/JsonApiDotNetCore/Internal/QueryStrings/ResourceFieldChainResolver.cs index b5baa8093d..bba7fae106 100644 --- a/src/JsonApiDotNetCore/Internal/QueryStrings/ResourceFieldChainResolver.cs +++ b/src/JsonApiDotNetCore/Internal/QueryStrings/ResourceFieldChainResolver.cs @@ -7,7 +7,7 @@ namespace JsonApiDotNetCore.Internal.QueryStrings { - public class ResourceFieldChainResolver + internal class ResourceFieldChainResolver { private readonly IResourceContextProvider _resourceContextProvider; diff --git a/src/JsonApiDotNetCore/Internal/QueryStrings/SortQueryStringParameterReader.cs b/src/JsonApiDotNetCore/Internal/QueryStrings/SortQueryStringParameterReader.cs index 14bdd5bfed..19e4a23a73 100644 --- a/src/JsonApiDotNetCore/Internal/QueryStrings/SortQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/Internal/QueryStrings/SortQueryStringParameterReader.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Exceptions; @@ -22,12 +21,25 @@ public interface ISortQueryStringParameterReader : IQueryStringParameterReader, public class SortQueryStringParameterReader : QueryStringParameterReader, ISortQueryStringParameterReader { + private readonly QueryStringParameterScopeParser _scopeParser; + private readonly SortParser _sortParser; private readonly List _constraints = new List(); private string _lastParameterName; public SortQueryStringParameterReader(ICurrentRequest currentRequest, IResourceContextProvider resourceContextProvider) : base(currentRequest, resourceContextProvider) { + _scopeParser = new QueryStringParameterScopeParser(resourceContextProvider, FieldChainRequirements.EndsInToMany); + _sortParser = new SortParser(resourceContextProvider, ValidateSingleField); + } + + private void ValidateSingleField(ResourceFieldAttribute field, ResourceContext resourceContext, string path) + { + if (field is AttrAttribute attribute && !attribute.Capabilities.HasFlag(AttrCapabilities.AllowSort)) + { + throw new InvalidQueryStringParameterException(_lastParameterName, "Sorting on the requested attribute is not allowed.", + $"Sorting on attribute '{attribute.PublicName}' is not allowed."); + } } public bool IsEnabled(DisableQueryAttribute disableQueryAttribute) @@ -61,10 +73,7 @@ public void Read(string parameterName, StringValues parameterValue) private ResourceFieldChainExpression GetScope(string parameterName) { - var parser = new QueryStringParameterScopeParser(parameterName, - (path, _) => ChainResolver.ResolveToManyChain(RequestResource, path)); - - var parameterScope = parser.Parse(FieldChainRequirements.EndsInToMany); + var parameterScope = _scopeParser.Parse(parameterName, RequestResource); if (parameterScope.Scope == null) { @@ -77,36 +86,7 @@ private ResourceFieldChainExpression GetScope(string parameterName) private SortExpression GetSort(string parameterValue, ResourceFieldChainExpression scope) { ResourceContext resourceContextInScope = GetResourceContextForScope(scope); - - var parser = new SortParser(parameterValue, - (path, chainRequirements) => ResolveChainInSort(chainRequirements, resourceContextInScope, path)); - - return parser.Parse(); - } - - private IReadOnlyCollection ResolveChainInSort(FieldChainRequirements chainRequirements, - ResourceContext resourceContextInScope, string path) - { - if (chainRequirements == FieldChainRequirements.EndsInToMany) - { - return ChainResolver.ResolveToOneChainEndingInToMany(resourceContextInScope, path); - } - - if (chainRequirements == FieldChainRequirements.EndsInAttribute) - { - return ChainResolver.ResolveToOneChainEndingInAttribute(resourceContextInScope, path, ValidateSort); - } - - throw new InvalidOperationException($"Unexpected combination of chain requirement flags '{chainRequirements}'."); - } - - private void ValidateSort(ResourceFieldAttribute field, ResourceContext resourceContext, string path) - { - if (field is AttrAttribute attribute && !attribute.Capabilities.HasFlag(AttrCapabilities.AllowSort)) - { - throw new InvalidQueryStringParameterException(_lastParameterName, "Sorting on the requested attribute is not allowed.", - $"Sorting on attribute '{attribute.PublicName}' is not allowed."); - } + return _sortParser.Parse(parameterValue, resourceContextInScope); } public IReadOnlyCollection GetConstraints() diff --git a/src/JsonApiDotNetCore/Internal/QueryStrings/SparseFieldSetQueryStringParameterReader.cs b/src/JsonApiDotNetCore/Internal/QueryStrings/SparseFieldSetQueryStringParameterReader.cs index d5bcae3884..fbc6473599 100644 --- a/src/JsonApiDotNetCore/Internal/QueryStrings/SparseFieldSetQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/Internal/QueryStrings/SparseFieldSetQueryStringParameterReader.cs @@ -21,13 +21,25 @@ public interface ISparseFieldSetQueryStringParameterReader : IQueryStringParamet public class SparseFieldSetQueryStringParameterReader : QueryStringParameterReader, ISparseFieldSetQueryStringParameterReader { + private readonly QueryStringParameterScopeParser _scopeParser; + private readonly SparseFieldSetParser _sparseFieldSetParser; private readonly List _constraints = new List(); - private string _lastParameterName; public SparseFieldSetQueryStringParameterReader(ICurrentRequest currentRequest, IResourceContextProvider resourceContextProvider) : base(currentRequest, resourceContextProvider) { + _sparseFieldSetParser = new SparseFieldSetParser(resourceContextProvider, ValidateSingleAttribute); + _scopeParser = new QueryStringParameterScopeParser(resourceContextProvider, FieldChainRequirements.IsRelationship); + } + + private void ValidateSingleAttribute(AttrAttribute attribute, ResourceContext resourceContext, string path) + { + if (!attribute.Capabilities.HasFlag(AttrCapabilities.AllowView)) + { + throw new InvalidQueryStringParameterException(_lastParameterName, "Retrieving the requested attribute is not allowed.", + $"Retrieving the attribute '{attribute.PublicName}' is not allowed."); + } } public bool IsEnabled(DisableQueryAttribute disableQueryAttribute) @@ -62,39 +74,14 @@ public void Read(string parameterName, StringValues parameterValue) private ResourceFieldChainExpression GetScope(string parameterName) { - var parser = new QueryStringParameterScopeParser(parameterName, - (path, _) => ChainResolver.ResolveRelationshipChain(RequestResource, path)); - - var parameterScope = parser.Parse(FieldChainRequirements.IsRelationship); + var parameterScope = _scopeParser.Parse(parameterName, RequestResource); return parameterScope.Scope; } private SparseFieldSetExpression GetSparseFieldSet(string parameterValue, ResourceFieldChainExpression scope) { ResourceContext resourceContextInScope = GetResourceContextForScope(scope); - - var parser = new SparseFieldSetParser(parameterValue, - (path, _) => ResolveSingleAttribute(path, resourceContextInScope)); - - return parser.Parse(); - } - - protected IReadOnlyCollection ResolveSingleAttribute(string path, ResourceContext resourceContext) - { - var attribute = ChainResolver.GetAttribute(path, resourceContext, path); - - ValidateAttribute(attribute); - - return new[] {attribute}; - } - - private void ValidateAttribute(AttrAttribute attribute) - { - if (!attribute.Capabilities.HasFlag(AttrCapabilities.AllowView)) - { - throw new InvalidQueryStringParameterException(_lastParameterName, "Retrieving the requested attribute is not allowed.", - $"Retrieving the attribute '{attribute.PublicName}' is not allowed."); - } + return _sparseFieldSetParser.Parse(parameterValue, resourceContextInScope); } public IReadOnlyCollection GetConstraints() diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/EagerLoadTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/EagerLoadTests.cs index 59ef0dbf59..2146efb130 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/EagerLoadTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/EagerLoadTests.cs @@ -3,7 +3,6 @@ using System.Net; using System.Threading.Tasks; using Bogus; -using FluentAssertions.Extensions; using JsonApiDotNetCore.Models; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Includes/IncludeTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Includes/IncludeTests.cs index 21839eab5e..e91c57b00c 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Includes/IncludeTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Includes/IncludeTests.cs @@ -4,7 +4,6 @@ using System.Threading.Tasks; using FluentAssertions; using FluentAssertions.Extensions; -using JsonApiDotNetCore; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Models.JsonApiDocuments; From 8af8219d5f76ae0af9c90cf15c636527f3320ec9 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 13 Aug 2020 15:13:40 +0200 Subject: [PATCH 08/13] Optimized check for maximum include depth (single pass). --- .../Internal/Queries/Parsing/IncludeParser.cs | 23 +++++++++++++--- .../IncludeQueryStringParameterReader.cs | 27 +------------------ .../IntegrationTests/Includes/IncludeTests.cs | 2 +- 3 files changed, 22 insertions(+), 30 deletions(-) diff --git a/src/JsonApiDotNetCore/Internal/Queries/Parsing/IncludeParser.cs b/src/JsonApiDotNetCore/Internal/Queries/Parsing/IncludeParser.cs index 0bd7090a41..82baea5b26 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/Parsing/IncludeParser.cs +++ b/src/JsonApiDotNetCore/Internal/Queries/Parsing/IncludeParser.cs @@ -19,19 +19,19 @@ public IncludeParser(IResourceContextProvider resourceContextProvider, _validateSingleRelationshipCallback = validateSingleRelationshipCallback; } - public IncludeExpression Parse(string source, ResourceContext resourceContextInScope) + public IncludeExpression Parse(string source, ResourceContext resourceContextInScope, int? maximumDepth) { _resourceContextInScope = resourceContextInScope ?? throw new ArgumentNullException(nameof(resourceContextInScope)); Tokenize(source); - var expression = ParseInclude(); + var expression = ParseInclude(maximumDepth); AssertTokenStackIsEmpty(); return expression; } - protected IncludeExpression ParseInclude() + protected IncludeExpression ParseInclude(int? maximumDepth) { ResourceFieldChainExpression firstChain = ParseFieldChain(FieldChainRequirements.IsRelationship, "Relationship name expected."); @@ -49,9 +49,26 @@ protected IncludeExpression ParseInclude() chains.Add(nextChain); } + ValidateMaximumIncludeDepth(maximumDepth, chains); + return IncludeChainConverter.FromRelationshipChains(chains); } + private static void ValidateMaximumIncludeDepth(int? maximumDepth, List chains) + { + if (maximumDepth != null) + { + foreach (var chain in chains) + { + if (chain.Fields.Count > maximumDepth) + { + var path = string.Join('.', chain.Fields.Select(field => field.PublicName)); + throw new QueryParseException($"Including '{path}' exceeds the maximum inclusion depth of {maximumDepth}."); + } + } + } + } + protected override IReadOnlyCollection OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) { return ChainResolver.ResolveRelationshipChain(_resourceContextInScope, path, _validateSingleRelationshipCallback); diff --git a/src/JsonApiDotNetCore/Internal/QueryStrings/IncludeQueryStringParameterReader.cs b/src/JsonApiDotNetCore/Internal/QueryStrings/IncludeQueryStringParameterReader.cs index bfb0f82ff6..d370fbc843 100644 --- a/src/JsonApiDotNetCore/Internal/QueryStrings/IncludeQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/Internal/QueryStrings/IncludeQueryStringParameterReader.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Linq; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Exceptions; @@ -75,31 +74,7 @@ public void Read(string parameterName, StringValues parameterValue) private IncludeExpression GetInclude(string parameterValue) { - IncludeExpression include = _includeParser.Parse(parameterValue, RequestResource); - - ValidateMaximumIncludeDepth(include); - - return include; - } - - private void ValidateMaximumIncludeDepth(IncludeExpression include) - { - if (_options.MaximumIncludeDepth != null) - { - var chains = IncludeChainConverter.GetRelationshipChains(include); - - foreach (var chain in chains) - { - if (chain.Fields.Count > _options.MaximumIncludeDepth) - { - var path = string.Join('.', chain.Fields.Select(field => field.PublicName)); - - throw new InvalidQueryStringParameterException(_lastParameterName, - "Including at the requested depth is not allowed.", - $"Including '{path}' exceeds the maximum inclusion depth of {_options.MaximumIncludeDepth}."); - } - } - } + return _includeParser.Parse(parameterValue, RequestResource, _options.MaximumIncludeDepth); } public IReadOnlyCollection GetConstraints() diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Includes/IncludeTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Includes/IncludeTests.cs index e91c57b00c..952ca23b2b 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Includes/IncludeTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Includes/IncludeTests.cs @@ -833,7 +833,7 @@ public async Task Cannot_exceed_configured_maximum_inclusion_depth() responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); - responseDocument.Errors[0].Title.Should().Be("Including at the requested depth is not allowed."); + responseDocument.Errors[0].Title.Should().Be("The specified include is invalid."); responseDocument.Errors[0].Detail.Should().Be("Including 'articles.revisions' exceeds the maximum inclusion depth of 1."); responseDocument.Errors[0].Source.Parameter.Should().Be("include"); } From c4258b26c67ecbe90166616a35e741222821c5d0 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 13 Aug 2020 15:55:20 +0200 Subject: [PATCH 09/13] Moved expressions and interfaces out of Internal namespace; added doc-comments on several types. --- benchmarks/DependencyFactory.cs | 2 +- benchmarks/Query/QueryParserBenchmarks.cs | 1 + .../JsonApiSerializerBenchmarks.cs | 2 +- .../Services/CustomArticleService.cs | 2 +- .../SkipCacheQueryStringParameterReader.cs | 2 +- .../Startups/Startup.cs | 2 +- .../Builders/JsonApiApplicationBuilder.cs | 4 +++- .../Data/EntityFrameworkCoreRepository.cs | 4 ++-- .../Data/IResourceReadRepository.cs | 4 ++-- .../Exceptions/InvalidQueryException.cs | 2 +- .../Hooks/Execution/HookExecutorHelper.cs | 4 ++-- .../Hooks/ResourceHookExecutor.cs | 4 ++-- .../Queries/Expressions/FilterExpression.cs | 6 ------ .../Queries/Expressions/FunctionExpression.cs | 6 ------ .../Expressions/IdentifierExpression.cs | 6 ------ .../Queries/Expressions/QueryExpression.cs | 8 ------- .../Queries/IQueryConstraintProvider.cs | 9 -------- ...nCallback.cs => FieldChainRequirements.cs} | 4 ---- .../Internal/Queries/Parsing/FilterParser.cs | 2 +- .../Internal/Queries/Parsing/IncludeParser.cs | 2 +- .../Queries/Parsing/PaginationParser.cs | 2 +- .../Internal/Queries/Parsing/QueryParser.cs | 9 ++++++-- .../QueryStringParameterScopeParser.cs | 2 +- .../Parsing}/ResourceFieldChainResolver.cs | 5 ++--- .../Internal/Queries/Parsing/SortParser.cs | 2 +- .../Queries/Parsing/SparseFieldSetParser.cs | 2 +- .../Internal/Queries/QueryLayerComposer.cs | 14 ++++++------- .../QueryableBuilding/IncludeClauseBuilder.cs | 5 ++++- .../Queries/QueryableBuilding/LambdaScope.cs | 3 +++ .../QueryableBuilding/OrderClauseBuilder.cs | 5 ++++- .../QueryableBuilding/QueryClauseBuilder.cs | 5 ++++- .../QueryableBuilding/QueryableBuilder.cs | 6 +++++- .../QueryableBuilding/SelectClauseBuilder.cs | 11 +++++++--- .../SkipTakeClauseBuilder.cs | 6 +++++- .../QueryableBuilding/WhereClauseBuilder.cs | 9 +++++--- .../DefaultsQueryStringParameterReader.cs | 13 +++++------- .../FilterQueryStringParameterReader.cs | 16 +++++++------- .../IncludeQueryStringParameterReader.cs | 16 +++++++------- .../NullsQueryStringParameterReader.cs | 13 +++++------- .../PaginationQueryStringParameterReader.cs | 16 +++++++------- .../QueryStringParameterReader.cs | 4 +--- .../QueryStrings/QueryStringReader.cs | 1 + .../RequestQueryStringAccessor.cs | 2 ++ ...ourceDefinitionQueryableParameterReader.cs | 21 ++++++++----------- .../SortQueryStringParameterReader.cs | 16 +++++++------- ...parseFieldSetQueryStringParameterReader.cs | 16 +++++++------- .../Middleware/QueryStringActionFilter.cs | 2 +- .../Models/ResourceDefinition.cs | 4 ++-- .../Queries/ExpressionInScope.cs | 8 +++++-- .../CollectionNotEmptyExpression.cs | 5 ++++- .../Expressions/ComparisonExpression.cs | 5 ++++- .../Queries/Expressions/ComparisonOperator.cs | 2 +- .../Queries/Expressions/CountExpression.cs | 5 ++++- .../Expressions/EqualsAnyOfExpression.cs | 5 ++++- .../Queries/Expressions/FilterExpression.cs | 9 ++++++++ .../Queries/Expressions/FunctionExpression.cs | 9 ++++++++ .../Expressions/IdentifierExpression.cs | 9 ++++++++ .../Expressions/IncludeChainConverter.cs | 8 +++++-- .../Expressions/IncludeElementExpression.cs | 5 ++++- .../Queries/Expressions/IncludeExpression.cs | 5 ++++- .../Expressions/LiteralConstantExpression.cs | 7 +++++-- .../Queries/Expressions/LogicalExpression.cs | 7 +++++-- .../Queries/Expressions/LogicalOperator.cs | 2 +- .../Expressions/MatchTextExpression.cs | 5 ++++- .../Queries/Expressions/NotExpression.cs | 5 ++++- .../Expressions/NullConstantExpression.cs | 5 ++++- ...nationElementQueryStringValueExpression.cs | 5 ++++- .../Expressions/PaginationExpression.cs | 5 ++++- .../PaginationQueryStringValueExpression.cs | 5 ++++- .../Queries/Expressions/QueryExpression.cs | 13 ++++++++++++ .../Expressions/QueryExpressionVisitor.cs | 5 ++++- .../QueryStringParameterScopeExpression.cs | 5 ++++- .../Expressions/QueryableHandlerExpression.cs | 5 ++++- .../ResourceFieldChainExpression.cs | 5 ++++- .../Expressions/SortElementExpression.cs | 5 ++++- .../Queries/Expressions/SortExpression.cs | 5 ++++- .../Expressions/SparseFieldSetExpression.cs | 5 ++++- .../SparseFieldSetExpressionExtensions.cs} | 6 +++--- .../Queries/Expressions/TextMatchKind.cs | 2 +- .../Queries/IQueryConstraintProvider.cs | 15 +++++++++++++ .../Queries/IQueryLayerComposer.cs | 21 +++++++++++++++++++ .../{Internal => }/Queries/QueryLayer.cs | 8 +++++-- .../IDefaultsQueryStringParameterReader.cs | 15 +++++++++++++ .../IFilterQueryStringParameterReader.cs | 11 ++++++++++ .../IIncludeQueryStringParameterReader.cs | 11 ++++++++++ .../INullsQueryStringParameterReader.cs | 15 +++++++++++++ .../IPaginationQueryStringParameterReader.cs | 11 ++++++++++ .../IQueryStringParameterReader.cs | 2 +- .../QueryStrings/IQueryStringReader.cs | 4 ++-- .../IRequestQueryStringAccessor.cs | 5 ++++- ...ourceDefinitionQueryableParameterReader.cs | 13 ++++++++++++ .../ISortQueryStringParameterReader.cs | 11 ++++++++++ ...parseFieldSetQueryStringParameterReader.cs | 11 ++++++++++ .../Server/Builders/LinkBuilder.cs | 2 +- .../Builders/ResponseResourceObjectBuilder.cs | 6 +++--- .../Serialization/Server/FieldsToSerialize.cs | 6 +++--- .../ResourceObjectBuilderSettingsProvider.cs | 2 +- .../Contract/IResourceDefinitionProvider.cs | 6 +++--- .../Services/JsonApiResourceService.cs | 4 ++-- .../Services/ResourceDefinitionProvider.cs | 4 ++-- .../ServiceDiscoveryFacadeTests.cs | 4 ++-- .../EntityFrameworkCoreRepositoryTests.cs | 4 ++-- .../Filtering/FilterOperatorTests.cs | 2 +- .../CallableResourceDefinition.cs | 2 +- .../ResultCapturingRepository.cs | 2 +- test/UnitTests/Builders/LinkBuilderTests.cs | 2 +- .../DefaultsParseTests.cs | 1 + .../QueryStringParameters/NullsParseTests.cs | 1 + .../PaginationParseTests.cs | 1 + .../Read/BeforeReadTests.cs | 2 +- .../ResourceHooks/ResourceHooksTestsSetup.cs | 4 ++-- .../Serialization/SerializerTestsSetup.cs | 4 ++-- .../Services/DefaultResourceService_Tests.cs | 2 +- 113 files changed, 462 insertions(+), 231 deletions(-) delete mode 100644 src/JsonApiDotNetCore/Internal/Queries/Expressions/FilterExpression.cs delete mode 100644 src/JsonApiDotNetCore/Internal/Queries/Expressions/FunctionExpression.cs delete mode 100644 src/JsonApiDotNetCore/Internal/Queries/Expressions/IdentifierExpression.cs delete mode 100644 src/JsonApiDotNetCore/Internal/Queries/Expressions/QueryExpression.cs delete mode 100644 src/JsonApiDotNetCore/Internal/Queries/IQueryConstraintProvider.cs rename src/JsonApiDotNetCore/Internal/Queries/Parsing/{ResolveFieldChainCallback.cs => FieldChainRequirements.cs} (55%) rename src/JsonApiDotNetCore/Internal/{QueryStrings => Queries/Parsing}/ResourceFieldChainResolver.cs (98%) rename src/JsonApiDotNetCore/{Internal => }/Queries/ExpressionInScope.cs (62%) rename src/JsonApiDotNetCore/{Internal => }/Queries/Expressions/CollectionNotEmptyExpression.cs (81%) rename src/JsonApiDotNetCore/{Internal => }/Queries/Expressions/ComparisonExpression.cs (83%) rename src/JsonApiDotNetCore/{Internal => }/Queries/Expressions/ComparisonOperator.cs (71%) rename src/JsonApiDotNetCore/{Internal => }/Queries/Expressions/CountExpression.cs (81%) rename src/JsonApiDotNetCore/{Internal => }/Queries/Expressions/EqualsAnyOfExpression.cs (88%) create mode 100644 src/JsonApiDotNetCore/Queries/Expressions/FilterExpression.cs create mode 100644 src/JsonApiDotNetCore/Queries/Expressions/FunctionExpression.cs create mode 100644 src/JsonApiDotNetCore/Queries/Expressions/IdentifierExpression.cs rename src/JsonApiDotNetCore/{Internal => }/Queries/Expressions/IncludeChainConverter.cs (94%) rename src/JsonApiDotNetCore/{Internal => }/Queries/Expressions/IncludeElementExpression.cs (90%) rename src/JsonApiDotNetCore/{Internal => }/Queries/Expressions/IncludeExpression.cs (87%) rename src/JsonApiDotNetCore/{Internal => }/Queries/Expressions/LiteralConstantExpression.cs (76%) rename src/JsonApiDotNetCore/{Internal => }/Queries/Expressions/LogicalExpression.cs (86%) rename src/JsonApiDotNetCore/{Internal => }/Queries/Expressions/LogicalOperator.cs (54%) rename src/JsonApiDotNetCore/{Internal => }/Queries/Expressions/MatchTextExpression.cs (87%) rename src/JsonApiDotNetCore/{Internal => }/Queries/Expressions/NotExpression.cs (77%) rename src/JsonApiDotNetCore/{Internal => }/Queries/Expressions/NullConstantExpression.cs (70%) rename src/JsonApiDotNetCore/{Internal => }/Queries/Expressions/PaginationElementQueryStringValueExpression.cs (81%) rename src/JsonApiDotNetCore/{Internal => }/Queries/Expressions/PaginationExpression.cs (80%) rename src/JsonApiDotNetCore/{Internal => }/Queries/Expressions/PaginationQueryStringValueExpression.cs (85%) create mode 100644 src/JsonApiDotNetCore/Queries/Expressions/QueryExpression.cs rename src/JsonApiDotNetCore/{Internal => }/Queries/Expressions/QueryExpressionVisitor.cs (95%) rename src/JsonApiDotNetCore/{Internal => }/Queries/Expressions/QueryStringParameterScopeExpression.cs (81%) rename src/JsonApiDotNetCore/{Internal => }/Queries/Expressions/QueryableHandlerExpression.cs (83%) rename src/JsonApiDotNetCore/{Internal => }/Queries/Expressions/ResourceFieldChainExpression.cs (90%) rename src/JsonApiDotNetCore/{Internal => }/Queries/Expressions/SortElementExpression.cs (90%) rename src/JsonApiDotNetCore/{Internal => }/Queries/Expressions/SortExpression.cs (84%) rename src/JsonApiDotNetCore/{Internal => }/Queries/Expressions/SparseFieldSetExpression.cs (85%) rename src/JsonApiDotNetCore/{Models/SparseFieldSetExtensions.cs => Queries/Expressions/SparseFieldSetExpressionExtensions.cs} (95%) rename src/JsonApiDotNetCore/{Internal => }/Queries/Expressions/TextMatchKind.cs (62%) create mode 100644 src/JsonApiDotNetCore/Queries/IQueryConstraintProvider.cs create mode 100644 src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs rename src/JsonApiDotNetCore/{Internal => }/Queries/QueryLayer.cs (93%) create mode 100644 src/JsonApiDotNetCore/QueryStrings/IDefaultsQueryStringParameterReader.cs create mode 100644 src/JsonApiDotNetCore/QueryStrings/IFilterQueryStringParameterReader.cs create mode 100644 src/JsonApiDotNetCore/QueryStrings/IIncludeQueryStringParameterReader.cs create mode 100644 src/JsonApiDotNetCore/QueryStrings/INullsQueryStringParameterReader.cs create mode 100644 src/JsonApiDotNetCore/QueryStrings/IPaginationQueryStringParameterReader.cs rename src/JsonApiDotNetCore/{Internal => }/QueryStrings/IQueryStringParameterReader.cs (94%) rename src/JsonApiDotNetCore/{Internal => }/QueryStrings/IQueryStringReader.cs (80%) rename src/JsonApiDotNetCore/{Internal => }/QueryStrings/IRequestQueryStringAccessor.cs (54%) create mode 100644 src/JsonApiDotNetCore/QueryStrings/IResourceDefinitionQueryableParameterReader.cs create mode 100644 src/JsonApiDotNetCore/QueryStrings/ISortQueryStringParameterReader.cs create mode 100644 src/JsonApiDotNetCore/QueryStrings/ISparseFieldSetQueryStringParameterReader.cs diff --git a/benchmarks/DependencyFactory.cs b/benchmarks/DependencyFactory.cs index d291fa77c0..0492a9a185 100644 --- a/benchmarks/DependencyFactory.cs +++ b/benchmarks/DependencyFactory.cs @@ -3,7 +3,7 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Query; +using JsonApiDotNetCore.Services.Contract; using Microsoft.Extensions.Logging.Abstractions; using Moq; diff --git a/benchmarks/Query/QueryParserBenchmarks.cs b/benchmarks/Query/QueryParserBenchmarks.cs index e2b34c1904..64f144b9e5 100644 --- a/benchmarks/Query/QueryParserBenchmarks.cs +++ b/benchmarks/Query/QueryParserBenchmarks.cs @@ -6,6 +6,7 @@ using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Internal.QueryStrings; +using JsonApiDotNetCore.QueryStrings; using JsonApiDotNetCore.RequestServices; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.WebUtilities; diff --git a/benchmarks/Serialization/JsonApiSerializerBenchmarks.cs b/benchmarks/Serialization/JsonApiSerializerBenchmarks.cs index 47c80a6444..c334e59235 100644 --- a/benchmarks/Serialization/JsonApiSerializerBenchmarks.cs +++ b/benchmarks/Serialization/JsonApiSerializerBenchmarks.cs @@ -2,8 +2,8 @@ using BenchmarkDotNet.Attributes; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Internal.Queries; using JsonApiDotNetCore.Internal.QueryStrings; +using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.RequestServices; using JsonApiDotNetCore.Serialization; using JsonApiDotNetCore.Serialization.Server; diff --git a/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs b/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs index 4edf6a14aa..31dba7ef66 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs @@ -6,7 +6,7 @@ using Microsoft.Extensions.Logging; using System.Threading.Tasks; using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Internal.Queries; +using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.RequestServices; using JsonApiDotNetCore.RequestServices.Contracts; diff --git a/src/Examples/JsonApiDotNetCoreExample/Services/SkipCacheQueryStringParameterReader.cs b/src/Examples/JsonApiDotNetCoreExample/Services/SkipCacheQueryStringParameterReader.cs index 85ed70c32e..a4dcaa50c2 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Services/SkipCacheQueryStringParameterReader.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Services/SkipCacheQueryStringParameterReader.cs @@ -1,7 +1,7 @@ using System.Linq; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Exceptions; -using JsonApiDotNetCore.Internal.QueryStrings; +using JsonApiDotNetCore.QueryStrings; using Microsoft.Extensions.Primitives; namespace JsonApiDotNetCoreExample.Services diff --git a/src/Examples/JsonApiDotNetCoreExample/Startups/Startup.cs b/src/Examples/JsonApiDotNetCoreExample/Startups/Startup.cs index 62f2cb65af..0b0d0eba07 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Startups/Startup.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Startups/Startup.cs @@ -7,7 +7,7 @@ using System; using JsonApiDotNetCore; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Internal.QueryStrings; +using JsonApiDotNetCore.QueryStrings; using JsonApiDotNetCoreExample.Services; using Microsoft.AspNetCore.Authentication; using Newtonsoft.Json; diff --git a/src/JsonApiDotNetCore/Builders/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Builders/JsonApiApplicationBuilder.cs index 68c727ce47..6102e9b851 100644 --- a/src/JsonApiDotNetCore/Builders/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Builders/JsonApiApplicationBuilder.cs @@ -16,12 +16,14 @@ using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Internal.Queries; using JsonApiDotNetCore.Internal.QueryStrings; -using JsonApiDotNetCore.Query; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.QueryStrings; using JsonApiDotNetCore.Serialization.Server.Builders; using JsonApiDotNetCore.Serialization.Server; using Microsoft.Extensions.DependencyInjection.Extensions; using JsonApiDotNetCore.RequestServices; using JsonApiDotNetCore.RequestServices.Contracts; +using JsonApiDotNetCore.Services.Contract; namespace JsonApiDotNetCore.Builders { diff --git a/src/JsonApiDotNetCore/Data/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Data/EntityFrameworkCoreRepository.cs index e1633ffe4f..406815c453 100644 --- a/src/JsonApiDotNetCore/Data/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Data/EntityFrameworkCoreRepository.cs @@ -7,11 +7,11 @@ using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Internal.Generics; -using JsonApiDotNetCore.Internal.Queries; -using JsonApiDotNetCore.Internal.Queries.Expressions; using JsonApiDotNetCore.Internal.Queries.QueryableBuilding; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Models.Annotation; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Serialization; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; diff --git a/src/JsonApiDotNetCore/Data/IResourceReadRepository.cs b/src/JsonApiDotNetCore/Data/IResourceReadRepository.cs index 57d41ee741..81b5c25b3a 100644 --- a/src/JsonApiDotNetCore/Data/IResourceReadRepository.cs +++ b/src/JsonApiDotNetCore/Data/IResourceReadRepository.cs @@ -1,8 +1,8 @@ using System.Collections.Generic; using System.Threading.Tasks; -using JsonApiDotNetCore.Internal.Queries; -using JsonApiDotNetCore.Internal.Queries.Expressions; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; namespace JsonApiDotNetCore.Data { diff --git a/src/JsonApiDotNetCore/Exceptions/InvalidQueryException.cs b/src/JsonApiDotNetCore/Exceptions/InvalidQueryException.cs index 687f6c4306..c803bd68a6 100644 --- a/src/JsonApiDotNetCore/Exceptions/InvalidQueryException.cs +++ b/src/JsonApiDotNetCore/Exceptions/InvalidQueryException.cs @@ -1,7 +1,7 @@ using System; using System.Net; -using JsonApiDotNetCore.Internal.Queries; using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCore.Queries; namespace JsonApiDotNetCore.Exceptions { diff --git a/src/JsonApiDotNetCore/Hooks/Execution/HookExecutorHelper.cs b/src/JsonApiDotNetCore/Hooks/Execution/HookExecutorHelper.cs index bc6e7d80d0..56491b4ad4 100644 --- a/src/JsonApiDotNetCore/Hooks/Execution/HookExecutorHelper.cs +++ b/src/JsonApiDotNetCore/Hooks/Execution/HookExecutorHelper.cs @@ -12,9 +12,9 @@ using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Internal.Queries; -using JsonApiDotNetCore.Internal.Queries.Expressions; using JsonApiDotNetCore.Models.Annotation; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; namespace JsonApiDotNetCore.Hooks { diff --git a/src/JsonApiDotNetCore/Hooks/ResourceHookExecutor.cs b/src/JsonApiDotNetCore/Hooks/ResourceHookExecutor.cs index bddbe04f07..c1de951efc 100644 --- a/src/JsonApiDotNetCore/Hooks/ResourceHookExecutor.cs +++ b/src/JsonApiDotNetCore/Hooks/ResourceHookExecutor.cs @@ -9,9 +9,9 @@ using RightType = System.Type; using JsonApiDotNetCore.Extensions; using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Internal.Queries; -using JsonApiDotNetCore.Internal.Queries.Expressions; using JsonApiDotNetCore.Models.Annotation; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Serialization; namespace JsonApiDotNetCore.Hooks diff --git a/src/JsonApiDotNetCore/Internal/Queries/Expressions/FilterExpression.cs b/src/JsonApiDotNetCore/Internal/Queries/Expressions/FilterExpression.cs deleted file mode 100644 index b9013756e0..0000000000 --- a/src/JsonApiDotNetCore/Internal/Queries/Expressions/FilterExpression.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace JsonApiDotNetCore.Internal.Queries.Expressions -{ - public abstract class FilterExpression : FunctionExpression - { - } -} diff --git a/src/JsonApiDotNetCore/Internal/Queries/Expressions/FunctionExpression.cs b/src/JsonApiDotNetCore/Internal/Queries/Expressions/FunctionExpression.cs deleted file mode 100644 index 27c07abb96..0000000000 --- a/src/JsonApiDotNetCore/Internal/Queries/Expressions/FunctionExpression.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace JsonApiDotNetCore.Internal.Queries.Expressions -{ - public abstract class FunctionExpression : QueryExpression - { - } -} diff --git a/src/JsonApiDotNetCore/Internal/Queries/Expressions/IdentifierExpression.cs b/src/JsonApiDotNetCore/Internal/Queries/Expressions/IdentifierExpression.cs deleted file mode 100644 index 7bec701114..0000000000 --- a/src/JsonApiDotNetCore/Internal/Queries/Expressions/IdentifierExpression.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace JsonApiDotNetCore.Internal.Queries.Expressions -{ - public abstract class IdentifierExpression : QueryExpression - { - } -} diff --git a/src/JsonApiDotNetCore/Internal/Queries/Expressions/QueryExpression.cs b/src/JsonApiDotNetCore/Internal/Queries/Expressions/QueryExpression.cs deleted file mode 100644 index ae80a245ec..0000000000 --- a/src/JsonApiDotNetCore/Internal/Queries/Expressions/QueryExpression.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace JsonApiDotNetCore.Internal.Queries.Expressions -{ - public abstract class QueryExpression - { - public abstract TResult - Accept(QueryExpressionVisitor visitor, TArgument argument); - } -} diff --git a/src/JsonApiDotNetCore/Internal/Queries/IQueryConstraintProvider.cs b/src/JsonApiDotNetCore/Internal/Queries/IQueryConstraintProvider.cs deleted file mode 100644 index 026a9413be..0000000000 --- a/src/JsonApiDotNetCore/Internal/Queries/IQueryConstraintProvider.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System.Collections.Generic; - -namespace JsonApiDotNetCore.Internal.Queries -{ - public interface IQueryConstraintProvider - { - public IReadOnlyCollection GetConstraints(); - } -} diff --git a/src/JsonApiDotNetCore/Internal/Queries/Parsing/ResolveFieldChainCallback.cs b/src/JsonApiDotNetCore/Internal/Queries/Parsing/FieldChainRequirements.cs similarity index 55% rename from src/JsonApiDotNetCore/Internal/Queries/Parsing/ResolveFieldChainCallback.cs rename to src/JsonApiDotNetCore/Internal/Queries/Parsing/FieldChainRequirements.cs index de8a67e7d7..5201e0b3fb 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/Parsing/ResolveFieldChainCallback.cs +++ b/src/JsonApiDotNetCore/Internal/Queries/Parsing/FieldChainRequirements.cs @@ -1,6 +1,4 @@ using System; -using System.Collections.Generic; -using JsonApiDotNetCore.Models.Annotation; namespace JsonApiDotNetCore.Internal.Queries.Parsing { @@ -13,6 +11,4 @@ public enum FieldChainRequirements IsRelationship = EndsInToOne | EndsInToMany } - - public delegate IReadOnlyCollection ResolveFieldChainCallback(string path, FieldChainRequirements requirements); } diff --git a/src/JsonApiDotNetCore/Internal/Queries/Parsing/FilterParser.cs b/src/JsonApiDotNetCore/Internal/Queries/Parsing/FilterParser.cs index bbda5918fc..ef9f51713c 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/Parsing/FilterParser.cs +++ b/src/JsonApiDotNetCore/Internal/Queries/Parsing/FilterParser.cs @@ -4,9 +4,9 @@ using System.Reflection; using Humanizer; using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Internal.Queries.Expressions; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Models.Annotation; +using JsonApiDotNetCore.Queries.Expressions; namespace JsonApiDotNetCore.Internal.Queries.Parsing { diff --git a/src/JsonApiDotNetCore/Internal/Queries/Parsing/IncludeParser.cs b/src/JsonApiDotNetCore/Internal/Queries/Parsing/IncludeParser.cs index 82baea5b26..abda6a3cbd 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/Parsing/IncludeParser.cs +++ b/src/JsonApiDotNetCore/Internal/Queries/Parsing/IncludeParser.cs @@ -2,8 +2,8 @@ using System.Collections.Generic; using System.Linq; using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Internal.Queries.Expressions; using JsonApiDotNetCore.Models.Annotation; +using JsonApiDotNetCore.Queries.Expressions; namespace JsonApiDotNetCore.Internal.Queries.Parsing { diff --git a/src/JsonApiDotNetCore/Internal/Queries/Parsing/PaginationParser.cs b/src/JsonApiDotNetCore/Internal/Queries/Parsing/PaginationParser.cs index ba081a79d2..c62a18ba92 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/Parsing/PaginationParser.cs +++ b/src/JsonApiDotNetCore/Internal/Queries/Parsing/PaginationParser.cs @@ -2,8 +2,8 @@ using System.Collections.Generic; using System.Linq; using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Internal.Queries.Expressions; using JsonApiDotNetCore.Models.Annotation; +using JsonApiDotNetCore.Queries.Expressions; namespace JsonApiDotNetCore.Internal.Queries.Parsing { diff --git a/src/JsonApiDotNetCore/Internal/Queries/Parsing/QueryParser.cs b/src/JsonApiDotNetCore/Internal/Queries/Parsing/QueryParser.cs index 6b08b36739..e1dc777a10 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/Parsing/QueryParser.cs +++ b/src/JsonApiDotNetCore/Internal/Queries/Parsing/QueryParser.cs @@ -1,12 +1,14 @@ using System.Collections.Generic; using System.Linq; using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Internal.Queries.Expressions; -using JsonApiDotNetCore.Internal.QueryStrings; using JsonApiDotNetCore.Models.Annotation; +using JsonApiDotNetCore.Queries.Expressions; namespace JsonApiDotNetCore.Internal.Queries.Parsing { + /// + /// Base class for parsing query string parameters. + /// public abstract class QueryParser { private protected ResourceFieldChainResolver ChainResolver { get; } @@ -18,6 +20,9 @@ protected QueryParser(IResourceContextProvider resourceContextProvider) ChainResolver = new ResourceFieldChainResolver(resourceContextProvider); } + /// + /// Takes a dotted path and walks the resource graph to produce a chain of fields. + /// protected abstract IReadOnlyCollection OnResolveFieldChain(string path, FieldChainRequirements chainRequirements); protected virtual void Tokenize(string source) diff --git a/src/JsonApiDotNetCore/Internal/Queries/Parsing/QueryStringParameterScopeParser.cs b/src/JsonApiDotNetCore/Internal/Queries/Parsing/QueryStringParameterScopeParser.cs index 9a53f42ae2..6c684cd78a 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/Parsing/QueryStringParameterScopeParser.cs +++ b/src/JsonApiDotNetCore/Internal/Queries/Parsing/QueryStringParameterScopeParser.cs @@ -1,8 +1,8 @@ using System; using System.Collections.Generic; using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Internal.Queries.Expressions; using JsonApiDotNetCore.Models.Annotation; +using JsonApiDotNetCore.Queries.Expressions; namespace JsonApiDotNetCore.Internal.Queries.Parsing { diff --git a/src/JsonApiDotNetCore/Internal/QueryStrings/ResourceFieldChainResolver.cs b/src/JsonApiDotNetCore/Internal/Queries/Parsing/ResourceFieldChainResolver.cs similarity index 98% rename from src/JsonApiDotNetCore/Internal/QueryStrings/ResourceFieldChainResolver.cs rename to src/JsonApiDotNetCore/Internal/Queries/Parsing/ResourceFieldChainResolver.cs index bba7fae106..4d66c8bf5f 100644 --- a/src/JsonApiDotNetCore/Internal/QueryStrings/ResourceFieldChainResolver.cs +++ b/src/JsonApiDotNetCore/Internal/Queries/Parsing/ResourceFieldChainResolver.cs @@ -2,12 +2,11 @@ using System.Collections.Generic; using System.Linq; using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Internal.Queries.Parsing; using JsonApiDotNetCore.Models.Annotation; -namespace JsonApiDotNetCore.Internal.QueryStrings +namespace JsonApiDotNetCore.Internal.Queries.Parsing { - internal class ResourceFieldChainResolver + internal sealed class ResourceFieldChainResolver { private readonly IResourceContextProvider _resourceContextProvider; diff --git a/src/JsonApiDotNetCore/Internal/Queries/Parsing/SortParser.cs b/src/JsonApiDotNetCore/Internal/Queries/Parsing/SortParser.cs index 274d2928d2..b2c4122c76 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/Parsing/SortParser.cs +++ b/src/JsonApiDotNetCore/Internal/Queries/Parsing/SortParser.cs @@ -2,8 +2,8 @@ using System.Collections.Generic; using System.Linq; using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Internal.Queries.Expressions; using JsonApiDotNetCore.Models.Annotation; +using JsonApiDotNetCore.Queries.Expressions; namespace JsonApiDotNetCore.Internal.Queries.Parsing { diff --git a/src/JsonApiDotNetCore/Internal/Queries/Parsing/SparseFieldSetParser.cs b/src/JsonApiDotNetCore/Internal/Queries/Parsing/SparseFieldSetParser.cs index 9b89e5c39e..791e75895a 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/Parsing/SparseFieldSetParser.cs +++ b/src/JsonApiDotNetCore/Internal/Queries/Parsing/SparseFieldSetParser.cs @@ -2,8 +2,8 @@ using System.Collections.Generic; using System.Linq; using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Internal.Queries.Expressions; using JsonApiDotNetCore.Models.Annotation; +using JsonApiDotNetCore.Queries.Expressions; namespace JsonApiDotNetCore.Internal.Queries.Parsing { diff --git a/src/JsonApiDotNetCore/Internal/Queries/QueryLayerComposer.cs b/src/JsonApiDotNetCore/Internal/Queries/QueryLayerComposer.cs index 564c03b00c..ca05dae17d 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/QueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Internal/Queries/QueryLayerComposer.cs @@ -3,19 +3,15 @@ using System.Linq; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Internal.Queries.Expressions; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Models.Annotation; -using JsonApiDotNetCore.Query; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Services.Contract; namespace JsonApiDotNetCore.Internal.Queries { - public interface IQueryLayerComposer - { - FilterExpression GetTopFilter(); - QueryLayer Compose(ResourceContext requestResource); - } - + /// public class QueryLayerComposer : IQueryLayerComposer { private readonly IEnumerable _constraintProviders; @@ -38,6 +34,7 @@ public QueryLayerComposer( _paginationContext = paginationContext ?? throw new ArgumentNullException(nameof(paginationContext)); } + /// public FilterExpression GetTopFilter() { var constraints = _constraintProviders.SelectMany(p => p.GetConstraints()).ToArray(); @@ -61,6 +58,7 @@ public FilterExpression GetTopFilter() return new LogicalExpression(LogicalOperator.And, topFilters); } + /// public QueryLayer Compose(ResourceContext requestResource) { if (requestResource == null) diff --git a/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/IncludeClauseBuilder.cs b/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/IncludeClauseBuilder.cs index 8621b296a1..6c169cf8a1 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/IncludeClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/IncludeClauseBuilder.cs @@ -3,12 +3,15 @@ using System.Linq; using System.Linq.Expressions; using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Internal.Queries.Expressions; using JsonApiDotNetCore.Models.Annotation; +using JsonApiDotNetCore.Queries.Expressions; using Microsoft.EntityFrameworkCore; namespace JsonApiDotNetCore.Internal.Queries.QueryableBuilding { + /// + /// Transforms into calls. + /// public class IncludeClauseBuilder : QueryClauseBuilder { private readonly Expression _source; diff --git a/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/LambdaScope.cs b/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/LambdaScope.cs index 427cb8a0f1..0ed434237d 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/LambdaScope.cs +++ b/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/LambdaScope.cs @@ -4,6 +4,9 @@ namespace JsonApiDotNetCore.Internal.Queries.QueryableBuilding { + /// + /// Contains details on a lambda expression, such as the name of the selector "x" in "x => x.Name". + /// public sealed class LambdaScope : IDisposable { private readonly LambdaParameterNameScope _parameterNameScope; diff --git a/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/OrderClauseBuilder.cs b/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/OrderClauseBuilder.cs index 2f5883a440..0287295d94 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/OrderClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/OrderClauseBuilder.cs @@ -2,11 +2,14 @@ using System.Collections.Generic; using System.Linq; using System.Linq.Expressions; -using JsonApiDotNetCore.Internal.Queries.Expressions; using JsonApiDotNetCore.Models.Annotation; +using JsonApiDotNetCore.Queries.Expressions; namespace JsonApiDotNetCore.Internal.Queries.QueryableBuilding { + /// + /// Transforms into calls. + /// public class OrderClauseBuilder : QueryClauseBuilder { private readonly Expression _source; diff --git a/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/QueryClauseBuilder.cs b/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/QueryClauseBuilder.cs index b934718e07..64eabbf2a5 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/QueryClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/QueryClauseBuilder.cs @@ -4,11 +4,14 @@ using System.Linq.Expressions; using System.Reflection; using JsonApiDotNetCore.Extensions; -using JsonApiDotNetCore.Internal.Queries.Expressions; using JsonApiDotNetCore.Models.Annotation; +using JsonApiDotNetCore.Queries.Expressions; namespace JsonApiDotNetCore.Internal.Queries.QueryableBuilding { + /// + /// Base class for transforming trees into system trees. + /// public abstract class QueryClauseBuilder : QueryExpressionVisitor { protected LambdaScope LambdaScope { get; } diff --git a/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/QueryableBuilder.cs b/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/QueryableBuilder.cs index b87f04a9f5..0e92d113cb 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/QueryableBuilder.cs +++ b/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/QueryableBuilder.cs @@ -3,12 +3,16 @@ using System.Linq; using System.Linq.Expressions; using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Internal.Queries.Expressions; using JsonApiDotNetCore.Models.Annotation; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; using Microsoft.EntityFrameworkCore.Metadata; namespace JsonApiDotNetCore.Internal.Queries.QueryableBuilding { + /// + /// Drives conversion from into system trees. + /// public sealed class QueryableBuilder { private readonly Expression _source; diff --git a/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/SelectClauseBuilder.cs b/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/SelectClauseBuilder.cs index 95cc4a535d..ded1dad414 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/SelectClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/SelectClauseBuilder.cs @@ -5,14 +5,19 @@ using System.Reflection; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Models.Annotation; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata; namespace JsonApiDotNetCore.Internal.Queries.QueryableBuilding { + /// + /// Transforms into calls. + /// public class SelectClauseBuilder : QueryClauseBuilder { - private static readonly ConstantExpression NullConstant = Expression.Constant(null); + private static readonly ConstantExpression _nullConstant = Expression.Constant(null); private readonly Expression _source; private readonly IModel _entityModel; @@ -187,8 +192,8 @@ private Expression CreateCollectionInitializer(LambdaScope lambdaScope, Property private static Expression TestForNull(Expression expressionToTest, Expression ifFalseExpression) { - BinaryExpression equalsNull = Expression.Equal(expressionToTest, NullConstant); - return Expression.Condition(equalsNull, Expression.Convert(NullConstant, expressionToTest.Type), ifFalseExpression); + BinaryExpression equalsNull = Expression.Equal(expressionToTest, _nullConstant); + return Expression.Condition(equalsNull, Expression.Convert(_nullConstant, expressionToTest.Type), ifFalseExpression); } private Expression SelectExtensionMethodCall(Expression source, Type elementType, Expression selectorBody) diff --git a/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/SkipTakeClauseBuilder.cs b/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/SkipTakeClauseBuilder.cs index f87db0cbec..8e693117a4 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/SkipTakeClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/SkipTakeClauseBuilder.cs @@ -1,9 +1,13 @@ using System; +using System.Linq; using System.Linq.Expressions; -using JsonApiDotNetCore.Internal.Queries.Expressions; +using JsonApiDotNetCore.Queries.Expressions; namespace JsonApiDotNetCore.Internal.Queries.QueryableBuilding { + /// + /// Transforms into and calls. + /// public class SkipTakeClauseBuilder : QueryClauseBuilder { private readonly Expression _source; diff --git a/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/WhereClauseBuilder.cs b/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/WhereClauseBuilder.cs index 5f75ee74b4..19f8158c2c 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/WhereClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Internal/Queries/QueryableBuilding/WhereClauseBuilder.cs @@ -4,14 +4,17 @@ using System.Linq; using System.Linq.Expressions; using JsonApiDotNetCore.Exceptions; -using JsonApiDotNetCore.Internal.Queries.Expressions; using JsonApiDotNetCore.Models.Annotation; +using JsonApiDotNetCore.Queries.Expressions; namespace JsonApiDotNetCore.Internal.Queries.QueryableBuilding { + /// + /// Transforms into calls. + /// public class WhereClauseBuilder : QueryClauseBuilder { - private static readonly ConstantExpression NullConstant = Expression.Constant(null); + private static readonly ConstantExpression _nullConstant = Expression.Constant(null); private readonly Expression _source; private readonly Type _extensionType; @@ -249,7 +252,7 @@ private static Expression WrapInConvert(Expression expression, Type targetType) public override Expression VisitNullConstant(NullConstantExpression expression, Type expressionType) { - return NullConstant; + return _nullConstant; } public override Expression VisitLiteralConstant(LiteralConstantExpression expression, Type expressionType) diff --git a/src/JsonApiDotNetCore/Internal/QueryStrings/DefaultsQueryStringParameterReader.cs b/src/JsonApiDotNetCore/Internal/QueryStrings/DefaultsQueryStringParameterReader.cs index 8f8a314cc4..3279347c0b 100644 --- a/src/JsonApiDotNetCore/Internal/QueryStrings/DefaultsQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/Internal/QueryStrings/DefaultsQueryStringParameterReader.cs @@ -1,19 +1,13 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Exceptions; +using JsonApiDotNetCore.QueryStrings; using Microsoft.Extensions.Primitives; using Newtonsoft.Json; namespace JsonApiDotNetCore.Internal.QueryStrings { - public interface IDefaultsQueryStringParameterReader : IQueryStringParameterReader - { - /// - /// Contains the effective value of default configuration and query string override, after parsing has occured. - /// - DefaultValueHandling SerializerDefaultValueHandling { get; } - } - + /// public class DefaultsQueryStringParameterReader : IDefaultsQueryStringParameterReader { private readonly IJsonApiOptions _options; @@ -27,17 +21,20 @@ public DefaultsQueryStringParameterReader(IJsonApiOptions options) _options = options; } + /// public bool IsEnabled(DisableQueryAttribute disableQueryAttribute) { return _options.AllowQueryStringOverrideForSerializerDefaultValueHandling && !disableQueryAttribute.ContainsParameter(StandardQueryStringParameters.Defaults); } + /// public bool CanRead(string parameterName) { return parameterName == "defaults"; } + /// public void Read(string parameterName, StringValues parameterValue) { if (!bool.TryParse(parameterValue, out var result)) diff --git a/src/JsonApiDotNetCore/Internal/QueryStrings/FilterQueryStringParameterReader.cs b/src/JsonApiDotNetCore/Internal/QueryStrings/FilterQueryStringParameterReader.cs index 6c285b6c34..577d4950fd 100644 --- a/src/JsonApiDotNetCore/Internal/QueryStrings/FilterQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/Internal/QueryStrings/FilterQueryStringParameterReader.cs @@ -5,23 +5,17 @@ using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Internal.Queries; -using JsonApiDotNetCore.Internal.Queries.Expressions; using JsonApiDotNetCore.Internal.Queries.Parsing; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Models.Annotation; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.QueryStrings; using JsonApiDotNetCore.RequestServices.Contracts; using Microsoft.Extensions.Primitives; namespace JsonApiDotNetCore.Internal.QueryStrings { - /// - /// Reads the 'filter' query string parameter and produces a set of query constraints from it. - /// - public interface IFilterQueryStringParameterReader : IQueryStringParameterReader, IQueryConstraintProvider - { - } - public class FilterQueryStringParameterReader : QueryStringParameterReader, IFilterQueryStringParameterReader { private static readonly LegacyFilterNotationConverter _legacyConverter = new LegacyFilterNotationConverter(); @@ -52,17 +46,20 @@ private void ValidateSingleField(ResourceFieldAttribute field, ResourceContext r } } + /// public bool IsEnabled(DisableQueryAttribute disableQueryAttribute) { return !disableQueryAttribute.ContainsParameter(StandardQueryStringParameters.Filter); } + /// public bool CanRead(string parameterName) { var isNested = parameterName.StartsWith("filter[") && parameterName.EndsWith("]"); return parameterName == "filter" || isNested; } + /// public void Read(string parameterName, StringValues parameterValues) { _lastParameterName = parameterName; @@ -128,6 +125,7 @@ private void StoreFilterInScope(FilterExpression filter, ResourceFieldChainExpre } } + /// public IReadOnlyCollection GetConstraints() { return EnumerateFiltersInScopes().ToList().AsReadOnly(); diff --git a/src/JsonApiDotNetCore/Internal/QueryStrings/IncludeQueryStringParameterReader.cs b/src/JsonApiDotNetCore/Internal/QueryStrings/IncludeQueryStringParameterReader.cs index d370fbc843..4940cb3876 100644 --- a/src/JsonApiDotNetCore/Internal/QueryStrings/IncludeQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/Internal/QueryStrings/IncludeQueryStringParameterReader.cs @@ -4,22 +4,16 @@ using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Internal.Queries; -using JsonApiDotNetCore.Internal.Queries.Expressions; using JsonApiDotNetCore.Internal.Queries.Parsing; using JsonApiDotNetCore.Models.Annotation; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.QueryStrings; using JsonApiDotNetCore.RequestServices.Contracts; using Microsoft.Extensions.Primitives; namespace JsonApiDotNetCore.Internal.QueryStrings { - /// - /// Reads the 'include' query string parameter and produces a set of query constraints from it. - /// - public interface IIncludeQueryStringParameterReader : IQueryStringParameterReader, IQueryConstraintProvider - { - } - public class IncludeQueryStringParameterReader : QueryStringParameterReader, IIncludeQueryStringParameterReader { private readonly IJsonApiOptions _options; @@ -47,16 +41,19 @@ private void ValidateSingleRelationship(RelationshipAttribute relationship, Reso } } + /// public bool IsEnabled(DisableQueryAttribute disableQueryAttribute) { return !disableQueryAttribute.ContainsParameter(StandardQueryStringParameters.Include); } + /// public bool CanRead(string parameterName) { return parameterName == "include"; } + /// public void Read(string parameterName, StringValues parameterValue) { _lastParameterName = parameterName; @@ -77,6 +74,7 @@ private IncludeExpression GetInclude(string parameterValue) return _includeParser.Parse(parameterValue, RequestResource, _options.MaximumIncludeDepth); } + /// public IReadOnlyCollection GetConstraints() { var expressionInScope = _includeExpression != null diff --git a/src/JsonApiDotNetCore/Internal/QueryStrings/NullsQueryStringParameterReader.cs b/src/JsonApiDotNetCore/Internal/QueryStrings/NullsQueryStringParameterReader.cs index 865d3a1f91..abf25b1c64 100644 --- a/src/JsonApiDotNetCore/Internal/QueryStrings/NullsQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/Internal/QueryStrings/NullsQueryStringParameterReader.cs @@ -1,19 +1,13 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Exceptions; +using JsonApiDotNetCore.QueryStrings; using Microsoft.Extensions.Primitives; using Newtonsoft.Json; namespace JsonApiDotNetCore.Internal.QueryStrings { - public interface INullsQueryStringParameterReader : IQueryStringParameterReader - { - /// - /// Contains the effective value of default configuration and query string override, after parsing has occured. - /// - NullValueHandling SerializerNullValueHandling { get; } - } - + /// public class NullsQueryStringParameterReader : INullsQueryStringParameterReader { private readonly IJsonApiOptions _options; @@ -27,17 +21,20 @@ public NullsQueryStringParameterReader(IJsonApiOptions options) _options = options; } + /// public bool IsEnabled(DisableQueryAttribute disableQueryAttribute) { return _options.AllowQueryStringOverrideForSerializerNullValueHandling && !disableQueryAttribute.ContainsParameter(StandardQueryStringParameters.Nulls); } + /// public bool CanRead(string parameterName) { return parameterName == "nulls"; } + /// public void Read(string parameterName, StringValues parameterValue) { if (!bool.TryParse(parameterValue, out var result)) diff --git a/src/JsonApiDotNetCore/Internal/QueryStrings/PaginationQueryStringParameterReader.cs b/src/JsonApiDotNetCore/Internal/QueryStrings/PaginationQueryStringParameterReader.cs index efa7ec8892..9d9afcaa14 100644 --- a/src/JsonApiDotNetCore/Internal/QueryStrings/PaginationQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/Internal/QueryStrings/PaginationQueryStringParameterReader.cs @@ -5,21 +5,15 @@ using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Internal.Queries; -using JsonApiDotNetCore.Internal.Queries.Expressions; using JsonApiDotNetCore.Internal.Queries.Parsing; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.QueryStrings; using JsonApiDotNetCore.RequestServices.Contracts; using Microsoft.Extensions.Primitives; namespace JsonApiDotNetCore.Internal.QueryStrings { - /// - /// Reads the 'page' query string parameter and produces a set of query constraints from it. - /// - public interface IPaginationQueryStringParameterReader : IQueryStringParameterReader, IQueryConstraintProvider - { - } - public class PaginationQueryStringParameterReader : QueryStringParameterReader, IPaginationQueryStringParameterReader { private const string _pageSizeParameterName = "page[size]"; @@ -38,16 +32,19 @@ public PaginationQueryStringParameterReader(ICurrentRequest currentRequest, IRes _paginationParser = new PaginationParser(resourceContextProvider); } + /// public bool IsEnabled(DisableQueryAttribute disableQueryAttribute) { return !disableQueryAttribute.ContainsParameter(StandardQueryStringParameters.Page); } + /// public bool CanRead(string parameterName) { return parameterName == _pageSizeParameterName || parameterName == _pageNumberParameterName; } + /// public void Read(string parameterName, StringValues parameterValue) { try @@ -116,6 +113,7 @@ private void ValidatePageNumber(PaginationQueryStringValueExpression constraint) } } + /// public IReadOnlyCollection GetConstraints() { var context = new PaginationContext(); diff --git a/src/JsonApiDotNetCore/Internal/QueryStrings/QueryStringParameterReader.cs b/src/JsonApiDotNetCore/Internal/QueryStrings/QueryStringParameterReader.cs index edfdbe159e..a8814ef445 100644 --- a/src/JsonApiDotNetCore/Internal/QueryStrings/QueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/Internal/QueryStrings/QueryStringParameterReader.cs @@ -1,9 +1,9 @@ using System; using System.Linq; using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Internal.Queries.Expressions; using JsonApiDotNetCore.Internal.Queries.Parsing; using JsonApiDotNetCore.Models.Annotation; +using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.RequestServices.Contracts; namespace JsonApiDotNetCore.Internal.QueryStrings @@ -14,7 +14,6 @@ public abstract class QueryStringParameterReader private readonly bool _isCollectionRequest; protected ResourceContext RequestResource { get; } - private protected ResourceFieldChainResolver ChainResolver { get; } protected QueryStringParameterReader(ICurrentRequest currentRequest, IResourceContextProvider resourceContextProvider) { @@ -26,7 +25,6 @@ protected QueryStringParameterReader(ICurrentRequest currentRequest, IResourceCo _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); _isCollectionRequest = currentRequest.IsCollection; RequestResource = currentRequest.SecondaryResource ?? currentRequest.PrimaryResource; - ChainResolver = new ResourceFieldChainResolver(resourceContextProvider); } protected ResourceContext GetResourceContextForScope(ResourceFieldChainExpression scope) diff --git a/src/JsonApiDotNetCore/Internal/QueryStrings/QueryStringReader.cs b/src/JsonApiDotNetCore/Internal/QueryStrings/QueryStringReader.cs index 2698536acd..b167387b74 100644 --- a/src/JsonApiDotNetCore/Internal/QueryStrings/QueryStringReader.cs +++ b/src/JsonApiDotNetCore/Internal/QueryStrings/QueryStringReader.cs @@ -3,6 +3,7 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Exceptions; +using JsonApiDotNetCore.QueryStrings; using Microsoft.Extensions.Logging; namespace JsonApiDotNetCore.Internal.QueryStrings diff --git a/src/JsonApiDotNetCore/Internal/QueryStrings/RequestQueryStringAccessor.cs b/src/JsonApiDotNetCore/Internal/QueryStrings/RequestQueryStringAccessor.cs index 6ff13bb2c5..d221ab3f3f 100644 --- a/src/JsonApiDotNetCore/Internal/QueryStrings/RequestQueryStringAccessor.cs +++ b/src/JsonApiDotNetCore/Internal/QueryStrings/RequestQueryStringAccessor.cs @@ -1,7 +1,9 @@ +using JsonApiDotNetCore.QueryStrings; using Microsoft.AspNetCore.Http; namespace JsonApiDotNetCore.Internal.QueryStrings { + /// internal sealed class RequestQueryStringAccessor : IRequestQueryStringAccessor { private readonly IHttpContextAccessor _httpContextAccessor; diff --git a/src/JsonApiDotNetCore/Internal/QueryStrings/ResourceDefinitionQueryableParameterReader.cs b/src/JsonApiDotNetCore/Internal/QueryStrings/ResourceDefinitionQueryableParameterReader.cs index 354b9abe38..a50ba3a2e8 100644 --- a/src/JsonApiDotNetCore/Internal/QueryStrings/ResourceDefinitionQueryableParameterReader.cs +++ b/src/JsonApiDotNetCore/Internal/QueryStrings/ResourceDefinitionQueryableParameterReader.cs @@ -1,23 +1,16 @@ using System.Collections.Generic; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Exceptions; -using JsonApiDotNetCore.Internal.Queries; -using JsonApiDotNetCore.Internal.Queries.Expressions; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Query; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.QueryStrings; using JsonApiDotNetCore.RequestServices.Contracts; +using JsonApiDotNetCore.Services.Contract; using Microsoft.Extensions.Primitives; namespace JsonApiDotNetCore.Internal.QueryStrings { - /// - /// Reads custom query string parameters for which handlers on are registered - /// and produces a set of query constraints from it. - /// - public interface IResourceDefinitionQueryableParameterReader : IQueryStringParameterReader, IQueryConstraintProvider - { - } - + /// public class ResourceDefinitionQueryableParameterReader : IResourceDefinitionQueryableParameterReader { private readonly ICurrentRequest _currentRequest; @@ -30,17 +23,20 @@ public ResourceDefinitionQueryableParameterReader(ICurrentRequest currentRequest _resourceDefinitionProvider = resourceDefinitionProvider; } + /// public bool IsEnabled(DisableQueryAttribute disableQueryAttribute) { return true; } + /// public bool CanRead(string parameterName) { var queryableHandler = GetQueryableHandler(parameterName); return queryableHandler != null; } + /// public void Read(string parameterName, StringValues parameterValue) { var queryableHandler = GetQueryableHandler(parameterName); @@ -62,6 +58,7 @@ private object GetQueryableHandler(string parameterName) return resourceDefinition?.GetQueryableHandlerForQueryStringParameter(parameterName); } + /// public IReadOnlyCollection GetConstraints() { return _constraints.AsReadOnly(); diff --git a/src/JsonApiDotNetCore/Internal/QueryStrings/SortQueryStringParameterReader.cs b/src/JsonApiDotNetCore/Internal/QueryStrings/SortQueryStringParameterReader.cs index 19e4a23a73..62c16f5e9e 100644 --- a/src/JsonApiDotNetCore/Internal/QueryStrings/SortQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/Internal/QueryStrings/SortQueryStringParameterReader.cs @@ -2,23 +2,17 @@ using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Internal.Queries; -using JsonApiDotNetCore.Internal.Queries.Expressions; using JsonApiDotNetCore.Internal.Queries.Parsing; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Models.Annotation; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.QueryStrings; using JsonApiDotNetCore.RequestServices.Contracts; using Microsoft.Extensions.Primitives; namespace JsonApiDotNetCore.Internal.QueryStrings { - /// - /// Reads the 'sort' query string parameter and produces a set of query constraints from it. - /// - public interface ISortQueryStringParameterReader : IQueryStringParameterReader, IQueryConstraintProvider - { - } - public class SortQueryStringParameterReader : QueryStringParameterReader, ISortQueryStringParameterReader { private readonly QueryStringParameterScopeParser _scopeParser; @@ -42,17 +36,20 @@ private void ValidateSingleField(ResourceFieldAttribute field, ResourceContext r } } + /// public bool IsEnabled(DisableQueryAttribute disableQueryAttribute) { return !disableQueryAttribute.ContainsParameter(StandardQueryStringParameters.Sort); } + /// public bool CanRead(string parameterName) { var isNested = parameterName.StartsWith("sort[") && parameterName.EndsWith("]"); return parameterName == "sort" || isNested; } + /// public void Read(string parameterName, StringValues parameterValue) { _lastParameterName = parameterName; @@ -89,6 +86,7 @@ private SortExpression GetSort(string parameterValue, ResourceFieldChainExpressi return _sortParser.Parse(parameterValue, resourceContextInScope); } + /// public IReadOnlyCollection GetConstraints() { return _constraints.AsReadOnly(); diff --git a/src/JsonApiDotNetCore/Internal/QueryStrings/SparseFieldSetQueryStringParameterReader.cs b/src/JsonApiDotNetCore/Internal/QueryStrings/SparseFieldSetQueryStringParameterReader.cs index fbc6473599..91f005c93e 100644 --- a/src/JsonApiDotNetCore/Internal/QueryStrings/SparseFieldSetQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/Internal/QueryStrings/SparseFieldSetQueryStringParameterReader.cs @@ -2,23 +2,17 @@ using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Internal.Queries; -using JsonApiDotNetCore.Internal.Queries.Expressions; using JsonApiDotNetCore.Internal.Queries.Parsing; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Models.Annotation; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.QueryStrings; using JsonApiDotNetCore.RequestServices.Contracts; using Microsoft.Extensions.Primitives; namespace JsonApiDotNetCore.Internal.QueryStrings { - /// - /// Reads the 'fields' query string parameter and produces a set of query constraints from it. - /// - public interface ISparseFieldSetQueryStringParameterReader : IQueryStringParameterReader, IQueryConstraintProvider - { - } - public class SparseFieldSetQueryStringParameterReader : QueryStringParameterReader, ISparseFieldSetQueryStringParameterReader { private readonly QueryStringParameterScopeParser _scopeParser; @@ -42,17 +36,20 @@ private void ValidateSingleAttribute(AttrAttribute attribute, ResourceContext re } } + /// public bool IsEnabled(DisableQueryAttribute disableQueryAttribute) { return !disableQueryAttribute.ContainsParameter(StandardQueryStringParameters.Fields); } + /// public bool CanRead(string parameterName) { var isNested = parameterName.StartsWith("fields[") && parameterName.EndsWith("]"); return parameterName == "fields" || isNested; } + /// public void Read(string parameterName, StringValues parameterValue) { _lastParameterName = parameterName; @@ -84,6 +81,7 @@ private SparseFieldSetExpression GetSparseFieldSet(string parameterValue, Resour return _sparseFieldSetParser.Parse(parameterValue, resourceContextInScope); } + /// public IReadOnlyCollection GetConstraints() { return _constraints.AsReadOnly(); diff --git a/src/JsonApiDotNetCore/Middleware/QueryStringActionFilter.cs b/src/JsonApiDotNetCore/Middleware/QueryStringActionFilter.cs index ae538f7a9e..2122e8d220 100644 --- a/src/JsonApiDotNetCore/Middleware/QueryStringActionFilter.cs +++ b/src/JsonApiDotNetCore/Middleware/QueryStringActionFilter.cs @@ -1,7 +1,7 @@ using System.Reflection; using System.Threading.Tasks; using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Internal.QueryStrings; +using JsonApiDotNetCore.QueryStrings; using Microsoft.AspNetCore.Mvc.Filters; namespace JsonApiDotNetCore.Middleware diff --git a/src/JsonApiDotNetCore/Models/ResourceDefinition.cs b/src/JsonApiDotNetCore/Models/ResourceDefinition.cs index 0b8f5982e6..086905e16f 100644 --- a/src/JsonApiDotNetCore/Models/ResourceDefinition.cs +++ b/src/JsonApiDotNetCore/Models/ResourceDefinition.cs @@ -5,7 +5,7 @@ using System.ComponentModel; using System.Linq; using System.Linq.Expressions; -using JsonApiDotNetCore.Internal.Queries.Expressions; +using JsonApiDotNetCore.Queries.Expressions; using Microsoft.Extensions.Primitives; namespace JsonApiDotNetCore.Models @@ -154,7 +154,7 @@ public virtual PaginationExpression OnApplyPagination(PaginationExpression exist /// /// Enables to extend, replace or remove a sparse fieldset that is being applied on a set of this resource type. - /// Tip: Use and + /// Tip: Use and /// to safely change the fieldset without worrying about nulls. /// /// diff --git a/src/JsonApiDotNetCore/Internal/Queries/ExpressionInScope.cs b/src/JsonApiDotNetCore/Queries/ExpressionInScope.cs similarity index 62% rename from src/JsonApiDotNetCore/Internal/Queries/ExpressionInScope.cs rename to src/JsonApiDotNetCore/Queries/ExpressionInScope.cs index c4197c6c2b..32c55aebf4 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/ExpressionInScope.cs +++ b/src/JsonApiDotNetCore/Queries/ExpressionInScope.cs @@ -1,8 +1,12 @@ using System; -using JsonApiDotNetCore.Internal.Queries.Expressions; +using JsonApiDotNetCore.Internal.Contracts; +using JsonApiDotNetCore.Queries.Expressions; -namespace JsonApiDotNetCore.Internal.Queries +namespace JsonApiDotNetCore.Queries { + /// + /// Represents an expression coming from query string. The scope determines at which depth in the to apply its expression. + /// public class ExpressionInScope { public ResourceFieldChainExpression Scope { get; } diff --git a/src/JsonApiDotNetCore/Internal/Queries/Expressions/CollectionNotEmptyExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/CollectionNotEmptyExpression.cs similarity index 81% rename from src/JsonApiDotNetCore/Internal/Queries/Expressions/CollectionNotEmptyExpression.cs rename to src/JsonApiDotNetCore/Queries/Expressions/CollectionNotEmptyExpression.cs index 904bb41113..f8ce03258e 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/Expressions/CollectionNotEmptyExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/CollectionNotEmptyExpression.cs @@ -1,8 +1,11 @@ using System; using JsonApiDotNetCore.Internal.Queries.Parsing; -namespace JsonApiDotNetCore.Internal.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions { + /// + /// Represents the "has" filter function, resulting from text such as: has(articles) + /// public class CollectionNotEmptyExpression : FilterExpression { public ResourceFieldChainExpression TargetCollection { get; } diff --git a/src/JsonApiDotNetCore/Internal/Queries/Expressions/ComparisonExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/ComparisonExpression.cs similarity index 83% rename from src/JsonApiDotNetCore/Internal/Queries/Expressions/ComparisonExpression.cs rename to src/JsonApiDotNetCore/Queries/Expressions/ComparisonExpression.cs index 704359bbfe..c017c38afe 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/Expressions/ComparisonExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/ComparisonExpression.cs @@ -1,8 +1,11 @@ using System; using Humanizer; -namespace JsonApiDotNetCore.Internal.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions { + /// + /// Represents a comparison filter function, resulting from text such as: equals(name,'Joe') + /// public class ComparisonExpression : FilterExpression { public ComparisonOperator Operator { get; } diff --git a/src/JsonApiDotNetCore/Internal/Queries/Expressions/ComparisonOperator.cs b/src/JsonApiDotNetCore/Queries/Expressions/ComparisonOperator.cs similarity index 71% rename from src/JsonApiDotNetCore/Internal/Queries/Expressions/ComparisonOperator.cs rename to src/JsonApiDotNetCore/Queries/Expressions/ComparisonOperator.cs index eaa627c5d5..fbe12f1b0c 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/Expressions/ComparisonOperator.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/ComparisonOperator.cs @@ -1,4 +1,4 @@ -namespace JsonApiDotNetCore.Internal.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions { public enum ComparisonOperator { diff --git a/src/JsonApiDotNetCore/Internal/Queries/Expressions/CountExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/CountExpression.cs similarity index 81% rename from src/JsonApiDotNetCore/Internal/Queries/Expressions/CountExpression.cs rename to src/JsonApiDotNetCore/Queries/Expressions/CountExpression.cs index ad224105b9..a48b3146e9 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/Expressions/CountExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/CountExpression.cs @@ -1,8 +1,11 @@ using System; using JsonApiDotNetCore.Internal.Queries.Parsing; -namespace JsonApiDotNetCore.Internal.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions { + /// + /// Represents the "count" function, resulting from text such as: count(articles) + /// public class CountExpression : FunctionExpression { public ResourceFieldChainExpression TargetCollection { get; } diff --git a/src/JsonApiDotNetCore/Internal/Queries/Expressions/EqualsAnyOfExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/EqualsAnyOfExpression.cs similarity index 88% rename from src/JsonApiDotNetCore/Internal/Queries/Expressions/EqualsAnyOfExpression.cs rename to src/JsonApiDotNetCore/Queries/Expressions/EqualsAnyOfExpression.cs index 7e3c44d968..47e839742f 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/Expressions/EqualsAnyOfExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/EqualsAnyOfExpression.cs @@ -4,8 +4,11 @@ using System.Text; using JsonApiDotNetCore.Internal.Queries.Parsing; -namespace JsonApiDotNetCore.Internal.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions { + /// + /// Represents the "any" filter function, resulting from text such as: any(name,'Jack','Joe') + /// public class EqualsAnyOfExpression : FilterExpression { public ResourceFieldChainExpression TargetAttribute { get; } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/FilterExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/FilterExpression.cs new file mode 100644 index 0000000000..bdae74f21d --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Expressions/FilterExpression.cs @@ -0,0 +1,9 @@ +namespace JsonApiDotNetCore.Queries.Expressions +{ + /// + /// Represents the base type for filter functions. + /// + public abstract class FilterExpression : FunctionExpression + { + } +} diff --git a/src/JsonApiDotNetCore/Queries/Expressions/FunctionExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/FunctionExpression.cs new file mode 100644 index 0000000000..4af5f1f4eb --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Expressions/FunctionExpression.cs @@ -0,0 +1,9 @@ +namespace JsonApiDotNetCore.Queries.Expressions +{ + /// + /// Represents the base type for functions. + /// + public abstract class FunctionExpression : QueryExpression + { + } +} diff --git a/src/JsonApiDotNetCore/Queries/Expressions/IdentifierExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/IdentifierExpression.cs new file mode 100644 index 0000000000..974c4892de --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Expressions/IdentifierExpression.cs @@ -0,0 +1,9 @@ +namespace JsonApiDotNetCore.Queries.Expressions +{ + /// + /// Represents the base type for an identifier, such as a field/relationship name, a constant between quotes or null. + /// + public abstract class IdentifierExpression : QueryExpression + { + } +} diff --git a/src/JsonApiDotNetCore/Internal/Queries/Expressions/IncludeChainConverter.cs b/src/JsonApiDotNetCore/Queries/Expressions/IncludeChainConverter.cs similarity index 94% rename from src/JsonApiDotNetCore/Internal/Queries/Expressions/IncludeChainConverter.cs rename to src/JsonApiDotNetCore/Queries/Expressions/IncludeChainConverter.cs index 05dd0bf4ba..80eed65603 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/Expressions/IncludeChainConverter.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/IncludeChainConverter.cs @@ -3,9 +3,13 @@ using System.Linq; using JsonApiDotNetCore.Models.Annotation; -namespace JsonApiDotNetCore.Internal.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions { - public static class IncludeChainConverter + /// + /// Converts includes between tree and chain formats. + /// Exists for backwards compatibility, subject to be removed in the future. + /// + internal static class IncludeChainConverter { /// /// Converts a tree of inclusions into a set of relationship chains. diff --git a/src/JsonApiDotNetCore/Internal/Queries/Expressions/IncludeElementExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/IncludeElementExpression.cs similarity index 90% rename from src/JsonApiDotNetCore/Internal/Queries/Expressions/IncludeElementExpression.cs rename to src/JsonApiDotNetCore/Queries/Expressions/IncludeElementExpression.cs index a04e16befd..48ee45e203 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/Expressions/IncludeElementExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/IncludeElementExpression.cs @@ -4,8 +4,11 @@ using System.Text; using JsonApiDotNetCore.Models.Annotation; -namespace JsonApiDotNetCore.Internal.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions { + /// + /// Represents an element in . + /// public class IncludeElementExpression : QueryExpression { public RelationshipAttribute Relationship { get; } diff --git a/src/JsonApiDotNetCore/Internal/Queries/Expressions/IncludeExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/IncludeExpression.cs similarity index 87% rename from src/JsonApiDotNetCore/Internal/Queries/Expressions/IncludeExpression.cs rename to src/JsonApiDotNetCore/Queries/Expressions/IncludeExpression.cs index f252cc53c4..8f2a879952 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/Expressions/IncludeExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/IncludeExpression.cs @@ -2,8 +2,11 @@ using System.Collections.Generic; using System.Linq; -namespace JsonApiDotNetCore.Internal.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions { + /// + /// Represents an inclusion tree, resulting from text such as: owner,articles.revisions + /// public class IncludeExpression : QueryExpression { public IReadOnlyCollection Elements { get; } diff --git a/src/JsonApiDotNetCore/Internal/Queries/Expressions/LiteralConstantExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/LiteralConstantExpression.cs similarity index 76% rename from src/JsonApiDotNetCore/Internal/Queries/Expressions/LiteralConstantExpression.cs rename to src/JsonApiDotNetCore/Queries/Expressions/LiteralConstantExpression.cs index 3568e0057e..c625f507f2 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/Expressions/LiteralConstantExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/LiteralConstantExpression.cs @@ -1,7 +1,10 @@ -using System; +using System; -namespace JsonApiDotNetCore.Internal.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions { + /// + /// Represents a non-null constant value, resulting from text such as: equals(firstName,'Jack') + /// public class LiteralConstantExpression : IdentifierExpression { public string Value { get; } diff --git a/src/JsonApiDotNetCore/Internal/Queries/Expressions/LogicalExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/LogicalExpression.cs similarity index 86% rename from src/JsonApiDotNetCore/Internal/Queries/Expressions/LogicalExpression.cs rename to src/JsonApiDotNetCore/Queries/Expressions/LogicalExpression.cs index f91e73d293..c97855dc8a 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/Expressions/LogicalExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/LogicalExpression.cs @@ -1,11 +1,14 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Text; using Humanizer; -namespace JsonApiDotNetCore.Internal.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions { + /// + /// Represents a logical filter function, resulting from text such as: and(equals(title,'Work'),has(articles)) + /// public class LogicalExpression : FilterExpression { public LogicalOperator Operator { get; } diff --git a/src/JsonApiDotNetCore/Internal/Queries/Expressions/LogicalOperator.cs b/src/JsonApiDotNetCore/Queries/Expressions/LogicalOperator.cs similarity index 54% rename from src/JsonApiDotNetCore/Internal/Queries/Expressions/LogicalOperator.cs rename to src/JsonApiDotNetCore/Queries/Expressions/LogicalOperator.cs index 314b88b7d8..3514820f86 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/Expressions/LogicalOperator.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/LogicalOperator.cs @@ -1,4 +1,4 @@ -namespace JsonApiDotNetCore.Internal.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions { public enum LogicalOperator { diff --git a/src/JsonApiDotNetCore/Internal/Queries/Expressions/MatchTextExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/MatchTextExpression.cs similarity index 87% rename from src/JsonApiDotNetCore/Internal/Queries/Expressions/MatchTextExpression.cs rename to src/JsonApiDotNetCore/Queries/Expressions/MatchTextExpression.cs index e98d2714b4..915dcc16d5 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/Expressions/MatchTextExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/MatchTextExpression.cs @@ -2,8 +2,11 @@ using System.Text; using Humanizer; -namespace JsonApiDotNetCore.Internal.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions { + /// + /// Represents a text-matching filter function, resulting from text such as: startsWith(name,'A') + /// public class MatchTextExpression : FilterExpression { public ResourceFieldChainExpression TargetAttribute { get; } diff --git a/src/JsonApiDotNetCore/Internal/Queries/Expressions/NotExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/NotExpression.cs similarity index 77% rename from src/JsonApiDotNetCore/Internal/Queries/Expressions/NotExpression.cs rename to src/JsonApiDotNetCore/Queries/Expressions/NotExpression.cs index 9bf4267225..e17fda583e 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/Expressions/NotExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/NotExpression.cs @@ -1,8 +1,11 @@ using System; using JsonApiDotNetCore.Internal.Queries.Parsing; -namespace JsonApiDotNetCore.Internal.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions { + /// + /// Represents the "not" filter function, resulting from text such as: not(equals(title,'Work')) + /// public class NotExpression : FilterExpression { public QueryExpression Child { get; } diff --git a/src/JsonApiDotNetCore/Internal/Queries/Expressions/NullConstantExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/NullConstantExpression.cs similarity index 70% rename from src/JsonApiDotNetCore/Internal/Queries/Expressions/NullConstantExpression.cs rename to src/JsonApiDotNetCore/Queries/Expressions/NullConstantExpression.cs index 244fd3479a..74fdb04835 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/Expressions/NullConstantExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/NullConstantExpression.cs @@ -1,7 +1,10 @@ using JsonApiDotNetCore.Internal.Queries.Parsing; -namespace JsonApiDotNetCore.Internal.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions { + /// + /// Represents the constant null, resulting from text such as: equals(lastName,null) + /// public class NullConstantExpression : IdentifierExpression { public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) diff --git a/src/JsonApiDotNetCore/Internal/Queries/Expressions/PaginationElementQueryStringValueExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/PaginationElementQueryStringValueExpression.cs similarity index 81% rename from src/JsonApiDotNetCore/Internal/Queries/Expressions/PaginationElementQueryStringValueExpression.cs rename to src/JsonApiDotNetCore/Queries/Expressions/PaginationElementQueryStringValueExpression.cs index 9904271289..8d08968d05 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/Expressions/PaginationElementQueryStringValueExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/PaginationElementQueryStringValueExpression.cs @@ -1,5 +1,8 @@ -namespace JsonApiDotNetCore.Internal.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions { + /// + /// Represents an element in . + /// public class PaginationElementQueryStringValueExpression : QueryExpression { public ResourceFieldChainExpression Scope { get; } diff --git a/src/JsonApiDotNetCore/Internal/Queries/Expressions/PaginationExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/PaginationExpression.cs similarity index 80% rename from src/JsonApiDotNetCore/Internal/Queries/Expressions/PaginationExpression.cs rename to src/JsonApiDotNetCore/Queries/Expressions/PaginationExpression.cs index 85c30ee674..89d171929f 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/Expressions/PaginationExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/PaginationExpression.cs @@ -1,7 +1,10 @@ using System; -namespace JsonApiDotNetCore.Internal.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions { + /// + /// Represents a pagination, produced from . + /// public class PaginationExpression : QueryExpression { public PageNumber PageNumber { get; } diff --git a/src/JsonApiDotNetCore/Internal/Queries/Expressions/PaginationQueryStringValueExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/PaginationQueryStringValueExpression.cs similarity index 85% rename from src/JsonApiDotNetCore/Internal/Queries/Expressions/PaginationQueryStringValueExpression.cs rename to src/JsonApiDotNetCore/Queries/Expressions/PaginationQueryStringValueExpression.cs index 868acaaf9d..848ad6fe12 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/Expressions/PaginationQueryStringValueExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/PaginationQueryStringValueExpression.cs @@ -2,8 +2,11 @@ using System.Collections.Generic; using System.Linq; -namespace JsonApiDotNetCore.Internal.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions { + /// + /// Represents pagination in a query string, resulting from text such as: 1,articles:2 + /// public class PaginationQueryStringValueExpression : QueryExpression { public IReadOnlyCollection Elements { get; } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpression.cs new file mode 100644 index 0000000000..6fc8467a93 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpression.cs @@ -0,0 +1,13 @@ +using System.Linq.Expressions; + +namespace JsonApiDotNetCore.Queries.Expressions +{ + /// + /// Represents the base data structure for immutable types that query string parameters are converted into. + /// This intermediate structure is later transformed into system trees that are handled by Entity Framework Core. + /// + public abstract class QueryExpression + { + public abstract TResult Accept(QueryExpressionVisitor visitor, TArgument argument); + } +} diff --git a/src/JsonApiDotNetCore/Internal/Queries/Expressions/QueryExpressionVisitor.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs similarity index 95% rename from src/JsonApiDotNetCore/Internal/Queries/Expressions/QueryExpressionVisitor.cs rename to src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs index 2c6b3472a5..0f14cb87f1 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/Expressions/QueryExpressionVisitor.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs @@ -1,5 +1,8 @@ -namespace JsonApiDotNetCore.Internal.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions { + /// + /// Implements the visitor design pattern that enables traversing a tree. + /// public class QueryExpressionVisitor { public virtual TResult Visit(QueryExpression expression, TArgument argument) diff --git a/src/JsonApiDotNetCore/Internal/Queries/Expressions/QueryStringParameterScopeExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryStringParameterScopeExpression.cs similarity index 81% rename from src/JsonApiDotNetCore/Internal/Queries/Expressions/QueryStringParameterScopeExpression.cs rename to src/JsonApiDotNetCore/Queries/Expressions/QueryStringParameterScopeExpression.cs index 8214829718..7f21d21e90 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/Expressions/QueryStringParameterScopeExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryStringParameterScopeExpression.cs @@ -1,7 +1,10 @@ using System; -namespace JsonApiDotNetCore.Internal.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions { + /// + /// Represents the scope of a query string parameter, resulting from text such as: ?filter[articles]=... + /// public class QueryStringParameterScopeExpression : QueryExpression { public LiteralConstantExpression ParameterName { get; } diff --git a/src/JsonApiDotNetCore/Internal/Queries/Expressions/QueryableHandlerExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryableHandlerExpression.cs similarity index 83% rename from src/JsonApiDotNetCore/Internal/Queries/Expressions/QueryableHandlerExpression.cs rename to src/JsonApiDotNetCore/Queries/Expressions/QueryableHandlerExpression.cs index 73a3e7dc9d..e7e33ab46a 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/Expressions/QueryableHandlerExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryableHandlerExpression.cs @@ -3,8 +3,11 @@ using JsonApiDotNetCore.Models; using Microsoft.Extensions.Primitives; -namespace JsonApiDotNetCore.Internal.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions { + /// + /// Holds a expression, used for custom query string handlers from s. + /// public class QueryableHandlerExpression : QueryExpression { private readonly object _queryableHandler; diff --git a/src/JsonApiDotNetCore/Internal/Queries/Expressions/ResourceFieldChainExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/ResourceFieldChainExpression.cs similarity index 90% rename from src/JsonApiDotNetCore/Internal/Queries/Expressions/ResourceFieldChainExpression.cs rename to src/JsonApiDotNetCore/Queries/Expressions/ResourceFieldChainExpression.cs index b09fbec6b3..cfea27049a 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/Expressions/ResourceFieldChainExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/ResourceFieldChainExpression.cs @@ -3,8 +3,11 @@ using System.Linq; using JsonApiDotNetCore.Models.Annotation; -namespace JsonApiDotNetCore.Internal.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions { + /// + /// Represents a chain of fields (relationships and attributes), resulting from text such as: articles.revisions.author + /// public class ResourceFieldChainExpression : IdentifierExpression, IEquatable { public IReadOnlyCollection Fields { get; } diff --git a/src/JsonApiDotNetCore/Internal/Queries/Expressions/SortElementExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/SortElementExpression.cs similarity index 90% rename from src/JsonApiDotNetCore/Internal/Queries/Expressions/SortElementExpression.cs rename to src/JsonApiDotNetCore/Queries/Expressions/SortElementExpression.cs index 6472e1e751..a7c24fca21 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/Expressions/SortElementExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SortElementExpression.cs @@ -1,8 +1,11 @@ using System; using System.Text; -namespace JsonApiDotNetCore.Internal.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions { + /// + /// Represents an element in . + /// public class SortElementExpression : QueryExpression { public ResourceFieldChainExpression TargetAttribute { get; } diff --git a/src/JsonApiDotNetCore/Internal/Queries/Expressions/SortExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/SortExpression.cs similarity index 84% rename from src/JsonApiDotNetCore/Internal/Queries/Expressions/SortExpression.cs rename to src/JsonApiDotNetCore/Queries/Expressions/SortExpression.cs index b49f4684f1..65584c1cd1 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/Expressions/SortExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SortExpression.cs @@ -2,8 +2,11 @@ using System.Collections.Generic; using System.Linq; -namespace JsonApiDotNetCore.Internal.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions { + /// + /// Represents a sorting, resulting from text such as: lastName,-lastModifiedAt + /// public class SortExpression : QueryExpression { public IReadOnlyCollection Elements { get; } diff --git a/src/JsonApiDotNetCore/Internal/Queries/Expressions/SparseFieldSetExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs similarity index 85% rename from src/JsonApiDotNetCore/Internal/Queries/Expressions/SparseFieldSetExpression.cs rename to src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs index 6f87b62ef6..7910354c58 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/Expressions/SparseFieldSetExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs @@ -3,8 +3,11 @@ using System.Linq; using JsonApiDotNetCore.Models.Annotation; -namespace JsonApiDotNetCore.Internal.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions { + /// + /// Represents a sparse fieldset, resulting from text such as: firstName,lastName + /// public class SparseFieldSetExpression : QueryExpression { public IReadOnlyCollection Attributes { get; } diff --git a/src/JsonApiDotNetCore/Models/SparseFieldSetExtensions.cs b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpressionExtensions.cs similarity index 95% rename from src/JsonApiDotNetCore/Models/SparseFieldSetExtensions.cs rename to src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpressionExtensions.cs index 26731d4ede..781d043417 100644 --- a/src/JsonApiDotNetCore/Models/SparseFieldSetExtensions.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpressionExtensions.cs @@ -2,12 +2,12 @@ using System.Linq; using System.Linq.Expressions; using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Internal.Queries.Expressions; +using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Models.Annotation; -namespace JsonApiDotNetCore.Models +namespace JsonApiDotNetCore.Queries.Expressions { - public static class SparseFieldSetExtensions + public static class SparseFieldSetExpressionExtensions { public static SparseFieldSetExpression Including(this SparseFieldSetExpression sparseFieldSet, Expression> attributeSelector, IResourceGraph resourceGraph) diff --git a/src/JsonApiDotNetCore/Internal/Queries/Expressions/TextMatchKind.cs b/src/JsonApiDotNetCore/Queries/Expressions/TextMatchKind.cs similarity index 62% rename from src/JsonApiDotNetCore/Internal/Queries/Expressions/TextMatchKind.cs rename to src/JsonApiDotNetCore/Queries/Expressions/TextMatchKind.cs index e44ba6e190..e51436b252 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/Expressions/TextMatchKind.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/TextMatchKind.cs @@ -1,4 +1,4 @@ -namespace JsonApiDotNetCore.Internal.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions { public enum TextMatchKind { diff --git a/src/JsonApiDotNetCore/Queries/IQueryConstraintProvider.cs b/src/JsonApiDotNetCore/Queries/IQueryConstraintProvider.cs new file mode 100644 index 0000000000..c9a82a7b36 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/IQueryConstraintProvider.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; + +namespace JsonApiDotNetCore.Queries +{ + /// + /// Provides constraints (such as filters, sorting, pagination, sparse fieldsets and inclusions) to be applied on a data set. + /// + public interface IQueryConstraintProvider + { + /// + /// Returns a set of scoped expressions. + /// + public IReadOnlyCollection GetConstraints(); + } +} diff --git a/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs new file mode 100644 index 0000000000..95a724d76b --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs @@ -0,0 +1,21 @@ +using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Queries.Expressions; + +namespace JsonApiDotNetCore.Queries +{ + /// + /// Takes scoped expressions from s and transforms them. + /// + public interface IQueryLayerComposer + { + /// + /// Builds a top-level filter from constraints, used to determine total resource count. + /// + FilterExpression GetTopFilter(); + + /// + /// Collects constraints and builds a out of them, used to retrieve the actual resources. + /// + QueryLayer Compose(ResourceContext requestResource); + } +} diff --git a/src/JsonApiDotNetCore/Internal/Queries/QueryLayer.cs b/src/JsonApiDotNetCore/Queries/QueryLayer.cs similarity index 93% rename from src/JsonApiDotNetCore/Internal/Queries/QueryLayer.cs rename to src/JsonApiDotNetCore/Queries/QueryLayer.cs index a2a0220dfc..72a2ae5ae1 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/QueryLayer.cs +++ b/src/JsonApiDotNetCore/Queries/QueryLayer.cs @@ -2,11 +2,15 @@ using System.Collections.Generic; using System.Linq; using System.Text; -using JsonApiDotNetCore.Internal.Queries.Expressions; +using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models.Annotation; +using JsonApiDotNetCore.Queries.Expressions; -namespace JsonApiDotNetCore.Internal.Queries +namespace JsonApiDotNetCore.Queries { + /// + /// A nested data structure that contains constraints per resource type. + /// public sealed class QueryLayer { public ResourceContext ResourceContext { get; } diff --git a/src/JsonApiDotNetCore/QueryStrings/IDefaultsQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/IDefaultsQueryStringParameterReader.cs new file mode 100644 index 0000000000..4ac078d38d --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/IDefaultsQueryStringParameterReader.cs @@ -0,0 +1,15 @@ +using Newtonsoft.Json; + +namespace JsonApiDotNetCore.QueryStrings +{ + /// + /// Reads the 'defaults' query string parameter. + /// + public interface IDefaultsQueryStringParameterReader : IQueryStringParameterReader + { + /// + /// Contains the effective value of default configuration and query string override, after parsing has occured. + /// + DefaultValueHandling SerializerDefaultValueHandling { get; } + } +} diff --git a/src/JsonApiDotNetCore/QueryStrings/IFilterQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/IFilterQueryStringParameterReader.cs new file mode 100644 index 0000000000..a9666d946c --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/IFilterQueryStringParameterReader.cs @@ -0,0 +1,11 @@ +using JsonApiDotNetCore.Queries; + +namespace JsonApiDotNetCore.QueryStrings +{ + /// + /// Reads the 'filter' query string parameter and produces a set of query constraints from it. + /// + public interface IFilterQueryStringParameterReader : IQueryStringParameterReader, IQueryConstraintProvider + { + } +} diff --git a/src/JsonApiDotNetCore/QueryStrings/IIncludeQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/IIncludeQueryStringParameterReader.cs new file mode 100644 index 0000000000..b03feed61c --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/IIncludeQueryStringParameterReader.cs @@ -0,0 +1,11 @@ +using JsonApiDotNetCore.Queries; + +namespace JsonApiDotNetCore.QueryStrings +{ + /// + /// Reads the 'include' query string parameter and produces a set of query constraints from it. + /// + public interface IIncludeQueryStringParameterReader : IQueryStringParameterReader, IQueryConstraintProvider + { + } +} diff --git a/src/JsonApiDotNetCore/QueryStrings/INullsQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/INullsQueryStringParameterReader.cs new file mode 100644 index 0000000000..d4a403bd7e --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/INullsQueryStringParameterReader.cs @@ -0,0 +1,15 @@ +using Newtonsoft.Json; + +namespace JsonApiDotNetCore.QueryStrings +{ + /// + /// Reads the 'nulls' query string parameter. + /// + public interface INullsQueryStringParameterReader : IQueryStringParameterReader + { + /// + /// Contains the effective value of default configuration and query string override, after parsing has occured. + /// + NullValueHandling SerializerNullValueHandling { get; } + } +} diff --git a/src/JsonApiDotNetCore/QueryStrings/IPaginationQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/IPaginationQueryStringParameterReader.cs new file mode 100644 index 0000000000..7d60dbec17 --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/IPaginationQueryStringParameterReader.cs @@ -0,0 +1,11 @@ +using JsonApiDotNetCore.Queries; + +namespace JsonApiDotNetCore.QueryStrings +{ + /// + /// Reads the 'page' query string parameter and produces a set of query constraints from it. + /// + public interface IPaginationQueryStringParameterReader : IQueryStringParameterReader, IQueryConstraintProvider + { + } +} diff --git a/src/JsonApiDotNetCore/Internal/QueryStrings/IQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/IQueryStringParameterReader.cs similarity index 94% rename from src/JsonApiDotNetCore/Internal/QueryStrings/IQueryStringParameterReader.cs rename to src/JsonApiDotNetCore/QueryStrings/IQueryStringParameterReader.cs index f51111f795..88c7d8b03d 100644 --- a/src/JsonApiDotNetCore/Internal/QueryStrings/IQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/IQueryStringParameterReader.cs @@ -1,7 +1,7 @@ using JsonApiDotNetCore.Controllers; using Microsoft.Extensions.Primitives; -namespace JsonApiDotNetCore.Internal.QueryStrings +namespace JsonApiDotNetCore.QueryStrings { /// /// The interface to implement for processing a specific type of query string parameter. diff --git a/src/JsonApiDotNetCore/Internal/QueryStrings/IQueryStringReader.cs b/src/JsonApiDotNetCore/QueryStrings/IQueryStringReader.cs similarity index 80% rename from src/JsonApiDotNetCore/Internal/QueryStrings/IQueryStringReader.cs rename to src/JsonApiDotNetCore/QueryStrings/IQueryStringReader.cs index c1608b858d..0a9743f961 100644 --- a/src/JsonApiDotNetCore/Internal/QueryStrings/IQueryStringReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/IQueryStringReader.cs @@ -1,9 +1,9 @@ using JsonApiDotNetCore.Controllers; -namespace JsonApiDotNetCore.Internal.QueryStrings +namespace JsonApiDotNetCore.QueryStrings { /// - /// Reads and processes the various query string parameters. + /// Reads and processes the various query string parameters for a HTTP request. /// public interface IQueryStringReader { diff --git a/src/JsonApiDotNetCore/Internal/QueryStrings/IRequestQueryStringAccessor.cs b/src/JsonApiDotNetCore/QueryStrings/IRequestQueryStringAccessor.cs similarity index 54% rename from src/JsonApiDotNetCore/Internal/QueryStrings/IRequestQueryStringAccessor.cs rename to src/JsonApiDotNetCore/QueryStrings/IRequestQueryStringAccessor.cs index 46cee4b9fe..c94d5dd533 100644 --- a/src/JsonApiDotNetCore/Internal/QueryStrings/IRequestQueryStringAccessor.cs +++ b/src/JsonApiDotNetCore/QueryStrings/IRequestQueryStringAccessor.cs @@ -1,7 +1,10 @@ using Microsoft.AspNetCore.Http; -namespace JsonApiDotNetCore.Internal.QueryStrings +namespace JsonApiDotNetCore.QueryStrings { + /// + /// Provides access to the query string of a URL in a HTTP request. + /// public interface IRequestQueryStringAccessor { QueryString QueryString { get; } diff --git a/src/JsonApiDotNetCore/QueryStrings/IResourceDefinitionQueryableParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/IResourceDefinitionQueryableParameterReader.cs new file mode 100644 index 0000000000..df4ea75d61 --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/IResourceDefinitionQueryableParameterReader.cs @@ -0,0 +1,13 @@ +using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Queries; + +namespace JsonApiDotNetCore.QueryStrings +{ + /// + /// Reads custom query string parameters for which handlers on are registered + /// and produces a set of query constraints from it. + /// + public interface IResourceDefinitionQueryableParameterReader : IQueryStringParameterReader, IQueryConstraintProvider + { + } +} diff --git a/src/JsonApiDotNetCore/QueryStrings/ISortQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/ISortQueryStringParameterReader.cs new file mode 100644 index 0000000000..6b66b43839 --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/ISortQueryStringParameterReader.cs @@ -0,0 +1,11 @@ +using JsonApiDotNetCore.Queries; + +namespace JsonApiDotNetCore.QueryStrings +{ + /// + /// Reads the 'sort' query string parameter and produces a set of query constraints from it. + /// + public interface ISortQueryStringParameterReader : IQueryStringParameterReader, IQueryConstraintProvider + { + } +} diff --git a/src/JsonApiDotNetCore/QueryStrings/ISparseFieldSetQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/ISparseFieldSetQueryStringParameterReader.cs new file mode 100644 index 0000000000..7d48297e0a --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/ISparseFieldSetQueryStringParameterReader.cs @@ -0,0 +1,11 @@ +using JsonApiDotNetCore.Queries; + +namespace JsonApiDotNetCore.QueryStrings +{ + /// + /// Reads the 'fields' query string parameter and produces a set of query constraints from it. + /// + public interface ISparseFieldSetQueryStringParameterReader : IQueryStringParameterReader, IQueryConstraintProvider + { + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Server/Builders/LinkBuilder.cs b/src/JsonApiDotNetCore/Serialization/Server/Builders/LinkBuilder.cs index 33525c1a15..a69291f9f3 100644 --- a/src/JsonApiDotNetCore/Serialization/Server/Builders/LinkBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Server/Builders/LinkBuilder.cs @@ -5,10 +5,10 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Internal.QueryStrings; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Models.Annotation; using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCore.QueryStrings; using JsonApiDotNetCore.RequestServices.Contracts; using Microsoft.AspNetCore.Http; diff --git a/src/JsonApiDotNetCore/Serialization/Server/Builders/ResponseResourceObjectBuilder.cs b/src/JsonApiDotNetCore/Serialization/Server/Builders/ResponseResourceObjectBuilder.cs index cf1e5dad75..977570345f 100644 --- a/src/JsonApiDotNetCore/Serialization/Server/Builders/ResponseResourceObjectBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Server/Builders/ResponseResourceObjectBuilder.cs @@ -1,11 +1,11 @@ using System.Collections.Generic; using System.Linq; using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Internal.Queries; -using JsonApiDotNetCore.Internal.Queries.Expressions; -using JsonApiDotNetCore.Internal.QueryStrings; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Models.Annotation; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.QueryStrings; using JsonApiDotNetCore.Serialization.Server.Builders; namespace JsonApiDotNetCore.Serialization.Server diff --git a/src/JsonApiDotNetCore/Serialization/Server/FieldsToSerialize.cs b/src/JsonApiDotNetCore/Serialization/Server/FieldsToSerialize.cs index 794ae7234c..ccc3a4e4fc 100644 --- a/src/JsonApiDotNetCore/Serialization/Server/FieldsToSerialize.cs +++ b/src/JsonApiDotNetCore/Serialization/Server/FieldsToSerialize.cs @@ -2,11 +2,11 @@ using System; using System.Collections.Generic; using System.Linq; -using JsonApiDotNetCore.Internal.Queries; -using JsonApiDotNetCore.Internal.Queries.Expressions; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Models.Annotation; -using JsonApiDotNetCore.Query; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Services.Contract; namespace JsonApiDotNetCore.Serialization.Server { diff --git a/src/JsonApiDotNetCore/Serialization/Server/ResourceObjectBuilderSettingsProvider.cs b/src/JsonApiDotNetCore/Serialization/Server/ResourceObjectBuilderSettingsProvider.cs index 1022887a4d..4375c3d59b 100644 --- a/src/JsonApiDotNetCore/Serialization/Server/ResourceObjectBuilderSettingsProvider.cs +++ b/src/JsonApiDotNetCore/Serialization/Server/ResourceObjectBuilderSettingsProvider.cs @@ -1,5 +1,5 @@ using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Internal.QueryStrings; +using JsonApiDotNetCore.QueryStrings; namespace JsonApiDotNetCore.Serialization.Server { diff --git a/src/JsonApiDotNetCore/Services/Contract/IResourceDefinitionProvider.cs b/src/JsonApiDotNetCore/Services/Contract/IResourceDefinitionProvider.cs index 7f25d22470..85281714ab 100644 --- a/src/JsonApiDotNetCore/Services/Contract/IResourceDefinitionProvider.cs +++ b/src/JsonApiDotNetCore/Services/Contract/IResourceDefinitionProvider.cs @@ -1,7 +1,7 @@ -using System; +using System; using JsonApiDotNetCore.Models; -namespace JsonApiDotNetCore.Query +namespace JsonApiDotNetCore.Services.Contract { /// /// Retrieves a from the DI container. @@ -16,4 +16,4 @@ public interface IResourceDefinitionProvider /// IResourceDefinition Get(Type resourceType); } -} \ No newline at end of file +} diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 1115dd2223..106d14a770 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -9,9 +9,9 @@ using System.Linq; using System.Threading.Tasks; using JsonApiDotNetCore.Exceptions; -using JsonApiDotNetCore.Internal.Queries; -using JsonApiDotNetCore.Internal.Queries.Expressions; using JsonApiDotNetCore.Models.Annotation; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.RequestServices; using JsonApiDotNetCore.RequestServices.Contracts; diff --git a/src/JsonApiDotNetCore/Services/ResourceDefinitionProvider.cs b/src/JsonApiDotNetCore/Services/ResourceDefinitionProvider.cs index 32005a984d..eb85a9ccff 100644 --- a/src/JsonApiDotNetCore/Services/ResourceDefinitionProvider.cs +++ b/src/JsonApiDotNetCore/Services/ResourceDefinitionProvider.cs @@ -1,9 +1,9 @@ using System; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Services; +using JsonApiDotNetCore.Services.Contract; -namespace JsonApiDotNetCore.Query +namespace JsonApiDotNetCore.Services { /// internal sealed class ResourceDefinitionProvider : IResourceDefinitionProvider diff --git a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs index 2287f8b61f..6fae07413f 100644 --- a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs +++ b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs @@ -7,14 +7,14 @@ using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Internal.Generics; -using JsonApiDotNetCore.Internal.Queries; using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Query; +using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.RequestServices; using JsonApiDotNetCore.RequestServices.Contracts; using JsonApiDotNetCore.Serialization; using JsonApiDotNetCore.Serialization.Server.Builders; using JsonApiDotNetCore.Services; +using JsonApiDotNetCore.Services.Contract; using JsonApiDotNetCoreExample.Models; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; diff --git a/test/IntegrationTests/Data/EntityFrameworkCoreRepositoryTests.cs b/test/IntegrationTests/Data/EntityFrameworkCoreRepositoryTests.cs index b00ab40599..50ac2e3c2a 100644 --- a/test/IntegrationTests/Data/EntityFrameworkCoreRepositoryTests.cs +++ b/test/IntegrationTests/Data/EntityFrameworkCoreRepositoryTests.cs @@ -14,9 +14,9 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Internal.Queries; -using JsonApiDotNetCore.Internal.Queries.Expressions; using JsonApiDotNetCore.Models.Annotation; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.Extensions.Logging.Abstractions; using Xunit; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterOperatorTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterOperatorTests.cs index e484aac1db..3ca1f69ce0 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterOperatorTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterOperatorTests.cs @@ -6,9 +6,9 @@ using FluentAssertions; using Humanizer; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Internal.Queries.Expressions; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCore.Queries.Expressions; using Microsoft.Extensions.DependencyInjection; using Xunit; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableResourceDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableResourceDefinition.cs index adc7cba090..c4f8d429c7 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableResourceDefinition.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableResourceDefinition.cs @@ -5,9 +5,9 @@ using JsonApiDotNetCore; using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Internal.Queries.Expressions; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCore.Queries.Expressions; using Microsoft.Extensions.Primitives; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/ResultCapturingRepository.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/ResultCapturingRepository.cs index e87c90d4e5..b9edcc9545 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/ResultCapturingRepository.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/ResultCapturingRepository.cs @@ -4,8 +4,8 @@ using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Internal.Generics; -using JsonApiDotNetCore.Internal.Queries; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Serialization; using Microsoft.Extensions.Logging; diff --git a/test/UnitTests/Builders/LinkBuilderTests.cs b/test/UnitTests/Builders/LinkBuilderTests.cs index 3791d832bd..fdeec4eabc 100644 --- a/test/UnitTests/Builders/LinkBuilderTests.cs +++ b/test/UnitTests/Builders/LinkBuilderTests.cs @@ -2,9 +2,9 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Internal.QueryStrings; using JsonApiDotNetCore.Models.Annotation; using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCore.QueryStrings; using JsonApiDotNetCore.RequestServices.Contracts; using JsonApiDotNetCoreExample.Models; using Moq; diff --git a/test/UnitTests/QueryStringParameters/DefaultsParseTests.cs b/test/UnitTests/QueryStringParameters/DefaultsParseTests.cs index 13cf147bcc..ea34478458 100644 --- a/test/UnitTests/QueryStringParameters/DefaultsParseTests.cs +++ b/test/UnitTests/QueryStringParameters/DefaultsParseTests.cs @@ -5,6 +5,7 @@ using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal.QueryStrings; +using JsonApiDotNetCore.QueryStrings; using Newtonsoft.Json; using Xunit; diff --git a/test/UnitTests/QueryStringParameters/NullsParseTests.cs b/test/UnitTests/QueryStringParameters/NullsParseTests.cs index 4f9e9109c3..c8cfff50b8 100644 --- a/test/UnitTests/QueryStringParameters/NullsParseTests.cs +++ b/test/UnitTests/QueryStringParameters/NullsParseTests.cs @@ -5,6 +5,7 @@ using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal.QueryStrings; +using JsonApiDotNetCore.QueryStrings; using Newtonsoft.Json; using Xunit; diff --git a/test/UnitTests/QueryStringParameters/PaginationParseTests.cs b/test/UnitTests/QueryStringParameters/PaginationParseTests.cs index 76f6a29f1f..3705de7c5e 100644 --- a/test/UnitTests/QueryStringParameters/PaginationParseTests.cs +++ b/test/UnitTests/QueryStringParameters/PaginationParseTests.cs @@ -6,6 +6,7 @@ using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Exceptions; using JsonApiDotNetCore.Internal.QueryStrings; +using JsonApiDotNetCore.QueryStrings; using Xunit; namespace UnitTests.QueryStringParameters diff --git a/test/UnitTests/ResourceHooks/ResourceHookExecutor/Read/BeforeReadTests.cs b/test/UnitTests/ResourceHooks/ResourceHookExecutor/Read/BeforeReadTests.cs index b3da136f62..d2edc2797c 100644 --- a/test/UnitTests/ResourceHooks/ResourceHookExecutor/Read/BeforeReadTests.cs +++ b/test/UnitTests/ResourceHooks/ResourceHookExecutor/Read/BeforeReadTests.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; using JsonApiDotNetCore.Hooks; -using JsonApiDotNetCore.Internal.Queries; +using JsonApiDotNetCore.Queries; using JsonApiDotNetCoreExample.Models; using Moq; using Xunit; diff --git a/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs b/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs index 79cefb40dd..e45e2d2c4a 100644 --- a/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs +++ b/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs @@ -15,10 +15,10 @@ using System.Linq; using Person = JsonApiDotNetCoreExample.Models.Person; using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Internal.Queries; -using JsonApiDotNetCore.Internal.Queries.Expressions; using JsonApiDotNetCore.Serialization; using JsonApiDotNetCore.Models.Annotation; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.Extensions.Logging.Abstractions; diff --git a/test/UnitTests/Serialization/SerializerTestsSetup.cs b/test/UnitTests/Serialization/SerializerTestsSetup.cs index d183b9fbe5..028668fe9b 100644 --- a/test/UnitTests/Serialization/SerializerTestsSetup.cs +++ b/test/UnitTests/Serialization/SerializerTestsSetup.cs @@ -2,11 +2,11 @@ using System.Collections.Generic; using System.Linq; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Internal.Queries; -using JsonApiDotNetCore.Internal.Queries.Expressions; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Models.Annotation; using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Serialization; using JsonApiDotNetCore.Serialization.Server; using JsonApiDotNetCore.Serialization.Server.Builders; diff --git a/test/UnitTests/Services/DefaultResourceService_Tests.cs b/test/UnitTests/Services/DefaultResourceService_Tests.cs index f10235b6ad..2bdaf42aab 100644 --- a/test/UnitTests/Services/DefaultResourceService_Tests.cs +++ b/test/UnitTests/Services/DefaultResourceService_Tests.cs @@ -9,7 +9,7 @@ using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Internal.Queries; -using JsonApiDotNetCore.Query; +using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.RequestServices; using JsonApiDotNetCore.Serialization; using JsonApiDotNetCore.Services; From 00fb84729ac37cd22efb65ad8b52ceee348ff6e5 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 14 Aug 2020 14:32:04 +0200 Subject: [PATCH 10/13] Clarification comments in source --- src/JsonApiDotNetCore/Internal/Queries/Parsing/FilterParser.cs | 1 + .../Internal/Queries/Parsing/QueryStringParameterScopeParser.cs | 1 + 2 files changed, 2 insertions(+) diff --git a/src/JsonApiDotNetCore/Internal/Queries/Parsing/FilterParser.cs b/src/JsonApiDotNetCore/Internal/Queries/Parsing/FilterParser.cs index ef9f51713c..b0c95df48b 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/Parsing/FilterParser.cs +++ b/src/JsonApiDotNetCore/Internal/Queries/Parsing/FilterParser.cs @@ -127,6 +127,7 @@ protected ComparisonExpression ParseComparison(string operatorName) EatText(operatorName); EatSingleCharacterToken(TokenKind.OpenParen); + // Allow equality comparison of a HasOne relationship with null. var leftChainRequirements = comparisonOperator == ComparisonOperator.Equals ? FieldChainRequirements.EndsInAttribute | FieldChainRequirements.EndsInToOne : FieldChainRequirements.EndsInAttribute; diff --git a/src/JsonApiDotNetCore/Internal/Queries/Parsing/QueryStringParameterScopeParser.cs b/src/JsonApiDotNetCore/Internal/Queries/Parsing/QueryStringParameterScopeParser.cs index 6c684cd78a..1bd9c614eb 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/Parsing/QueryStringParameterScopeParser.cs +++ b/src/JsonApiDotNetCore/Internal/Queries/Parsing/QueryStringParameterScopeParser.cs @@ -59,6 +59,7 @@ protected override IReadOnlyCollection OnResolveFieldCha { if (chainRequirements == FieldChainRequirements.EndsInToMany) { + // The mismatch here (ends-in-to-many being interpreted as entire-chain-must-be-to-many) is intentional. return ChainResolver.ResolveToManyChain(_resourceContextInScope, path, _validateSingleFieldCallback); } From bcbf04af4e493a4e00c60d01244010d7f40bf18d Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 14 Aug 2020 14:32:16 +0200 Subject: [PATCH 11/13] Added overview documentation --- docs/docfx.json | 1 + docs/internals/index.md | 3 + docs/internals/queries.md | 125 ++++++++++++++++++++++++++++++++++++++ docs/internals/toc.md | 1 + docs/toc.yml | 6 +- 5 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 docs/internals/index.md create mode 100644 docs/internals/queries.md create mode 100644 docs/internals/toc.md diff --git a/docs/docfx.json b/docs/docfx.json index ccc9762111..68351f5471 100644 --- a/docs/docfx.json +++ b/docs/docfx.json @@ -25,6 +25,7 @@ "getting-started/**/toc.yml", "usage/**.md", "request-examples/**.md", + "internals/**.md", "toc.yml", "*.md" ] diff --git a/docs/internals/index.md b/docs/internals/index.md new file mode 100644 index 0000000000..7e27842923 --- /dev/null +++ b/docs/internals/index.md @@ -0,0 +1,3 @@ +# Internals + +The section contains overviews for the inner workings of the JsonApiDotNetCore library. diff --git a/docs/internals/queries.md b/docs/internals/queries.md new file mode 100644 index 0000000000..dc3d575217 --- /dev/null +++ b/docs/internals/queries.md @@ -0,0 +1,125 @@ +# Processing queries + +_since v4.0_ + +The query pipeline roughly looks like this: + +``` +HTTP --[ASP.NET Core]--> QueryString --[JADNC:QueryStringParameterReader]--> QueryExpression[] --[JADNC:ResourceService]--> QueryLayer --[JADNC:Repository]--> IQueryable --[EF Core]--> SQL +``` + +Processing a request involves the following steps: +- `JsonApiMiddleware` collects resource info from routing data for the current request. +- `JsonApiReader` transforms json request body into objects. +- `JsonApiController` accepts get/post/patch/delete verb and delegates to service. +- `IQueryStringParameterReader`s delegate to `QueryParser`s that transform query string text into `QueryExpression` objects. + - By using prefix notation in filters, we don't need users to remember operator precedence and associativity rules. + - These validated expressions contain direct references to attributes and relationships. + - The readers also implement `IQueryConstraintProvider`, which exposes expressions through `ExpressionInScope` objects. +- `QueryLayerComposer` (used from `JsonApiResourceService`) collects all query constraints. + - It combines them with default options and `ResourceDefinition` overrides and composes a tree of `QueryLayer` objects. + - It lifts the tree for nested endpoints like /blogs/1/articles and rewrites includes. + - `JsonApiResourceService` contains no more usage of `IQueryable`. +- `EntityFrameworkCoreRepository` delegates to `QueryableBuilder` to transform the `QueryLayer` tree into `IQueryable` expression trees. + `QueryBuilder` depends on `QueryClauseBuilder` implementations that visit the tree nodes, transforming them to `System.Linq.Expression` equivalents. + The `IQueryable` expression trees are executed by EF Core, which produces SQL statements out of them. +- `JsonApiWriter` transforms resource objects into json response. + +# Example +To get a sense of what this all looks like, let's look at an example query string: + +``` +/api/v1/blogs? + include=owner,articles.revisions.author& + filter=has(articles)& + sort=count(articles)& + page[number]=3& + fields=title& + filter[articles]=and(not(equals(author.firstName,null)),has(revisions))& + sort[articles]=author.lastName& + fields[articles]=url& + filter[articles.revisions]=and(greaterThan(publishTime,'2001-01-01'),startsWith(author.firstName,'J'))& + sort[articles.revisions]=-publishTime,author.lastName& + fields[articles.revisions]=publishTime +``` + +After parsing, the set of scoped expressions is transformed into the following tree by `QueryLayerComposer`: + +``` +QueryLayer +{ + Include: owner,articles.revisions + Filter: has(articles) + Sort: count(articles) + Pagination: Page number: 3, size: 5 + Projection + { + title + id + owner: QueryLayer + { + Sort: id + Pagination: Page number: 1, size: 5 + } + articles: QueryLayer
+ { + Filter: and(not(equals(author.firstName,null)),has(revisions)) + Sort: author.lastName + Pagination: Page number: 1, size: 5 + Projection + { + url + id + revisions: QueryLayer + { + Filter: and(greaterThan(publishTime,'2001-01-01'),startsWith(author.firstName,'J')) + Sort: -publishTime,author.lastName + Pagination: Page number: 1, size: 5 + Projection + { + publishTime + id + } + } + } + } + } +} +``` + +Next, the repository translates this into a LINQ query that the following C# code would represent: + +```c# +var query = dbContext.Blogs + .Include("Owner") + .Include("Articles.Revisions") + .Where(blog => blog.Articles.Any()) + .OrderBy(blog => blog.Articles.Count) + .Skip(10) + .Take(5) + .Select(blog => new Blog + { + Title = blog.Title, + Id = blog.Id, + Owner = blog.Owner, + Articles = new List
(blog.Articles + .Where(article => article.Author.FirstName != null && article.Revisions.Any()) + .OrderBy(article => article.Author.LastName) + .Take(5) + .Select(article => new Article + { + Url = article.Url, + Id = article.Id, + Revisions = new HashSet(article.Revisions + .Where(revision => revision.PublishTime > DateTime.Parse("2001-01-01") && revision.Author.FirstName.StartsWith("J")) + .OrderByDescending(revision => revision.PublishTime) + .ThenBy(revision => revision.Author.LastName) + .Take(5) + .Select(revision => new Revision + { + PublishTime = revision.PublishTime, + Id = revision.Id + })) + })) + }); +``` diff --git a/docs/internals/toc.md b/docs/internals/toc.md new file mode 100644 index 0000000000..0533dc5272 --- /dev/null +++ b/docs/internals/toc.md @@ -0,0 +1 @@ +# [Queries](queries.md) diff --git a/docs/toc.yml b/docs/toc.yml index 0cd11f3c9e..8ed0880347 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -14,4 +14,8 @@ # # - name: Request Examples # href: request-examples/ -# homepage: request-examples/index.md \ No newline at end of file +# homepage: request-examples/index.md + +- name: Internals + href: internals/ + homepage: internals/index.md From c25c6c8b2811c5b6d825c3c115ebdee5d9817a51 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 14 Aug 2020 16:26:06 +0200 Subject: [PATCH 12/13] Review feedback: rename method --- .../Internal/Queries/Parsing/QueryTokenizer.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/JsonApiDotNetCore/Internal/Queries/Parsing/QueryTokenizer.cs b/src/JsonApiDotNetCore/Internal/Queries/Parsing/QueryTokenizer.cs index b08f0e5971..47a302dbdb 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/Parsing/QueryTokenizer.cs +++ b/src/JsonApiDotNetCore/Internal/Queries/Parsing/QueryTokenizer.cs @@ -54,7 +54,7 @@ public IEnumerable EnumerateTokens() _isInQuotedSection = false; - Token literalToken = FlushTextBuffer(true); + Token literalToken = ProduceTokenFromTextBuffer(true); yield return literalToken; } else @@ -73,7 +73,7 @@ public IEnumerable EnumerateTokens() if (singleCharacterTokenKind != null && !IsMinusInsideText(singleCharacterTokenKind.Value)) { - Token identifierToken = FlushTextBuffer(false); + Token identifierToken = ProduceTokenFromTextBuffer(false); if (identifierToken != null) { @@ -101,7 +101,7 @@ public IEnumerable EnumerateTokens() throw new QueryParseException("' expected."); } - Token lastToken = FlushTextBuffer(false); + Token lastToken = ProduceTokenFromTextBuffer(false); if (lastToken != null) { @@ -124,7 +124,7 @@ private bool IsMinusInsideText(TokenKind kind) return SingleCharacterToTokenKinds.ContainsKey(ch) ? (TokenKind?)SingleCharacterToTokenKinds[ch] : null; } - private Token FlushTextBuffer(bool isQuotedText) + private Token ProduceTokenFromTextBuffer(bool isQuotedText) { if (isQuotedText || _textBuffer.Length > 0) { From 1a211faa7a95bd72822acb037656b095f81dacb4 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Sun, 16 Aug 2020 16:51:39 +0200 Subject: [PATCH 13/13] Added additional explaining comments as requested in review feedback. Renamed QueryParser to QueryExpressionParser, because that is the ultimate base expression type being produced. --- .../Queries/Parsing/FieldChainRequirements.cs | 20 +++++++++++++++++++ .../Internal/Queries/Parsing/FilterParser.cs | 2 +- .../Internal/Queries/Parsing/IncludeParser.cs | 2 +- .../Queries/Parsing/PaginationParser.cs | 2 +- ...ueryParser.cs => QueryExpressionParser.cs} | 10 +++++++--- .../QueryStringParameterScopeParser.cs | 2 +- .../Parsing/ResourceFieldChainResolver.cs | 3 +++ .../Internal/Queries/Parsing/SortParser.cs | 2 +- .../Queries/Parsing/SparseFieldSetParser.cs | 2 +- 9 files changed, 36 insertions(+), 9 deletions(-) rename src/JsonApiDotNetCore/Internal/Queries/Parsing/{QueryParser.cs => QueryExpressionParser.cs} (85%) diff --git a/src/JsonApiDotNetCore/Internal/Queries/Parsing/FieldChainRequirements.cs b/src/JsonApiDotNetCore/Internal/Queries/Parsing/FieldChainRequirements.cs index 5201e0b3fb..ef7d634f52 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/Parsing/FieldChainRequirements.cs +++ b/src/JsonApiDotNetCore/Internal/Queries/Parsing/FieldChainRequirements.cs @@ -1,14 +1,34 @@ using System; +using JsonApiDotNetCore.Models.Annotation; namespace JsonApiDotNetCore.Internal.Queries.Parsing { + /// + /// Used internally when parsing subexpressions in the query string parsers to indicate requirements when resolving a chain of fields. + /// Note these may be interpreted differently or even discarded completely by the various parser implementations, + /// as they tend to better understand the characteristics of the entire expression being parsed. + /// [Flags] public enum FieldChainRequirements { + /// + /// Indicates a single , optionally preceded by a chain of s. + /// EndsInAttribute = 1, + + /// + /// Indicates a single , optionally preceded by a chain of s. + /// EndsInToOne = 2, + + /// + /// Indicates a single , optionally preceded by a chain of s. + /// EndsInToMany = 4, + /// + /// Indicates one or a chain of s. + /// IsRelationship = EndsInToOne | EndsInToMany } } diff --git a/src/JsonApiDotNetCore/Internal/Queries/Parsing/FilterParser.cs b/src/JsonApiDotNetCore/Internal/Queries/Parsing/FilterParser.cs index b0c95df48b..e7dd99f64d 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/Parsing/FilterParser.cs +++ b/src/JsonApiDotNetCore/Internal/Queries/Parsing/FilterParser.cs @@ -10,7 +10,7 @@ namespace JsonApiDotNetCore.Internal.Queries.Parsing { - public class FilterParser : QueryParser + public class FilterParser : QueryExpressionParser { private readonly IResourceFactory _resourceFactory; private readonly Action _validateSingleFieldCallback; diff --git a/src/JsonApiDotNetCore/Internal/Queries/Parsing/IncludeParser.cs b/src/JsonApiDotNetCore/Internal/Queries/Parsing/IncludeParser.cs index abda6a3cbd..7896531a99 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/Parsing/IncludeParser.cs +++ b/src/JsonApiDotNetCore/Internal/Queries/Parsing/IncludeParser.cs @@ -7,7 +7,7 @@ namespace JsonApiDotNetCore.Internal.Queries.Parsing { - public class IncludeParser : QueryParser + public class IncludeParser : QueryExpressionParser { private readonly Action _validateSingleRelationshipCallback; private ResourceContext _resourceContextInScope; diff --git a/src/JsonApiDotNetCore/Internal/Queries/Parsing/PaginationParser.cs b/src/JsonApiDotNetCore/Internal/Queries/Parsing/PaginationParser.cs index c62a18ba92..7010b2962e 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/Parsing/PaginationParser.cs +++ b/src/JsonApiDotNetCore/Internal/Queries/Parsing/PaginationParser.cs @@ -7,7 +7,7 @@ namespace JsonApiDotNetCore.Internal.Queries.Parsing { - public class PaginationParser : QueryParser + public class PaginationParser : QueryExpressionParser { private readonly Action _validateSingleFieldCallback; private ResourceContext _resourceContextInScope; diff --git a/src/JsonApiDotNetCore/Internal/Queries/Parsing/QueryParser.cs b/src/JsonApiDotNetCore/Internal/Queries/Parsing/QueryExpressionParser.cs similarity index 85% rename from src/JsonApiDotNetCore/Internal/Queries/Parsing/QueryParser.cs rename to src/JsonApiDotNetCore/Internal/Queries/Parsing/QueryExpressionParser.cs index e1dc777a10..21e0e65508 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/Parsing/QueryParser.cs +++ b/src/JsonApiDotNetCore/Internal/Queries/Parsing/QueryExpressionParser.cs @@ -7,15 +7,19 @@ namespace JsonApiDotNetCore.Internal.Queries.Parsing { /// - /// Base class for parsing query string parameters. + /// The base class for parsing query string parameters, using the Recursive Descent algorithm. /// - public abstract class QueryParser + /// + /// Uses a tokenizer to populate a stack of tokens, which is then manipulated from the various parsing routines for subexpressions. + /// Implementations should throw on invalid input. + /// + public abstract class QueryExpressionParser { private protected ResourceFieldChainResolver ChainResolver { get; } protected Stack TokenStack { get; private set; } - protected QueryParser(IResourceContextProvider resourceContextProvider) + protected QueryExpressionParser(IResourceContextProvider resourceContextProvider) { ChainResolver = new ResourceFieldChainResolver(resourceContextProvider); } diff --git a/src/JsonApiDotNetCore/Internal/Queries/Parsing/QueryStringParameterScopeParser.cs b/src/JsonApiDotNetCore/Internal/Queries/Parsing/QueryStringParameterScopeParser.cs index 1bd9c614eb..21be71efb3 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/Parsing/QueryStringParameterScopeParser.cs +++ b/src/JsonApiDotNetCore/Internal/Queries/Parsing/QueryStringParameterScopeParser.cs @@ -6,7 +6,7 @@ namespace JsonApiDotNetCore.Internal.Queries.Parsing { - public class QueryStringParameterScopeParser : QueryParser + public class QueryStringParameterScopeParser : QueryExpressionParser { private readonly FieldChainRequirements _chainRequirements; private readonly Action _validateSingleFieldCallback; diff --git a/src/JsonApiDotNetCore/Internal/Queries/Parsing/ResourceFieldChainResolver.cs b/src/JsonApiDotNetCore/Internal/Queries/Parsing/ResourceFieldChainResolver.cs index 4d66c8bf5f..1f0860c4e7 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/Parsing/ResourceFieldChainResolver.cs +++ b/src/JsonApiDotNetCore/Internal/Queries/Parsing/ResourceFieldChainResolver.cs @@ -6,6 +6,9 @@ namespace JsonApiDotNetCore.Internal.Queries.Parsing { + /// + /// Provides helper methods to resolve a chain of fields (relationships and attributes) from the resource graph. + /// internal sealed class ResourceFieldChainResolver { private readonly IResourceContextProvider _resourceContextProvider; diff --git a/src/JsonApiDotNetCore/Internal/Queries/Parsing/SortParser.cs b/src/JsonApiDotNetCore/Internal/Queries/Parsing/SortParser.cs index b2c4122c76..d5d9751963 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/Parsing/SortParser.cs +++ b/src/JsonApiDotNetCore/Internal/Queries/Parsing/SortParser.cs @@ -7,7 +7,7 @@ namespace JsonApiDotNetCore.Internal.Queries.Parsing { - public class SortParser : QueryParser + public class SortParser : QueryExpressionParser { private readonly Action _validateSingleFieldCallback; private ResourceContext _resourceContextInScope; diff --git a/src/JsonApiDotNetCore/Internal/Queries/Parsing/SparseFieldSetParser.cs b/src/JsonApiDotNetCore/Internal/Queries/Parsing/SparseFieldSetParser.cs index 791e75895a..562359e412 100644 --- a/src/JsonApiDotNetCore/Internal/Queries/Parsing/SparseFieldSetParser.cs +++ b/src/JsonApiDotNetCore/Internal/Queries/Parsing/SparseFieldSetParser.cs @@ -7,7 +7,7 @@ namespace JsonApiDotNetCore.Internal.Queries.Parsing { - public class SparseFieldSetParser : QueryParser + public class SparseFieldSetParser : QueryExpressionParser { private readonly Action _validateSingleAttributeCallback; private ResourceContext _resourceContextInScope;