From 27f31be7af7a42109230b92c71c29dcef0cde2c4 Mon Sep 17 00:00:00 2001 From: maurei Date: Thu, 10 Sep 2020 13:01:39 +0200 Subject: [PATCH 1/5] chore: review --- .gitignore | 6 + Build.ps1 | 13 +- Directory.Build.props | 5 +- README.md | 16 +- benchmarks/BenchmarkResource.cs | 5 +- benchmarks/DependencyFactory.cs | 7 +- ...nkBuilderGetNamespaceFromPathBenchmarks.cs | 24 +- benchmarks/Query/QueryParserBenchmarks.cs | 82 +- .../JsonApiDeserializerBenchmarks.cs | 10 +- .../JsonApiSerializerBenchmarks.cs | 26 +- docs/api/index.md | 6 +- docs/docfx.json | 3 +- docs/getting-started/step-by-step.md | 4 +- docs/index.md | 4 +- docs/internals/index.md | 3 + docs/internals/queries.md | 125 +++ docs/internals/toc.md | 1 + docs/toc.yml | 6 +- docs/usage/extensibility/controllers.md | 20 +- .../extensibility/custom-query-formats.md | 15 +- docs/usage/extensibility/layer-overview.md | 8 +- docs/usage/extensibility/repositories.md | 25 +- docs/usage/extensibility/services.md | 54 +- docs/usage/filtering.md | 150 ++-- docs/usage/including-relationships.md | 4 +- docs/usage/meta.md | 2 +- docs/usage/options.md | 42 +- 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 | 162 +++- docs/usage/routing.md | 6 +- 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 | 4 +- .../Controllers/PeopleController.cs | 4 +- src/Examples/GettingStarted/Models/Article.cs | 3 +- src/Examples/GettingStarted/Models/Person.cs | 3 +- src/Examples/GettingStarted/Startup.cs | 2 +- .../Controllers/ArticlesController.cs | 5 +- ...delsController.cs => AuthorsController.cs} | 10 +- .../Controllers/BlogsController.cs | 18 + .../Controllers/CountriesController.cs | 21 + .../Controllers/KebabCasedModelsController.cs | 4 +- .../Controllers/PassportsController.cs | 12 +- .../Controllers/PeopleController.cs | 4 +- .../Controllers/PersonRolesController.cs | 4 +- .../Restricted/ReadOnlyController.cs | 17 +- .../Controllers/TagsController.cs | 7 +- .../ThrowingResourcesController.cs | 4 +- .../Controllers/TodoCollectionsController.cs | 14 +- .../Controllers/TodoItemsController.cs | 4 +- .../Controllers/TodoItemsCustomController.cs | 39 +- .../Controllers/TodoItemsTestController.cs | 29 +- .../Controllers/UsersController.cs | 8 +- .../Controllers/VisasController.cs | 18 + .../Data/AppDbContext.cs | 7 + .../Definitions/ArticleDefinition.cs | 16 +- .../Definitions/LockableDefinition.cs | 12 +- .../Definitions/ModelDefinition.cs | 19 - .../Definitions/PassportDefinition.cs | 16 +- .../Definitions/PersonDefinition.cs | 16 +- .../Definitions/TagDefinition.cs | 12 +- .../Definitions/TodoDefinition.cs | 8 +- .../Definitions/UserDefinition.cs | 37 - .../Models/Address.cs | 17 + .../Models/Article.cs | 16 +- .../Models/ArticleTag.cs | 10 +- .../JsonApiDotNetCoreExample/Models/Author.cs | 20 +- .../JsonApiDotNetCoreExample/Models/Blog.cs | 21 + .../Models/Country.cs | 7 +- .../Models/KebabCasedModel.cs | 3 +- .../JsonApiDotNetCoreExample/Models/Model.cs | 10 - .../Models/Passport.cs | 13 +- .../JsonApiDotNetCoreExample/Models/Person.cs | 10 +- .../Models/Revision.cs | 18 + .../JsonApiDotNetCoreExample/Models/Tag.cs | 18 +- .../Models/ThrowingResource.cs | 5 +- .../Models/TodoItem.cs | 7 +- .../Models/TodoItemCollection.cs | 3 +- .../JsonApiDotNetCoreExample/Models/User.cs | 7 +- .../JsonApiDotNetCoreExample/Models/Visa.cs | 11 +- .../JsonApiDotNetCoreExample/Program.cs | 14 +- .../Services/CustomArticleService.cs | 32 +- ...=> SkipCacheQueryStringParameterReader.cs} | 16 +- .../Startups/EmptyStartup.cs | 26 + .../Startups/KebabCaseStartup.cs | 24 - .../Startups/MetaStartup.cs | 32 - .../Startups/NoDefaultPageSizeStartup.cs | 23 - .../Startups/NoNamespaceStartup.cs | 23 - .../Startups/Startup.cs | 65 +- .../Startups/TestStartup.cs | 15 +- .../Controllers/WorkItemsController.cs | 6 +- .../Models/WorkItem.cs | 3 +- .../Services/WorkItemService.cs | 18 +- .../NoEntityFrameworkExample/Startup.cs | 4 +- .../Controllers/ReportsController.cs | 8 +- src/Examples/ReportsExample/Models/Report.cs | 3 +- .../ReportsExample/Services/ReportService.cs | 5 +- src/Examples/ReportsExample/Startup.cs | 2 +- src/JsonApiDotNetCore/AssemblyInfo.cs | 6 - .../Builders/IResourceGraphBuilder.cs | 44 - .../ApplicationBuilderExtensions.cs | 5 +- .../Configuration/GenericServiceFactory.cs | 41 + .../Configuration/IGenericServiceFactory.cs | 31 + .../Configuration/IInverseRelationships.cs | 23 + .../Configuration/IJsonApiOptions.cs | 175 +++- .../Configuration/ILinksConfiguration.cs | 72 -- .../Configuration/IRelatedIdMapper.cs | 18 + .../IRequestScopedServiceProvider.cs | 11 + .../IResourceContextProvider.cs | 14 +- .../Configuration/IResourceGraph.cs | 65 ++ .../Configuration/IResourceGraphBuilder.cs | 42 + .../Configuration/IServiceDiscoveryFacade.cs | 20 + .../IdentifiableTypeCache.cs | 14 +- .../Configuration/InverseRelationships.cs | 45 + .../JsonApiApplicationBuilder.cs | 149 ++-- .../Configuration/JsonApiOptions.cs | 159 ++-- .../Configuration/PageNumber.cs | 51 ++ .../Configuration/PageSize.cs | 49 + .../Configuration/RelatedIdMapper.cs | 9 + .../RequestScopedServiceProvider.cs} | 23 +- .../Configuration/ResourceContext.cs | 90 ++ .../ResourceDescriptor.cs | 14 +- .../Configuration/ResourceGraph.cs | 178 ++++ .../ResourceGraphBuilder.cs | 105 ++- .../Configuration/ResourceNameFormatter.cs | 28 + .../ServiceCollectionExtensions.cs | 44 +- .../ServiceDiscoveryFacade.cs | 42 +- .../{Graph => Configuration}/TypeLocator.cs | 49 +- .../DisableQueryStringAttribute.cs | 63 ++ .../DisableRoutingConventionAttribute.cs | 15 + .../Annotations/HttpReadOnlyAttribute.cs | 16 + .../Annotations/HttpRestrictAttribute.cs | 36 + .../Annotations/NoHttpDeleteAttribute.cs | 16 + .../Annotations/NoHttpPatchAttribute.cs | 16 + .../Annotations/NoHttpPostAttribute.cs | 16 + .../Controllers/BaseJsonApiController.cs | 228 +++-- ...ollerMixin.cs => CoreJsonApiController.cs} | 14 +- .../Controllers/DisableQueryAttribute.cs | 47 - .../DisableRoutingConventionAttribute.cs | 8 - .../HttpMethodRestrictionFilter.cs | 52 -- .../Controllers/JsonApiCommandController.cs | 56 +- .../Controllers/JsonApiController.cs | 98 +- .../Controllers/JsonApiQueryController.cs | 52 +- .../Data/DefaultResourceRepository.cs | 489 ---------- .../Data/IDbContextResolver.cs | 9 - .../Data/IResourceReadRepository.cs | 64 -- .../Data/IResourceRepository.cs | 15 - .../Data/IResourceWriteRepository.cs | 25 - .../Errors/InvalidConfigurationException.cs | 13 + .../InvalidModelStateException.cs | 21 +- .../Errors/InvalidQueryException.cs | 22 + .../InvalidQueryStringParameterException.cs | 9 +- .../InvalidRequestBodyException.cs | 4 +- .../JsonApiException.cs | 14 +- .../RelationshipNotFoundException.cs | 4 +- .../RequestMethodNotAllowedException.cs | 4 +- ...ourceIdInPostRequestNotAllowedException.cs | 6 +- .../ResourceIdMismatchException.cs | 10 +- .../ResourceNotFoundException.cs | 6 +- .../ResourceTypeMismatchException.cs | 8 +- .../UnsuccessfulActionResultException.cs | 7 +- .../Extensions/DbContextExtensions.cs | 108 --- .../Extensions/EnumerableExtensions.cs | 18 - .../Extensions/HttpContextExtensions.cs | 18 - .../Extensions/QueryableExtensions.cs | 448 ---------- .../Extensions/SystemCollectionExtensions.cs | 31 - .../Extensions/TypeExtensions.cs | 100 --- .../Graph/IServiceDiscoveryFacade.cs | 10 - .../Graph/ResourceIdMapper.cs | 27 - .../Graph/ResourceNameFormatter.cs | 41 - .../Hooks/Execution/DiffableEntityHashSet.cs | 122 --- .../Hooks/IResourceHookContainer.cs | 238 ----- .../Hooks/IResourceHookExecutor.cs | 171 ---- .../Discovery/HooksDiscovery.cs | 11 +- .../Discovery/IHooksDiscovery.cs | 11 +- .../Discovery/LoadDatabaseValuesAttribute.cs | 3 +- .../Execution/DiffableResourceHashSet.cs | 81 ++ .../Execution/HookExecutorHelper.cs | 128 +-- .../Execution/IByAffectedRelationships.cs | 18 + .../Execution/IDiffableResourceHashSet.cs | 22 + .../Execution/IHookExecutorHelper.cs | 29 +- .../Execution/IRelationshipGetters.cs | 28 + .../Execution/IRelationshipsDictionary.cs | 7 + .../Internal/Execution/IResourceHashSet.cs | 13 + .../Execution/RelationshipsDictionary.cs | 52 +- .../Internal/Execution/ResourceDiffPair.cs | 26 + .../Execution/ResourceHashSet.cs} | 28 +- .../Execution/ResourceHook.cs} | 2 +- .../Execution/ResourcePipeline.cs} | 8 +- .../Hooks/Internal/ICreateHookContainer.cs | 49 + .../Hooks/Internal/ICreateHookExecutor.cs | 39 + .../Hooks/Internal/IDeleteHookContainer.cs | 43 + .../Hooks/Internal/IDeleteHookExecutor.cs | 40 + .../Hooks/Internal/IOnReturnHookContainer.cs | 28 + .../Hooks/Internal/IOnReturnHookExecutor.cs | 24 + .../Hooks/Internal/IReadHookContainer.cs | 31 + .../Hooks/Internal/IReadHookExecutor.cs | 36 + .../Hooks/Internal/IResourceHookContainer.cs | 17 + .../Hooks/Internal/IResourceHookExecutor.cs | 18 + .../Hooks/Internal/IUpdateHookContainer.cs | 103 +++ .../Hooks/Internal/IUpdateHookExecutor.cs | 48 + .../Hooks/Internal/IdentifiableComparer.cs | 37 + .../{ => Internal}/ResourceHookExecutor.cs | 270 +++--- .../{ => Internal}/Traversal/ChildNode.cs | 22 +- .../Internal/Traversal/IRelationshipGroup.cs | 11 + .../IRelationshipsFromPreviousLayer.cs | 23 + .../Traversal/IResourceNode.cs} | 14 +- .../Traversal/ITraversalHelper.cs | 16 +- .../Hooks/Internal/Traversal/NodeLayer.cs | 36 + .../Internal/Traversal/RelationshipGroup.cs | 18 + .../Traversal/RelationshipProxy.cs | 69 +- .../RelationshipsFromPreviousLayer.cs | 34 +- .../{ => Internal}/Traversal/RootNode.cs | 32 +- .../Traversal/TraversalHelper.cs | 191 ++-- .../Hooks/Traversal/RelationshipGroup.cs | 24 - .../Contracts/IResourceGraphExplorer.cs | 60 -- .../Exceptions/JsonApiSetupException.cs | 13 - .../Generics/GenericServiceFactory.cs | 56 -- .../Internal/IResourceFactory.cs | 90 -- .../Internal/IdentifiableComparer.cs | 22 - .../Internal/InverseRelationships.cs | 68 -- .../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 - .../Internal/ResourceContext.cs | 79 -- .../Internal/ResourceGraph.cs | 149 ---- .../Internal/ValidationResults.cs | 16 - .../JsonApiDotNetCore.csproj | 15 +- .../ConvertEmptyActionResultFilter.cs | 8 +- .../Middleware/DefaultTypeMatchFilter.cs | 48 - .../Middleware/EndpointKind.cs | 20 + ...xceptionHandler.cs => ExceptionHandler.cs} | 23 +- .../Middleware/HeaderConstants.cs | 2 +- .../Middleware/HttpContextExtensions.cs | 46 + .../IControllerResourceMapping.cs | 6 +- .../Middleware/IExceptionHandler.cs | 2 +- .../IJsonApiExceptionFilterProvider.cs | 10 +- .../Middleware/IJsonApiRequest.cs | 61 ++ .../IJsonApiRoutingConvention.cs | 6 +- .../IJsonApiTypeMatchFilterProvider.cs | 8 +- ...nFilter.cs => IQueryStringActionFilter.cs} | 9 +- .../Middleware/IncomingTypeMatchFilter.cs | 74 ++ ...ionFilter.cs => JsonApiExceptionFilter.cs} | 10 +- .../JsonApiExceptionFilterProvider.cs | 10 + .../JsonApiInputFormatter.cs | 12 +- .../Middleware/JsonApiMiddleware.cs | 111 +-- .../JsonApiOutputFormatter.cs | 12 +- .../Middleware/JsonApiRequest.cs | 33 + .../JsonApiRoutingConvention.cs} | 74 +- .../JsonApiTypeMatchFilterProvider.cs | 10 + .../Middleware/QueryParameterFilter.cs | 22 - .../Middleware/QueryStringActionFilter.cs | 30 + .../Middleware/TraceLogWriter.cs | 143 +++ .../Models/Annotation/AttrAttribute.cs | 131 --- .../Models/Annotation/HasManyAttribute.cs | 34 - .../Annotation/HasManyThroughAttribute.cs | 212 ----- .../Models/Annotation/HasOneAttribute.cs | 67 -- .../Models/Annotation/LinksAttribute.cs | 43 - .../Annotation/RelationshipAttribute.cs | 106 --- src/JsonApiDotNetCore/Models/IHasMeta.cs | 9 - .../Models/IResourceField.cs | 7 - .../Models/JsonApiDocuments/IIdentifiable.cs | 12 - .../Models/JsonApiDocuments/Identifiable.cs | 67 -- .../ResourceObjectComparer.cs | 18 - .../Models/JsonApiDocuments/TopLevelLinks.cs | 51 -- .../Models/ResourceAttribute.cs | 15 - .../Models/ResourceDefinition.cs | 173 ---- .../Properties/AssemblyInfo.cs | 7 +- .../Queries/ExpressionInScope.cs | 26 + .../CollectionNotEmptyExpression.cs | 28 + .../Expressions/ComparisonExpression.cs | 32 + .../Queries/Expressions/ComparisonOperator.cs | 11 + .../Queries/Expressions/CountExpression.cs | 28 + .../Expressions/EqualsAnyOfExpression.cs | 43 + .../Queries/Expressions/FilterExpression.cs | 9 + .../Queries/Expressions/FunctionExpression.cs | 9 + .../Expressions/IdentifierExpression.cs | 9 + .../Expressions/IncludeChainConverter.cs | 160 ++++ .../Expressions/IncludeElementExpression.cs | 48 + .../Queries/Expressions/IncludeExpression.cs | 42 + .../Expressions/LiteralConstantExpression.cs | 28 + .../Queries/Expressions/LogicalExpression.cs | 50 ++ .../Queries/Expressions/LogicalOperator.cs | 8 + .../Expressions/MatchTextExpression.cs | 41 + .../Queries/Expressions/NotExpression.cs | 28 + .../Expressions/NullConstantExpression.cs | 20 + ...nationElementQueryStringValueExpression.cs | 27 + .../Expressions/PaginationExpression.cs | 30 + .../PaginationQueryStringValueExpression.cs | 36 + .../Queries/Expressions/QueryExpression.cs | 13 + .../Expressions/QueryExpressionVisitor.cs | 118 +++ .../QueryStringParameterScopeExpression.cs | 29 + .../Expressions/QueryableHandlerExpression.cs | 39 + .../ResourceFieldChainExpression.cs | 71 ++ .../Expressions/SortElementExpression.cs | 53 ++ .../Queries/Expressions/SortExpression.cs | 34 + .../Expressions/SparseFieldSetExpression.cs | 35 + .../SparseFieldSetExpressionExtensions.cs | 85 ++ .../Queries/Expressions/TextMatchKind.cs | 9 + .../Queries/IPaginationContext.cs | 34 + .../Queries/IQueryConstraintProvider.cs | 15 + .../Queries/IQueryLayerComposer.cs | 35 + .../Parsing/FieldChainRequirements.cs | 34 + .../Queries/Internal/Parsing/FilterParser.cs | 322 +++++++ .../Queries/Internal/Parsing/IncludeParser.cs | 77 ++ .../Queries/Internal/Parsing/Keywords.cs | 21 + .../Internal/Parsing/PaginationParser.cs | 107 +++ .../Internal/Parsing/QueryExpressionParser.cs | 95 ++ .../Internal/Parsing/QueryParseException.cs | 11 + .../QueryStringParameterScopeParser.cs | 74 ++ .../Internal/Parsing/QueryTokenizer.cs | 139 +++ .../Parsing/ResourceFieldChainResolver.cs | 263 ++++++ .../Queries/Internal/Parsing/SortParser.cs | 90 ++ .../Internal/Parsing/SparseFieldSetParser.cs | 62 ++ .../Queries/Internal/Parsing/Token.cs | 19 + .../Queries/Internal/Parsing/TokenKind.cs | 15 + .../Queries/Internal/QueryLayerComposer.cs | 325 +++++++ .../QueryableBuilding/IncludeClauseBuilder.cs | 85 ++ .../LambdaParameterNameFactory.cs | 53 ++ .../LambdaParameterNameScope.cs | 22 + .../Internal/QueryableBuilding/LambdaScope.cs | 47 + .../QueryableBuilding/LambdaScopeFactory.cs | 28 + .../QueryableBuilding/OrderClauseBuilder.cs | 81 ++ .../QueryableBuilding/QueryClauseBuilder.cs | 119 +++ .../QueryableBuilding/QueryableBuilder.cs | 115 +++ .../QueryableBuilding/SelectClauseBuilder.cs | 236 +++++ .../SkipTakeClauseBuilder.cs | 62 ++ .../QueryableBuilding/WhereClauseBuilder.cs | 288 ++++++ .../Queries/PaginationContext.cs | 23 + src/JsonApiDotNetCore/Queries/QueryLayer.cs | 123 +++ .../Common/IQueryParameterParser.cs | 19 - .../Common/IQueryParameterService.cs | 26 - .../Common/QueryParameterParser.cs | 65 -- .../Common/QueryParameterService.cs | 79 -- .../Contracts/IFilterService.cs | 16 - .../Contracts/IIncludeService.cs | 16 - .../Contracts/IPageService.cs | 33 - .../Contracts/ISortService.cs | 16 - .../Contracts/ISparseFieldsService.cs | 22 - .../QueryParameterServices/DefaultsService.cs | 49 - .../QueryParameterServices/FilterService.cs | 140 --- .../QueryParameterServices/IncludeService.cs | 75 -- .../QueryParameterServices/NullsService.cs | 49 - .../QueryParameterServices/PageService.cs | 138 --- .../QueryParameterServices/SortService.cs | 115 --- .../SparseFieldsService.cs | 183 ---- .../IDefaultsQueryStringParameterReader.cs} | 8 +- .../IFilterQueryStringParameterReader.cs | 11 + .../IIncludeQueryStringParameterReader.cs | 11 + .../INullsQueryStringParameterReader.cs} | 8 +- .../IPaginationQueryStringParameterReader.cs | 11 + .../IQueryStringParameterReader.cs | 26 + .../QueryStrings/IQueryStringReader.cs | 18 + .../IRequestQueryStringAccessor.cs | 5 +- ...ourceDefinitionQueryableParameterReader.cs | 13 + .../ISortQueryStringParameterReader.cs | 11 + ...parseFieldSetQueryStringParameterReader.cs | 11 + .../DefaultsQueryStringParameterReader.cs | 52 ++ .../FilterQueryStringParameterReader.cs | 154 ++++ .../IncludeQueryStringParameterReader.cs | 87 ++ .../Internal/LegacyFilterNotationConverter.cs | 121 +++ .../NullsQueryStringParameterReader.cs | 52 ++ .../PaginationQueryStringParameterReader.cs | 203 +++++ .../Internal/QueryStringParameterReader.cs | 51 ++ .../Internal/QueryStringReader.cs | 70 ++ .../Internal}/RequestQueryStringAccessor.cs | 6 +- ...ourceDefinitionQueryableParameterReader.cs | 67 ++ .../SortQueryStringParameterReader.cs | 96 ++ ...parseFieldSetQueryStringParameterReader.cs | 91 ++ .../StandardQueryStringParameters.cs | 6 +- .../Repositories/DbContextExtensions.cs | 54 ++ .../DbContextResolver.cs | 6 +- .../EntityFrameworkCoreRepository.cs | 419 +++++++++ .../Repositories/IDbContextResolver.cs | 12 + .../IRepositoryRelationshipUpdateHelper.cs | 26 + .../Repositories/IResourceReadRepository.cs | 33 + .../Repositories/IResourceRepository.cs | 21 + .../Repositories/IResourceWriteRepository.cs | 51 ++ .../RepositoryRelationshipUpdateHelper.cs | 56 +- .../Repositories/SafeTransactionProxy.cs | 68 ++ .../Contracts/ICurrentRequest.cs | 43 - .../Contracts/ITargetedFields.cs | 20 - .../RequestServices/CurrentRequest.cs | 30 - .../RequestServices/TargetedFields.cs | 15 - .../Resources/Annotations/AttrAttribute.cs | 74 ++ .../Annotations}/AttrCapabilities.cs | 24 +- .../Annotations}/EagerLoadAttribute.cs | 4 +- .../Resources/Annotations/HasManyAttribute.cs | 28 + .../Annotations/HasManyThroughAttribute.cs | 151 ++++ .../Resources/Annotations/HasOneAttribute.cs | 62 ++ .../Annotations/IsRequiredAttribute.cs | 32 + .../Annotations/LinkTypes.cs} | 4 +- .../Annotations/RelationshipAttribute.cs | 108 +++ .../Annotations/ResourceAttribute.cs | 22 + .../Annotations/ResourceFieldAttribute.cs | 41 + .../Annotations/ResourceLinksAttribute.cs | 75 ++ src/JsonApiDotNetCore/Resources/IHasMeta.cs | 12 + .../Resources/IIdentifiable.cs | 26 + .../Resources/IResourceChangeTracker.cs | 32 + .../Resources/IResourceDefinition.cs | 18 + .../IResourceDefinitionProvider.cs | 8 +- .../Resources/IResourceFactory.cs | 26 + .../Resources/ITargetedFields.cs | 21 + .../Resources/Identifiable.cs | 43 + .../ResourceChangeTracker.cs} | 72 +- .../Resources/ResourceDefinition.cs | 243 +++++ .../Resources/ResourceDefinitionProvider.cs | 27 + .../Resources/ResourceFactory.cs | 137 +++ .../Resources/TargetedFields.cs | 15 + .../Serialization/BaseDeserializer.cs | 263 ++++++ .../Serialization/BaseSerializer.cs | 76 ++ .../IIncludedResourceObjectBuilder.cs | 21 + .../Contracts => Building}/ILinkBuilder.cs | 9 +- .../Contracts => Building}/IMetaBuilder.cs | 20 +- .../Building/IResourceObjectBuilder.cs | 24 + .../IResourceObjectBuilderSettingsProvider.cs | 6 +- .../IncludedResourceObjectBuilder.cs | 67 +- .../Builders => Building}/LinkBuilder.cs | 134 +-- .../Serialization/Building/MetaBuilder.cs | 70 ++ .../ResourceIdentifierObjectComparer.cs | 34 + .../Building/ResourceObjectBuilder.cs | 165 ++++ .../ResourceObjectBuilderSettings.cs | 4 +- .../ResourceObjectBuilderSettingsProvider.cs | 28 + .../Building/ResponseResourceObjectBuilder.cs | 100 +++ .../Client/DeserializedResponse.cs | 36 - .../Client/IResponseDeserializer.cs | 26 - .../Internal/DeserializedResponseBase.cs | 17 + .../{ => Internal}/IRequestSerializer.cs | 31 +- .../Client/Internal/IResponseDeserializer.cs | 26 + .../Client/Internal/ManyResponse.cs | 14 + .../Client/Internal/RequestSerializer.cs | 112 +++ .../{ => Internal}/ResponseDeserializer.cs | 77 +- .../Client/Internal/SingleResponse.cs | 13 + .../Serialization/Client/RequestSerializer.cs | 109 --- .../Common/BaseDocumentParser.cs | 264 ------ .../Serialization/Common/DocumentBuilder.cs | 70 -- .../Common/IResourceObjectBuilder.cs | 22 - .../Common/ResourceObjectBuilder.cs | 159 ---- .../Serialization/FieldsToSerialize.cs | 89 ++ .../Serialization/IFieldsToSerialize.cs | 26 + .../Contracts => }/IJsonApiDeserializer.cs | 10 +- .../IJsonApiReader.cs | 6 +- .../Contracts => }/IJsonApiSerializer.cs | 6 +- .../IJsonApiSerializerFactory.cs | 2 +- .../IJsonApiWriter.cs | 2 +- .../IRequestMeta.cs | 9 +- .../Contracts => }/IResponseSerializer.cs | 4 +- .../JsonApiReader.cs | 41 +- .../JsonApiWriter.cs | 20 +- .../JsonSerializerExtensions.cs | 2 +- .../Objects}/Document.cs | 9 +- .../Objects}/Error.cs | 6 +- .../Objects}/ErrorDocument.cs | 13 +- .../Objects}/ErrorLinks.cs | 2 +- .../Objects}/ErrorMeta.cs | 6 +- .../Objects}/ErrorSource.cs | 4 +- .../Objects}/ExposableData.cs | 30 +- .../Objects}/RelationshipEntry.cs | 3 +- .../Objects}/RelationshipLinks.cs | 6 +- .../Objects}/ResourceIdentifierObject.cs | 3 +- .../Objects}/ResourceLinks.cs | 4 +- .../Objects}/ResourceObject.cs | 7 +- .../Serialization/Objects/TopLevelLinks.cs | 32 + .../Serialization/RequestDeserializer.cs | 105 +++ .../Serialization/ResponseSerializer.cs | 144 +++ .../ResponseSerializerFactory.cs | 44 + .../Server/Builders/MetaBuilder.cs | 59 -- .../Builders/ResponseResourceObjectBuilder.cs | 75 -- .../Server/Contracts/IFieldsToSerialize.cs | 29 - .../IIncludedResourceObjectBuilder.cs | 18 - .../Serialization/Server/FieldsToSerialize.cs | 62 -- .../Server/RequestDeserializer.cs | 54 -- .../ResourceObjectBuilderSettingsProvider.cs | 27 - .../Server/ResponseSerializer.cs | 187 ---- .../Server/ResponseSerializerFactory.cs | 49 - .../Services/Contract/ICreateService.cs | 15 - .../Services/Contract/IDeleteService.cs | 15 - .../Services/Contract/IGetAllService.cs | 16 - .../Services/Contract/IGetByIdService.cs | 15 - .../Contract/IGetRelationshipService.cs | 15 - .../Contract/IGetRelationshipsService.cs | 15 - .../Contract/IResourceCommandService.cs | 21 - .../Contract/IResourceQueryService.cs | 21 - .../Services/Contract/IResourceService.cs | 14 - .../Contract/IUpdateRelationshipService.cs | 15 - .../Services/Contract/IUpdateService.cs | 15 - .../Services/DefaultResourceService.cs | 447 ---------- .../Services/ICreateService.cs | 20 + .../Services/IDeleteService.cs | 20 + .../Services/IGetAllService.cs | 21 + .../Services/IGetByIdService.cs | 20 + .../Services/IGetRelationshipService.cs | 20 + .../Services/IGetSecondaryService.cs | 20 + .../Services/IResourceCommandService.cs | 30 + .../Services/IResourceQueryService.cs | 30 + .../Services/IResourceService.cs | 23 + .../Services/IUpdateRelationshipService.cs | 20 + .../Services/IUpdateService.cs | 20 + .../Services/JsonApiResourceService.cs | 362 ++++++++ .../Services/ResourceDefinitionProvider.cs | 26 - .../{Internal => }/TypeHelper.cs | 169 +++- src/JsonApiDotNetCore/index.md | 4 - .../ServiceDiscoveryFacadeTests.cs | 48 +- .../EntityFrameworkCoreRepositoryTests.cs | 108 +++ .../Data/EntityRepositoryTests.cs | 201 ----- .../Acceptance/ActionResultTests.cs | 4 +- .../Extensibility/CustomControllerTests.cs | 15 +- .../Extensibility/CustomErrorHandlingTests.cs | 6 +- .../Extensibility/IgnoreDefaultValuesTests.cs | 18 +- .../Extensibility/IgnoreNullValuesTests.cs | 18 +- .../Extensibility/RequestMetaTests.cs | 71 +- .../HttpReadOnlyTests.cs | 5 +- .../NoHttpDeleteTests.cs | 5 +- .../NoHttpPatchTests.cs | 5 +- .../HttpMethodRestrictions/NoHttpPostTests.cs | 5 +- .../Acceptance/InjectableResourceTests.cs | 29 +- .../Acceptance/KebabCaseFormatterTests.cs | 156 +++- .../Acceptance/ManyToManyTests.cs | 153 +--- .../Acceptance/ModelStateValidationTests.cs | 420 ++++++++- .../Acceptance/NonJsonApiControllerTests.cs | 9 +- .../ResourceDefinitions/QueryFiltersTests.cs | 92 -- .../ResourceDefinitionTests.cs | 204 ++--- .../Acceptance/SerializationTests.cs | 19 +- .../Acceptance/Spec/AttributeFilterTests.cs | 349 -------- .../Acceptance/Spec/AttributeSortTests.cs | 108 --- .../Spec/ContentNegotiationTests.cs | 29 +- .../Acceptance/Spec/CreatingDataTests.cs | 116 +-- ...CreatingDataWithClientGeneratedIdsTests.cs | 62 ++ .../Spec/DeeplyNestedInclusionTests.cs | 337 ------- .../Acceptance/Spec/DeletingDataTests.cs | 12 +- .../Spec/DisableQueryAttributeTests.cs | 4 +- .../Acceptance/Spec/DocumentTests/Included.cs | 468 ---------- .../DocumentTests/LinksWithNamespaceTests.cs | 12 +- .../LinksWithoutNamespaceTests.cs | 87 +- .../Acceptance/Spec/DocumentTests/Meta.cs | 64 +- .../Spec/DocumentTests/Relationships.cs | 21 +- .../Acceptance/Spec/EagerLoadTests.cs | 40 +- .../Acceptance/Spec/EndToEndTest.cs | 122 +-- .../Acceptance/Spec/FetchingDataTests.cs | 40 +- .../Spec/FetchingRelationshipsTests.cs | 152 +++- .../Spec/FunctionalTestCollection.cs | 117 +++ .../Acceptance/Spec/PaginationLinkTests.cs | 110 +++ .../Acceptance/Spec/PagingTests.cs | 155 ---- .../Acceptance/Spec/QueryParameterTests.cs | 158 ---- .../Spec/ResourceTypeMismatchTests.cs | 84 ++ .../Acceptance/Spec/SparseFieldSetTests.cs | 448 ---------- .../Acceptance/Spec/ThrowingResourceTests.cs | 4 +- .../Acceptance/Spec/UpdatingDataTests.cs | 86 +- .../Spec/UpdatingRelationshipsTests.cs | 75 +- .../Acceptance/TestFixture.cs | 36 +- .../Acceptance/TodoItemControllerTests.cs | 402 +++++++++ .../Acceptance/TodoItemsControllerTests.cs | 808 ----------------- .../AppDbContextExtensions.cs | 55 ++ ...> ClientGeneratedIdsApplicationFactory.cs} | 11 +- .../Factories/CustomApplicationFactoryBase.cs | 4 +- .../Factories/KebabCaseApplicationFactory.cs | 13 - .../NoNamespaceApplicationFactory.cs | 13 - .../ResourceHooksApplicationFactory.cs | 33 + .../Factories/StandardApplicationFactory.cs | 2 +- .../Helpers/Extensions/DocumentExtensions.cs | 21 - .../Helpers/Extensions/StringExtensions.cs | 10 + .../Helpers/Models/TodoItemClient.cs | 7 +- .../HttpResponseMessageExtensions.cs | 59 ++ .../IntegrationTestContext.cs | 201 +++++ .../Filtering/FilterDataTypeTests.cs | 334 +++++++ .../Filtering/FilterDbContext.cs | 13 + .../Filtering/FilterDepthTests.cs | 660 ++++++++++++++ .../Filtering/FilterOperatorTests.cs | 569 ++++++++++++ .../IntegrationTests/Filtering/FilterTests.cs | 196 ++++ .../Filtering/FilterableResource.cs | 47 + .../FilterableResourcesController.cs | 16 + .../IntegrationTests/Includes/IncludeTests.cs | 840 ++++++++++++++++++ .../Pagination/PaginationRangeTests.cs | 153 ++++ .../PaginationRangeWithMaximumTests.cs | 145 +++ .../Pagination/PaginationTests.cs | 503 +++++++++++ .../QueryStrings/QueryStringTests.cs | 89 ++ .../ResourceDefinitions/CallableDbContext.cs | 13 + .../ResourceDefinitions/CallableResource.cs | 37 + .../CallableResourceDefinition.cs | 117 +++ .../CallableResourcesController.cs | 16 + .../ResourceDefinitionQueryCallbackTests.cs | 547 ++++++++++++ .../IntegrationTests/Sorting/SortTests.cs | 752 ++++++++++++++++ .../SparseFieldSets/ResourceCaptureStore.cs | 20 + .../ResultCapturingRepository.cs | 43 + .../SparseFieldSets/SparseFieldSetTests.cs | 663 ++++++++++++++ .../IntegrationTests/TestableStartup.cs | 37 + .../JsonApiDotNetCoreExampleTests.csproj | 1 + test/NoEntityFrameworkTests/WorkItemTests.cs | 14 +- .../Builders/ContextGraphBuilder_Tests.cs | 20 +- test/UnitTests/Builders/LinkBuilderTests.cs | 244 +++-- test/UnitTests/Builders/LinkTests.cs | 52 +- .../BaseJsonApiController_Tests.cs | 49 +- ...Tests.cs => CoreJsonApiControllerTests.cs} | 5 +- .../Data/DefaultEntityRepositoryTest.cs | 75 -- .../IServiceCollectionExtensionsTests.cs | 73 +- .../Extensions/TypeExtensions_Tests.cs | 55 -- .../Graph/IdentifiableTypeCacheTests.cs | 4 +- test/UnitTests/Graph/TypeLocator_Tests.cs | 4 +- test/UnitTests/Internal/ErrorDocumentTests.cs | 2 +- .../RequestScopedServiceProviderTests.cs | 12 +- .../Internal/ResourceGraphBuilderTests.cs | 87 ++ .../Internal/ResourceGraphBuilder_Tests.cs | 52 -- test/UnitTests/Internal/TypeHelper_Tests.cs | 48 +- .../Middleware/JsonApiMiddlewareTests.cs | 47 +- .../UnitTests/Models/AttributesEqualsTests.cs | 30 +- test/UnitTests/Models/IdentifiableTests.cs | 10 +- test/UnitTests/Models/LinkTests.cs | 62 +- .../UnitTests/Models/RelationshipDataTests.cs | 2 +- .../ResourceConstructionExpressionTests.cs | 8 +- .../Models/ResourceConstructionTests.cs | 31 +- .../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 ----- .../QueryStringParameters/BaseParseTests.cs | 35 + .../DefaultsParseTests.cs | 130 +++ .../QueryStringParameters/FilterParseTests.cs | 142 +++ .../IncludeParseTests.cs | 96 ++ .../LegacyFilterParseTests.cs | 95 ++ .../QueryStringParameters/NullsParseTests.cs | 130 +++ .../PaginationParseTests.cs | 156 ++++ .../QueryStringParameters/SortParseTests.cs | 114 +++ .../SparseFieldSetParseTests.cs | 101 +++ .../UnitTests/ResourceHooks/DiscoveryTests.cs | 39 +- ...ests.cs => RelationshipDictionaryTests.cs} | 138 +-- .../Create/AfterCreateTests.cs | 4 +- .../Create/BeforeCreateTests.cs | 10 +- .../Create/BeforeCreate_WithDbValues_Tests.cs | 18 +- .../Delete/AfterDeleteTests.cs | 4 +- .../Delete/BeforeDeleteTests.cs | 4 +- .../Delete/BeforeDelete_WithDbValue_Tests.cs | 10 +- .../IdentifiableManyToMany_OnReturnTests.cs | 7 +- .../ManyToMany_OnReturnTests.cs | 6 +- .../Read/BeforeReadTests.cs | 37 +- .../IdentifiableManyToMany_AfterReadTests.cs | 6 +- .../Read/ManyToMany_AfterReadTests.cs | 6 +- .../ResourceHookExecutor/ScenarioTests.cs | 14 +- .../Update/AfterUpdateTests.cs | 4 +- .../Update/BeforeUpdateTests.cs | 8 +- .../Update/BeforeUpdate_WithDbValues_Tests.cs | 36 +- .../ResourceHooks/ResourceHooksTestsSetup.cs | 193 ++-- .../Client/RequestSerializerTests.cs | 36 +- .../Client/ResponseDeserializerTests.cs | 91 +- .../Common/DocumentBuilderTests.cs | 30 +- .../Common/DocumentParserTests.cs | 18 +- .../Common/ResourceObjectBuilderTests.cs | 72 +- .../Serialization/DeserializerTestsSetup.cs | 39 +- .../SerializationTestsSetupBase.cs | 32 +- .../Serialization/SerializerTestsSetup.cs | 54 +- .../IncludedResourceObjectBuilderTests.cs | 12 +- .../Server/RequestDeserializerTests.cs | 13 +- .../ResponseResourceObjectBuilderTests.cs | 22 +- .../Server/ResponseSerializerTests.cs | 107 +-- .../Services/DefaultResourceService_Tests.cs | 92 ++ .../Services/EntityResourceService_Tests.cs | 132 --- test/UnitTests/TestModels.cs | 7 +- test/UnitTests/TestScopedServiceProvider.cs | 6 +- test/UnitTests/UnitTests.csproj | 2 + 674 files changed, 24782 insertions(+), 15523 deletions(-) create mode 100644 docs/internals/index.md create mode 100644 docs/internals/queries.md create mode 100644 docs/internals/toc.md delete mode 100644 docs/usage/sparse-field-selection.md create mode 100644 docs/usage/sparse-fieldset-selection.md rename src/Examples/JsonApiDotNetCoreExample/Controllers/{ModelsController.cs => AuthorsController.cs} (53%) create mode 100644 src/Examples/JsonApiDotNetCoreExample/Controllers/BlogsController.cs 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} (55%) 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/AssemblyInfo.cs delete mode 100644 src/JsonApiDotNetCore/Builders/IResourceGraphBuilder.cs rename src/JsonApiDotNetCore/{Extensions => Configuration}/ApplicationBuilderExtensions.cs (86%) create mode 100644 src/JsonApiDotNetCore/Configuration/GenericServiceFactory.cs create mode 100644 src/JsonApiDotNetCore/Configuration/IGenericServiceFactory.cs create mode 100644 src/JsonApiDotNetCore/Configuration/IInverseRelationships.cs delete mode 100644 src/JsonApiDotNetCore/Configuration/ILinksConfiguration.cs create mode 100644 src/JsonApiDotNetCore/Configuration/IRelatedIdMapper.cs create mode 100644 src/JsonApiDotNetCore/Configuration/IRequestScopedServiceProvider.cs rename src/JsonApiDotNetCore/{Internal/Contracts => Configuration}/IResourceContextProvider.cs (60%) create mode 100644 src/JsonApiDotNetCore/Configuration/IResourceGraph.cs create mode 100644 src/JsonApiDotNetCore/Configuration/IResourceGraphBuilder.cs create mode 100644 src/JsonApiDotNetCore/Configuration/IServiceDiscoveryFacade.cs rename src/JsonApiDotNetCore/{Graph => Configuration}/IdentifiableTypeCache.cs (56%) create mode 100644 src/JsonApiDotNetCore/Configuration/InverseRelationships.cs rename src/JsonApiDotNetCore/{Builders => Configuration}/JsonApiApplicationBuilder.cs (64%) create mode 100644 src/JsonApiDotNetCore/Configuration/PageNumber.cs create mode 100644 src/JsonApiDotNetCore/Configuration/PageSize.cs create mode 100644 src/JsonApiDotNetCore/Configuration/RelatedIdMapper.cs rename src/JsonApiDotNetCore/{Services/ScopedServiceProvider.cs => Configuration/RequestScopedServiceProvider.cs} (55%) create mode 100644 src/JsonApiDotNetCore/Configuration/ResourceContext.cs rename src/JsonApiDotNetCore/{Graph => Configuration}/ResourceDescriptor.cs (81%) create mode 100644 src/JsonApiDotNetCore/Configuration/ResourceGraph.cs rename src/JsonApiDotNetCore/{Builders => Configuration}/ResourceGraphBuilder.cs (62%) create mode 100644 src/JsonApiDotNetCore/Configuration/ResourceNameFormatter.cs rename src/JsonApiDotNetCore/{Extensions => Configuration}/ServiceCollectionExtensions.cs (81%) rename src/JsonApiDotNetCore/{Graph => Configuration}/ServiceDiscoveryFacade.cs (82%) rename src/JsonApiDotNetCore/{Graph => Configuration}/TypeLocator.cs (71%) create mode 100644 src/JsonApiDotNetCore/Controllers/Annotations/DisableQueryStringAttribute.cs create mode 100644 src/JsonApiDotNetCore/Controllers/Annotations/DisableRoutingConventionAttribute.cs create mode 100644 src/JsonApiDotNetCore/Controllers/Annotations/HttpReadOnlyAttribute.cs create mode 100644 src/JsonApiDotNetCore/Controllers/Annotations/HttpRestrictAttribute.cs create mode 100644 src/JsonApiDotNetCore/Controllers/Annotations/NoHttpDeleteAttribute.cs create mode 100644 src/JsonApiDotNetCore/Controllers/Annotations/NoHttpPatchAttribute.cs create mode 100644 src/JsonApiDotNetCore/Controllers/Annotations/NoHttpPostAttribute.cs rename src/JsonApiDotNetCore/Controllers/{JsonApiControllerMixin.cs => CoreJsonApiController.cs} (53%) delete mode 100644 src/JsonApiDotNetCore/Controllers/DisableQueryAttribute.cs delete mode 100644 src/JsonApiDotNetCore/Controllers/DisableRoutingConventionAttribute.cs delete mode 100644 src/JsonApiDotNetCore/Controllers/HttpMethodRestrictionFilter.cs delete mode 100644 src/JsonApiDotNetCore/Data/DefaultResourceRepository.cs delete mode 100644 src/JsonApiDotNetCore/Data/IDbContextResolver.cs delete mode 100644 src/JsonApiDotNetCore/Data/IResourceReadRepository.cs delete mode 100644 src/JsonApiDotNetCore/Data/IResourceRepository.cs delete mode 100644 src/JsonApiDotNetCore/Data/IResourceWriteRepository.cs create mode 100644 src/JsonApiDotNetCore/Errors/InvalidConfigurationException.cs rename src/JsonApiDotNetCore/{Exceptions => Errors}/InvalidModelStateException.cs (73%) create mode 100644 src/JsonApiDotNetCore/Errors/InvalidQueryException.cs rename src/JsonApiDotNetCore/{Exceptions => Errors}/InvalidQueryStringParameterException.cs (79%) rename src/JsonApiDotNetCore/{Exceptions => Errors}/InvalidRequestBodyException.cs (94%) rename src/JsonApiDotNetCore/{Exceptions => Errors}/JsonApiException.cs (71%) rename src/JsonApiDotNetCore/{Exceptions => Errors}/RelationshipNotFoundException.cs (86%) rename src/JsonApiDotNetCore/{Exceptions => Errors}/RequestMethodNotAllowedException.cs (88%) rename src/JsonApiDotNetCore/{Exceptions => Errors}/ResourceIdInPostRequestNotAllowedException.cs (78%) rename src/JsonApiDotNetCore/{Exceptions => Errors}/ResourceIdMismatchException.cs (56%) rename src/JsonApiDotNetCore/{Exceptions => Errors}/ResourceNotFoundException.cs (75%) rename src/JsonApiDotNetCore/{Exceptions => Errors}/ResourceTypeMismatchException.cs (82%) rename src/JsonApiDotNetCore/{Exceptions => Errors}/UnsuccessfulActionResultException.cs (87%) delete mode 100644 src/JsonApiDotNetCore/Extensions/DbContextExtensions.cs delete mode 100644 src/JsonApiDotNetCore/Extensions/EnumerableExtensions.cs delete mode 100644 src/JsonApiDotNetCore/Extensions/HttpContextExtensions.cs delete mode 100644 src/JsonApiDotNetCore/Extensions/QueryableExtensions.cs delete mode 100644 src/JsonApiDotNetCore/Extensions/SystemCollectionExtensions.cs delete mode 100644 src/JsonApiDotNetCore/Extensions/TypeExtensions.cs delete mode 100644 src/JsonApiDotNetCore/Graph/IServiceDiscoveryFacade.cs delete mode 100644 src/JsonApiDotNetCore/Graph/ResourceIdMapper.cs delete mode 100644 src/JsonApiDotNetCore/Graph/ResourceNameFormatter.cs delete mode 100644 src/JsonApiDotNetCore/Hooks/Execution/DiffableEntityHashSet.cs delete mode 100644 src/JsonApiDotNetCore/Hooks/IResourceHookContainer.cs delete mode 100644 src/JsonApiDotNetCore/Hooks/IResourceHookExecutor.cs rename src/JsonApiDotNetCore/Hooks/{ => Internal}/Discovery/HooksDiscovery.cs (90%) rename src/JsonApiDotNetCore/Hooks/{ => Internal}/Discovery/IHooksDiscovery.cs (80%) rename src/JsonApiDotNetCore/Hooks/{ => Internal}/Discovery/LoadDatabaseValuesAttribute.cs (84%) create mode 100644 src/JsonApiDotNetCore/Hooks/Internal/Execution/DiffableResourceHashSet.cs rename src/JsonApiDotNetCore/Hooks/{ => Internal}/Execution/HookExecutorHelper.cs (56%) create mode 100644 src/JsonApiDotNetCore/Hooks/Internal/Execution/IByAffectedRelationships.cs create mode 100644 src/JsonApiDotNetCore/Hooks/Internal/Execution/IDiffableResourceHashSet.cs rename src/JsonApiDotNetCore/Hooks/{ => Internal}/Execution/IHookExecutorHelper.cs (63%) create mode 100644 src/JsonApiDotNetCore/Hooks/Internal/Execution/IRelationshipGetters.cs create mode 100644 src/JsonApiDotNetCore/Hooks/Internal/Execution/IRelationshipsDictionary.cs create mode 100644 src/JsonApiDotNetCore/Hooks/Internal/Execution/IResourceHashSet.cs rename src/JsonApiDotNetCore/Hooks/{ => Internal}/Execution/RelationshipsDictionary.cs (51%) create mode 100644 src/JsonApiDotNetCore/Hooks/Internal/Execution/ResourceDiffPair.cs rename src/JsonApiDotNetCore/Hooks/{Execution/EntityHashSet.cs => Internal/Execution/ResourceHashSet.cs} (64%) rename src/JsonApiDotNetCore/Hooks/{Execution/ResourceHookEnum.cs => Internal/Execution/ResourceHook.cs} (90%) rename src/JsonApiDotNetCore/Hooks/{Execution/ResourcePipelineEnum.cs => Internal/Execution/ResourcePipeline.cs} (65%) create mode 100644 src/JsonApiDotNetCore/Hooks/Internal/ICreateHookContainer.cs create mode 100644 src/JsonApiDotNetCore/Hooks/Internal/ICreateHookExecutor.cs create mode 100644 src/JsonApiDotNetCore/Hooks/Internal/IDeleteHookContainer.cs create mode 100644 src/JsonApiDotNetCore/Hooks/Internal/IDeleteHookExecutor.cs create mode 100644 src/JsonApiDotNetCore/Hooks/Internal/IOnReturnHookContainer.cs create mode 100644 src/JsonApiDotNetCore/Hooks/Internal/IOnReturnHookExecutor.cs create mode 100644 src/JsonApiDotNetCore/Hooks/Internal/IReadHookContainer.cs create mode 100644 src/JsonApiDotNetCore/Hooks/Internal/IReadHookExecutor.cs create mode 100644 src/JsonApiDotNetCore/Hooks/Internal/IResourceHookContainer.cs create mode 100644 src/JsonApiDotNetCore/Hooks/Internal/IResourceHookExecutor.cs create mode 100644 src/JsonApiDotNetCore/Hooks/Internal/IUpdateHookContainer.cs create mode 100644 src/JsonApiDotNetCore/Hooks/Internal/IUpdateHookExecutor.cs create mode 100644 src/JsonApiDotNetCore/Hooks/Internal/IdentifiableComparer.cs rename src/JsonApiDotNetCore/Hooks/{ => Internal}/ResourceHookExecutor.cs (63%) rename src/JsonApiDotNetCore/Hooks/{ => Internal}/Traversal/ChildNode.cs (77%) create mode 100644 src/JsonApiDotNetCore/Hooks/Internal/Traversal/IRelationshipGroup.cs create mode 100644 src/JsonApiDotNetCore/Hooks/Internal/Traversal/IRelationshipsFromPreviousLayer.cs rename src/JsonApiDotNetCore/Hooks/{Traversal/IEntityNode.cs => Internal/Traversal/IResourceNode.cs} (76%) rename src/JsonApiDotNetCore/Hooks/{ => Internal}/Traversal/ITraversalHelper.cs (63%) create mode 100644 src/JsonApiDotNetCore/Hooks/Internal/Traversal/NodeLayer.cs create mode 100644 src/JsonApiDotNetCore/Hooks/Internal/Traversal/RelationshipGroup.cs rename src/JsonApiDotNetCore/Hooks/{ => Internal}/Traversal/RelationshipProxy.cs (54%) rename src/JsonApiDotNetCore/Hooks/{ => Internal}/Traversal/RelationshipsFromPreviousLayer.cs (53%) rename src/JsonApiDotNetCore/Hooks/{ => Internal}/Traversal/RootNode.cs (66%) rename src/JsonApiDotNetCore/Hooks/{ => Internal}/Traversal/TraversalHelper.cs (59%) delete mode 100644 src/JsonApiDotNetCore/Hooks/Traversal/RelationshipGroup.cs delete mode 100644 src/JsonApiDotNetCore/Internal/Contracts/IResourceGraphExplorer.cs delete mode 100644 src/JsonApiDotNetCore/Internal/Exceptions/JsonApiSetupException.cs delete mode 100644 src/JsonApiDotNetCore/Internal/Generics/GenericServiceFactory.cs delete mode 100644 src/JsonApiDotNetCore/Internal/IResourceFactory.cs delete mode 100644 src/JsonApiDotNetCore/Internal/IdentifiableComparer.cs delete mode 100644 src/JsonApiDotNetCore/Internal/InverseRelationships.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 delete mode 100644 src/JsonApiDotNetCore/Internal/ResourceContext.cs delete mode 100644 src/JsonApiDotNetCore/Internal/ResourceGraph.cs delete mode 100644 src/JsonApiDotNetCore/Internal/ValidationResults.cs delete mode 100644 src/JsonApiDotNetCore/Middleware/DefaultTypeMatchFilter.cs create mode 100644 src/JsonApiDotNetCore/Middleware/EndpointKind.cs rename src/JsonApiDotNetCore/Middleware/{DefaultExceptionHandler.cs => ExceptionHandler.cs} (72%) create mode 100644 src/JsonApiDotNetCore/Middleware/HttpContextExtensions.cs rename src/JsonApiDotNetCore/{Internal => Middleware}/IControllerResourceMapping.cs (52%) create mode 100644 src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs rename src/JsonApiDotNetCore/{Internal => Middleware}/IJsonApiRoutingConvention.cs (68%) rename src/JsonApiDotNetCore/Middleware/{IQueryParameterActionFilter.cs => IQueryStringActionFilter.cs} (51%) create mode 100644 src/JsonApiDotNetCore/Middleware/IncomingTypeMatchFilter.cs rename src/JsonApiDotNetCore/Middleware/{DefaultExceptionFilter.cs => JsonApiExceptionFilter.cs} (68%) create mode 100644 src/JsonApiDotNetCore/Middleware/JsonApiExceptionFilterProvider.cs rename src/JsonApiDotNetCore/{Formatters => Middleware}/JsonApiInputFormatter.cs (63%) rename src/JsonApiDotNetCore/{Formatters => Middleware}/JsonApiOutputFormatter.cs (63%) create mode 100644 src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs rename src/JsonApiDotNetCore/{Internal/DefaultRoutingConvention.cs => Middleware/JsonApiRoutingConvention.cs} (61%) create mode 100644 src/JsonApiDotNetCore/Middleware/JsonApiTypeMatchFilterProvider.cs delete mode 100644 src/JsonApiDotNetCore/Middleware/QueryParameterFilter.cs create mode 100644 src/JsonApiDotNetCore/Middleware/QueryStringActionFilter.cs create mode 100644 src/JsonApiDotNetCore/Middleware/TraceLogWriter.cs delete mode 100644 src/JsonApiDotNetCore/Models/Annotation/AttrAttribute.cs delete mode 100644 src/JsonApiDotNetCore/Models/Annotation/HasManyAttribute.cs delete mode 100644 src/JsonApiDotNetCore/Models/Annotation/HasManyThroughAttribute.cs delete mode 100644 src/JsonApiDotNetCore/Models/Annotation/HasOneAttribute.cs delete mode 100644 src/JsonApiDotNetCore/Models/Annotation/LinksAttribute.cs delete mode 100644 src/JsonApiDotNetCore/Models/Annotation/RelationshipAttribute.cs delete mode 100644 src/JsonApiDotNetCore/Models/IHasMeta.cs delete mode 100644 src/JsonApiDotNetCore/Models/IResourceField.cs delete mode 100644 src/JsonApiDotNetCore/Models/JsonApiDocuments/IIdentifiable.cs delete mode 100644 src/JsonApiDotNetCore/Models/JsonApiDocuments/Identifiable.cs delete mode 100644 src/JsonApiDotNetCore/Models/JsonApiDocuments/ResourceObjectComparer.cs delete mode 100644 src/JsonApiDotNetCore/Models/JsonApiDocuments/TopLevelLinks.cs delete mode 100644 src/JsonApiDotNetCore/Models/ResourceAttribute.cs delete mode 100644 src/JsonApiDotNetCore/Models/ResourceDefinition.cs create mode 100644 src/JsonApiDotNetCore/Queries/ExpressionInScope.cs create mode 100644 src/JsonApiDotNetCore/Queries/Expressions/CollectionNotEmptyExpression.cs create mode 100644 src/JsonApiDotNetCore/Queries/Expressions/ComparisonExpression.cs create mode 100644 src/JsonApiDotNetCore/Queries/Expressions/ComparisonOperator.cs create mode 100644 src/JsonApiDotNetCore/Queries/Expressions/CountExpression.cs create mode 100644 src/JsonApiDotNetCore/Queries/Expressions/EqualsAnyOfExpression.cs 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 create mode 100644 src/JsonApiDotNetCore/Queries/Expressions/IncludeChainConverter.cs create mode 100644 src/JsonApiDotNetCore/Queries/Expressions/IncludeElementExpression.cs create mode 100644 src/JsonApiDotNetCore/Queries/Expressions/IncludeExpression.cs create mode 100644 src/JsonApiDotNetCore/Queries/Expressions/LiteralConstantExpression.cs create mode 100644 src/JsonApiDotNetCore/Queries/Expressions/LogicalExpression.cs create mode 100644 src/JsonApiDotNetCore/Queries/Expressions/LogicalOperator.cs create mode 100644 src/JsonApiDotNetCore/Queries/Expressions/MatchTextExpression.cs create mode 100644 src/JsonApiDotNetCore/Queries/Expressions/NotExpression.cs create mode 100644 src/JsonApiDotNetCore/Queries/Expressions/NullConstantExpression.cs create mode 100644 src/JsonApiDotNetCore/Queries/Expressions/PaginationElementQueryStringValueExpression.cs create mode 100644 src/JsonApiDotNetCore/Queries/Expressions/PaginationExpression.cs create mode 100644 src/JsonApiDotNetCore/Queries/Expressions/PaginationQueryStringValueExpression.cs create mode 100644 src/JsonApiDotNetCore/Queries/Expressions/QueryExpression.cs create mode 100644 src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs create mode 100644 src/JsonApiDotNetCore/Queries/Expressions/QueryStringParameterScopeExpression.cs create mode 100644 src/JsonApiDotNetCore/Queries/Expressions/QueryableHandlerExpression.cs create mode 100644 src/JsonApiDotNetCore/Queries/Expressions/ResourceFieldChainExpression.cs create mode 100644 src/JsonApiDotNetCore/Queries/Expressions/SortElementExpression.cs create mode 100644 src/JsonApiDotNetCore/Queries/Expressions/SortExpression.cs create mode 100644 src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs create mode 100644 src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpressionExtensions.cs create mode 100644 src/JsonApiDotNetCore/Queries/Expressions/TextMatchKind.cs create mode 100644 src/JsonApiDotNetCore/Queries/IPaginationContext.cs create mode 100644 src/JsonApiDotNetCore/Queries/IQueryConstraintProvider.cs create mode 100644 src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs create mode 100644 src/JsonApiDotNetCore/Queries/Internal/Parsing/FieldChainRequirements.cs create mode 100644 src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs create mode 100644 src/JsonApiDotNetCore/Queries/Internal/Parsing/IncludeParser.cs create mode 100644 src/JsonApiDotNetCore/Queries/Internal/Parsing/Keywords.cs create mode 100644 src/JsonApiDotNetCore/Queries/Internal/Parsing/PaginationParser.cs create mode 100644 src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryExpressionParser.cs create mode 100644 src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryParseException.cs create mode 100644 src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryStringParameterScopeParser.cs create mode 100644 src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryTokenizer.cs create mode 100644 src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldChainResolver.cs create mode 100644 src/JsonApiDotNetCore/Queries/Internal/Parsing/SortParser.cs create mode 100644 src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldSetParser.cs create mode 100644 src/JsonApiDotNetCore/Queries/Internal/Parsing/Token.cs create mode 100644 src/JsonApiDotNetCore/Queries/Internal/Parsing/TokenKind.cs create mode 100644 src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs create mode 100644 src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/IncludeClauseBuilder.cs create mode 100644 src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaParameterNameFactory.cs create mode 100644 src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaParameterNameScope.cs create mode 100644 src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScope.cs create mode 100644 src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScopeFactory.cs create mode 100644 src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/OrderClauseBuilder.cs create mode 100644 src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryClauseBuilder.cs create mode 100644 src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryableBuilder.cs create mode 100644 src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs create mode 100644 src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SkipTakeClauseBuilder.cs create mode 100644 src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/WhereClauseBuilder.cs create mode 100644 src/JsonApiDotNetCore/Queries/PaginationContext.cs create mode 100644 src/JsonApiDotNetCore/Queries/QueryLayer.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/IFilterService.cs delete mode 100644 src/JsonApiDotNetCore/QueryParameterServices/Contracts/IIncludeService.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/DefaultsService.cs delete mode 100644 src/JsonApiDotNetCore/QueryParameterServices/FilterService.cs delete mode 100644 src/JsonApiDotNetCore/QueryParameterServices/IncludeService.cs delete mode 100644 src/JsonApiDotNetCore/QueryParameterServices/NullsService.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 rename src/JsonApiDotNetCore/{QueryParameterServices/Contracts/IDefaultsService.cs => QueryStrings/IDefaultsQueryStringParameterReader.cs} (51%) create mode 100644 src/JsonApiDotNetCore/QueryStrings/IFilterQueryStringParameterReader.cs create mode 100644 src/JsonApiDotNetCore/QueryStrings/IIncludeQueryStringParameterReader.cs rename src/JsonApiDotNetCore/{QueryParameterServices/Contracts/INullsService.cs => QueryStrings/INullsQueryStringParameterReader.cs} (51%) create mode 100644 src/JsonApiDotNetCore/QueryStrings/IPaginationQueryStringParameterReader.cs create mode 100644 src/JsonApiDotNetCore/QueryStrings/IQueryStringParameterReader.cs create mode 100644 src/JsonApiDotNetCore/QueryStrings/IQueryStringReader.cs rename src/JsonApiDotNetCore/{QueryParameterServices/Common => 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 create mode 100644 src/JsonApiDotNetCore/QueryStrings/Internal/DefaultsQueryStringParameterReader.cs create mode 100644 src/JsonApiDotNetCore/QueryStrings/Internal/FilterQueryStringParameterReader.cs create mode 100644 src/JsonApiDotNetCore/QueryStrings/Internal/IncludeQueryStringParameterReader.cs create mode 100644 src/JsonApiDotNetCore/QueryStrings/Internal/LegacyFilterNotationConverter.cs create mode 100644 src/JsonApiDotNetCore/QueryStrings/Internal/NullsQueryStringParameterReader.cs create mode 100644 src/JsonApiDotNetCore/QueryStrings/Internal/PaginationQueryStringParameterReader.cs create mode 100644 src/JsonApiDotNetCore/QueryStrings/Internal/QueryStringParameterReader.cs create mode 100644 src/JsonApiDotNetCore/QueryStrings/Internal/QueryStringReader.cs rename src/JsonApiDotNetCore/{QueryParameterServices/Common => QueryStrings/Internal}/RequestQueryStringAccessor.cs (70%) create mode 100644 src/JsonApiDotNetCore/QueryStrings/Internal/ResourceDefinitionQueryableParameterReader.cs create mode 100644 src/JsonApiDotNetCore/QueryStrings/Internal/SortQueryStringParameterReader.cs create mode 100644 src/JsonApiDotNetCore/QueryStrings/Internal/SparseFieldSetQueryStringParameterReader.cs rename src/JsonApiDotNetCore/{Controllers => QueryStrings}/StandardQueryStringParameters.cs (59%) create mode 100644 src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs rename src/JsonApiDotNetCore/{Data => Repositories}/DbContextResolver.cs (67%) create mode 100644 src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs create mode 100644 src/JsonApiDotNetCore/Repositories/IDbContextResolver.cs create mode 100644 src/JsonApiDotNetCore/Repositories/IRepositoryRelationshipUpdateHelper.cs create mode 100644 src/JsonApiDotNetCore/Repositories/IResourceReadRepository.cs create mode 100644 src/JsonApiDotNetCore/Repositories/IResourceRepository.cs create mode 100644 src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs rename src/JsonApiDotNetCore/{Internal/Generics => Repositories}/RepositoryRelationshipUpdateHelper.cs (72%) create mode 100644 src/JsonApiDotNetCore/Repositories/SafeTransactionProxy.cs delete mode 100644 src/JsonApiDotNetCore/RequestServices/Contracts/ICurrentRequest.cs delete mode 100644 src/JsonApiDotNetCore/RequestServices/Contracts/ITargetedFields.cs delete mode 100644 src/JsonApiDotNetCore/RequestServices/CurrentRequest.cs delete mode 100644 src/JsonApiDotNetCore/RequestServices/TargetedFields.cs create mode 100644 src/JsonApiDotNetCore/Resources/Annotations/AttrAttribute.cs rename src/JsonApiDotNetCore/{Models => Resources/Annotations}/AttrCapabilities.cs (60%) rename src/JsonApiDotNetCore/{Models/Annotation => Resources/Annotations}/EagerLoadAttribute.cs (90%) create mode 100644 src/JsonApiDotNetCore/Resources/Annotations/HasManyAttribute.cs create mode 100644 src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs create mode 100644 src/JsonApiDotNetCore/Resources/Annotations/HasOneAttribute.cs create mode 100644 src/JsonApiDotNetCore/Resources/Annotations/IsRequiredAttribute.cs rename src/JsonApiDotNetCore/{Models/JsonApiDocuments/Link.cs => Resources/Annotations/LinkTypes.cs} (73%) create mode 100644 src/JsonApiDotNetCore/Resources/Annotations/RelationshipAttribute.cs create mode 100644 src/JsonApiDotNetCore/Resources/Annotations/ResourceAttribute.cs create mode 100644 src/JsonApiDotNetCore/Resources/Annotations/ResourceFieldAttribute.cs create mode 100644 src/JsonApiDotNetCore/Resources/Annotations/ResourceLinksAttribute.cs create mode 100644 src/JsonApiDotNetCore/Resources/IHasMeta.cs create mode 100644 src/JsonApiDotNetCore/Resources/IIdentifiable.cs create mode 100644 src/JsonApiDotNetCore/Resources/IResourceChangeTracker.cs create mode 100644 src/JsonApiDotNetCore/Resources/IResourceDefinition.cs rename src/JsonApiDotNetCore/{Services/Contract => Resources}/IResourceDefinitionProvider.cs (79%) create mode 100644 src/JsonApiDotNetCore/Resources/IResourceFactory.cs create mode 100644 src/JsonApiDotNetCore/Resources/ITargetedFields.cs create mode 100644 src/JsonApiDotNetCore/Resources/Identifiable.cs rename src/JsonApiDotNetCore/{RequestServices/DefaultResourceChangeTracker.cs => Resources/ResourceChangeTracker.cs} (53%) create mode 100644 src/JsonApiDotNetCore/Resources/ResourceDefinition.cs create mode 100644 src/JsonApiDotNetCore/Resources/ResourceDefinitionProvider.cs create mode 100644 src/JsonApiDotNetCore/Resources/ResourceFactory.cs create mode 100644 src/JsonApiDotNetCore/Resources/TargetedFields.cs create mode 100644 src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs create mode 100644 src/JsonApiDotNetCore/Serialization/BaseSerializer.cs create mode 100644 src/JsonApiDotNetCore/Serialization/Building/IIncludedResourceObjectBuilder.cs rename src/JsonApiDotNetCore/Serialization/{Server/Contracts => Building}/ILinkBuilder.cs (77%) rename src/JsonApiDotNetCore/Serialization/{Server/Contracts => Building}/IMetaBuilder.cs (51%) create mode 100644 src/JsonApiDotNetCore/Serialization/Building/IResourceObjectBuilder.cs rename src/JsonApiDotNetCore/Serialization/{Server/Contracts => Building}/IResourceObjectBuilderSettingsProvider.cs (62%) rename src/JsonApiDotNetCore/Serialization/{Server/Builders => Building}/IncludedResourceObjectBuilder.cs (65%) rename src/JsonApiDotNetCore/Serialization/{Server/Builders => Building}/LinkBuilder.cs (55%) create mode 100644 src/JsonApiDotNetCore/Serialization/Building/MetaBuilder.cs create mode 100644 src/JsonApiDotNetCore/Serialization/Building/ResourceIdentifierObjectComparer.cs create mode 100644 src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilder.cs rename src/JsonApiDotNetCore/Serialization/{Common => Building}/ResourceObjectBuilderSettings.cs (89%) create mode 100644 src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilderSettingsProvider.cs create mode 100644 src/JsonApiDotNetCore/Serialization/Building/ResponseResourceObjectBuilder.cs delete mode 100644 src/JsonApiDotNetCore/Serialization/Client/DeserializedResponse.cs delete mode 100644 src/JsonApiDotNetCore/Serialization/Client/IResponseDeserializer.cs create mode 100644 src/JsonApiDotNetCore/Serialization/Client/Internal/DeserializedResponseBase.cs rename src/JsonApiDotNetCore/Serialization/Client/{ => Internal}/IRequestSerializer.cs (54%) create mode 100644 src/JsonApiDotNetCore/Serialization/Client/Internal/IResponseDeserializer.cs create mode 100644 src/JsonApiDotNetCore/Serialization/Client/Internal/ManyResponse.cs create mode 100644 src/JsonApiDotNetCore/Serialization/Client/Internal/RequestSerializer.cs rename src/JsonApiDotNetCore/Serialization/Client/{ => Internal}/ResponseDeserializer.cs (52%) create mode 100644 src/JsonApiDotNetCore/Serialization/Client/Internal/SingleResponse.cs delete mode 100644 src/JsonApiDotNetCore/Serialization/Client/RequestSerializer.cs delete mode 100644 src/JsonApiDotNetCore/Serialization/Common/BaseDocumentParser.cs delete mode 100644 src/JsonApiDotNetCore/Serialization/Common/DocumentBuilder.cs delete mode 100644 src/JsonApiDotNetCore/Serialization/Common/IResourceObjectBuilder.cs delete mode 100644 src/JsonApiDotNetCore/Serialization/Common/ResourceObjectBuilder.cs create mode 100644 src/JsonApiDotNetCore/Serialization/FieldsToSerialize.cs create mode 100644 src/JsonApiDotNetCore/Serialization/IFieldsToSerialize.cs rename src/JsonApiDotNetCore/Serialization/{Server/Contracts => }/IJsonApiDeserializer.cs (50%) rename src/JsonApiDotNetCore/{Formatters => Serialization}/IJsonApiReader.cs (62%) rename src/JsonApiDotNetCore/Serialization/{Server/Contracts => }/IJsonApiSerializer.cs (69%) rename src/JsonApiDotNetCore/Serialization/{Server/Contracts => }/IJsonApiSerializerFactory.cs (81%) rename src/JsonApiDotNetCore/{Formatters => Serialization}/IJsonApiWriter.cs (81%) rename src/JsonApiDotNetCore/{Services/Contract => Serialization}/IRequestMeta.cs (65%) rename src/JsonApiDotNetCore/Serialization/{Server/Contracts => }/IResponseSerializer.cs (76%) rename src/JsonApiDotNetCore/{Formatters => Serialization}/JsonApiReader.cs (74%) rename src/JsonApiDotNetCore/{Formatters => Serialization}/JsonApiWriter.cs (81%) rename src/JsonApiDotNetCore/{Extensions => Serialization}/JsonSerializerExtensions.cs (98%) rename src/JsonApiDotNetCore/{Models/JsonApiDocuments => Serialization/Objects}/Document.cs (81%) rename src/JsonApiDotNetCore/{Models/JsonApiDocuments => Serialization/Objects}/Error.cs (93%) rename src/JsonApiDotNetCore/{Models/JsonApiDocuments => Serialization/Objects}/ErrorDocument.cs (72%) rename src/JsonApiDotNetCore/{Models/JsonApiDocuments => Serialization/Objects}/ErrorLinks.cs (84%) rename src/JsonApiDotNetCore/{Models/JsonApiDocuments => Serialization/Objects}/ErrorMeta.cs (71%) rename src/JsonApiDotNetCore/{Models/JsonApiDocuments => Serialization/Objects}/ErrorSource.cs (71%) rename src/JsonApiDotNetCore/{Models/JsonApiDocuments => Serialization/Objects}/ExposableData.cs (72%) rename src/JsonApiDotNetCore/{Models/JsonApiDocuments => Serialization/Objects}/RelationshipEntry.cs (77%) rename src/JsonApiDotNetCore/{Models/JsonApiDocuments => Serialization/Objects}/RelationshipLinks.cs (62%) rename src/JsonApiDotNetCore/{Models/JsonApiDocuments => Serialization/Objects}/ResourceIdentifierObject.cs (91%) rename src/JsonApiDotNetCore/{Models/JsonApiDocuments => Serialization/Objects}/ResourceLinks.cs (65%) rename src/JsonApiDotNetCore/{Models/JsonApiDocuments => Serialization/Objects}/ResourceObject.cs (66%) create mode 100644 src/JsonApiDotNetCore/Serialization/Objects/TopLevelLinks.cs create mode 100644 src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs create mode 100644 src/JsonApiDotNetCore/Serialization/ResponseSerializer.cs create mode 100644 src/JsonApiDotNetCore/Serialization/ResponseSerializerFactory.cs delete mode 100644 src/JsonApiDotNetCore/Serialization/Server/Builders/MetaBuilder.cs delete mode 100644 src/JsonApiDotNetCore/Serialization/Server/Builders/ResponseResourceObjectBuilder.cs delete mode 100644 src/JsonApiDotNetCore/Serialization/Server/Contracts/IFieldsToSerialize.cs delete mode 100644 src/JsonApiDotNetCore/Serialization/Server/Contracts/IIncludedResourceObjectBuilder.cs delete mode 100644 src/JsonApiDotNetCore/Serialization/Server/FieldsToSerialize.cs delete mode 100644 src/JsonApiDotNetCore/Serialization/Server/RequestDeserializer.cs delete mode 100644 src/JsonApiDotNetCore/Serialization/Server/ResourceObjectBuilderSettingsProvider.cs delete mode 100644 src/JsonApiDotNetCore/Serialization/Server/ResponseSerializer.cs delete mode 100644 src/JsonApiDotNetCore/Serialization/Server/ResponseSerializerFactory.cs delete mode 100644 src/JsonApiDotNetCore/Services/Contract/ICreateService.cs delete mode 100644 src/JsonApiDotNetCore/Services/Contract/IDeleteService.cs delete mode 100644 src/JsonApiDotNetCore/Services/Contract/IGetAllService.cs delete mode 100644 src/JsonApiDotNetCore/Services/Contract/IGetByIdService.cs delete mode 100644 src/JsonApiDotNetCore/Services/Contract/IGetRelationshipService.cs delete mode 100644 src/JsonApiDotNetCore/Services/Contract/IGetRelationshipsService.cs delete mode 100644 src/JsonApiDotNetCore/Services/Contract/IResourceCommandService.cs delete mode 100644 src/JsonApiDotNetCore/Services/Contract/IResourceQueryService.cs delete mode 100644 src/JsonApiDotNetCore/Services/Contract/IResourceService.cs delete mode 100644 src/JsonApiDotNetCore/Services/Contract/IUpdateRelationshipService.cs delete mode 100644 src/JsonApiDotNetCore/Services/Contract/IUpdateService.cs delete mode 100644 src/JsonApiDotNetCore/Services/DefaultResourceService.cs create mode 100644 src/JsonApiDotNetCore/Services/ICreateService.cs create mode 100644 src/JsonApiDotNetCore/Services/IDeleteService.cs create mode 100644 src/JsonApiDotNetCore/Services/IGetAllService.cs create mode 100644 src/JsonApiDotNetCore/Services/IGetByIdService.cs create mode 100644 src/JsonApiDotNetCore/Services/IGetRelationshipService.cs create mode 100644 src/JsonApiDotNetCore/Services/IGetSecondaryService.cs create mode 100644 src/JsonApiDotNetCore/Services/IResourceCommandService.cs create mode 100644 src/JsonApiDotNetCore/Services/IResourceQueryService.cs create mode 100644 src/JsonApiDotNetCore/Services/IResourceService.cs create mode 100644 src/JsonApiDotNetCore/Services/IUpdateRelationshipService.cs create mode 100644 src/JsonApiDotNetCore/Services/IUpdateService.cs create mode 100644 src/JsonApiDotNetCore/Services/JsonApiResourceService.cs delete mode 100644 src/JsonApiDotNetCore/Services/ResourceDefinitionProvider.cs rename src/JsonApiDotNetCore/{Internal => }/TypeHelper.cs (57%) delete mode 100644 src/JsonApiDotNetCore/index.md 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 create mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ResourceTypeMismatchTests.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} (74%) 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/Helpers/Extensions/StringExtensions.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} (93%) delete mode 100644 test/UnitTests/Data/DefaultEntityRepositoryTest.cs delete mode 100644 test/UnitTests/Extensions/TypeExtensions_Tests.cs create mode 100644 test/UnitTests/Internal/ResourceGraphBuilderTests.cs delete mode 100644 test/UnitTests/Internal/ResourceGraphBuilder_Tests.cs 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/BaseParseTests.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/SortParseTests.cs create mode 100644 test/UnitTests/QueryStringParameters/SparseFieldSetParseTests.cs rename test/UnitTests/ResourceHooks/{AffectedEntitiesHelperTests.cs => RelationshipDictionaryTests.cs} (51%) create mode 100644 test/UnitTests/Services/DefaultResourceService_Tests.cs delete mode 100644 test/UnitTests/Services/EntityResourceService_Tests.cs diff --git a/.gitignore b/.gitignore index 2ad42ca00c..1b99ea253e 100644 --- a/.gitignore +++ b/.gitignore @@ -97,6 +97,9 @@ StyleCopReport.xml *.svclog *.scc +# MacOS file systems +**/.DS_STORE + # Chutzpah Test files _Chutzpah* @@ -131,6 +134,9 @@ _ReSharper*/ *.[Rr]e[Ss]harper *.DotSettings.user +# JetBrains Rider +.idea/ + # TeamCity is a build add-in _TeamCity* diff --git a/Build.ps1 b/Build.ps1 index 15123ebe79..db20a7e029 100644 --- a/Build.ps1 +++ b/Build.ps1 @@ -38,18 +38,19 @@ If($env:APPVEYOR_REPO_TAG -eq $true) { IF ([string]::IsNullOrWhitespace($revision)){ Write-Output "RUNNING dotnet pack .\src\JsonApiDotNetCore -c Release -o .\artifacts" - dotnet pack .\src\JsonApiDotNetCore -c Release -o .\artifacts --include-symbols + dotnet pack .\src\JsonApiDotNetCore -c Release -o .\artifacts CheckLastExitCode } Else { Write-Output "RUNNING dotnet pack .\src\JsonApiDotNetCore -c Release -o .\artifacts --version-suffix=$revision" - dotnet pack .\src\JsonApiDotNetCore -c Release -o .\artifacts --version-suffix=$revision --include-symbols + dotnet pack .\src\JsonApiDotNetCore -c Release -o .\artifacts --version-suffix=$revision CheckLastExitCode } } -Else { - Write-Output "VERSION-SUFFIX: alpha5-$revision" - Write-Output "RUNNING dotnet pack .\src\JsonApiDotNetCore -c Release -o .\artifacts --version-suffix=alpha1-$revision" - dotnet pack .\src\JsonApiDotNetCore -c Release -o .\artifacts --version-suffix=alpha1-$revision --include-symbols +Else { + $packageVersionSuffix="alpha5-$revision" + Write-Output "VERSION-SUFFIX: $packageVersionSuffix" + Write-Output "RUNNING dotnet pack .\src\JsonApiDotNetCore -c Release -o .\artifacts --version-suffix=$packageVersionSuffix" + dotnet pack .\src\JsonApiDotNetCore -c Release -o .\artifacts --version-suffix=$packageVersionSuffix CheckLastExitCode } \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props index d55c6ddae7..9aaa62aba4 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,7 +1,6 @@ netcoreapp3.1 - netstandard2.1 3.1.* 3.1.* 3.1.* @@ -10,12 +9,14 @@ $(NoWarn);1591 true + true 2.4.1 + 5.10.3 29.0.1 4.13.1 - \ No newline at end of file + diff --git a/README.md b/README.md index 66c5d570c1..bc3dd8e80c 100644 --- a/README.md +++ b/README.md @@ -54,10 +54,10 @@ public class Article : Identifiable public class ArticlesController : JsonApiController
{ public ArticlesController( - IJsonApiOptions jsonApiOptions, + IJsonApiOptions options, IResourceService
resourceService, ILoggerFactory loggerFactory) - : base(jsonApiOptions, resourceService, loggerFactory) + : base(options, resourceService, loggerFactory) { } } ``` @@ -121,3 +121,15 @@ A lot of changes were introduced in v4.0.0, the following chart should help you | ----------------- | ------------- | | 2.* | v3.* | | 3.* | v4.* | + +## Trying out the latest build + +After each commit, a new prerelease NuGet package is automatically published to AppVeyor at https://ci.appveyor.com/nuget/jsonapidotnetcore. To try it out, follow the next steps: + +* In Visual Studio: **Tools**, **NuGet Package Manager**, **Package Manager Settings**, **Package Sources** + * Click **+** + * Name: **AppVeyor JADNC**, Source: **https://ci.appveyor.com/nuget/jsonapidotnetcore** + * Click **Update**, **Ok** +* Open the NuGet package manager console (**Tools**, **NuGet Package Manager**, **Package Manager Console**) + * Select **AppVeyor JADNC** as package source + * Run command: `Install-Package JonApiDotNetCore -pre` diff --git a/benchmarks/BenchmarkResource.cs b/benchmarks/BenchmarkResource.cs index 6fe5eea7ca..0353078601 100644 --- a/benchmarks/BenchmarkResource.cs +++ b/benchmarks/BenchmarkResource.cs @@ -1,10 +1,11 @@ -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; namespace Benchmarks { public sealed class BenchmarkResource : Identifiable { - [Attr(BenchmarkResourcePublicNames.NameAttr)] + [Attr(PublicName = BenchmarkResourcePublicNames.NameAttr)] public string Name { get; set; } [HasOne] diff --git a/benchmarks/DependencyFactory.cs b/benchmarks/DependencyFactory.cs index d291fa77c0..87680c7ba1 100644 --- a/benchmarks/DependencyFactory.cs +++ b/benchmarks/DependencyFactory.cs @@ -1,9 +1,6 @@ using System; -using JsonApiDotNetCore.Builders; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Query; +using JsonApiDotNetCore.Resources; using Microsoft.Extensions.Logging.Abstractions; using Moq; @@ -14,7 +11,7 @@ internal static class DependencyFactory public static IResourceGraph CreateResourceGraph(IJsonApiOptions options) { IResourceGraphBuilder builder = new ResourceGraphBuilder(options, NullLoggerFactory.Instance); - builder.AddResource(BenchmarkResourcePublicNames.Type); + builder.Add(BenchmarkResourcePublicNames.Type); return builder.Build(); } diff --git a/benchmarks/LinkBuilder/LinkBuilderGetNamespaceFromPathBenchmarks.cs b/benchmarks/LinkBuilder/LinkBuilderGetNamespaceFromPathBenchmarks.cs index 5524f83e5f..60b5f31ce1 100644 --- a/benchmarks/LinkBuilder/LinkBuilderGetNamespaceFromPathBenchmarks.cs +++ b/benchmarks/LinkBuilder/LinkBuilderGetNamespaceFromPathBenchmarks.cs @@ -1,6 +1,6 @@ -using BenchmarkDotNet.Attributes; using System; using System.Text; +using BenchmarkDotNet.Attributes; namespace Benchmarks.LinkBuilder { @@ -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..b5be39eb03 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.Contracts; -using JsonApiDotNetCore.Managers; -using JsonApiDotNetCore.Query; -using JsonApiDotNetCore.QueryParameterServices.Common; -using JsonApiDotNetCore.Services; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.QueryStrings; +using JsonApiDotNetCore.QueryStrings.Internal; +using JsonApiDotNetCore.Resources; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.Logging.Abstractions; @@ -17,56 +17,58 @@ 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 request = new JsonApiRequest + { + PrimaryResource = resourceGraph.GetResourceContext(typeof(BenchmarkResource)) + }; - _queryParameterParserForSort = CreateQueryParameterDiscoveryForSort(resourceGraph, currentRequest, resourceDefinitionProvider, options, _queryStringAccessor); - _queryParameterParserForAll = CreateQueryParameterDiscoveryForAll(resourceGraph, currentRequest, resourceDefinitionProvider, options, _queryStringAccessor); + _queryStringReaderForSort = CreateQueryParameterDiscoveryForSort(resourceGraph, request, options, _queryStringAccessor); + _queryStringReaderForAll = CreateQueryParameterDiscoveryForAll(resourceGraph, request, options, _queryStringAccessor); } - private static QueryParameterParser CreateQueryParameterDiscoveryForSort(IResourceGraph resourceGraph, - CurrentRequest currentRequest, IResourceDefinitionProvider resourceDefinitionProvider, - IJsonApiOptions options, FakeRequestQueryStringAccessor queryStringAccessor) + private static QueryStringReader CreateQueryParameterDiscoveryForSort(IResourceGraph resourceGraph, + JsonApiRequest request, IJsonApiOptions options, FakeRequestQueryStringAccessor queryStringAccessor) { - ISortService sortService = new SortService(resourceDefinitionProvider, resourceGraph, currentRequest); - - var queryServices = new List + var sortReader = new SortQueryStringParameterReader(request, 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, + JsonApiRequest request, 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(request, resourceGraph, resourceFactory, options); + var sortReader = new SortQueryStringParameterReader(request, resourceGraph); + var sparseFieldSetReader = new SparseFieldSetQueryStringParameterReader(request, resourceGraph); + var paginationReader = new PaginationQueryStringParameterReader(request, 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 +77,7 @@ public void AscendingSort() var queryString = $"?sort={BenchmarkResourcePublicNames.NameAttr}"; _queryStringAccessor.SetQueryString(queryString); - _queryParameterParserForSort.Parse(null); + _queryStringReaderForSort.ReadAll(null); } [Benchmark] @@ -84,7 +86,7 @@ public void DescendingSort() var queryString = $"?sort=-{BenchmarkResourcePublicNames.NameAttr}"; _queryStringAccessor.SetQueryString(queryString); - _queryParameterParserForSort.Parse(null); + _queryStringReaderForSort.ReadAll(null); } [Benchmark] @@ -93,7 +95,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 b75bacd347..6a373eb73c 100644 --- a/benchmarks/Serialization/JsonApiDeserializerBenchmarks.cs +++ b/benchmarks/Serialization/JsonApiDeserializerBenchmarks.cs @@ -3,11 +3,10 @@ using System.ComponentModel.Design; using BenchmarkDotNet.Attributes; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization; -using JsonApiDotNetCore.Serialization.Server; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.AspNetCore.Http; using Newtonsoft.Json; namespace Benchmarks.Serialization @@ -38,8 +37,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); + _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..7f8b961548 100644 --- a/benchmarks/Serialization/JsonApiSerializerBenchmarks.cs +++ b/benchmarks/Serialization/JsonApiSerializerBenchmarks.cs @@ -1,12 +1,11 @@ using System; using BenchmarkDotNet.Attributes; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Managers; -using JsonApiDotNetCore.Query; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.QueryStrings.Internal; using JsonApiDotNetCore.Serialization; -using JsonApiDotNetCore.Serialization.Server; -using JsonApiDotNetCore.Serialization.Server.Builders; +using JsonApiDotNetCore.Serialization.Building; using Moq; namespace Benchmarks.Serialization @@ -14,7 +13,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 +39,19 @@ public JsonApiSerializerBenchmarks() private static FieldsToSerialize CreateFieldsToSerialize(IResourceGraph resourceGraph) { + var request = new JsonApiRequest(); + + var constraintProviders = new IQueryConstraintProvider[] + { + new SparseFieldSetQueryStringParameterReader(request, resourceGraph) + }; + var resourceDefinitionProvider = DependencyFactory.CreateResourceDefinitionProvider(resourceGraph); - var currentRequest = new CurrentRequest(); - var sparseFieldsService = new SparseFieldsService(resourceGraph, currentRequest); - - return new FieldsToSerialize(resourceGraph, sparseFieldsService, resourceDefinitionProvider); + + 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..6d92763517 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.Configuration.IResourceGraph.yml) +- [`ResourceDefinition`](JsonApiDotNetCore.Resources.ResourceDefinition-1.yml) diff --git a/docs/docfx.json b/docs/docfx.json index ccc9762111..45e6c353dd 100644 --- a/docs/docfx.json +++ b/docs/docfx.json @@ -10,7 +10,7 @@ "dest": "api", "disableGitFeatures": false, "properties": { - "targetFramework": "netstandard2.0" + "targetFramework": "netcoreapp3.1" } } ], @@ -25,6 +25,7 @@ "getting-started/**/toc.yml", "usage/**.md", "request-examples/**.md", + "internals/**.md", "toc.yml", "*.md" ] diff --git a/docs/getting-started/step-by-step.md b/docs/getting-started/step-by-step.md index ac0b03fc74..2520f4f0dc 100644 --- a/docs/getting-started/step-by-step.md +++ b/docs/getting-started/step-by-step.md @@ -68,10 +68,10 @@ where `T` is the model that inherits from `Identifiable` public class PeopleController : JsonApiController { public PeopleController( - IJsonApiOptions jsonApiOptions, + IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(jsonApiOptions, loggerFactory, resourceService) + : base(options, loggerFactory, resourceService) { } } ``` 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/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 diff --git a/docs/usage/extensibility/controllers.md b/docs/usage/extensibility/controllers.md index b6e4e8d686..1040d33f1b 100644 --- a/docs/usage/extensibility/controllers.md +++ b/docs/usage/extensibility/controllers.md @@ -6,10 +6,10 @@ You need to create controllers that inherit from `JsonApiController` public class ArticlesController : JsonApiController
{ public ArticlesController( - IJsonApiOptions jsonApiOptions, + IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService
resourceService) - : base(jsonApiOptions, loggerFactory, resourceService) + : base(options, loggerFactory, resourceService) { } } ``` @@ -23,11 +23,11 @@ public class ArticlesController : JsonApiController //---------------------------------------------------------- ^^^^ { public ArticlesController( - IJsonApiOptions jsonApiOptions, + IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) //----------------------- ^^^^ - : base(jsonApiOptions, loggerFactory, resourceService) + : base(options, loggerFactory, resourceService) { } } ``` @@ -44,10 +44,10 @@ This approach is ok, but introduces some boilerplate that can easily be avoided. public class ArticlesController : BaseJsonApiController
{ public ArticlesController( - IJsonApiOptions jsonApiOptions, + IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService
resourceService) - : base(jsonApiOptions, loggerFactory, resourceService) + : base(options, loggerFactory, resourceService) { } [HttpGet] @@ -81,10 +81,10 @@ An attempt to use one blacklisted methods will result in a HTTP 405 Method Not A public class ArticlesController : BaseJsonApiController
{ public ArticlesController( - IJsonApiOptions jsonApiOptions, + IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService
resourceService) - : base(jsonApiOptions, loggerFactory, resourceService) + : base(options, loggerFactory, resourceService) { } } ``` @@ -101,10 +101,10 @@ For more information about resource injection, see the next section titled Resou public class ReportsController : BaseJsonApiController { public ReportsController( - IJsonApiOptions jsonApiOptions, + IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(jsonApiOptions, loggerFactory, resourceService) + : base(options, loggerFactory, resourceService) { } [HttpGet] 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..84d7ac3897 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 @@ -148,17 +148,17 @@ Then in the controller, you should inherit from the base controller and pass the public class ArticlesController : BaseJsonApiController
{ public ArticlesController( - IJsonApiOptions jsonApiOptions, + IJsonApiOptions options, ILoggerFactory loggerFactory, ICreateService create, IDeleteService delete) - : base(jsonApiOptions, loggerFactory, create: create, delete: delete) + : base(options, loggerFactory, create: create, delete: delete) { } [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 5283055728..fe5751aa60 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,21 @@ 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 is returned for unknown query string parameters. ```c# -options.AllowCustomQueryStringParameters = true; +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 @@ -81,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. @@ -89,3 +100,12 @@ If you would like to use ASP.NET Core ModelState validation into your controller 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# +public class Person : Identifiable +{ + [IsRequired(AllowEmptyStrings = true)] + public string FirstName { get; set; } +} +``` 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..d35f2e301f 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,126 @@ 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 - -_since v3.0.0_ +## Exclude soft-deleted resources -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 + 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}); + } +} +``` + +## 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))) { - (account => account.LastLoginTime, SortDirection.Descending), - (account => account.UserName, SortDirection.Ascending) - }; + throw new JsonApiException(new Error(HttpStatusCode.BadRequest) + { + Title = "Including the manager of employees is not permitted." + }); + } + + return existingIncludes; } } ``` -## 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..b4261d2516 100644 --- a/docs/usage/routing.md +++ b/docs/usage/routing.md @@ -30,10 +30,10 @@ You can disable the default casing convention and specify your own template by u public class CamelCasedModelsController : JsonApiController { public CamelCasedModelsController( - IJsonApiOptions jsonApiOptions, + IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(jsonApiOptions, loggerFactory, resourceService) + : base(options, loggerFactory, resourceService) { } } ``` @@ -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/GettingStarted/Controllers/ArticlesController.cs b/src/Examples/GettingStarted/Controllers/ArticlesController.cs index fc79e4bed8..ad73a9e9bd 100644 --- a/src/Examples/GettingStarted/Controllers/ArticlesController.cs +++ b/src/Examples/GettingStarted/Controllers/ArticlesController.cs @@ -9,10 +9,10 @@ namespace GettingStarted.Controllers public sealed class ArticlesController : JsonApiController
{ public ArticlesController( - IJsonApiOptions jsonApiOptions, + IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService
resourceService) - : base(jsonApiOptions, loggerFactory, resourceService) + : base(options, loggerFactory, resourceService) { } } } diff --git a/src/Examples/GettingStarted/Controllers/PeopleController.cs b/src/Examples/GettingStarted/Controllers/PeopleController.cs index 05baa40b68..0337a159d3 100644 --- a/src/Examples/GettingStarted/Controllers/PeopleController.cs +++ b/src/Examples/GettingStarted/Controllers/PeopleController.cs @@ -9,10 +9,10 @@ namespace GettingStarted.Controllers public sealed class PeopleController : JsonApiController { public PeopleController( - IJsonApiOptions jsonApiOptions, + IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(jsonApiOptions, loggerFactory, resourceService) + : base(options, loggerFactory, resourceService) { } } } diff --git a/src/Examples/GettingStarted/Models/Article.cs b/src/Examples/GettingStarted/Models/Article.cs index b10ede2fb7..eb7d0d8093 100644 --- a/src/Examples/GettingStarted/Models/Article.cs +++ b/src/Examples/GettingStarted/Models/Article.cs @@ -1,4 +1,5 @@ -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; namespace GettingStarted.Models { diff --git a/src/Examples/GettingStarted/Models/Person.cs b/src/Examples/GettingStarted/Models/Person.cs index afbdc091ba..161ed14e58 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.Resources; +using JsonApiDotNetCore.Resources.Annotations; namespace GettingStarted.Models { diff --git a/src/Examples/GettingStarted/Startup.cs b/src/Examples/GettingStarted/Startup.cs index dd499af5ba..5d9c096457 100644 --- a/src/Examples/GettingStarted/Startup.cs +++ b/src/Examples/GettingStarted/Startup.cs @@ -1,6 +1,6 @@ using GettingStarted.Data; using GettingStarted.Models; -using JsonApiDotNetCore; +using JsonApiDotNetCore.Configuration; using Microsoft.AspNetCore.Builder; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/ArticlesController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/ArticlesController.cs index 270aeee9bf..a553851ccd 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/ArticlesController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/ArticlesController.cs @@ -6,14 +6,13 @@ namespace JsonApiDotNetCoreExample.Controllers { - [DisableQuery(StandardQueryStringParameters.Sort | StandardQueryStringParameters.Page)] public sealed class ArticlesController : JsonApiController
{ public ArticlesController( - IJsonApiOptions jsonApiOptions, + IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService
resourceService) - : base(jsonApiOptions, loggerFactory, resourceService) + : base(options, loggerFactory, resourceService) { } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/ModelsController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/AuthorsController.cs similarity index 53% rename from src/Examples/JsonApiDotNetCoreExample/Controllers/ModelsController.cs rename to src/Examples/JsonApiDotNetCoreExample/Controllers/AuthorsController.cs index 2c9ce2898a..789c31cb95 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/ModelsController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/AuthorsController.cs @@ -6,13 +6,13 @@ namespace JsonApiDotNetCoreExample.Controllers { - public sealed class ModelsController : JsonApiController + public sealed class AuthorsController : JsonApiController { - public ModelsController( - IJsonApiOptions jsonApiOptions, + public AuthorsController( + IJsonApiOptions options, ILoggerFactory loggerFactory, - IResourceService resourceService) - : base(jsonApiOptions, loggerFactory, resourceService) + IResourceService resourceService) + : base(options, loggerFactory, resourceService) { } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/BlogsController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/BlogsController.cs new file mode 100644 index 0000000000..824b7a30a6 --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/BlogsController.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 BlogsController : JsonApiController + { + public BlogsController( + IJsonApiOptions options, + ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { } + } +} diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/CountriesController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/CountriesController.cs new file mode 100644 index 0000000000..2f37dcb387 --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/CountriesController.cs @@ -0,0 +1,21 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Controllers.Annotations; +using JsonApiDotNetCore.QueryStrings; +using JsonApiDotNetCore.Services; +using JsonApiDotNetCoreExample.Models; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExample.Controllers +{ + [DisableQueryString(StandardQueryStringParameters.Sort | StandardQueryStringParameters.Page)] + public sealed class CountriesController : JsonApiController + { + public CountriesController( + IJsonApiOptions options, + ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { } + } +} diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/KebabCasedModelsController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/KebabCasedModelsController.cs index 3f1ac5e2ed..a473241247 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/KebabCasedModelsController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/KebabCasedModelsController.cs @@ -9,10 +9,10 @@ namespace JsonApiDotNetCoreExample.Controllers public sealed class KebabCasedModelsController : JsonApiController { public KebabCasedModelsController( - IJsonApiOptions jsonApiOptions, + IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(jsonApiOptions, loggerFactory, resourceService) + : base(options, loggerFactory, resourceService) { } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/PassportsController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/PassportsController.cs index 0fe73da0e1..152628ad96 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/PassportsController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/PassportsController.cs @@ -11,10 +11,10 @@ namespace JsonApiDotNetCoreExample.Controllers public sealed class PassportsController : BaseJsonApiController { public PassportsController( - IJsonApiOptions jsonApiOptions, + IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(jsonApiOptions, loggerFactory, resourceService) + : base(options, loggerFactory, resourceService) { } [HttpGet] @@ -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/PeopleController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/PeopleController.cs index eae404b7bc..4b0116ece6 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/PeopleController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/PeopleController.cs @@ -9,10 +9,10 @@ namespace JsonApiDotNetCoreExample.Controllers public sealed class PeopleController : JsonApiController { public PeopleController( - IJsonApiOptions jsonApiOptions, + IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(jsonApiOptions, loggerFactory, resourceService) + : base(options, loggerFactory, resourceService) { } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/PersonRolesController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/PersonRolesController.cs index 4dfb9232b0..75c930126f 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/PersonRolesController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/PersonRolesController.cs @@ -9,10 +9,10 @@ namespace JsonApiDotNetCoreExample.Controllers public sealed class PersonRolesController : JsonApiController { public PersonRolesController( - IJsonApiOptions jsonApiOptions, + IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(jsonApiOptions, loggerFactory, resourceService) + : base(options, loggerFactory, resourceService) { } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/Restricted/ReadOnlyController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/Restricted/ReadOnlyController.cs index 66e67fc9d6..cbdbdbc2a8 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/Restricted/ReadOnlyController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/Restricted/ReadOnlyController.cs @@ -1,5 +1,6 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Controllers.Annotations; using JsonApiDotNetCore.Services; using JsonApiDotNetCoreExample.Models; using Microsoft.AspNetCore.Mvc; @@ -12,10 +13,10 @@ namespace JsonApiDotNetCoreExample.Controllers.Restricted public class ReadOnlyController : BaseJsonApiController
{ public ReadOnlyController( - IJsonApiOptions jsonApiOptions, + IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService
resourceService) - : base(jsonApiOptions, loggerFactory, resourceService) + : base(options, loggerFactory, resourceService) { } [HttpGet] @@ -36,10 +37,10 @@ public ReadOnlyController( public class NoHttpPostController : BaseJsonApiController
{ public NoHttpPostController( - IJsonApiOptions jsonApiOptions, + IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService
resourceService) - : base(jsonApiOptions, loggerFactory, resourceService) + : base(options, loggerFactory, resourceService) { } [HttpGet] @@ -60,10 +61,10 @@ public NoHttpPostController( public class NoHttpPatchController : BaseJsonApiController
{ public NoHttpPatchController( - IJsonApiOptions jsonApiOptions, + IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService
resourceService) - : base(jsonApiOptions, loggerFactory, resourceService) + : base(options, loggerFactory, resourceService) { } [HttpGet] @@ -84,10 +85,10 @@ public NoHttpPatchController( public class NoHttpDeleteController : BaseJsonApiController
{ public NoHttpDeleteController( - IJsonApiOptions jsonApiOptions, + IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService
resourceService) - : base(jsonApiOptions, loggerFactory, resourceService) + : base(options, loggerFactory, resourceService) { } [HttpGet] diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/TagsController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/TagsController.cs index c134c8422d..bccf2192d6 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/TagsController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/TagsController.cs @@ -1,19 +1,20 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Controllers.Annotations; using JsonApiDotNetCore.Services; using JsonApiDotNetCoreExample.Models; using Microsoft.Extensions.Logging; namespace JsonApiDotNetCoreExample.Controllers { - [DisableQuery("skipCache")] + [DisableQueryString("skipCache")] public sealed class TagsController : JsonApiController { public TagsController( - IJsonApiOptions jsonApiOptions, + IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(jsonApiOptions, loggerFactory, resourceService) + : base(options, loggerFactory, resourceService) { } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/ThrowingResourcesController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/ThrowingResourcesController.cs index 3c87a76777..8b662cde09 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/ThrowingResourcesController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/ThrowingResourcesController.cs @@ -9,10 +9,10 @@ namespace JsonApiDotNetCoreExample.Controllers public sealed class ThrowingResourcesController : JsonApiController { public ThrowingResourcesController( - IJsonApiOptions jsonApiOptions, + IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(jsonApiOptions, loggerFactory, resourceService) + : base(options, loggerFactory, resourceService) { } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoCollectionsController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoCollectionsController.cs index ccdcb088ca..a6c130940b 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoCollectionsController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoCollectionsController.cs @@ -3,7 +3,7 @@ using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Data; +using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Services; using JsonApiDotNetCoreExample.Models; using Microsoft.AspNetCore.Mvc; @@ -17,25 +17,25 @@ public sealed class TodoCollectionsController : JsonApiController resourceService) - : base(jsonApiOptions, loggerFactory, resourceService) + : base(options, loggerFactory, resourceService) { _dbResolver = contextResolver; } [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/TodoItemsController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsController.cs index d21f6c016d..dbd9057a12 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsController.cs @@ -9,10 +9,10 @@ namespace JsonApiDotNetCoreExample.Controllers public sealed class TodoItemsController : JsonApiController { public TodoItemsController( - IJsonApiOptions jsonApiOptions, + IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(jsonApiOptions, loggerFactory, resourceService) + : base(options, loggerFactory, resourceService) { } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs index 1b2865c098..fb75b37226 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs @@ -2,9 +2,10 @@ using System.Net; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Exceptions; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Controllers.Annotations; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCore.Services; using JsonApiDotNetCoreExample.Models; using Microsoft.AspNetCore.Mvc; @@ -61,8 +62,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 +71,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 +85,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 +97,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 +135,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..2480d40f04 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsTestController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsTestController.cs @@ -2,8 +2,9 @@ using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCore.Controllers.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCore.Services; using JsonApiDotNetCoreExample.Models; using Microsoft.AspNetCore.Mvc; @@ -15,10 +16,10 @@ public abstract class AbstractTodoItemsController : BaseJsonApiController where T : class, IIdentifiable { protected AbstractTodoItemsController( - IJsonApiOptions jsonApiOptions, + IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService service) - : base(jsonApiOptions, loggerFactory, service) + : base(options, loggerFactory, service) { } } @@ -27,10 +28,10 @@ protected AbstractTodoItemsController( public class TodoItemsTestController : AbstractTodoItemsController { public TodoItemsTestController( - IJsonApiOptions jsonApiOptions, + IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService service) - : base(jsonApiOptions, loggerFactory, service) + : base(options, loggerFactory, service) { } [HttpGet] @@ -40,15 +41,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 +60,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 +68,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/UsersController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/UsersController.cs index e07456cc90..2411879bb7 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/UsersController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/UsersController.cs @@ -9,20 +9,20 @@ namespace JsonApiDotNetCoreExample.Controllers public sealed class UsersController : JsonApiController { public UsersController( - IJsonApiOptions jsonApiOptions, + IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(jsonApiOptions, loggerFactory, resourceService) + : base(options, loggerFactory, resourceService) { } } public sealed class SuperUsersController : JsonApiController { public SuperUsersController( - IJsonApiOptions jsonApiOptions, + IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(jsonApiOptions, loggerFactory, resourceService) + : base(options, loggerFactory, resourceService) { } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/VisasController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/VisasController.cs new file mode 100644 index 0000000000..1cb637160e --- /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 options, + ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, 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..b28817b6f0 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Definitions/ArticleDefinition.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Definitions/ArticleDefinition.cs @@ -1,11 +1,11 @@ using System.Collections.Generic; using System.Linq; using System.Net; -using JsonApiDotNetCore.Exceptions; -using JsonApiDotNetCore.Hooks; -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Hooks.Internal.Execution; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExample.Models; namespace JsonApiDotNetCoreExample.Definitions @@ -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..02f66eafaf 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Definitions/LockableDefinition.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Definitions/LockableDefinition.cs @@ -1,10 +1,10 @@ using System.Collections.Generic; using System.Linq; using System.Net; -using JsonApiDotNetCore.Exceptions; -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExample.Models; namespace JsonApiDotNetCoreExample.Definitions @@ -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..84c1e8ec95 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Definitions/PassportDefinition.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Definitions/PassportDefinition.cs @@ -1,11 +1,11 @@ using System.Collections.Generic; using System.Linq; using System.Net; -using JsonApiDotNetCore.Exceptions; -using JsonApiDotNetCore.Hooks; -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Hooks.Internal.Execution; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExample.Models; namespace JsonApiDotNetCoreExample.Definitions @@ -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..7041e1fd42 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Definitions/PersonDefinition.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Definitions/PersonDefinition.cs @@ -1,8 +1,8 @@ using System.Collections.Generic; using System.Linq; -using JsonApiDotNetCore.Hooks; -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Hooks.Internal.Execution; +using JsonApiDotNetCore.Resources; using JsonApiDotNetCoreExample.Models; namespace JsonApiDotNetCoreExample.Definitions @@ -11,18 +11,18 @@ 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() + public IReadOnlyDictionary GetMeta() { return new Dictionary { { "copyright", "Copyright 2015 Example Corp." }, diff --git a/src/Examples/JsonApiDotNetCoreExample/Definitions/TagDefinition.cs b/src/Examples/JsonApiDotNetCoreExample/Definitions/TagDefinition.cs index d9d7366437..bcc423c1d5 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Definitions/TagDefinition.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Definitions/TagDefinition.cs @@ -1,8 +1,8 @@ using System.Collections.Generic; using System.Linq; -using JsonApiDotNetCore.Hooks; -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Hooks.Internal.Execution; +using JsonApiDotNetCore.Resources; using JsonApiDotNetCoreExample.Models; namespace JsonApiDotNetCoreExample.Definitions @@ -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/TodoDefinition.cs b/src/Examples/JsonApiDotNetCoreExample/Definitions/TodoDefinition.cs index 6f27e716a3..bdcb687b86 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Definitions/TodoDefinition.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Definitions/TodoDefinition.cs @@ -1,10 +1,10 @@ using System.Collections.Generic; using System.Linq; using System.Net; -using JsonApiDotNetCore.Exceptions; -using JsonApiDotNetCore.Hooks; -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Hooks.Internal.Execution; +using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExample.Models; namespace JsonApiDotNetCoreExample.Definitions 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..a84436df31 --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Address.cs @@ -0,0 +1,17 @@ +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +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 01b0d1e352..cdfc216110 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Article.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Article.cs @@ -1,17 +1,21 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations.Schema; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCoreExample.Models { public sealed class Article : Identifiable { [Attr] - public string Name { get; set; } + [IsRequired(AllowEmptyStrings = true)] + 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))] @@ -22,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 57232e3b34..317ecf5e65 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/ArticleTag.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/ArticleTag.cs @@ -1,6 +1,5 @@ -using System; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCoreExample.Data; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCoreExample.Models { @@ -11,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 27b817ca9c..88793599e9 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Author.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Author.cs @@ -1,15 +1,29 @@ -using JsonApiDotNetCore.Models; +using System; using System.Collections.Generic; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCoreExample.Models { public sealed class Author : Identifiable { [Attr] - public string Name { get; set; } + public string FirstName { get; set; } + + [Attr] + [IsRequired(AllowEmptyStrings = true)] + 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..0330150ca3 --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Blog.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +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..0a30443aed 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Country.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Country.cs @@ -1,8 +1,11 @@ +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + 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/KebabCasedModel.cs b/src/Examples/JsonApiDotNetCoreExample/Models/KebabCasedModel.cs index ad36d928f3..d9e4c4bbf9 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/KebabCasedModel.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/KebabCasedModel.cs @@ -1,4 +1,5 @@ -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCoreExample.Models { diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Model.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Model.cs deleted file mode 100644 index 977292d0a7..0000000000 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Model.cs +++ /dev/null @@ -1,10 +0,0 @@ -using JsonApiDotNetCore.Models; - -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 b9c37f447c..c42367aaff 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Passport.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Passport.cs @@ -2,7 +2,8 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations.Schema; using System.Linq; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCoreExample.Data; using Microsoft.AspNetCore.Authentication; @@ -13,7 +14,7 @@ public class Passport : Identifiable private readonly ISystemClock _systemClock; private int? _socialSecurityNumber; - protected override string GetStringId(object value) + protected override string GetStringId(int value) { return HexadecimalObfuscationCodec.Encode(value); } @@ -53,11 +54,7 @@ public string BirthCountryName get => BirthCountry.Name; set { - if (BirthCountry == null) - { - BirthCountry = new Country(); - } - + BirthCountry ??= new Country(); BirthCountry.Name = value; } } @@ -65,7 +62,7 @@ public string BirthCountryName [EagerLoad] public Country BirthCountry { get; set; } - [Attr(AttrCapabilities.All & ~AttrCapabilities.AllowMutate)] + [Attr(Capabilities = 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 7c6a171854..db610ac0f2 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using System.Linq; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Models.Links; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCoreExample.Models { @@ -37,7 +37,7 @@ public string FirstName [Attr] public string LastName { get; set; } - [Attr("the-Age")] + [Attr(PublicName = "the-Age")] public int Age { get; set; } [Attr] @@ -53,7 +53,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; } @@ -66,7 +66,7 @@ public string FirstName public TodoItem StakeHolderTodoItem { get; set; } public int? StakeHolderTodoItemId { get; set; } - [HasOne(links: Link.All, canInclude: false)] + [HasOne(Links = LinkTypes.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..7b9beb3f7c --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Revision.cs @@ -0,0 +1,18 @@ +using System; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +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 e63df2b9ad..21b74c0fd4 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs @@ -1,7 +1,6 @@ -using System; using System.ComponentModel.DataAnnotations; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCoreExample.Data; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCoreExample.Models { @@ -11,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/ThrowingResource.cs b/src/Examples/JsonApiDotNetCoreExample/Models/ThrowingResource.cs index 01eda5d7d0..cf2f963e2a 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/ThrowingResource.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/ThrowingResource.cs @@ -1,8 +1,9 @@ using System; using System.Diagnostics; using System.Linq; -using JsonApiDotNetCore.Formatters; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization; namespace JsonApiDotNetCoreExample.Models { diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs index 000e60d238..c87ea2e5bb 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.Resources; +using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCoreExample.Models { @@ -32,13 +33,13 @@ public string AlwaysChangingValue [Attr] public DateTime CreatedDate { get; set; } - [Attr(AttrCapabilities.All & ~(AttrCapabilities.AllowFilter | AttrCapabilities.AllowSort))] + [Attr(Capabilities = AttrCapabilities.All & ~(AttrCapabilities.AllowFilter | AttrCapabilities.AllowSort))] public DateTime? AchievedDate { get; set; } [Attr] public DateTime? UpdatedDate { get; set; } - [Attr(AttrCapabilities.All & ~AttrCapabilities.AllowMutate)] + [Attr(Capabilities = AttrCapabilities.All & ~AttrCapabilities.AllowChange)] public string CalculatedValue => "calculated"; [Attr] diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItemCollection.cs b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItemCollection.cs index edb6e98692..9b0a515f25 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.Resources; +using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCoreExample.Models { diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/User.cs b/src/Examples/JsonApiDotNetCoreExample/Models/User.cs index 89018b997b..2760278844 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.Resources; +using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCoreExample.Data; using Microsoft.AspNetCore.Authentication; @@ -10,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(Capabilities = AttrCapabilities.AllowChange)] public string Password { get => _password; diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Visa.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Visa.cs index a7b31743e2..781a391a8e 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.Resources; +using JsonApiDotNetCore.Resources.Annotations; 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..430b65e27e 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs @@ -1,37 +1,37 @@ +using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Data; -using JsonApiDotNetCore.Hooks; -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Query; +using JsonApiDotNetCore.Hooks.Internal; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Repositories; +using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Services; using JsonApiDotNetCoreExample.Models; using Microsoft.Extensions.Logging; -using System.Collections.Generic; -using System.Threading.Tasks; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.RequestServices; 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, + IJsonApiRequest request, IResourceChangeTracker
resourceChangeTracker, IResourceFactory resourceFactory, IResourceHookExecutor hookExecutor = null) - : base(queryParameters, options, loggerFactory, repository, provider, resourceChangeTracker, resourceFactory, hookExecutor) + : base(repository, queryLayerComposer, paginationContext, options, loggerFactory, request, + 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 55% rename from src/Examples/JsonApiDotNetCoreExample/Services/SkipCacheQueryParameterService.cs rename to src/Examples/JsonApiDotNetCoreExample/Services/SkipCacheQueryStringParameterReader.cs index e5892ccf3f..95e2676d9b 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Services/SkipCacheQueryParameterService.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Services/SkipCacheQueryStringParameterReader.cs @@ -1,28 +1,28 @@ using System.Linq; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Exceptions; -using JsonApiDotNetCore.Query; +using JsonApiDotNetCore.Controllers.Annotations; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.QueryStrings; using Microsoft.Extensions.Primitives; namespace JsonApiDotNetCoreExample.Services { - public class SkipCacheQueryParameterService : IQueryParameterService + public class SkipCacheQueryStringParameterReader : IQueryStringParameterReader { private const string _skipCacheParameterName = "skipCache"; public bool SkipCache { get; private set; } - public bool IsEnabled(DisableQueryAttribute disableQueryAttribute) + public bool IsEnabled(DisableQueryStringAttribute disableQueryStringAttribute) { - return !disableQueryAttribute.ParameterNames.Contains(_skipCacheParameterName.ToLowerInvariant()); + return !disableQueryStringAttribute.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..18d40e9ccd --- /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 ASP.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..c6b0cfd87b 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Startups/Startup.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Startups/Startup.cs @@ -1,51 +1,43 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using JsonApiDotNetCoreExample.Data; -using Microsoft.EntityFrameworkCore; using System; -using JsonApiDotNetCore; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Query; +using JsonApiDotNetCore.QueryStrings; +using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Services; using Microsoft.AspNetCore.Authentication; +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 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 +52,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/Controllers/WorkItemsController.cs b/src/Examples/NoEntityFrameworkExample/Controllers/WorkItemsController.cs index 6d1abae1f0..79b994d479 100644 --- a/src/Examples/NoEntityFrameworkExample/Controllers/WorkItemsController.cs +++ b/src/Examples/NoEntityFrameworkExample/Controllers/WorkItemsController.cs @@ -1,18 +1,18 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Services; -using NoEntityFrameworkExample.Models; using Microsoft.Extensions.Logging; +using NoEntityFrameworkExample.Models; namespace NoEntityFrameworkExample.Controllers { public sealed class WorkItemsController : JsonApiController { public WorkItemsController( - IJsonApiOptions jsonApiOptions, + IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(jsonApiOptions, loggerFactory, resourceService) + : base(options, loggerFactory, resourceService) { } } } diff --git a/src/Examples/NoEntityFrameworkExample/Models/WorkItem.cs b/src/Examples/NoEntityFrameworkExample/Models/WorkItem.cs index d2d1b274e6..a3a929bd5a 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.Resources; +using JsonApiDotNetCore.Resources.Annotations; namespace NoEntityFrameworkExample.Models { 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/NoEntityFrameworkExample/Startup.cs b/src/Examples/NoEntityFrameworkExample/Startup.cs index ef3bcb5287..4754f6003d 100644 --- a/src/Examples/NoEntityFrameworkExample/Startup.cs +++ b/src/Examples/NoEntityFrameworkExample/Startup.cs @@ -1,5 +1,5 @@ using System; -using JsonApiDotNetCore; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Services; using Microsoft.AspNetCore.Builder; using Microsoft.EntityFrameworkCore; @@ -26,7 +26,7 @@ public void ConfigureServices(IServiceCollection services) { services.AddJsonApi( options => options.Namespace = "api/v1", - resources: builder => builder.AddResource("workItems") + resources: builder => builder.Add("workItems") ); services.AddScoped, WorkItemService>(); diff --git a/src/Examples/ReportsExample/Controllers/ReportsController.cs b/src/Examples/ReportsExample/Controllers/ReportsController.cs index 511f691f68..c80aba4680 100644 --- a/src/Examples/ReportsExample/Controllers/ReportsController.cs +++ b/src/Examples/ReportsExample/Controllers/ReportsController.cs @@ -1,8 +1,8 @@ using System.Threading.Tasks; -using Microsoft.AspNetCore.Mvc; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Services; -using JsonApiDotNetCore.Configuration; +using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using ReportsExample.Models; @@ -12,10 +12,10 @@ namespace ReportsExample.Controllers public class ReportsController : BaseJsonApiController { public ReportsController( - IJsonApiOptions jsonApiOptions, + IJsonApiOptions options, ILoggerFactory loggerFactory, IGetAllService getAll) - : base(jsonApiOptions, loggerFactory, getAll) + : base(options, loggerFactory, getAll) { } [HttpGet] diff --git a/src/Examples/ReportsExample/Models/Report.cs b/src/Examples/ReportsExample/Models/Report.cs index b7433e89cc..8125f1f0ab 100644 --- a/src/Examples/ReportsExample/Models/Report.cs +++ b/src/Examples/ReportsExample/Models/Report.cs @@ -1,4 +1,5 @@ -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; namespace ReportsExample.Models { 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/Examples/ReportsExample/Startup.cs b/src/Examples/ReportsExample/Startup.cs index 7f01ea28db..fcb5274e8f 100644 --- a/src/Examples/ReportsExample/Startup.cs +++ b/src/Examples/ReportsExample/Startup.cs @@ -1,4 +1,4 @@ -using JsonApiDotNetCore; +using JsonApiDotNetCore.Configuration; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; diff --git a/src/JsonApiDotNetCore/AssemblyInfo.cs b/src/JsonApiDotNetCore/AssemblyInfo.cs deleted file mode 100644 index 6fa08b113d..0000000000 --- a/src/JsonApiDotNetCore/AssemblyInfo.cs +++ /dev/null @@ -1,6 +0,0 @@ -using System.Runtime.CompilerServices; -[assembly:InternalsVisibleTo("UnitTests")] -[assembly:InternalsVisibleTo("JsonApiDotNetCoreExampleTests")] -[assembly:InternalsVisibleTo("NoEntityFrameworkTests")] -[assembly:InternalsVisibleTo("Benchmarks")] -[assembly:InternalsVisibleTo("ResourceEntitySeparationExampleTests")] diff --git a/src/JsonApiDotNetCore/Builders/IResourceGraphBuilder.cs b/src/JsonApiDotNetCore/Builders/IResourceGraphBuilder.cs deleted file mode 100644 index 143c05650d..0000000000 --- a/src/JsonApiDotNetCore/Builders/IResourceGraphBuilder.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Models; - -namespace JsonApiDotNetCore.Builders -{ - public interface IResourceGraphBuilder - { - /// - /// Construct the - /// - IResourceGraph Build(); - /// - /// Add a json:api resource - /// - /// The resource model type - /// - /// The pluralized name that should be exposed by the API. - /// If nothing is specified, the configured name formatter will be used. - /// - IResourceGraphBuilder AddResource(string pluralizedTypeName = null) where TResource : class, IIdentifiable; - /// - /// Add a json:api resource - /// - /// The resource model type - /// The resource model identifier type - /// - /// The pluralized name that should be exposed by the API. - /// If nothing is specified, the configured name formatter will be used. - /// - IResourceGraphBuilder AddResource(string pluralizedTypeName = null) where TResource : class, IIdentifiable; - /// - /// Add a Json:Api resource - /// - /// The resource model type - /// The resource model identifier type - /// - /// The pluralized name that should be exposed by the API. - /// If nothing is specified, the configured name formatter will be used. - /// - IResourceGraphBuilder AddResource(Type resourceType, Type idType = null, string pluralizedTypeName = null); - } -} diff --git a/src/JsonApiDotNetCore/Extensions/ApplicationBuilderExtensions.cs b/src/JsonApiDotNetCore/Configuration/ApplicationBuilderExtensions.cs similarity index 86% rename from src/JsonApiDotNetCore/Extensions/ApplicationBuilderExtensions.cs rename to src/JsonApiDotNetCore/Configuration/ApplicationBuilderExtensions.cs index 8d943101e0..ebff426d1a 100644 --- a/src/JsonApiDotNetCore/Extensions/ApplicationBuilderExtensions.cs +++ b/src/JsonApiDotNetCore/Configuration/ApplicationBuilderExtensions.cs @@ -1,7 +1,8 @@ +using System; using JsonApiDotNetCore.Middleware; using Microsoft.AspNetCore.Builder; -namespace JsonApiDotNetCore +namespace JsonApiDotNetCore.Configuration { public static class ApplicationBuilderExtensions { @@ -20,6 +21,8 @@ public static class ApplicationBuilderExtensions /// public static void UseJsonApi(this IApplicationBuilder builder) { + if (builder == null) throw new ArgumentNullException(nameof(builder)); + builder.UseMiddleware(); } } diff --git a/src/JsonApiDotNetCore/Configuration/GenericServiceFactory.cs b/src/JsonApiDotNetCore/Configuration/GenericServiceFactory.cs new file mode 100644 index 0000000000..d421102c08 --- /dev/null +++ b/src/JsonApiDotNetCore/Configuration/GenericServiceFactory.cs @@ -0,0 +1,41 @@ +using System; + +namespace JsonApiDotNetCore.Configuration +{ + /// + public sealed class GenericServiceFactory : IGenericServiceFactory + { + private readonly IServiceProvider _serviceProvider; + + public GenericServiceFactory(IRequestScopedServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + } + + /// + public TInterface Get(Type openGenericType, Type resourceType) + { + if (openGenericType == null) throw new ArgumentNullException(nameof(openGenericType)); + if (resourceType == null) throw new ArgumentNullException(nameof(resourceType)); + + return GetInternal(openGenericType, resourceType); + } + + /// + public TInterface Get(Type openGenericType, Type resourceType, Type keyType) + { + if (openGenericType == null) throw new ArgumentNullException(nameof(openGenericType)); + if (resourceType == null) throw new ArgumentNullException(nameof(resourceType)); + if (keyType == null) throw new ArgumentNullException(nameof(keyType)); + + return GetInternal(openGenericType, resourceType, keyType); + } + + private TInterface GetInternal(Type openGenericType, params Type[] types) + { + var concreteType = openGenericType.MakeGenericType(types); + + return (TInterface)_serviceProvider.GetService(concreteType); + } + } +} diff --git a/src/JsonApiDotNetCore/Configuration/IGenericServiceFactory.cs b/src/JsonApiDotNetCore/Configuration/IGenericServiceFactory.cs new file mode 100644 index 0000000000..70ac627218 --- /dev/null +++ b/src/JsonApiDotNetCore/Configuration/IGenericServiceFactory.cs @@ -0,0 +1,31 @@ +using System; + +namespace JsonApiDotNetCore.Configuration +{ + /// + /// Represents the Service Locator design pattern. Used to obtain object instances for types are not known until runtime. + /// The typical use case would be for accessing relationship data or resolving operations processors. + /// + public interface IGenericServiceFactory + { + /// + /// Constructs the generic type and locates the service, then casts to . + /// + /// + /// (typeof(GenericProcessor<>), typeof(TResource)); + /// ]]> + /// + TInterface Get(Type openGenericType, Type resourceType); + + /// + /// Constructs the generic type and locates the service, then casts to . + /// + /// + /// (typeof(GenericProcessor<>), typeof(TResource), typeof(TId)); + /// ]]> + /// + TInterface Get(Type openGenericType, Type resourceType, Type keyType); + } +} diff --git a/src/JsonApiDotNetCore/Configuration/IInverseRelationships.cs b/src/JsonApiDotNetCore/Configuration/IInverseRelationships.cs new file mode 100644 index 0000000000..b15afea2ce --- /dev/null +++ b/src/JsonApiDotNetCore/Configuration/IInverseRelationships.cs @@ -0,0 +1,23 @@ +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Configuration +{ + /// + /// Responsible for populating the property. + /// + /// This service is instantiated in the configure phase of the application. + /// + /// When using a data access layer different from EF Core, and when using ResourceHooks + /// that depend on the inverse navigation property (BeforeImplicitUpdateRelationship), + /// you will need to override this service, or pass along the inverseNavigationProperty in + /// the RelationshipAttribute. + /// + public interface IInverseRelationships + { + /// + /// This method is called upon startup by JsonApiDotNetCore. It should + /// deal with resolving the inverse relationships. + /// + void Resolve(); + } +} diff --git a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs index 1a38100276..8c2455caf6 100644 --- a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs @@ -1,51 +1,176 @@ using System; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Objects; using Newtonsoft.Json; using Newtonsoft.Json.Serialization; namespace JsonApiDotNetCore.Configuration { - public interface IJsonApiOptions : ILinksConfiguration + /// + /// Global options that configure the behavior of JsonApiDotNetCore. + /// + 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; } + + /// + /// Use relative links for all resources. + /// + /// + /// + /// 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; } + + /// + /// 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. /// - bool IncludeExceptionStackTraceInErrors { get; set; } + LinkTypes TopLevelLinks { get; } /// - /// Whether or not database values should be included by default - /// for resource hooks. Ignored if EnableResourceHooks is set false. - /// - /// Defaults to . + /// 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. /// - bool LoadDatabaseValues { get; set; } + LinkTypes ResourceLinks { 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. + /// This option can also be specified per relationship by using the associated links argument + /// in the constructor of . /// /// - /// options.IncludeTotalRecordCount = true; + /// + /// options.RelationshipLinks = LinkTypes.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; } + LinkTypes 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; } + + /// + /// 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 . @@ -62,12 +187,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 7d1c1b38d8..0000000000 --- a/src/JsonApiDotNetCore/Configuration/ILinksConfiguration.cs +++ /dev/null @@ -1,72 +0,0 @@ -using JsonApiDotNetCore.Models; -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/IRelatedIdMapper.cs b/src/JsonApiDotNetCore/Configuration/IRelatedIdMapper.cs new file mode 100644 index 0000000000..71c813d608 --- /dev/null +++ b/src/JsonApiDotNetCore/Configuration/IRelatedIdMapper.cs @@ -0,0 +1,18 @@ +namespace JsonApiDotNetCore.Configuration +{ + /// + /// Provides an interface for formatting relationship identifiers from the navigation property name. + /// + public interface IRelatedIdMapper + { + /// + /// Gets the internal property name for the database mapped identifier property. + /// + /// + /// + /// RelatedIdMapper.GetRelatedIdPropertyName("Article"); // returns "ArticleId" + /// + /// + string GetRelatedIdPropertyName(string propertyName); + } +} diff --git a/src/JsonApiDotNetCore/Configuration/IRequestScopedServiceProvider.cs b/src/JsonApiDotNetCore/Configuration/IRequestScopedServiceProvider.cs new file mode 100644 index 0000000000..a3e72e1fb8 --- /dev/null +++ b/src/JsonApiDotNetCore/Configuration/IRequestScopedServiceProvider.cs @@ -0,0 +1,11 @@ +using System; + +namespace JsonApiDotNetCore.Configuration +{ + /// + /// An interface used to separate the registration of the global + /// from a request-scoped service provider. This is useful in cases when we need to + /// manually resolve services from the request scope (e.g. operation processors). + /// + public interface IRequestScopedServiceProvider : IServiceProvider { } +} diff --git a/src/JsonApiDotNetCore/Internal/Contracts/IResourceContextProvider.cs b/src/JsonApiDotNetCore/Configuration/IResourceContextProvider.cs similarity index 60% rename from src/JsonApiDotNetCore/Internal/Contracts/IResourceContextProvider.cs rename to src/JsonApiDotNetCore/Configuration/IResourceContextProvider.cs index cef144a9ab..a03b40870d 100644 --- a/src/JsonApiDotNetCore/Internal/Contracts/IResourceContextProvider.cs +++ b/src/JsonApiDotNetCore/Configuration/IResourceContextProvider.cs @@ -1,8 +1,8 @@ using System; using System.Collections.Generic; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Resources; -namespace JsonApiDotNetCore.Internal.Contracts +namespace JsonApiDotNetCore.Configuration { /// /// Responsible for getting s from the . @@ -10,22 +10,22 @@ namespace JsonApiDotNetCore.Internal.Contracts public interface IResourceContextProvider { /// - /// Gets all registered context entities + /// Gets all registered resource contexts. /// - IEnumerable GetResourceContexts(); + IReadOnlyCollection GetResourceContexts(); /// - /// Get the resource metadata by the DbSet property name + /// Gets the resource metadata for the specified exposed resource name. /// ResourceContext GetResourceContext(string resourceName); /// - /// Get the resource metadata by the resource type + /// Gets the resource metadata for the specified resource type. /// ResourceContext GetResourceContext(Type resourceType); /// - /// Get the resource metadata by the resource type + /// Gets the resource metadata for the specified resource type. /// ResourceContext GetResourceContext() where TResource : class, IIdentifiable; } diff --git a/src/JsonApiDotNetCore/Configuration/IResourceGraph.cs b/src/JsonApiDotNetCore/Configuration/IResourceGraph.cs new file mode 100644 index 0000000000..8a11d587f1 --- /dev/null +++ b/src/JsonApiDotNetCore/Configuration/IResourceGraph.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Configuration +{ + /// + /// Enables retrieving the exposed resource fields (attributes and relationships) of resources registered in the resource graph. + /// + public interface IResourceGraph : IResourceContextProvider + { + /// + /// Gets all fields (attributes and relationships) for + /// that are targeted by the selector. If no selector is provided, all + /// exposed fields are returned. + /// + /// The resource for which to retrieve fields. + /// Should be of the form: (TResource e) => new { e.Field1, e.Field2 } + IReadOnlyCollection GetFields(Expression> selector = null) where TResource : class, IIdentifiable; + + /// + /// Gets all attributes for + /// that are targeted by the selector. If no selector is provided, all + /// exposed fields are returned. + /// + /// The resource for which to retrieve attributes. + /// Should be of the form: (TResource e) => new { e.Attribute1, e.Attribute2 } + IReadOnlyCollection GetAttributes(Expression> selector = null) where TResource : class, IIdentifiable; + + /// + /// Gets all relationships for + /// that are targeted by the selector. If no selector is provided, all + /// exposed fields are returned. + /// + /// The resource for which to retrieve relationships. + /// Should be of the form: (TResource e) => new { e.Relationship1, e.Relationship2 } + IReadOnlyCollection GetRelationships(Expression> selector = null) where TResource : class, IIdentifiable; + + /// + /// Gets all exposed fields (attributes and relationships) for the specified type. + /// + /// The resource type. Must implement . + IReadOnlyCollection GetFields(Type type); + + /// + /// Gets all exposed attributes for the specified type. + /// + /// The resource type. Must implement . + IReadOnlyCollection GetAttributes(Type type); + + /// + /// Gets all exposed relationships for the specified type. + /// + /// The resource type. Must implement . + IReadOnlyCollection GetRelationships(Type type); + + /// + /// Traverses the resource graph, looking for the inverse relationship of the specified + /// . + /// + RelationshipAttribute GetInverseRelationship(RelationshipAttribute relationship); + } +} diff --git a/src/JsonApiDotNetCore/Configuration/IResourceGraphBuilder.cs b/src/JsonApiDotNetCore/Configuration/IResourceGraphBuilder.cs new file mode 100644 index 0000000000..705bade6e9 --- /dev/null +++ b/src/JsonApiDotNetCore/Configuration/IResourceGraphBuilder.cs @@ -0,0 +1,42 @@ +using System; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.Configuration +{ + public interface IResourceGraphBuilder + { + /// + /// Constructs the . + /// + IResourceGraph Build(); + /// + /// Adds a json:api resource. + /// + /// The resource model type. + /// + /// The pluralized name, under which the resource is publicly exposed by the API. + /// If nothing is specified, the configured casing convention formatter will be applied. + /// + IResourceGraphBuilder Add(string pluralizedTypeName = null) where TResource : class, IIdentifiable; + /// + /// Adds a json:api resource. + /// + /// The resource model type. + /// The resource model identifier type. + /// + /// The pluralized name, under which the resource is publicly exposed by the API. + /// If nothing is specified, the configured casing convention formatter will be applied. + /// + IResourceGraphBuilder Add(string pluralizedTypeName = null) where TResource : class, IIdentifiable; + /// + /// Adds a json:api resource. + /// + /// The resource model type. + /// The resource model identifier type. + /// + /// The pluralized name, under which the resource is publicly exposed by the API. + /// If nothing is specified, the configured casing convention formatter will be applied. + /// + IResourceGraphBuilder Add(Type resourceType, Type idType = null, string pluralizedTypeName = null); + } +} diff --git a/src/JsonApiDotNetCore/Configuration/IServiceDiscoveryFacade.cs b/src/JsonApiDotNetCore/Configuration/IServiceDiscoveryFacade.cs new file mode 100644 index 0000000000..4e952d9f82 --- /dev/null +++ b/src/JsonApiDotNetCore/Configuration/IServiceDiscoveryFacade.cs @@ -0,0 +1,20 @@ +using System.Reflection; + +namespace JsonApiDotNetCore.Configuration +{ + /// + /// Scans for types like resources, services, repositories and resource definitions in an assembly and registers them to the IoC container. This is part of the resource auto-discovery process. + /// + public interface IServiceDiscoveryFacade + { + /// + /// Scans in the specified assembly. + /// + ServiceDiscoveryFacade AddAssembly(Assembly assembly); + + /// + /// Scans in the calling assembly. + /// + ServiceDiscoveryFacade AddCurrentAssembly(); + } +} diff --git a/src/JsonApiDotNetCore/Graph/IdentifiableTypeCache.cs b/src/JsonApiDotNetCore/Configuration/IdentifiableTypeCache.cs similarity index 56% rename from src/JsonApiDotNetCore/Graph/IdentifiableTypeCache.cs rename to src/JsonApiDotNetCore/Configuration/IdentifiableTypeCache.cs index cc45386e9e..f0c5b309e8 100644 --- a/src/JsonApiDotNetCore/Graph/IdentifiableTypeCache.cs +++ b/src/JsonApiDotNetCore/Configuration/IdentifiableTypeCache.cs @@ -2,23 +2,23 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Resources; -namespace JsonApiDotNetCore.Graph +namespace JsonApiDotNetCore.Configuration { /// - /// Used to cache and locate types, to facilitate auto-resource discovery + /// Used to cache and locate types, to facilitate resource auto-discovery. /// internal sealed class IdentifiableTypeCache { - private readonly ConcurrentDictionary> _typeCache = new ConcurrentDictionary>(); + private readonly ConcurrentDictionary> _typeCache = new ConcurrentDictionary>(); /// - /// Get all implementations of in the assembly + /// Gets all implementations of in the assembly. /// - public IEnumerable GetIdentifiableTypes(Assembly assembly) + public IReadOnlyCollection GetIdentifiableTypes(Assembly assembly) { - return _typeCache.GetOrAdd(assembly, asm => FindIdentifiableTypes(asm).ToList()); + return _typeCache.GetOrAdd(assembly, asm => FindIdentifiableTypes(asm).ToArray()); } private static IEnumerable FindIdentifiableTypes(Assembly assembly) diff --git a/src/JsonApiDotNetCore/Configuration/InverseRelationships.cs b/src/JsonApiDotNetCore/Configuration/InverseRelationships.cs new file mode 100644 index 0000000000..5c217373dd --- /dev/null +++ b/src/JsonApiDotNetCore/Configuration/InverseRelationships.cs @@ -0,0 +1,45 @@ +using System; +using JsonApiDotNetCore.Repositories; +using JsonApiDotNetCore.Resources.Annotations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace JsonApiDotNetCore.Configuration +{ + /// + public class InverseRelationships : IInverseRelationships + { + private readonly IResourceContextProvider _provider; + private readonly IDbContextResolver _resolver; + + public InverseRelationships(IResourceContextProvider provider, IDbContextResolver resolver = null) + { + _provider = provider ?? throw new ArgumentNullException(nameof(provider)); + _resolver = resolver; + } + + /// + public void Resolve() + { + if (IsEntityFrameworkCoreEnabled()) + { + DbContext context = _resolver.GetContext(); + + foreach (ResourceContext ce in _provider.GetResourceContexts()) + { + IEntityType meta = context.Model.FindEntityType(ce.ResourceType); + if (meta == null) continue; + foreach (var attr in ce.Relationships) + { + if (attr is HasManyThroughAttribute) continue; + INavigation inverseNavigation = meta.FindNavigation(attr.Property.Name)?.FindInverse(); + attr.InverseNavigation = inverseNavigation?.Name; + } + } + } + } + + // If EF Core is not being used, we're expecting the resolver to not be registered. + private bool IsEntityFrameworkCoreEnabled() => _resolver != null; + } +} diff --git a/src/JsonApiDotNetCore/Builders/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs similarity index 64% rename from src/JsonApiDotNetCore/Builders/JsonApiApplicationBuilder.cs rename to src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs index f542ba40e3..87222097e0 100644 --- a/src/JsonApiDotNetCore/Builders/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs @@ -1,29 +1,24 @@ using System; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Data; -using JsonApiDotNetCore.Formatters; -using JsonApiDotNetCore.Graph; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Internal.Generics; -using JsonApiDotNetCore.Managers; -using JsonApiDotNetCore.Managers.Contracts; +using JsonApiDotNetCore.Hooks.Internal; +using JsonApiDotNetCore.Hooks.Internal.Discovery; +using JsonApiDotNetCore.Hooks.Internal.Execution; +using JsonApiDotNetCore.Hooks.Internal.Traversal; using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Internal; +using JsonApiDotNetCore.QueryStrings; +using JsonApiDotNetCore.QueryStrings.Internal; +using JsonApiDotNetCore.Repositories; +using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization; -using JsonApiDotNetCore.Hooks; +using JsonApiDotNetCore.Serialization.Building; using JsonApiDotNetCore.Services; using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Query; -using JsonApiDotNetCore.Serialization.Server.Builders; -using JsonApiDotNetCore.Serialization.Server; using Microsoft.Extensions.DependencyInjection.Extensions; -using JsonApiDotNetCore.QueryParameterServices.Common; -using JsonApiDotNetCore.RequestServices; -namespace JsonApiDotNetCore.Builders +namespace JsonApiDotNetCore.Configuration { /// /// A utility class that builds a JsonApi application. It registers all required services @@ -40,12 +35,12 @@ internal sealed class JsonApiApplicationBuilder public JsonApiApplicationBuilder(IServiceCollection services, IMvcCoreBuilder mvcBuilder) { - _services = services; - _mvcBuilder = mvcBuilder; + _services = services ?? throw new ArgumentNullException(nameof(services)); + _mvcBuilder = mvcBuilder ?? throw new ArgumentNullException(nameof(mvcBuilder)); } /// - /// Executes the action provided by the user to configure + /// Executes the action provided by the user to configure . /// public void ConfigureJsonApiOptions(Action options) { @@ -53,7 +48,7 @@ public void ConfigureJsonApiOptions(Action options) } /// - /// Configures built-in .NET Core MVC (things like middleware, routing). Most of this configuration can be adjusted for the developers' need. + /// Configures built-in ASP.NET Core MVC (things like middleware, routing). Most of this configuration can be adjusted for the developers' need. /// Before calling .AddJsonApi(), a developer can register their own implementation of the following services to customize startup: /// , , , /// and . @@ -106,7 +101,7 @@ private void AddResourceTypesFromDbContext(ServiceProvider intermediateProvider) foreach (var entityType in dbContext.Model.GetEntityTypes()) { - _resourceGraphBuilder.AddResource(entityType.ClrType); + _resourceGraphBuilder.Add(entityType.ClrType); } } } @@ -120,7 +115,7 @@ public void AutoDiscover(Action autoDiscover) } /// - /// Executes the action provided by the user to configure the resources using + /// Executes the action provided by the user to configure the resources using . /// public void ConfigureResources(Action resources) { @@ -145,82 +140,96 @@ 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(); + _services.AddScoped(); + _services.AddScoped(); _services.AddScoped(); _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 +257,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/Configuration/JsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs index 6f0c005fc7..b62e86f0af 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs @@ -1,133 +1,73 @@ -using JsonApiDotNetCore.Graph; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Models.Links; +using JsonApiDotNetCore.Resources.Annotations; using Newtonsoft.Json; using Newtonsoft.Json.Serialization; namespace JsonApiDotNetCore.Configuration { - /// - /// Global options - /// - public class JsonApiOptions : IJsonApiOptions + /// + public sealed 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 LinkTypes TopLevelLinks { get; set; } = LinkTypes.All; - /// - public bool IncludeExceptionStackTraceInErrors { get; set; } = false; + /// + public LinkTypes ResourceLinks { get; set; } = LinkTypes.All; - /// - /// Whether or not resource hooks are enabled. - /// This is currently an experimental feature and defaults to . - /// - public bool EnableResourceHooks { get; set; } = false; + /// + public LinkTypes RelationshipLinks { get; set; } = LinkTypes.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 int? MaximumIncludeDepth { get; set; } + + /// public JsonSerializerSettings SerializerSettings { get; } = new JsonSerializerSettings { ContractResolver = new DefaultContractResolver @@ -135,5 +75,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/Configuration/PageNumber.cs b/src/JsonApiDotNetCore/Configuration/PageNumber.cs new file mode 100644 index 0000000000..bc94b3ca75 --- /dev/null +++ b/src/JsonApiDotNetCore/Configuration/PageNumber.cs @@ -0,0 +1,51 @@ +using System; + +namespace JsonApiDotNetCore.Configuration +{ + 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/Configuration/PageSize.cs b/src/JsonApiDotNetCore/Configuration/PageSize.cs new file mode 100644 index 0000000000..c1e3de877c --- /dev/null +++ b/src/JsonApiDotNetCore/Configuration/PageSize.cs @@ -0,0 +1,49 @@ +using System; + +namespace JsonApiDotNetCore.Configuration +{ + 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/Configuration/RelatedIdMapper.cs b/src/JsonApiDotNetCore/Configuration/RelatedIdMapper.cs new file mode 100644 index 0000000000..0d238aeb5b --- /dev/null +++ b/src/JsonApiDotNetCore/Configuration/RelatedIdMapper.cs @@ -0,0 +1,9 @@ +namespace JsonApiDotNetCore.Configuration +{ + /// + public sealed class RelatedIdMapper : IRelatedIdMapper + { + /// + public string GetRelatedIdPropertyName(string propertyName) => propertyName + "Id"; + } +} diff --git a/src/JsonApiDotNetCore/Services/ScopedServiceProvider.cs b/src/JsonApiDotNetCore/Configuration/RequestScopedServiceProvider.cs similarity index 55% rename from src/JsonApiDotNetCore/Services/ScopedServiceProvider.cs rename to src/JsonApiDotNetCore/Configuration/RequestScopedServiceProvider.cs index 65ea08df58..1601e08c73 100644 --- a/src/JsonApiDotNetCore/Services/ScopedServiceProvider.cs +++ b/src/JsonApiDotNetCore/Configuration/RequestScopedServiceProvider.cs @@ -1,36 +1,29 @@ -using Microsoft.AspNetCore.Http; using System; +using Microsoft.AspNetCore.Http; -namespace JsonApiDotNetCore.Services +namespace JsonApiDotNetCore.Configuration { - /// - /// An interface used to separate the registration of the global ServiceProvider - /// from a request scoped service provider. This is useful in cases when we need to - /// manually resolve services from the request scope (e.g. operation processors) - /// - public interface IScopedServiceProvider : IServiceProvider { } - - /// - /// A service provider that uses the current HttpContext request scope - /// - public sealed class RequestScopedServiceProvider : IScopedServiceProvider + /// + public sealed class RequestScopedServiceProvider : IRequestScopedServiceProvider { private readonly IHttpContextAccessor _httpContextAccessor; public RequestScopedServiceProvider(IHttpContextAccessor httpContextAccessor) { - _httpContextAccessor = httpContextAccessor; + _httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor)); } /// public object GetService(Type serviceType) { + if (serviceType == null) throw new ArgumentNullException(nameof(serviceType)); + if (_httpContextAccessor.HttpContext == null) { throw new InvalidOperationException( $"Cannot resolve scoped service '{serviceType.FullName}' outside the context of an HTTP request. " + "If you are hitting this error in automated tests, you should instead inject your own " + - "IScopedServiceProvider implementation. See the GitHub repository for how we do this internally. " + + "IRequestScopedServiceProvider implementation. See the GitHub repository for how we do this internally. " + "https://github.com/json-api-dotnet/JsonApiDotNetCore/search?q=TestScopedServiceProvider&unscoped_q=TestScopedServiceProvider"); } diff --git a/src/JsonApiDotNetCore/Configuration/ResourceContext.cs b/src/JsonApiDotNetCore/Configuration/ResourceContext.cs new file mode 100644 index 0000000000..eb5af30a56 --- /dev/null +++ b/src/JsonApiDotNetCore/Configuration/ResourceContext.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Configuration +{ + /// + /// Provides metadata for a resource, such as its attributes and relationships. + /// + public class ResourceContext + { + /// + /// The publicly exposed resource name. + /// + public string ResourceName { get; set; } + + /// + /// The CLR type of the resource. + /// + public Type ResourceType { get; set; } + + /// + /// The identity type of the resource. + /// + public Type IdentityType { get; set; } + + /// + /// The concrete type. + /// We store this so that we don't need to re-compute the generic type. + /// + public Type ResourceDefinitionType { get; set; } + + /// + /// Exposed resource attributes. + /// See https://jsonapi.org/format/#document-resource-object-attributes. + /// + public IReadOnlyCollection Attributes { get; set; } + + /// + /// Exposed resource relationships. + /// See https://jsonapi.org/format/#document-resource-object-relationships. + /// + public IReadOnlyCollection Relationships { get; set; } + + /// + /// Related entities that are not exposed as resource relationships. + /// + public IReadOnlyCollection EagerLoads { get; set; } + + private IReadOnlyCollection _fields; + + /// + /// Exposed resource attributes and relationships. + /// See https://jsonapi.org/format/#document-resource-object-fields. + /// + public IReadOnlyCollection Fields => _fields ??= Attributes.Cast().Concat(Relationships).ToArray(); + + /// + /// Configures which links to show in the + /// object for this resource. If set to , + /// the configuration will be read from . + /// Defaults to . + /// + public LinkTypes TopLevelLinks { get; internal set; } = LinkTypes.NotConfigured; + + /// + /// Configures which links to show in the + /// object for this resource. If set to , + /// the configuration will be read from . + /// Defaults to . + /// + public LinkTypes ResourceLinks { get; internal set; } = LinkTypes.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 + /// be read from or + /// . Defaults to . + /// + public LinkTypes RelationshipLinks { get; internal set; } = LinkTypes.NotConfigured; + + public override string ToString() + { + return ResourceName; + } + } +} diff --git a/src/JsonApiDotNetCore/Graph/ResourceDescriptor.cs b/src/JsonApiDotNetCore/Configuration/ResourceDescriptor.cs similarity index 81% rename from src/JsonApiDotNetCore/Graph/ResourceDescriptor.cs rename to src/JsonApiDotNetCore/Configuration/ResourceDescriptor.cs index 99da83ab25..a038930b65 100644 --- a/src/JsonApiDotNetCore/Graph/ResourceDescriptor.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceDescriptor.cs @@ -1,18 +1,18 @@ using System; -namespace JsonApiDotNetCore.Graph +namespace JsonApiDotNetCore.Configuration { - internal struct ResourceDescriptor + internal class ResourceDescriptor { + public Type ResourceType { get; } + public Type IdType { get; } + + internal static readonly ResourceDescriptor Empty = new ResourceDescriptor(null, null); + public ResourceDescriptor(Type resourceType, Type idType) { ResourceType = resourceType; IdType = idType; } - - public Type ResourceType { get; } - public Type IdType { get; } - - internal static readonly ResourceDescriptor Empty = new ResourceDescriptor(null, null); } } diff --git a/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs new file mode 100644 index 0000000000..f8367af7f5 --- /dev/null +++ b/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs @@ -0,0 +1,178 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Configuration +{ + /// + public class ResourceGraph : IResourceGraph + { + private readonly IReadOnlyCollection _resources; + private static readonly Type _proxyTargetAccessorType = Type.GetType("Castle.DynamicProxy.IProxyTargetAccessor, Castle.Core"); + + public ResourceGraph(IReadOnlyCollection resources) + { + _resources = resources ?? throw new ArgumentNullException(nameof(resources)); + } + + /// + public IReadOnlyCollection GetResourceContexts() => _resources; + + /// + public ResourceContext GetResourceContext(string resourceName) + { + if (resourceName == null) throw new ArgumentNullException(nameof(resourceName)); + + return _resources.SingleOrDefault(e => e.ResourceName == resourceName); + } + + /// + public ResourceContext GetResourceContext(Type resourceType) + { + if (resourceType == null) throw new ArgumentNullException(nameof(resourceType)); + + return IsLazyLoadingProxyForResourceType(resourceType) + ? _resources.SingleOrDefault(e => e.ResourceType == resourceType.BaseType) + : _resources.SingleOrDefault(e => e.ResourceType == resourceType); + } + + /// + public ResourceContext GetResourceContext() where TResource : class, IIdentifiable + => GetResourceContext(typeof(TResource)); + + /// + public IReadOnlyCollection GetFields(Expression> selector = null) where TResource : class, IIdentifiable + { + return Getter(selector); + } + + /// + public IReadOnlyCollection GetAttributes(Expression> selector = null) where TResource : class, IIdentifiable + { + return Getter(selector, FieldFilterType.Attribute).Cast().ToArray(); + } + + /// + public IReadOnlyCollection GetRelationships(Expression> selector = null) where TResource : class, IIdentifiable + { + return Getter(selector, FieldFilterType.Relationship).Cast().ToArray(); + } + + /// + public IReadOnlyCollection GetFields(Type type) + { + if (type == null) throw new ArgumentNullException(nameof(type)); + + return GetResourceContext(type).Fields; + } + + /// + public IReadOnlyCollection GetAttributes(Type type) + { + if (type == null) throw new ArgumentNullException(nameof(type)); + + return GetResourceContext(type).Attributes; + } + + /// + public IReadOnlyCollection GetRelationships(Type type) + { + if (type == null) throw new ArgumentNullException(nameof(type)); + + return GetResourceContext(type).Relationships; + } + + /// + public RelationshipAttribute GetInverseRelationship(RelationshipAttribute relationship) + { + if (relationship == null) throw new ArgumentNullException(nameof(relationship)); + + if (relationship.InverseNavigation == null) return null; + return GetResourceContext(relationship.RightType) + .Relationships + .SingleOrDefault(r => r.Property.Name == relationship.InverseNavigation); + } + + private IReadOnlyCollection Getter(Expression> selector = null, FieldFilterType type = FieldFilterType.None) where TResource : class, IIdentifiable + { + IReadOnlyCollection available; + if (type == FieldFilterType.Attribute) + available = GetResourceContext(typeof(TResource)).Attributes; + else if (type == FieldFilterType.Relationship) + available = GetResourceContext(typeof(TResource)).Relationships; + else + available = GetResourceContext(typeof(TResource)).Fields; + + if (selector == null) + return available; + + var targeted = new List(); + + var selectorBody = RemoveConvert(selector.Body); + + if (selectorBody is MemberExpression memberExpression) + { + // model => model.Field1 + try + { + targeted.Add(available.Single(f => f.Property.Name == memberExpression.Member.Name)); + return targeted; + } + catch (InvalidOperationException) + { + ThrowNotExposedError(memberExpression.Member.Name, type); + } + } + + if (selectorBody is NewExpression newExpression) + { + // model => new { model.Field1, model.Field2 } + string memberName = null; + try + { + if (newExpression.Members == null) + return targeted; + + foreach (var member in newExpression.Members) + { + memberName = member.Name; + targeted.Add(available.Single(f => f.Property.Name == memberName)); + } + return targeted; + } + catch (InvalidOperationException) + { + ThrowNotExposedError(memberName, type); + } + } + + throw new ArgumentException( + $"The expression '{selector}' should select a single property or select multiple properties into an anonymous type. " + + "For example: 'article => article.Title' or 'article => new { article.Title, article.PageCount }'."); + } + + private bool IsLazyLoadingProxyForResourceType(Type resourceType) => + _proxyTargetAccessorType?.IsAssignableFrom(resourceType) ?? false; + + private static Expression RemoveConvert(Expression expression) + => expression is UnaryExpression unaryExpression + && unaryExpression.NodeType == ExpressionType.Convert + ? RemoveConvert(unaryExpression.Operand) + : expression; + + private void ThrowNotExposedError(string memberName, FieldFilterType type) + { + throw new ArgumentException($"{memberName} is not an json:api exposed {type:g}."); + } + + private enum FieldFilterType + { + None, + Attribute, + Relationship + } + } +} diff --git a/src/JsonApiDotNetCore/Builders/ResourceGraphBuilder.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs similarity index 62% rename from src/JsonApiDotNetCore/Builders/ResourceGraphBuilder.cs rename to src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs index 558db0ad0a..50d635ea13 100644 --- a/src/JsonApiDotNetCore/Builders/ResourceGraphBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs @@ -2,18 +2,14 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Extensions; -using JsonApiDotNetCore.Graph; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Models.Links; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; using Microsoft.Extensions.Logging; -using Newtonsoft.Json.Serialization; -namespace JsonApiDotNetCore.Builders +namespace JsonApiDotNetCore.Configuration { + /// public class ResourceGraphBuilder : IResourceGraphBuilder { private readonly IJsonApiOptions _options; @@ -22,7 +18,9 @@ public class ResourceGraphBuilder : IResourceGraphBuilder public ResourceGraphBuilder(IJsonApiOptions options, ILoggerFactory loggerFactory) { - _options = options; + if (loggerFactory == null) throw new ArgumentNullException(nameof(loggerFactory)); + + _options = options ?? throw new ArgumentNullException(nameof(options)); _logger = loggerFactory.CreateLogger(); } @@ -35,7 +33,7 @@ public IResourceGraph Build() private void SetResourceLinksOptions(ResourceContext resourceContext) { - var attribute = (LinksAttribute)resourceContext.ResourceType.GetCustomAttribute(typeof(LinksAttribute)); + var attribute = (ResourceLinksAttribute)resourceContext.ResourceType.GetCustomAttribute(typeof(ResourceLinksAttribute)); if (attribute != null) { resourceContext.RelationshipLinks = attribute.RelationshipLinks; @@ -45,19 +43,21 @@ private void SetResourceLinksOptions(ResourceContext resourceContext) } /// - public IResourceGraphBuilder AddResource(string pluralizedTypeName = null) where TResource : class, IIdentifiable - => AddResource(pluralizedTypeName); + public IResourceGraphBuilder Add(string pluralizedTypeName = null) where TResource : class, IIdentifiable + => Add(pluralizedTypeName); /// - public IResourceGraphBuilder AddResource(string pluralizedTypeName = null) where TResource : class, IIdentifiable - => AddResource(typeof(TResource), typeof(TId), pluralizedTypeName); + public IResourceGraphBuilder Add(string pluralizedTypeName = null) where TResource : class, IIdentifiable + => Add(typeof(TResource), typeof(TId), pluralizedTypeName); /// - public IResourceGraphBuilder AddResource(Type resourceType, Type idType = null, string pluralizedTypeName = null) + public IResourceGraphBuilder Add(Type resourceType, Type idType = null, string pluralizedTypeName = null) { + if (resourceType == null) throw new ArgumentNullException(nameof(resourceType)); + if (_resources.All(e => e.ResourceType != resourceType)) { - if (resourceType.IsOrImplementsInterface(typeof(IIdentifiable))) + if (TypeHelper.IsOrImplementsInterface(resourceType, typeof(IIdentifiable))) { pluralizedTypeName ??= FormatResourceName(resourceType); idType ??= TypeLocator.GetIdType(resourceType); @@ -73,34 +73,36 @@ 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 IReadOnlyCollection GetAttributes(Type resourceType) { + if (resourceType == null) throw new ArgumentNullException(nameof(resourceType)); + var attributes = new List(); - foreach (var property in entityType.GetProperties()) + foreach (var property in resourceType.GetProperties()) { var attribute = (AttrAttribute)property.GetCustomAttribute(typeof(AttrAttribute)); // Although strictly not correct, 'id' is added to the list of attributes for convenience. - // For example, it enables to filter on id, without the need to special-case existing logic. + // For example, it enables to filter on ID, without the need to special-case existing logic. // And when using sparse fields, it silently adds 'id' to the set of attributes to retrieve. if (property.Name == nameof(Identifiable.Id) && attribute == null) { var idAttr = new AttrAttribute { - PublicAttributeName = FormatPropertyName(property), - PropertyInfo = property, + PublicName = FormatPropertyName(property), + Property = property, Capabilities = _options.DefaultAttrCapabilities }; attributes.Add(idAttr); @@ -110,8 +112,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) { @@ -123,30 +125,32 @@ protected virtual List GetAttributes(Type entityType) return attributes; } - protected virtual List GetRelationships(Type entityType) + protected virtual IReadOnlyCollection GetRelationships(Type resourceType) { + if (resourceType == null) throw new ArgumentNullException(nameof(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)); 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; + 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.PropertyInfo.Name}': Resource does not contain a property named '{hasManyThroughAttribute.ThroughPropertyName}'."); + throw new InvalidConfigurationException($"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.PropertyInfo.Name}': Referenced property '{throughProperty.Name}' does not implement 'ICollection'."); + throw new InvalidConfigurationException($"Invalid {nameof(HasManyThroughAttribute)} on '{resourceType}.{attribute.Property.Name}': Referenced property '{throughProperty.Name}' does not implement 'ICollection'."); // ICollection hasManyThroughAttribute.ThroughProperty = throughProperty; @@ -157,22 +161,22 @@ 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 InvalidConfigurationException($"{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 InvalidConfigurationException($"{throughType} does not contain a relationship ID property to type {resourceType} with name {leftIdPropertyName}"); // ArticleTag.Tag hasManyThroughAttribute.RightProperty = throughProperties.SingleOrDefault(x => x.PropertyType == hasManyThroughAttribute.RightType) - ?? throw new JsonApiSetupException($"{throughType} does not contain a navigation property to type {hasManyThroughAttribute.RightType}"); + ?? throw new InvalidConfigurationException($"{throughType} does not contain a navigation property to type {hasManyThroughAttribute.RightType}"); // ArticleTag.TagId var rightIdPropertyName = JsonApiOptions.RelatedIdMapper.GetRelatedIdPropertyName(hasManyThroughAttribute.RightProperty.Name); hasManyThroughAttribute.RightIdProperty = throughProperties.SingleOrDefault(x => x.Name == rightIdPropertyName) - ?? throw new JsonApiSetupException($"{throughType} does not contain a relationship id property to type {hasManyThroughAttribute.RightType} with name {rightIdPropertyName}"); + ?? throw new InvalidConfigurationException($"{throughType} does not contain a relationship ID property to type {hasManyThroughAttribute.RightType} with name {rightIdPropertyName}"); } } @@ -187,7 +191,7 @@ private static Type TryGetThroughType(PropertyInfo throughProperty) if (typeArguments.Length == 1) { var constructedThroughType = typeof(ICollection<>).MakeGenericType(typeArguments[0]); - if (throughProperty.PropertyType.IsOrImplementsInterface(constructedThroughType)) + if (TypeHelper.IsOrImplementsInterface(throughProperty.PropertyType, constructedThroughType)) { return typeArguments[0]; } @@ -197,10 +201,15 @@ private static Type TryGetThroughType(PropertyInfo throughProperty) return null; } - protected virtual Type GetRelationshipType(RelationshipAttribute relation, PropertyInfo prop) => - relation is HasOneAttribute ? prop.PropertyType : prop.PropertyType.GetGenericArguments()[0]; + protected virtual Type GetRelationshipType(RelationshipAttribute relationship, PropertyInfo property) + { + if (relationship == null) throw new ArgumentNullException(nameof(relationship)); + if (property == null) throw new ArgumentNullException(nameof(property)); + + return relationship is HasOneAttribute ? property.PropertyType : property.PropertyType.GetGenericArguments()[0]; + } - private List GetEagerLoads(Type entityType, int recursionDepth = 0) + private IReadOnlyCollection GetEagerLoads(Type resourceType, int recursionDepth = 0) { if (recursionDepth >= 500) { @@ -208,7 +217,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) { @@ -233,7 +242,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/ResourceNameFormatter.cs b/src/JsonApiDotNetCore/Configuration/ResourceNameFormatter.cs new file mode 100644 index 0000000000..e58816186f --- /dev/null +++ b/src/JsonApiDotNetCore/Configuration/ResourceNameFormatter.cs @@ -0,0 +1,28 @@ +using System; +using System.Reflection; +using Humanizer; +using JsonApiDotNetCore.Resources.Annotations; +using Newtonsoft.Json.Serialization; + +namespace JsonApiDotNetCore.Configuration +{ + internal sealed class ResourceNameFormatter + { + private readonly NamingStrategy _namingStrategy; + + public ResourceNameFormatter(IJsonApiOptions options) + { + _namingStrategy = options.SerializerContractResolver.NamingStrategy; + } + + /// + /// Gets the publicly visible resource name for the internal type name using the configured casing convention. + /// + public string FormatResourceName(Type resourceType) + { + return resourceType.GetCustomAttribute(typeof(ResourceAttribute)) is ResourceAttribute attribute + ? attribute.PublicName + : _namingStrategy.GetPropertyName(resourceType.Name.Pluralize(), false); + } + } +} diff --git a/src/JsonApiDotNetCore/Extensions/ServiceCollectionExtensions.cs b/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs similarity index 81% rename from src/JsonApiDotNetCore/Extensions/ServiceCollectionExtensions.cs rename to src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs index ea460c8a5d..199937ed4c 100644 --- a/src/JsonApiDotNetCore/Extensions/ServiceCollectionExtensions.cs +++ b/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs @@ -2,17 +2,13 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; -using JsonApiDotNetCore.Builders; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Graph; -using JsonApiDotNetCore.Internal; -using Microsoft.Extensions.DependencyInjection; -using JsonApiDotNetCore.Serialization.Client; -using JsonApiDotNetCore.Serialization; -using JsonApiDotNetCore.Internal.Contracts; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Serialization.Building; +using JsonApiDotNetCore.Serialization.Client.Internal; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; -namespace JsonApiDotNetCore +namespace JsonApiDotNetCore.Configuration { public static class ServiceCollectionExtensions { @@ -25,6 +21,8 @@ public static IServiceCollection AddJsonApi(this IServiceCollection services, Action resources = null, IMvcCoreBuilder mvcBuilder = null) { + if (services == null) throw new ArgumentNullException(nameof(services)); + SetupApplicationBuilder(services, options, discovery, resources, mvcBuilder, null); ResolveInverseRelationships(services); @@ -41,6 +39,8 @@ public static IServiceCollection AddJsonApi(this IServiceCollection IMvcCoreBuilder mvcBuilder = null) where TDbContext : DbContext { + if (services == null) throw new ArgumentNullException(nameof(services)); + SetupApplicationBuilder(services, options, discovery, resources, mvcBuilder, typeof(TDbContext)); ResolveInverseRelationships(services); @@ -76,8 +76,10 @@ private static void ResolveInverseRelationships(IServiceCollection services) /// public static IServiceCollection AddClientSerialization(this IServiceCollection services) { - services.AddSingleton(); - services.AddSingleton(sp => + if (services == null) throw new ArgumentNullException(nameof(services)); + + services.AddScoped(); + services.AddScoped(sp => { var graph = sp.GetService(); return new RequestSerializer(graph, new ResourceObjectBuilder(graph, new ResourceObjectBuilderSettings())); @@ -86,14 +88,16 @@ public static IServiceCollection AddClientSerialization(this IServiceCollection } /// - /// Adds all required registrations for the service to the container + /// Adds all required registrations for the service to the container. /// - /// - public static IServiceCollection AddResourceService(this IServiceCollection services) + /// + public static IServiceCollection AddResourceService(this IServiceCollection services) { + if (services == null) throw new ArgumentNullException(nameof(services)); + var typeImplementsAnExpectedInterface = false; - var serviceImplementationType = typeof(T); + var serviceImplementationType = typeof(TService); // it is _possible_ that a single concrete type could be used for multiple resources... var resourceDescriptors = GetResourceTypesFromServiceImplementation(serviceImplementationType); @@ -102,11 +106,11 @@ public static IServiceCollection AddResourceService(this IServiceCollection s { foreach (var openGenericType in ServiceDiscoveryFacade.ServiceInterfaces) { - // A shorthand interface is one where the id type is omitted - // e.g. IResourceService is the shorthand for IResourceService + // A shorthand interface is one where the ID type is omitted + // e.g. IResourceService is the shorthand for IResourceService var isShorthandInterface = openGenericType.GetTypeInfo().GenericTypeParameters.Length == 1; if (isShorthandInterface && resourceDescriptor.IdType != typeof(int)) - continue; // we can't create a shorthand for id types other than int + continue; // we can't create a shorthand for ID types other than int var concreteGenericType = isShorthandInterface ? openGenericType.MakeGenericType(resourceDescriptor.ResourceType) @@ -120,8 +124,8 @@ public static IServiceCollection AddResourceService(this IServiceCollection s } } - if (typeImplementsAnExpectedInterface == false) - throw new JsonApiSetupException($"{serviceImplementationType} does not implement any of the expected JsonApiDotNetCore interfaces."); + if (!typeImplementsAnExpectedInterface) + throw new InvalidConfigurationException($"{serviceImplementationType} does not implement any of the expected JsonApiDotNetCore interfaces."); return services; } diff --git a/src/JsonApiDotNetCore/Graph/ServiceDiscoveryFacade.cs b/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs similarity index 82% rename from src/JsonApiDotNetCore/Graph/ServiceDiscoveryFacade.cs rename to src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs index 7d3488f984..d20c4dcc44 100644 --- a/src/JsonApiDotNetCore/Graph/ServiceDiscoveryFacade.cs +++ b/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs @@ -1,17 +1,17 @@ -using JsonApiDotNetCore.Builders; -using JsonApiDotNetCore.Data; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Services; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; using System; using System.Collections.Generic; using System.Linq; using System.Reflection; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Repositories; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; -namespace JsonApiDotNetCore.Graph +namespace JsonApiDotNetCore.Configuration { + /// public class ServiceDiscoveryFacade : IServiceDiscoveryFacade { internal static readonly HashSet ServiceInterfaces = new HashSet { @@ -27,17 +27,17 @@ 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<>), typeof(IDeleteService<,>) }; - private static readonly HashSet RepositoryInterfaces = new HashSet { + private static readonly HashSet _repositoryInterfaces = new HashSet { typeof(IResourceRepository<>), typeof(IResourceRepository<,>), typeof(IResourceWriteRepository<>), @@ -52,20 +52,18 @@ public class ServiceDiscoveryFacade : IServiceDiscoveryFacade public ServiceDiscoveryFacade(IServiceCollection services, IResourceGraphBuilder resourceGraphBuilder) { - _services = services; - _resourceGraphBuilder = resourceGraphBuilder; + _services = services ?? throw new ArgumentNullException(nameof(services)); + _resourceGraphBuilder = resourceGraphBuilder ?? throw new ArgumentNullException(nameof(resourceGraphBuilder)); } - /// - /// Adds resource, service and repository implementations to the container. - /// + /// public ServiceDiscoveryFacade AddCurrentAssembly() => AddAssembly(Assembly.GetCallingAssembly()); - /// - /// Adds resource, service and repository implementations defined in the specified assembly to the container. - /// + /// public ServiceDiscoveryFacade AddAssembly(Assembly assembly) { + if (assembly == null) throw new ArgumentNullException(nameof(assembly)); + AddDbContextResolvers(assembly); var resourceDescriptors = _typeCache.GetIdentifiableTypes(assembly); @@ -92,7 +90,7 @@ private void AddResource(Assembly assembly, ResourceDescriptor resourceDescripto { RegisterResourceDefinition(assembly, resourceDescriptor); - _resourceGraphBuilder.AddResource(resourceDescriptor.ResourceType, resourceDescriptor.IdType); + _resourceGraphBuilder.Add(resourceDescriptor.ResourceType, resourceDescriptor.IdType); } private void RegisterResourceDefinition(Assembly assembly, ResourceDescriptor identifiable) @@ -107,7 +105,7 @@ private void RegisterResourceDefinition(Assembly assembly, ResourceDescriptor id } catch (InvalidOperationException e) { - throw new JsonApiSetupException($"Cannot define multiple ResourceDefinition<> implementations for '{identifiable.ResourceType}'", e); + throw new InvalidConfigurationException($"Cannot define multiple ResourceDefinition<> implementations for '{identifiable.ResourceType}'", e); } } @@ -121,7 +119,7 @@ private void AddServices(Assembly assembly, ResourceDescriptor resourceDescripto private void AddRepositories(Assembly assembly, ResourceDescriptor resourceDescriptor) { - foreach (var serviceInterface in RepositoryInterfaces) + foreach (var serviceInterface in _repositoryInterfaces) { RegisterServiceImplementations(assembly, serviceInterface, resourceDescriptor); } diff --git a/src/JsonApiDotNetCore/Graph/TypeLocator.cs b/src/JsonApiDotNetCore/Configuration/TypeLocator.cs similarity index 71% rename from src/JsonApiDotNetCore/Graph/TypeLocator.cs rename to src/JsonApiDotNetCore/Configuration/TypeLocator.cs index 5b0d9e9a58..4661f19dd0 100644 --- a/src/JsonApiDotNetCore/Graph/TypeLocator.cs +++ b/src/JsonApiDotNetCore/Configuration/TypeLocator.cs @@ -1,19 +1,18 @@ -using JsonApiDotNetCore.Extensions; -using JsonApiDotNetCore.Models; using System; using System.Collections.Generic; using System.Linq; using System.Reflection; +using JsonApiDotNetCore.Resources; -namespace JsonApiDotNetCore.Graph +namespace JsonApiDotNetCore.Configuration { /// - /// Used to locate types and facilitate auto-resource discovery + /// Used to locate types and facilitate resource auto-discovery. /// internal static class TypeLocator { /// - /// Determine whether or not this is a json:api resource by checking if it implements . + /// Determine whether or not this is a json:api resource by checking if it implements . /// public static Type GetIdType(Type resourceType) { @@ -22,14 +21,14 @@ public static Type GetIdType(Type resourceType) } /// - /// Attempts to get a descriptor of the resource type. + /// Attempts to get a descriptor for the resource type. /// /// - /// True if the type is a valid json:api type (must implement ), false otherwise. + /// true if the type is a valid json:api type (must implement ); false, otherwise. /// internal static bool TryGetResourceDescriptor(Type type, out ResourceDescriptor descriptor) { - if (type.IsOrImplementsInterface(typeof(IIdentifiable))) + if (TypeHelper.IsOrImplementsInterface(type, typeof(IIdentifiable))) { descriptor = new ResourceDescriptor(type, GetIdType(type)); return true; @@ -38,15 +37,15 @@ internal static bool TryGetResourceDescriptor(Type type, out ResourceDescriptor return false; } /// - /// Get all implementations of the generic interface + /// Gets all implementations of the generic interface. /// - /// The assembly to search - /// The open generic type, e.g. `typeof(IResourceService<>)` - /// Parameters to the generic type + /// The assembly to search. + /// The 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) { @@ -54,7 +53,7 @@ public static (Type implementation, Type registrationInterface) GetGenericInterf if (openGenericInterfaceType == null) throw new ArgumentNullException(nameof(openGenericInterfaceType)); if (genericInterfaceArguments == null) throw new ArgumentNullException(nameof(genericInterfaceArguments)); if (genericInterfaceArguments.Length == 0) throw new ArgumentException("No arguments supplied for the generic interface.", nameof(genericInterfaceArguments)); - if (openGenericInterfaceType.IsGenericType == false) throw new ArgumentException("Requested type is not a generic type.", nameof(openGenericInterfaceType)); + if (!openGenericInterfaceType.IsGenericType) throw new ArgumentException("Requested type is not a generic type.", nameof(openGenericInterfaceType)); foreach (var type in assembly.GetTypes()) { @@ -79,27 +78,27 @@ public static (Type implementation, Type registrationInterface) GetGenericInterf } /// - /// Get all derivatives of the concrete, generic type. + /// Gets all derivatives of the concrete, generic type. /// - /// The assembly to search - /// The open generic type, e.g. `typeof(ResourceDefinition<>)` - /// Parameters to the generic type + /// The assembly to search. + /// The open generic type, e.g. `typeof(ResourceDefinition<>)`. + /// Parameters to the generic type. /// /// ), typeof(Article)) /// ]]> /// - public static IEnumerable GetDerivedGenericTypes(Assembly assembly, Type openGenericType, params Type[] genericArguments) + public static IReadOnlyCollection GetDerivedGenericTypes(Assembly assembly, Type openGenericType, params Type[] genericArguments) { var genericType = openGenericType.MakeGenericType(genericArguments); - return GetDerivedTypes(assembly, genericType); + return GetDerivedTypes(assembly, genericType).ToArray(); } /// - /// Get all derivatives of the specified type. + /// Gets all derivatives of the specified type. /// - /// The assembly to search - /// The inherited type + /// The assembly to search. + /// The inherited type. /// /// /// GetDerivedGenericTypes(assembly, typeof(DbContext)) diff --git a/src/JsonApiDotNetCore/Controllers/Annotations/DisableQueryStringAttribute.cs b/src/JsonApiDotNetCore/Controllers/Annotations/DisableQueryStringAttribute.cs new file mode 100644 index 0000000000..c1012d6100 --- /dev/null +++ b/src/JsonApiDotNetCore/Controllers/Annotations/DisableQueryStringAttribute.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using JsonApiDotNetCore.QueryStrings; + +namespace JsonApiDotNetCore.Controllers.Annotations +{ + /// + /// Used on an ASP.NET Core controller class to indicate which query string parameters are blocked. + /// + /// { } + /// ]]> + /// { } + /// ]]> + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Interface)] + public sealed class DisableQueryStringAttribute : Attribute + { + public IReadOnlyCollection ParameterNames { get; } + + public static readonly DisableQueryStringAttribute Empty = new DisableQueryStringAttribute(StandardQueryStringParameters.None); + + /// + /// Disables one or more of the builtin query parameters for a controller. + /// + public DisableQueryStringAttribute(StandardQueryStringParameters parameters) + { + var 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()); + } + } + + ParameterNames = parameterNames; + } + + /// + /// It is allowed to use a comma-separated list of strings to indicate which query parameters + /// should be disabled, because the user may have defined custom query parameters that are + /// not included in the enum. + /// + public DisableQueryStringAttribute(string parameterNames) + { + if (parameterNames == null) throw new ArgumentNullException(nameof(parameterNames)); + + ParameterNames = parameterNames.Split(",").Select(x => x.Trim().ToLowerInvariant()).ToList(); + } + + public bool ContainsParameter(StandardQueryStringParameters parameter) + { + var name = parameter.ToString().ToLowerInvariant(); + return ParameterNames.Contains(name); + } + } +} diff --git a/src/JsonApiDotNetCore/Controllers/Annotations/DisableRoutingConventionAttribute.cs b/src/JsonApiDotNetCore/Controllers/Annotations/DisableRoutingConventionAttribute.cs new file mode 100644 index 0000000000..ecd55377ab --- /dev/null +++ b/src/JsonApiDotNetCore/Controllers/Annotations/DisableRoutingConventionAttribute.cs @@ -0,0 +1,15 @@ +using System; + +namespace JsonApiDotNetCore.Controllers.Annotations +{ + /// + /// Used on an ASP.NET Core controller class to indicate that a custom route is used instead of the built-in routing convention. + /// + /// { } + /// ]]> + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Interface)] + public sealed class DisableRoutingConventionAttribute : Attribute + { } +} diff --git a/src/JsonApiDotNetCore/Controllers/Annotations/HttpReadOnlyAttribute.cs b/src/JsonApiDotNetCore/Controllers/Annotations/HttpReadOnlyAttribute.cs new file mode 100644 index 0000000000..46b0f93f79 --- /dev/null +++ b/src/JsonApiDotNetCore/Controllers/Annotations/HttpReadOnlyAttribute.cs @@ -0,0 +1,16 @@ +namespace JsonApiDotNetCore.Controllers.Annotations +{ + /// + /// Used on an ASP.NET Core controller class to indicate write actions must be blocked. + /// + /// + /// { + /// } + /// ]]> + public sealed class HttpReadOnlyAttribute : HttpRestrictAttribute + { + protected override string[] Methods { get; } = { "POST", "PATCH", "DELETE" }; + } +} diff --git a/src/JsonApiDotNetCore/Controllers/Annotations/HttpRestrictAttribute.cs b/src/JsonApiDotNetCore/Controllers/Annotations/HttpRestrictAttribute.cs new file mode 100644 index 0000000000..dcb3fd88af --- /dev/null +++ b/src/JsonApiDotNetCore/Controllers/Annotations/HttpRestrictAttribute.cs @@ -0,0 +1,36 @@ +using System; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using JsonApiDotNetCore.Errors; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace JsonApiDotNetCore.Controllers.Annotations +{ + public abstract class HttpRestrictAttribute : ActionFilterAttribute + { + protected abstract string[] Methods { get; } + + public override async Task OnActionExecutionAsync( + ActionExecutingContext context, + ActionExecutionDelegate next) + { + if (context == null) throw new ArgumentNullException(nameof(context)); + if (next == null) throw new ArgumentNullException(nameof(next)); + + var method = context.HttpContext.Request.Method; + + if (!CanExecuteAction(method)) + { + throw new RequestMethodNotAllowedException(new HttpMethod(method)); + } + + await next(); + } + + private bool CanExecuteAction(string requestMethod) + { + return !Methods.Contains(requestMethod); + } + } +} diff --git a/src/JsonApiDotNetCore/Controllers/Annotations/NoHttpDeleteAttribute.cs b/src/JsonApiDotNetCore/Controllers/Annotations/NoHttpDeleteAttribute.cs new file mode 100644 index 0000000000..02a5fbdba1 --- /dev/null +++ b/src/JsonApiDotNetCore/Controllers/Annotations/NoHttpDeleteAttribute.cs @@ -0,0 +1,16 @@ +namespace JsonApiDotNetCore.Controllers.Annotations +{ + /// + /// Used on an ASP.NET Core controller class to indicate the DELETE verb must be blocked. + /// + /// + /// { + /// } + /// ]]> + public sealed class NoHttpDeleteAttribute : HttpRestrictAttribute + { + protected override string[] Methods { get; } = { "DELETE" }; + } +} diff --git a/src/JsonApiDotNetCore/Controllers/Annotations/NoHttpPatchAttribute.cs b/src/JsonApiDotNetCore/Controllers/Annotations/NoHttpPatchAttribute.cs new file mode 100644 index 0000000000..7039356db2 --- /dev/null +++ b/src/JsonApiDotNetCore/Controllers/Annotations/NoHttpPatchAttribute.cs @@ -0,0 +1,16 @@ +namespace JsonApiDotNetCore.Controllers.Annotations +{ + /// + /// Used on an ASP.NET Core controller class to indicate the PATCH verb must be blocked. + /// + /// + /// { + /// } + /// ]]> + public sealed class NoHttpPatchAttribute : HttpRestrictAttribute + { + protected override string[] Methods { get; } = { "PATCH" }; + } +} diff --git a/src/JsonApiDotNetCore/Controllers/Annotations/NoHttpPostAttribute.cs b/src/JsonApiDotNetCore/Controllers/Annotations/NoHttpPostAttribute.cs new file mode 100644 index 0000000000..a40f1a3574 --- /dev/null +++ b/src/JsonApiDotNetCore/Controllers/Annotations/NoHttpPostAttribute.cs @@ -0,0 +1,16 @@ +namespace JsonApiDotNetCore.Controllers.Annotations +{ + /// + /// Used on an ASP.NET Core controller class to indicate the POST verb must be blocked. + /// + /// + /// { + /// } + /// ]]> + public sealed class NoHttpPostAttribute : HttpRestrictAttribute + { + protected override string[] Methods { get; } = { "POST" }; + } +} diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index e9464fb269..d33ad1aecc 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs @@ -1,160 +1,210 @@ +using System; using System.Net.Http; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Exceptions; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Services; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -using Newtonsoft.Json.Serialization; namespace JsonApiDotNetCore.Controllers { - public abstract class BaseJsonApiController : JsonApiControllerMixin where T : class, IIdentifiable + /// + /// Implements the foundational ASP.NET Core controller layer in the JsonApiDotNetCore architecture that delegates to a Resource Service. + /// + /// The resource type. + /// The resource identifier type. + public abstract class BaseJsonApiController : CoreJsonApiController where TResource : class, IIdentifiable { - private readonly IJsonApiOptions _jsonApiOptions; - private readonly IGetAllService _getAll; - private readonly IGetByIdService _getById; - private readonly IGetRelationshipService _getRelationship; - private readonly IGetRelationshipsService _getRelationships; - private readonly ICreateService _create; - private readonly IUpdateService _update; - private readonly IUpdateRelationshipService _updateRelationships; - private readonly IDeleteService _delete; - private readonly ILogger> _logger; - + private readonly IJsonApiOptions _options; + private readonly IGetAllService _getAll; + private readonly IGetByIdService _getById; + private readonly IGetSecondaryService _getSecondary; + private readonly IGetRelationshipService _getRelationship; + private readonly ICreateService _create; + private readonly IUpdateService _update; + private readonly IUpdateRelationshipService _updateRelationships; + private readonly IDeleteService _delete; + private readonly TraceLogWriter> _traceWriter; + + /// + /// Creates an instance from a read/write service. + /// protected BaseJsonApiController( - IJsonApiOptions jsonApiOptions, + IJsonApiOptions options, ILoggerFactory loggerFactory, - IResourceService resourceService) - : this(jsonApiOptions, loggerFactory, resourceService, resourceService, resourceService, resourceService, + IResourceService resourceService) + : this(options, loggerFactory, resourceService, resourceService, resourceService, resourceService, resourceService, resourceService, resourceService, resourceService) { } + /// + /// Creates an instance from separate services for reading and writing. + /// protected BaseJsonApiController( - IJsonApiOptions jsonApiOptions, + IJsonApiOptions options, ILoggerFactory loggerFactory, - IResourceQueryService queryService = null, - IResourceCommandService commandService = null) - : this(jsonApiOptions, loggerFactory, queryService, queryService, queryService, queryService, commandService, + IResourceQueryService queryService = null, + IResourceCommandService commandService = null) + : this(options, loggerFactory, queryService, queryService, queryService, queryService, commandService, commandService, commandService, commandService) { } + /// + /// Creates an instance from separate services for the various individual read and write methods. + /// protected BaseJsonApiController( - IJsonApiOptions jsonApiOptions, + IJsonApiOptions options, ILoggerFactory loggerFactory, - IGetAllService getAll = null, - IGetByIdService getById = null, - IGetRelationshipService getRelationship = null, - IGetRelationshipsService getRelationships = null, - ICreateService create = null, - IUpdateService update = null, - IUpdateRelationshipService updateRelationships = null, - IDeleteService delete = null) + IGetAllService getAll = null, + IGetByIdService getById = null, + IGetSecondaryService getSecondary = null, + IGetRelationshipService getRelationship = null, + ICreateService create = null, + IUpdateService update = null, + IUpdateRelationshipService updateRelationships = null, + IDeleteService delete = null) { - _jsonApiOptions = jsonApiOptions; - _logger = loggerFactory.CreateLogger>(); + if (loggerFactory == null) throw new ArgumentNullException(nameof(loggerFactory)); + + _options = options ?? throw new ArgumentNullException(nameof(options)); + _traceWriter = new TraceLogWriter>(loggerFactory); _getAll = getAll; _getById = getById; + _getSecondary = getSecondary; _getRelationship = getRelationship; - _getRelationships = getRelationships; _create = create; _update = update; _updateRelationships = updateRelationships; _delete = delete; } + /// + /// Gets a collection of top-level (non-nested) resources. + /// Example: GET /articles HTTP/1.1 + /// public virtual async Task GetAsync() { - _logger.LogTrace($"Entering {nameof(GetAsync)}()."); + _traceWriter.LogMethodStart(); if (_getAll == null) throw new RequestMethodNotAllowedException(HttpMethod.Get); - var entities = await _getAll.GetAsync(); - return Ok(entities); + var resources = await _getAll.GetAsync(); + return Ok(resources); } + /// + /// Gets a single top-level (non-nested) resource by ID. + /// Example: /articles/1 + /// public virtual async Task GetAsync(TId id) { - _logger.LogTrace($"Entering {nameof(GetAsync)}('{id}')."); + _traceWriter.LogMethodStart(new {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) + /// + /// Gets a single resource relationship. + /// Example: GET /articles/1/relationships/author HTTP/1.1 + /// + public virtual async Task GetRelationshipAsync(TId id, string relationshipName) { - _logger.LogTrace($"Entering {nameof(GetRelationshipsAsync)}('{id}', '{relationshipName}')."); + _traceWriter.LogMethodStart(new {id, relationshipName}); + if (relationshipName == null) throw new ArgumentNullException(nameof(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) + /// + /// Gets a single resource or multiple resources at a nested endpoint. + /// Examples: + /// GET /articles/1/author HTTP/1.1 + /// GET /articles/1/revisions HTTP/1.1 + /// + public virtual async Task GetSecondaryAsync(TId id, string relationshipName) { - _logger.LogTrace($"Entering {nameof(GetRelationshipAsync)}('{id}', '{relationshipName}')."); + _traceWriter.LogMethodStart(new {id, relationshipName}); + if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); - if (_getRelationship == null) throw new RequestMethodNotAllowedException(HttpMethod.Get); - var relationship = await _getRelationship.GetRelationshipAsync(id, relationshipName); + if (_getSecondary == null) throw new RequestMethodNotAllowedException(HttpMethod.Get); + var relationship = await _getSecondary.GetSecondaryAsync(id, relationshipName); return Ok(relationship); } - public virtual async Task PostAsync([FromBody] T entity) + /// + /// Creates a new resource. + /// + public virtual async Task PostAsync([FromBody] TResource resource) { - _logger.LogTrace($"Entering {nameof(PostAsync)}({(entity == null ? "null" : "object")})."); + _traceWriter.LogMethodStart(new {resource}); 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 (!_options.AllowClientGeneratedIds && !string.IsNullOrEmpty(resource.StringId)) throw new ResourceIdInPostRequestNotAllowedException(); - if (_jsonApiOptions.ValidateModelState && !ModelState.IsValid) + if (_options.ValidateModelState && !ModelState.IsValid) { - var namingStrategy = _jsonApiOptions.SerializerContractResolver.NamingStrategy; - throw new InvalidModelStateException(ModelState, typeof(T), _jsonApiOptions.IncludeExceptionStackTraceInErrors, namingStrategy); + var namingStrategy = _options.SerializerContractResolver.NamingStrategy; + throw new InvalidModelStateException(ModelState, typeof(TResource), _options.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) + /// + /// Updates an existing resource. May contain a partial set of attributes. + /// + public virtual async Task PatchAsync(TId id, [FromBody] TResource resource) { - _logger.LogTrace($"Entering {nameof(PatchAsync)}('{id}', {(entity == null ? "null" : "object")})."); + _traceWriter.LogMethodStart(new {id, resource}); 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) + if (_options.ValidateModelState && !ModelState.IsValid) { - var namingStrategy = _jsonApiOptions.SerializerContractResolver.NamingStrategy; - throw new InvalidModelStateException(ModelState, typeof(T), _jsonApiOptions.IncludeExceptionStackTraceInErrors, namingStrategy); + var namingStrategy = _options.SerializerContractResolver.NamingStrategy; + throw new InvalidModelStateException(ModelState, typeof(TResource), _options.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) + /// + /// Updates a relationship. + /// + public virtual async Task PatchRelationshipAsync(TId id, string relationshipName, [FromBody] object relationships) { - _logger.LogTrace($"Entering {nameof(PatchRelationshipsAsync)}('{id}', '{relationshipName}', {(relationships == null ? "null" : "object")})."); + _traceWriter.LogMethodStart(new {id, relationshipName, relationships}); + if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); if (_updateRelationships == null) throw new RequestMethodNotAllowedException(HttpMethod.Patch); - await _updateRelationships.UpdateRelationshipsAsync(id, relationshipName, relationships); + await _updateRelationships.UpdateRelationshipAsync(id, relationshipName, relationships); return Ok(); } + /// + /// Deletes a resource. + /// public virtual async Task DeleteAsync(TId id) { - _logger.LogTrace($"Entering {nameof(DeleteAsync)}('{id})."); + _traceWriter.LogMethodStart(new {id}); if (_delete == null) throw new RequestMethodNotAllowedException(HttpMethod.Delete); await _delete.DeleteAsync(id); @@ -163,35 +213,39 @@ public virtual async Task DeleteAsync(TId id) } } - public abstract class BaseJsonApiController : BaseJsonApiController where T : class, IIdentifiable + /// + public abstract class BaseJsonApiController : BaseJsonApiController where TResource : class, IIdentifiable { + /// protected BaseJsonApiController( - IJsonApiOptions jsonApiOptions, + IJsonApiOptions options, ILoggerFactory loggerFactory, - IResourceService resourceService) - : base(jsonApiOptions, loggerFactory, resourceService, resourceService) + IResourceService resourceService) + : base(options, loggerFactory, resourceService, resourceService) { } + /// protected BaseJsonApiController( - IJsonApiOptions jsonApiOptions, + IJsonApiOptions options, ILoggerFactory loggerFactory, - IResourceQueryService queryService = null, - IResourceCommandService commandService = null) - : base(jsonApiOptions, loggerFactory, queryService, commandService) + IResourceQueryService queryService = null, + IResourceCommandService commandService = null) + : base(options, loggerFactory, queryService, commandService) { } + /// protected BaseJsonApiController( - IJsonApiOptions jsonApiOptions, + IJsonApiOptions options, ILoggerFactory loggerFactory, - IGetAllService getAll = null, - IGetByIdService getById = 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, + IGetAllService getAll = null, + IGetByIdService getById = null, + IGetSecondaryService getSecondary = null, + IGetRelationshipService getRelationship = null, + ICreateService create = null, + IUpdateService update = null, + IUpdateRelationshipService updateRelationships = null, + IDeleteService delete = null) + : base(options, 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 53% rename from src/JsonApiDotNetCore/Controllers/JsonApiControllerMixin.cs rename to src/JsonApiDotNetCore/Controllers/CoreJsonApiController.cs index 6b10f89944..16685e7143 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiControllerMixin.cs +++ b/src/JsonApiDotNetCore/Controllers/CoreJsonApiController.cs @@ -1,20 +1,28 @@ +using System; using System.Collections.Generic; using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCore.Serialization.Objects; using Microsoft.AspNetCore.Mvc; namespace JsonApiDotNetCore.Controllers { - [ServiceFilter(typeof(IQueryParameterActionFilter))] - public abstract class JsonApiControllerMixin : ControllerBase + /// + /// Provides helper methods to raise json:api compliant errors from controller actions. + /// + [ServiceFilter(typeof(IQueryStringActionFilter))] + public abstract class CoreJsonApiController : ControllerBase { protected IActionResult Error(Error error) { + if (error == null) throw new ArgumentNullException(nameof(error)); + return Error(new[] {error}); } protected IActionResult Error(IEnumerable errors) { + if (errors == null) throw new ArgumentNullException(nameof(errors)); + var document = new ErrorDocument(errors); return new ObjectResult(document) diff --git a/src/JsonApiDotNetCore/Controllers/DisableQueryAttribute.cs b/src/JsonApiDotNetCore/Controllers/DisableQueryAttribute.cs deleted file mode 100644 index e94cce4264..0000000000 --- a/src/JsonApiDotNetCore/Controllers/DisableQueryAttribute.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; - -namespace JsonApiDotNetCore.Controllers -{ - [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Interface)] - public sealed class DisableQueryAttribute : Attribute - { - private readonly List _parameterNames; - - public IReadOnlyCollection ParameterNames => _parameterNames.AsReadOnly(); - - public static readonly DisableQueryAttribute Empty = new DisableQueryAttribute(StandardQueryStringParameters.None); - - /// - /// Disables one or more of the builtin query parameters for a controller. - /// - public DisableQueryAttribute(StandardQueryStringParameters parameters) - { - _parameterNames = parameters != StandardQueryStringParameters.None - ? ParseList(parameters.ToString()) - : new List(); - } - - /// - /// It is allowed to use a comma-separated list of strings to indicate which query parameters - /// should be disabled, because the user may have defined custom query parameters that are - /// not included in the enum. - /// - public DisableQueryAttribute(string parameterNames) - { - _parameterNames = ParseList(parameterNames); - } - - private static List ParseList(string parameterNames) - { - return parameterNames.Split(",").Select(x => x.Trim().ToLowerInvariant()).ToList(); - } - - public bool ContainsParameter(StandardQueryStringParameters parameter) - { - var name = parameter.ToString().ToLowerInvariant(); - return _parameterNames.Contains(name); - } - } -} diff --git a/src/JsonApiDotNetCore/Controllers/DisableRoutingConventionAttribute.cs b/src/JsonApiDotNetCore/Controllers/DisableRoutingConventionAttribute.cs deleted file mode 100644 index 9b2090f6d6..0000000000 --- a/src/JsonApiDotNetCore/Controllers/DisableRoutingConventionAttribute.cs +++ /dev/null @@ -1,8 +0,0 @@ -using System; - -namespace JsonApiDotNetCore.Controllers -{ - [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Interface)] - public sealed class DisableRoutingConventionAttribute : Attribute - { } -} diff --git a/src/JsonApiDotNetCore/Controllers/HttpMethodRestrictionFilter.cs b/src/JsonApiDotNetCore/Controllers/HttpMethodRestrictionFilter.cs deleted file mode 100644 index 7f797419a4..0000000000 --- a/src/JsonApiDotNetCore/Controllers/HttpMethodRestrictionFilter.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System.Linq; -using System.Net.Http; -using System.Threading.Tasks; -using JsonApiDotNetCore.Exceptions; -using Microsoft.AspNetCore.Mvc.Filters; - -namespace JsonApiDotNetCore.Controllers -{ - public abstract class HttpRestrictAttribute : ActionFilterAttribute - { - protected abstract string[] Methods { get; } - - public override async Task OnActionExecutionAsync( - ActionExecutingContext context, - ActionExecutionDelegate next) - { - var method = context.HttpContext.Request.Method; - - if (CanExecuteAction(method) == false) - { - throw new RequestMethodNotAllowedException(new HttpMethod(method)); - } - - await next(); - } - - private bool CanExecuteAction(string requestMethod) - { - return Methods.Contains(requestMethod) == false; - } - } - - public sealed class HttpReadOnlyAttribute : HttpRestrictAttribute - { - protected override string[] Methods { get; } = new string[] { "POST", "PATCH", "DELETE" }; - } - - public sealed class NoHttpPostAttribute : HttpRestrictAttribute - { - protected override string[] Methods { get; } = new string[] { "POST" }; - } - - public sealed class NoHttpPatchAttribute : HttpRestrictAttribute - { - protected override string[] Methods { get; } = new string[] { "PATCH" }; - } - - public sealed class NoHttpDeleteAttribute : HttpRestrictAttribute - { - protected override string[] Methods { get; } = new string[] { "DELETE" }; - } -} diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs index 868355062c..6e4b85dc4d 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs @@ -1,45 +1,59 @@ using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Services; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; namespace JsonApiDotNetCore.Controllers { - public abstract class JsonApiCommandController : JsonApiCommandController where T : class, IIdentifiable + /// + /// The base class to derive resource-specific write-only controllers from. + /// This class delegates all work to but adds attributes for routing templates. + /// If you want to provide routing templates yourself, you should derive from BaseJsonApiController directly. + /// + /// The resource type. + /// The resource identifier type. + public abstract class JsonApiCommandController : BaseJsonApiController where TResource : class, IIdentifiable { + /// protected JsonApiCommandController( - IJsonApiOptions jsonApiOptions, + IJsonApiOptions options, ILoggerFactory loggerFactory, - IResourceCommandService commandService) - : base(jsonApiOptions, loggerFactory, commandService) - { } - } - - public abstract class JsonApiCommandController : BaseJsonApiController where T : class, IIdentifiable - { - protected JsonApiCommandController( - IJsonApiOptions jsonApiOptions, - ILoggerFactory loggerFactory, - IResourceCommandService commandService) - : base(jsonApiOptions, loggerFactory, null, commandService) + IResourceCommandService commandService) + : base(options, loggerFactory, null, commandService) { } + /// [HttpPost] - public override async Task PostAsync([FromBody] T entity) - => await base.PostAsync(entity); + public override async Task PostAsync([FromBody] TResource 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] TResource 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); } + + /// + public abstract class JsonApiCommandController : JsonApiCommandController where TResource : class, IIdentifiable + { + /// + protected JsonApiCommandController( + IJsonApiOptions options, + ILoggerFactory loggerFactory, + IResourceCommandService commandService) + : base(options, loggerFactory, commandService) + { } + } } diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs index 73877ca996..1fd42b97aa 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs @@ -1,90 +1,110 @@ using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Services; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; namespace JsonApiDotNetCore.Controllers { - public class JsonApiController : BaseJsonApiController where T : class, IIdentifiable + /// + /// The base class to derive resource-specific controllers from. + /// This class delegates all work to but adds attributes for routing templates. + /// If you want to provide routing templates yourself, you should derive from BaseJsonApiController directly. + /// + /// The resource type. + /// The resource identifier type. + public class JsonApiController : BaseJsonApiController where TResource : class, IIdentifiable { + /// public JsonApiController( - IJsonApiOptions jsonApiOptions, + IJsonApiOptions options, ILoggerFactory loggerFactory, - IResourceService resourceService) - : base(jsonApiOptions, loggerFactory, resourceService) + IResourceService resourceService) + : base(options, loggerFactory, resourceService) { } + /// public JsonApiController( - IJsonApiOptions jsonApiOptions, + IJsonApiOptions options, ILoggerFactory loggerFactory, - IGetAllService getAll = null, - IGetByIdService getById = 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, + IGetAllService getAll = null, + IGetByIdService getById = null, + IGetSecondaryService getSecondary = null, + IGetRelationshipService getRelationship = null, + ICreateService create = null, + IUpdateService update = null, + IUpdateRelationshipService updateRelationships = null, + IDeleteService delete = null) + : base(options, loggerFactory, getAll, getById, getSecondary, getRelationship, create, update, updateRelationships, delete) { } + /// [HttpGet] public override async Task GetAsync() => await base.GetAsync(); + /// [HttpGet("{id}")] 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] TResource 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] TResource 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); } - public class JsonApiController : JsonApiController where T : class, IIdentifiable + /// + public class JsonApiController : JsonApiController where TResource : class, IIdentifiable { + /// public JsonApiController( - IJsonApiOptions jsonApiOptions, + IJsonApiOptions options, ILoggerFactory loggerFactory, - IResourceService resourceService) - : base(jsonApiOptions, loggerFactory, resourceService) + IResourceService resourceService) + : base(options, loggerFactory, resourceService) { } + /// public JsonApiController( - IJsonApiOptions jsonApiOptions, + IJsonApiOptions options, ILoggerFactory loggerFactory, - IGetAllService getAll = null, - IGetByIdService getById = 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, + IGetAllService getAll = null, + IGetByIdService getById = null, + IGetSecondaryService getSecondary = null, + IGetRelationshipService getRelationship = null, + ICreateService create = null, + IUpdateService update = null, + IUpdateRelationshipService updateRelationships = null, + IDeleteService delete = null) + : base(options, 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..89af9d95c8 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiQueryController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiQueryController.cs @@ -1,43 +1,57 @@ using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Services; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; namespace JsonApiDotNetCore.Controllers { - public abstract class JsonApiQueryController : JsonApiQueryController where T : class, IIdentifiable + /// + /// The base class to derive resource-specific read-only controllers from. + /// This class delegates all work to but adds attributes for routing templates. + /// If you want to provide routing templates yourself, you should derive from BaseJsonApiController directly. + /// + /// The resource type. + /// The resource identifier type. + public abstract class JsonApiQueryController : BaseJsonApiController where TResource : class, IIdentifiable { + /// protected JsonApiQueryController( - IJsonApiOptions jsonApiOptions, + IJsonApiOptions context, ILoggerFactory loggerFactory, - IResourceQueryService queryService) - : base(jsonApiOptions, loggerFactory, queryService) - { } - } - - public abstract class JsonApiQueryController : BaseJsonApiController where T : class, IIdentifiable - { - protected JsonApiQueryController( - IJsonApiOptions jsonApiContext, - ILoggerFactory loggerFactory, - IResourceQueryService queryService) - : base(jsonApiContext, loggerFactory, queryService) + IResourceQueryService queryService) + : base(context, loggerFactory, queryService) { } + /// [HttpGet] public override async Task GetAsync() => await base.GetAsync(); + /// [HttpGet("{id}")] 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); + } + + /// + public abstract class JsonApiQueryController : JsonApiQueryController where TResource : class, IIdentifiable + { + /// + protected JsonApiQueryController( + IJsonApiOptions options, + ILoggerFactory loggerFactory, + IResourceQueryService queryService) + : base(options, loggerFactory, queryService) + { } } } diff --git a/src/JsonApiDotNetCore/Data/DefaultResourceRepository.cs b/src/JsonApiDotNetCore/Data/DefaultResourceRepository.cs deleted file mode 100644 index 28ba02f1bf..0000000000 --- a/src/JsonApiDotNetCore/Data/DefaultResourceRepository.cs +++ /dev/null @@ -1,489 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using JsonApiDotNetCore.Extensions; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Internal.Generics; -using JsonApiDotNetCore.Internal.Query; -using JsonApiDotNetCore.Models; -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. - /// - public class DefaultResourceRepository : IResourceRepository - where TResource : class, IIdentifiable - { - private readonly ITargetedFields _targetedFields; - private readonly DbContext _context; - private readonly DbSet _dbSet; - private readonly IResourceGraph _resourceGraph; - private readonly IGenericServiceFactory _genericServiceFactory; - private readonly IResourceFactory _resourceFactory; - private readonly ILogger> _logger; - - public DefaultResourceRepository( - ITargetedFields targetedFields, - IDbContextResolver contextResolver, - IResourceGraph resourceGraph, - IGenericServiceFactory genericServiceFactory, - IResourceFactory resourceFactory, - ILoggerFactory loggerFactory) - { - _targetedFields = targetedFields; - _resourceGraph = resourceGraph; - _genericServiceFactory = genericServiceFactory; - _resourceFactory = resourceFactory; - _context = contextResolver.GetContext(); - _dbSet = _context.Set(); - _logger = loggerFactory.CreateLogger>(); - } - - /// - public virtual IQueryable Get() - { - _logger.LogTrace($"Entering {nameof(Get)}()."); - - 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)})."); - - return entities.Select(propertyNames, _resourceFactory); - } - - /// - public virtual IQueryable Filter(IQueryable entities, FilterQueryContext filterQueryContext) - { - _logger.LogTrace($"Entering {nameof(Filter)}({nameof(entities)}, {nameof(filterQueryContext)})."); - - if (filterQueryContext.IsCustom) - { - var query = (Func, FilterQuery, IQueryable>)filterQueryContext.CustomQuery; - return query(entities, filterQueryContext.Query); - } - return entities.Filter(filterQueryContext); - } - - /// - public virtual IQueryable Sort(IQueryable entities, IReadOnlyCollection sortQueryContexts) - { - _logger.LogTrace($"Entering {nameof(Sort)}({nameof(entities)}, {nameof(sortQueryContexts)})."); - - if (!sortQueryContexts.Any()) - { - return entities; - } - - var primarySort = sortQueryContexts.First(); - var entitiesSorted = entities.Sort(primarySort); - - foreach (var secondarySort in sortQueryContexts.Skip(1)) - { - entitiesSorted = entitiesSorted.Sort(secondarySort); - } - - return entitiesSorted; - } - - /// - public virtual async Task CreateAsync(TResource entity) - { - _logger.LogTrace($"Entering {nameof(CreateAsync)}({(entity == null ? "null" : "object")})."); - - foreach (var relationshipAttr in _targetedFields.Relationships) - { - object trackedRelationshipValue = GetTrackedRelationshipValue(relationshipAttr, entity, 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. - // 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); - } - _dbSet.Add(entity); - await _context.SaveChangesAsync(); - - FlushFromCache(entity); - - // 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); - } - - /// - /// Loads the inverse relationships to prevent foreign key constraints from being violated - /// to support implicit removes, see https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/502. - /// - /// Consider the following example: - /// person.todoItems = [t1,t2] is updated to [t3, t4]. If t3, and/or t4 was - /// already related to a other person, and these persons are NOT loaded in to the - /// db context, then the query may cause a foreign key constraint. Loading - /// these "inverse relationships" into the DB context ensures EF core to take - /// this into account. - /// - /// - private void LoadInverseRelationships(object trackedRelationshipValue, RelationshipAttribute relationshipAttr) - { - if (relationshipAttr.InverseNavigation == null || trackedRelationshipValue == null) return; - if (relationshipAttr is HasOneAttribute hasOneAttr) - { - var relationEntry = _context.Entry((IIdentifiable)trackedRelationshipValue); - if (IsHasOneRelationship(hasOneAttr.InverseNavigation, trackedRelationshipValue.GetType())) - relationEntry.Reference(hasOneAttr.InverseNavigation).Load(); - else - relationEntry.Collection(hasOneAttr.InverseNavigation).Load(); - } - else if (relationshipAttr is HasManyAttribute hasManyAttr && !(relationshipAttr is HasManyThroughAttribute)) - { - foreach (IIdentifiable relationshipValue in (IEnumerable)trackedRelationshipValue) - _context.Entry(relationshipValue).Reference(hasManyAttr.InverseNavigation).Load(); - } - } - - private bool IsHasOneRelationship(string internalRelationshipName, Type type) - { - var relationshipAttr = _resourceGraph.GetRelationships(type).FirstOrDefault(r => r.PropertyInfo.Name == internalRelationshipName); - if (relationshipAttr != null) - { - if (relationshipAttr is HasOneAttribute) - return true; - - return false; - } - // relationshipAttr is null when we don't put a [RelationshipAttribute] on the inverse navigation property. - // In this case we use reflection to figure out what kind of relationship is pointing back. - return !type.GetProperty(internalRelationshipName).PropertyType.IsOrImplementsInterface(typeof(IEnumerable)); - } - - private void DetachRelationships(TResource entity) - { - foreach (var relationship in _targetedFields.Relationships) - { - var value = relationship.GetValue(entity); - if (value == null) - continue; - - if (value is IEnumerable collection) - { - foreach (IIdentifiable single in collection) - _context.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); - } - else - { - _context.Entry(value).State = EntityState.Detached; - } - } - } - - /// - public virtual async Task UpdateAsync(TResource requestEntity, TResource databaseEntity) - { - _logger.LogTrace($"Entering {nameof(UpdateAsync)}({(requestEntity == null ? "null" : "object")}, {(databaseEntity == null ? "null" : "object")})."); - - foreach (var attribute in _targetedFields.Attributes) - attribute.SetValue(databaseEntity, attribute.GetValue(requestEntity)); - - foreach (var relationshipAttr in _targetedFields.Relationships) - { - // loads databasePerson.todoItems - LoadCurrentRelationships(databaseEntity, 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 _); - // 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); - } - - await _context.SaveChangesAsync(); - } - - /// - /// Responsible for getting the relationship value for a given relationship - /// attribute of a given entity. 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) - { - wasAlreadyAttached = false; - if (relationshipAttr is HasOneAttribute hasOneAttr) - { - var relationshipValue = (IIdentifiable)hasOneAttr.GetValue(entity); - if (relationshipValue == null) - return null; - return GetTrackedHasOneRelationshipValue(relationshipValue, ref wasAlreadyAttached); - } - - IEnumerable relationshipValueList = (IEnumerable)relationshipAttr.GetValue(entity); - if (relationshipValueList == null) - return null; - - return GetTrackedManyRelationshipValue(relationshipValueList, relationshipAttr, ref wasAlreadyAttached); - } - - // helper method used in GetTrackedRelationshipValue. See comments below. - private IEnumerable GetTrackedManyRelationshipValue(IEnumerable relationshipValueList, RelationshipAttribute relationshipAttr, ref bool wasAlreadyAttached) - { - if (relationshipValueList == null) return null; - bool newWasAlreadyAttached = false; - var trackedPointerCollection = relationshipValueList.Select(pointer => - { - // convert each element in the value list to relationshipAttr.DependentType. - var tracked = AttachOrGetTracked(pointer); - if (tracked != null) newWasAlreadyAttached = true; - return Convert.ChangeType(tracked ?? pointer, relationshipAttr.RightType); - }) - .CopyToTypedCollection(relationshipAttr.PropertyInfo.PropertyType); - - if (newWasAlreadyAttached) wasAlreadyAttached = true; - return trackedPointerCollection; - } - - // helper method used in GetTrackedRelationshipValue. See comments there. - private IIdentifiable GetTrackedHasOneRelationshipValue(IIdentifiable relationshipValue, ref bool wasAlreadyAttached) - { - var tracked = AttachOrGetTracked(relationshipValue); - if (tracked != null) wasAlreadyAttached = true; - return tracked ?? relationshipValue; - } - - /// - public async Task UpdateRelationshipsAsync(object parent, RelationshipAttribute relationship, IEnumerable relationshipIds) - { - _logger.LogTrace($"Entering {nameof(UpdateRelationshipsAsync)}({nameof(parent)}, {nameof(relationship)}, {nameof(relationshipIds)})."); - - var typeToUpdate = (relationship is HasManyThroughAttribute hasManyThrough) - ? hasManyThrough.ThroughType - : relationship.RightType; - - var helper = _genericServiceFactory.Get(typeof(RepositoryRelationshipUpdateHelper<>), typeToUpdate); - await helper.UpdateRelationshipAsync((IIdentifiable)parent, relationship, relationshipIds); - - await _context.SaveChangesAsync(); - } - - /// - 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)})."); - - if (inclusionChain == null || !inclusionChain.Any()) - { - return entities; - } - - string internalRelationshipPath = null; - foreach (var relationship in inclusionChain) - { - internalRelationshipPath = internalRelationshipPath == null - ? relationship.RelationshipPath - : $"{internalRelationshipPath}.{relationship.RelationshipPath}"; - - var resourceContext = _resourceGraph.GetResourceContext(relationship.RightType); - entities = EagerLoad(entities, resourceContext.EagerLoads, internalRelationshipPath); - } - - 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(); - } - - if (entities is IAsyncEnumerable) - { - // 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(); - } - else - { - 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 entities.Count(); - } - - /// - public virtual async Task FirstOrDefaultAsync(IQueryable entities) - { - _logger.LogTrace($"Entering {nameof(FirstOrDefaultAsync)}({nameof(entities)})."); - - 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(); - } - - /// - /// Before assigning new relationship values (UpdateAsync), we need to - /// attach the current database values of the relationship to the dbContext, else - /// it will not perform a complete-replace which is required for - /// one-to-many and many-to-many. - /// - /// For example: a person `p1` has 2 todo-items: `t1` and `t2`. - /// If we want to update this todo-item set to `t3` and `t4`, simply assigning - /// `p1.todoItems = [t3, t4]` will result in EF Core adding them to the set, - /// resulting in `[t1 ... t4]`. Instead, we should first include `[t1, t2]`, - /// after which the reassignment `p1.todoItems = [t3, t4]` will actually - /// make EF Core perform a complete replace. This method does the loading of `[t1, t2]`. - /// - protected void LoadCurrentRelationships(TResource oldEntity, RelationshipAttribute relationshipAttribute) - { - if (relationshipAttribute is HasManyThroughAttribute throughAttribute) - { - _context.Entry(oldEntity).Collection(throughAttribute.ThroughProperty.Name).Load(); - } - else if (relationshipAttribute is HasManyAttribute hasManyAttribute) - { - _context.Entry(oldEntity).Collection(hasManyAttribute.PropertyInfo.Name).Load(); - } - } - - /// - /// Given a IIdentifiable relationship value, verify if an entity of the underlying - /// type with the same ID is already attached to the dbContext, and if so, return it. - /// If not, attach the relationship value to the dbContext. - /// - /// useful article: https://stackoverflow.com/questions/30987806/dbset-attachentity-vs-dbcontext-entryentity-state-entitystate-modified - /// - private IIdentifiable AttachOrGetTracked(IIdentifiable relationshipValue) - { - var trackedEntity = _context.GetTrackedEntity(relationshipValue); - - if (trackedEntity != null) - { - // there already was an instance of this type and ID tracked - // by EF Core. Reattaching will produce a conflict, so from now on we - // will use the already attached instance instead. This entry might - // contain updated fields as a result of business logic elsewhere in the application - return trackedEntity; - } - - // 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; - return null; - } - } - - /// - public class DefaultResourceRepository : DefaultResourceRepository, IResourceRepository - where TResource : class, IIdentifiable - { - public DefaultResourceRepository( - ITargetedFields targetedFields, - IDbContextResolver contextResolver, - IResourceGraph resourceGraph, - IGenericServiceFactory genericServiceFactory, - IResourceFactory resourceFactory, - ILoggerFactory loggerFactory) - : base(targetedFields, contextResolver, resourceGraph, genericServiceFactory, resourceFactory, loggerFactory) - { } - } -} diff --git a/src/JsonApiDotNetCore/Data/IDbContextResolver.cs b/src/JsonApiDotNetCore/Data/IDbContextResolver.cs deleted file mode 100644 index f1b4533f26..0000000000 --- a/src/JsonApiDotNetCore/Data/IDbContextResolver.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Microsoft.EntityFrameworkCore; - -namespace JsonApiDotNetCore.Data -{ - public interface IDbContextResolver - { - DbContext GetContext(); - } -} diff --git a/src/JsonApiDotNetCore/Data/IResourceReadRepository.cs b/src/JsonApiDotNetCore/Data/IResourceReadRepository.cs deleted file mode 100644 index d77b57e0ae..0000000000 --- a/src/JsonApiDotNetCore/Data/IResourceReadRepository.cs +++ /dev/null @@ -1,64 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using JsonApiDotNetCore.Internal.Query; -using JsonApiDotNetCore.Models; - -namespace JsonApiDotNetCore.Data -{ - public interface IResourceReadRepository - : IResourceReadRepository - where TResource : class, IIdentifiable - { } - - 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. - /// - 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); - /// - /// Convert the collection to a materialized list - /// - Task> ToListAsync(IQueryable entities); - } -} diff --git a/src/JsonApiDotNetCore/Data/IResourceRepository.cs b/src/JsonApiDotNetCore/Data/IResourceRepository.cs deleted file mode 100644 index 100ea63961..0000000000 --- a/src/JsonApiDotNetCore/Data/IResourceRepository.cs +++ /dev/null @@ -1,15 +0,0 @@ -using JsonApiDotNetCore.Models; - -namespace JsonApiDotNetCore.Data -{ - public interface IResourceRepository - : IResourceRepository - where TResource : class, IIdentifiable - { } - - public interface IResourceRepository - : IResourceReadRepository, - IResourceWriteRepository - where TResource : class, IIdentifiable - { } -} \ No newline at end of file diff --git a/src/JsonApiDotNetCore/Data/IResourceWriteRepository.cs b/src/JsonApiDotNetCore/Data/IResourceWriteRepository.cs deleted file mode 100644 index 54d43367dd..0000000000 --- a/src/JsonApiDotNetCore/Data/IResourceWriteRepository.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; -using JsonApiDotNetCore.Models; - -namespace JsonApiDotNetCore.Data -{ - public interface IResourceWriteRepository - : IResourceWriteRepository - where TResource : class, IIdentifiable - { } - - public interface IResourceWriteRepository - where TResource : class, IIdentifiable - { - Task CreateAsync(TResource entity); - - Task UpdateAsync(TResource requestEntity, TResource databaseEntity); - - Task UpdateRelationshipsAsync(object parent, RelationshipAttribute relationship, IEnumerable relationshipIds); - - Task DeleteAsync(TId id); - - void FlushFromCache(TResource entity); - } -} diff --git a/src/JsonApiDotNetCore/Errors/InvalidConfigurationException.cs b/src/JsonApiDotNetCore/Errors/InvalidConfigurationException.cs new file mode 100644 index 0000000000..c838e2ca9d --- /dev/null +++ b/src/JsonApiDotNetCore/Errors/InvalidConfigurationException.cs @@ -0,0 +1,13 @@ +using System; + +namespace JsonApiDotNetCore.Errors +{ + /// + /// The error that is thrown when configured usage of this library is invalid. + /// + public sealed class InvalidConfigurationException : Exception + { + public InvalidConfigurationException(string message, Exception innerException = null) + : base(message, innerException) { } + } +} diff --git a/src/JsonApiDotNetCore/Exceptions/InvalidModelStateException.cs b/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs similarity index 73% rename from src/JsonApiDotNetCore/Exceptions/InvalidModelStateException.cs rename to src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs index e227ab30ce..038dee1166 100644 --- a/src/JsonApiDotNetCore/Exceptions/InvalidModelStateException.cs +++ b/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs @@ -3,40 +3,43 @@ using System.Linq; using System.Net; using System.Reflection; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Objects; using Microsoft.AspNetCore.Mvc.ModelBinding; using Newtonsoft.Json.Serialization; -namespace JsonApiDotNetCore.Exceptions +namespace JsonApiDotNetCore.Errors { /// /// The error that is thrown when model state validation fails. /// public class InvalidModelStateException : Exception { - public IList Errors { get; } + public IReadOnlyCollection Errors { get; } public InvalidModelStateException(ModelStateDictionary modelState, Type resourceType, bool includeExceptionStackTraceInErrors, NamingStrategy namingStrategy) { + if (modelState == null) throw new ArgumentNullException(nameof(modelState)); + if (resourceType == null) throw new ArgumentNullException(nameof(resourceType)); + if (namingStrategy == null) throw new ArgumentNullException(nameof(namingStrategy)); + Errors = FromModelState(modelState, resourceType, includeExceptionStackTraceInErrors, namingStrategy); } - private static List FromModelState(ModelStateDictionary modelState, Type resourceType, + private static IReadOnlyCollection FromModelState(ModelStateDictionary modelState, Type resourceType, bool includeExceptionStackTraceInErrors, NamingStrategy namingStrategy) { List errors = new List(); - foreach (var pair in modelState.Where(x => x.Value.Errors.Any())) + foreach (var (propertyName, entry) in modelState.Where(x => x.Value.Errors.Any())) { - var propertyName = pair.Key; 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) + foreach (var modelError in entry.Errors) { if (modelError.Exception is JsonApiException jsonApiException) { diff --git a/src/JsonApiDotNetCore/Errors/InvalidQueryException.cs b/src/JsonApiDotNetCore/Errors/InvalidQueryException.cs new file mode 100644 index 0000000000..3b68dfc931 --- /dev/null +++ b/src/JsonApiDotNetCore/Errors/InvalidQueryException.cs @@ -0,0 +1,22 @@ +using System; +using System.Net; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Errors +{ + /// + /// 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/Errors/InvalidQueryStringParameterException.cs similarity index 79% rename from src/JsonApiDotNetCore/Exceptions/InvalidQueryStringParameterException.cs rename to src/JsonApiDotNetCore/Errors/InvalidQueryStringParameterException.cs index df94a4eb61..12e66112d0 100644 --- a/src/JsonApiDotNetCore/Exceptions/InvalidQueryStringParameterException.cs +++ b/src/JsonApiDotNetCore/Errors/InvalidQueryStringParameterException.cs @@ -1,7 +1,8 @@ +using System; using System.Net; -using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Exceptions +namespace JsonApiDotNetCore.Errors { /// /// The error that is thrown when processing the request fails due to an error in the request query string. @@ -11,7 +12,7 @@ public sealed class InvalidQueryStringParameterException : JsonApiException public string QueryParameterName { get; } public InvalidQueryStringParameterException(string queryParameterName, string genericMessage, - string specificMessage) + string specificMessage, Exception innerException = null) : base(new Error(HttpStatusCode.BadRequest) { Title = genericMessage, @@ -20,7 +21,7 @@ public InvalidQueryStringParameterException(string queryParameterName, string ge { Parameter = queryParameterName } - }) + }, innerException) { QueryParameterName = queryParameterName; } diff --git a/src/JsonApiDotNetCore/Exceptions/InvalidRequestBodyException.cs b/src/JsonApiDotNetCore/Errors/InvalidRequestBodyException.cs similarity index 94% rename from src/JsonApiDotNetCore/Exceptions/InvalidRequestBodyException.cs rename to src/JsonApiDotNetCore/Errors/InvalidRequestBodyException.cs index 5f976001e0..1c91f34e9e 100644 --- a/src/JsonApiDotNetCore/Exceptions/InvalidRequestBodyException.cs +++ b/src/JsonApiDotNetCore/Errors/InvalidRequestBodyException.cs @@ -1,8 +1,8 @@ using System; using System.Net; -using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Exceptions +namespace JsonApiDotNetCore.Errors { /// /// The error that is thrown when deserializing the request body fails. diff --git a/src/JsonApiDotNetCore/Exceptions/JsonApiException.cs b/src/JsonApiDotNetCore/Errors/JsonApiException.cs similarity index 71% rename from src/JsonApiDotNetCore/Exceptions/JsonApiException.cs rename to src/JsonApiDotNetCore/Errors/JsonApiException.cs index 381040b495..81609a740f 100644 --- a/src/JsonApiDotNetCore/Exceptions/JsonApiException.cs +++ b/src/JsonApiDotNetCore/Errors/JsonApiException.cs @@ -1,9 +1,12 @@ using System; -using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCore.Serialization.Objects; using Newtonsoft.Json; -namespace JsonApiDotNetCore.Exceptions +namespace JsonApiDotNetCore.Errors { + /// + /// The base class for an that represents a json:api error object in an unsuccessful response. + /// public class JsonApiException : Exception { private static readonly JsonSerializerSettings _errorSerializerSettings = new JsonSerializerSettings @@ -14,12 +17,7 @@ public class JsonApiException : Exception public Error Error { get; } - public JsonApiException(Error error) - : this(error, null) - { - } - - public JsonApiException(Error error, Exception innerException) + public JsonApiException(Error error, Exception innerException = null) : base(error.Title, innerException) { Error = error; diff --git a/src/JsonApiDotNetCore/Exceptions/RelationshipNotFoundException.cs b/src/JsonApiDotNetCore/Errors/RelationshipNotFoundException.cs similarity index 86% rename from src/JsonApiDotNetCore/Exceptions/RelationshipNotFoundException.cs rename to src/JsonApiDotNetCore/Errors/RelationshipNotFoundException.cs index a8c2fc3be2..a56091151a 100644 --- a/src/JsonApiDotNetCore/Exceptions/RelationshipNotFoundException.cs +++ b/src/JsonApiDotNetCore/Errors/RelationshipNotFoundException.cs @@ -1,7 +1,7 @@ using System.Net; -using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Exceptions +namespace JsonApiDotNetCore.Errors { /// /// The error that is thrown when a relationship does not exist. diff --git a/src/JsonApiDotNetCore/Exceptions/RequestMethodNotAllowedException.cs b/src/JsonApiDotNetCore/Errors/RequestMethodNotAllowedException.cs similarity index 88% rename from src/JsonApiDotNetCore/Exceptions/RequestMethodNotAllowedException.cs rename to src/JsonApiDotNetCore/Errors/RequestMethodNotAllowedException.cs index 3dfbffa2ef..7444d6cc46 100644 --- a/src/JsonApiDotNetCore/Exceptions/RequestMethodNotAllowedException.cs +++ b/src/JsonApiDotNetCore/Errors/RequestMethodNotAllowedException.cs @@ -1,8 +1,8 @@ using System.Net; using System.Net.Http; -using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Exceptions +namespace JsonApiDotNetCore.Errors { /// /// The error that is thrown when a request is received that contains an unsupported HTTP verb. diff --git a/src/JsonApiDotNetCore/Exceptions/ResourceIdInPostRequestNotAllowedException.cs b/src/JsonApiDotNetCore/Errors/ResourceIdInPostRequestNotAllowedException.cs similarity index 78% rename from src/JsonApiDotNetCore/Exceptions/ResourceIdInPostRequestNotAllowedException.cs rename to src/JsonApiDotNetCore/Errors/ResourceIdInPostRequestNotAllowedException.cs index fdf6a0287d..6b621faa6f 100644 --- a/src/JsonApiDotNetCore/Exceptions/ResourceIdInPostRequestNotAllowedException.cs +++ b/src/JsonApiDotNetCore/Errors/ResourceIdInPostRequestNotAllowedException.cs @@ -1,7 +1,7 @@ using System.Net; -using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Exceptions +namespace JsonApiDotNetCore.Errors { /// /// The error that is thrown when a POST request is received that contains a client-generated ID. @@ -11,7 +11,7 @@ public sealed class ResourceIdInPostRequestNotAllowedException : JsonApiExceptio public ResourceIdInPostRequestNotAllowedException() : base(new Error(HttpStatusCode.Forbidden) { - Title = "Specifying the resource id in POST requests is not allowed.", + Title = "Specifying the resource ID in POST requests is not allowed.", Source = { Pointer = "/data/id" diff --git a/src/JsonApiDotNetCore/Exceptions/ResourceIdMismatchException.cs b/src/JsonApiDotNetCore/Errors/ResourceIdMismatchException.cs similarity index 56% rename from src/JsonApiDotNetCore/Exceptions/ResourceIdMismatchException.cs rename to src/JsonApiDotNetCore/Errors/ResourceIdMismatchException.cs index 72c91a0191..b463bbee0d 100644 --- a/src/JsonApiDotNetCore/Exceptions/ResourceIdMismatchException.cs +++ b/src/JsonApiDotNetCore/Errors/ResourceIdMismatchException.cs @@ -1,18 +1,18 @@ using System.Net; -using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Exceptions +namespace JsonApiDotNetCore.Errors { /// - /// The error that is thrown when the resource id in the request body does not match the id in the current endpoint URL. + /// The error that is thrown when the resource ID in the request body does not match the ID in the current endpoint URL. /// public sealed class ResourceIdMismatchException : JsonApiException { public ResourceIdMismatchException(string bodyId, string endpointId, string requestPath) : base(new Error(HttpStatusCode.Conflict) { - Title = "Resource id mismatch between request body and endpoint URL.", - Detail = $"Expected resource id '{endpointId}' in PATCH request body at endpoint '{requestPath}', instead of '{bodyId}'." + Title = "Resource ID mismatch between request body and endpoint URL.", + Detail = $"Expected resource ID '{endpointId}' in PATCH request body at endpoint '{requestPath}', instead of '{bodyId}'." }) { } diff --git a/src/JsonApiDotNetCore/Exceptions/ResourceNotFoundException.cs b/src/JsonApiDotNetCore/Errors/ResourceNotFoundException.cs similarity index 75% rename from src/JsonApiDotNetCore/Exceptions/ResourceNotFoundException.cs rename to src/JsonApiDotNetCore/Errors/ResourceNotFoundException.cs index 28f8ac73cf..22f6a57eaa 100644 --- a/src/JsonApiDotNetCore/Exceptions/ResourceNotFoundException.cs +++ b/src/JsonApiDotNetCore/Errors/ResourceNotFoundException.cs @@ -1,7 +1,7 @@ using System.Net; -using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Exceptions +namespace JsonApiDotNetCore.Errors { /// /// The error that is thrown when a resource does not exist. @@ -11,7 +11,7 @@ public sealed class ResourceNotFoundException : JsonApiException public ResourceNotFoundException(string resourceId, string resourceType) : base(new Error(HttpStatusCode.NotFound) { Title = "The requested resource does not exist.", - Detail = $"Resource of type '{resourceType}' with id '{resourceId}' does not exist." + Detail = $"Resource of type '{resourceType}' with ID '{resourceId}' does not exist." }) { } diff --git a/src/JsonApiDotNetCore/Exceptions/ResourceTypeMismatchException.cs b/src/JsonApiDotNetCore/Errors/ResourceTypeMismatchException.cs similarity index 82% rename from src/JsonApiDotNetCore/Exceptions/ResourceTypeMismatchException.cs rename to src/JsonApiDotNetCore/Errors/ResourceTypeMismatchException.cs index 05eca67e1d..42159c9214 100644 --- a/src/JsonApiDotNetCore/Exceptions/ResourceTypeMismatchException.cs +++ b/src/JsonApiDotNetCore/Errors/ResourceTypeMismatchException.cs @@ -1,9 +1,9 @@ using System.Net; using System.Net.Http; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Exceptions +namespace JsonApiDotNetCore.Errors { /// /// The error that is thrown when the resource type in the request body does not match the type expected at the current endpoint URL. @@ -14,7 +14,7 @@ public ResourceTypeMismatchException(HttpMethod method, string requestPath, Reso : base(new Error(HttpStatusCode.Conflict) { Title = "Resource type mismatch between request body and endpoint URL.", - Detail = $"Expected resource of type '{expected.ResourceName}' in {method} request body at endpoint '{requestPath}', instead of '{actual.ResourceName}'." + Detail = $"Expected resource of type '{expected.ResourceName}' in {method} request body at endpoint '{requestPath}', instead of '{actual?.ResourceName}'." }) { } diff --git a/src/JsonApiDotNetCore/Exceptions/UnsuccessfulActionResultException.cs b/src/JsonApiDotNetCore/Errors/UnsuccessfulActionResultException.cs similarity index 87% rename from src/JsonApiDotNetCore/Exceptions/UnsuccessfulActionResultException.cs rename to src/JsonApiDotNetCore/Errors/UnsuccessfulActionResultException.cs index 7bf8bb89fc..7f8cd6fc9d 100644 --- a/src/JsonApiDotNetCore/Exceptions/UnsuccessfulActionResultException.cs +++ b/src/JsonApiDotNetCore/Errors/UnsuccessfulActionResultException.cs @@ -1,8 +1,9 @@ +using System; using System.Net; -using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCore.Serialization.Objects; using Microsoft.AspNetCore.Mvc; -namespace JsonApiDotNetCore.Exceptions +namespace JsonApiDotNetCore.Errors { /// /// The error that is thrown when an with non-success status is returned from a controller method. @@ -24,6 +25,8 @@ public UnsuccessfulActionResultException(ProblemDetails problemDetails) private static Error ToError(ProblemDetails problemDetails) { + if (problemDetails == null) throw new ArgumentNullException(nameof(problemDetails)); + var status = problemDetails.Status != null ? (HttpStatusCode) problemDetails.Status.Value : HttpStatusCode.InternalServerError; diff --git a/src/JsonApiDotNetCore/Extensions/DbContextExtensions.cs b/src/JsonApiDotNetCore/Extensions/DbContextExtensions.cs deleted file mode 100644 index c97e039ff4..0000000000 --- a/src/JsonApiDotNetCore/Extensions/DbContextExtensions.cs +++ /dev/null @@ -1,108 +0,0 @@ -using System; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using JsonApiDotNetCore.Models; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Storage; - -namespace JsonApiDotNetCore.Extensions -{ - 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) - { - if (entity == null) - throw new ArgumentNullException(nameof(entity)); - - var trackedEntries = context.ChangeTracker - .Entries() - .FirstOrDefault(entry => - entry.Entity.GetType() == entity.GetType() - && ((IIdentifiable)entry.Entity).StringId == entity.StringId - ); - - return (IIdentifiable)trackedEntries?.Entity; - } - - /// - /// Gets the current transaction or creates a new one. - /// If a transaction already exists, commit, rollback and dispose - /// will not be called. It is assumed the creator of the original - /// transaction should be responsible for disposal. - /// - /// - /// - /// - /// using(var transaction = _context.GetCurrentOrCreateTransaction()) - /// { - /// // perform multiple operations on the context and then save... - /// _context.SaveChanges(); - /// } - /// - /// - public static async Task GetCurrentOrCreateTransactionAsync(this DbContext context) - => await SafeTransactionProxy.GetOrCreateAsync(context.Database); - } - - /// - /// Gets the current transaction or creates a new one. - /// If a transaction already exists, commit, rollback and dispose - /// will not be called. It is assumed the creator of the original - /// transaction should be responsible for disposal. - /// - internal struct SafeTransactionProxy : IDbContextTransaction - { - private readonly bool _shouldExecute; - private readonly IDbContextTransaction _transaction; - - private SafeTransactionProxy(IDbContextTransaction transaction, bool shouldExecute) - { - _transaction = transaction; - _shouldExecute = shouldExecute; - } - - public static async Task GetOrCreateAsync(DatabaseFacade databaseFacade) - => (databaseFacade.CurrentTransaction != null) - ? new SafeTransactionProxy(databaseFacade.CurrentTransaction, shouldExecute: false) - : new SafeTransactionProxy(await databaseFacade.BeginTransactionAsync(), shouldExecute: true); - - /// - public Guid TransactionId => _transaction.TransactionId; - - /// - public void Commit() => Proxy(t => t.Commit()); - - /// - public void Rollback() => Proxy(t => t.Rollback()); - - /// - public void Dispose() => Proxy(t => t.Dispose()); - - private void Proxy(Action func) - { - if(_shouldExecute) - func(_transaction); - } - - public Task CommitAsync(CancellationToken cancellationToken = default) - { - return _transaction.CommitAsync(cancellationToken); - } - - public Task RollbackAsync(CancellationToken cancellationToken = default) - { - return _transaction.RollbackAsync(cancellationToken); - } - - public ValueTask DisposeAsync() - { - return _transaction.DisposeAsync(); - } - } -} diff --git a/src/JsonApiDotNetCore/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/HttpContextExtensions.cs b/src/JsonApiDotNetCore/Extensions/HttpContextExtensions.cs deleted file mode 100644 index c6a4a8988c..0000000000 --- a/src/JsonApiDotNetCore/Extensions/HttpContextExtensions.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Microsoft.AspNetCore.Http; - -namespace JsonApiDotNetCore.Extensions -{ - public static class HttpContextExtensions - { - public static bool IsJsonApiRequest(this HttpContext httpContext) - { - string value = httpContext.Items["IsJsonApiRequest"] as string; - return value == bool.TrueString; - } - - internal static void SetJsonApiRequest(this HttpContext httpContext) - { - httpContext.Items["IsJsonApiRequest"] = bool.TrueString; - } - } -} diff --git a/src/JsonApiDotNetCore/Extensions/QueryableExtensions.cs b/src/JsonApiDotNetCore/Extensions/QueryableExtensions.cs deleted file mode 100644 index c4c4cee0ba..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.PropertyInfo.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); - } - else - member = Expression.Property(entity, filter.Attribute.PropertyInfo.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.PropertyInfo.Name); - if (relationProperty == null) - throw new ArgumentException($"'{filter.Relationship.PropertyInfo.Name}' is not a valid relationship of '{concreteType}'"); - - var relatedType = filter.Relationship.RightType; - property = relatedType.GetProperty(filter.Attribute.PropertyInfo.Name); - if (property == null) - throw new ArgumentException($"'{filter.Attribute.PropertyInfo.Name}' is not a valid attribute of '{filter.Relationship.PropertyInfo.Name}'"); - - var leftRelationship = Expression.PropertyOrField(parameter, filter.Relationship.PropertyInfo.Name); - // {model.Relationship} - left = Expression.PropertyOrField(leftRelationship, property.Name); - } - // Is standalone attribute - else - { - property = concreteType.GetProperty(filter.Attribute.PropertyInfo.Name); - if (property == null) - throw new ArgumentException($"'{filter.Attribute.PropertyInfo.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/Extensions/SystemCollectionExtensions.cs b/src/JsonApiDotNetCore/Extensions/SystemCollectionExtensions.cs deleted file mode 100644 index 04eb2bd8e4..0000000000 --- a/src/JsonApiDotNetCore/Extensions/SystemCollectionExtensions.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; - -namespace JsonApiDotNetCore.Extensions -{ - internal static class SystemCollectionExtensions - { - public static void AddRange(this ICollection source, IEnumerable items) - { - if (source == null) throw new ArgumentNullException(nameof(source)); - if (items == null) throw new ArgumentNullException(nameof(items)); - - foreach (var item in items) - { - source.Add(item); - } - } - - public static void AddRange(this IList source, IEnumerable items) - { - if (source == null) throw new ArgumentNullException(nameof(source)); - if (items == null) throw new ArgumentNullException(nameof(items)); - - foreach (var item in items) - { - source.Add(item); - } - } - } -} diff --git a/src/JsonApiDotNetCore/Extensions/TypeExtensions.cs b/src/JsonApiDotNetCore/Extensions/TypeExtensions.cs deleted file mode 100644 index e8ea2ebdf9..0000000000 --- a/src/JsonApiDotNetCore/Extensions/TypeExtensions.cs +++ /dev/null @@ -1,100 +0,0 @@ -using JsonApiDotNetCore.Internal; -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; - -namespace JsonApiDotNetCore.Extensions -{ - internal static class TypeExtensions - { - /// - /// Extension to use the LINQ cast method in a non-generic way: - /// - /// Type targetType = typeof(TResource) - /// ((IList)myList).CopyToList(targetType). - /// - /// - public static IList CopyToList(this IEnumerable copyFrom, Type elementType, Converter elementConverter = null) - { - Type collectionType = typeof(List<>).MakeGenericType(elementType); - - if (elementConverter != null) - { - var converted = copyFrom.Cast().Select(element => elementConverter(element)); - return (IList) CopyToTypedCollection(converted, collectionType); - } - - return (IList)CopyToTypedCollection(copyFrom, collectionType); - } - - /// - /// Creates a collection instance based on the specified collection type and copies the specified elements into it. - /// - /// Source to copy from. - /// Target collection type, for example: typeof(List{Article}) or typeof(ISet{Person}). - /// - public static IEnumerable CopyToTypedCollection(this IEnumerable source, Type collectionType) - { - if (source == null) throw new ArgumentNullException(nameof(source)); - if (collectionType == null) throw new ArgumentNullException(nameof(collectionType)); - - var concreteCollectionType = collectionType.ToConcreteCollectionType(); - dynamic concreteCollectionInstance = TypeHelper.CreateInstance(concreteCollectionType); - - foreach (var item in source) - { - concreteCollectionInstance.Add((dynamic) item); - } - - return concreteCollectionInstance; - } - - /// - /// Whether the specified source type implements or equals the specified interface. - /// - public static bool IsOrImplementsInterface(this Type source, Type interfaceType) - { - if (interfaceType == null) - { - throw new ArgumentNullException(nameof(interfaceType)); - } - - if (source == null) - { - return false; - } - - return source == interfaceType || source.GetInterfaces().Any(type => type == interfaceType); - } - - public static bool HasSingleConstructorWithoutParameters(this Type type) - { - ConstructorInfo[] constructors = type.GetConstructors().Where(c => !c.IsStatic).ToArray(); - - return constructors.Length == 1 && constructors[0].GetParameters().Length == 0; - } - - public static ConstructorInfo GetLongestConstructor(this Type type) - { - ConstructorInfo[] constructors = type.GetConstructors().Where(c => !c.IsStatic).ToArray(); - - ConstructorInfo bestMatch = constructors[0]; - int maxParameterLength = constructors[0].GetParameters().Length; - - for (int index = 1; index < constructors.Length; index++) - { - var constructor = constructors[index]; - int length = constructor.GetParameters().Length; - if (length > maxParameterLength) - { - bestMatch = constructor; - maxParameterLength = length; - } - } - - return bestMatch; - } - } -} diff --git a/src/JsonApiDotNetCore/Graph/IServiceDiscoveryFacade.cs b/src/JsonApiDotNetCore/Graph/IServiceDiscoveryFacade.cs deleted file mode 100644 index 53e4e80cc5..0000000000 --- a/src/JsonApiDotNetCore/Graph/IServiceDiscoveryFacade.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System.Reflection; - -namespace JsonApiDotNetCore.Graph -{ - public interface IServiceDiscoveryFacade - { - ServiceDiscoveryFacade AddAssembly(Assembly assembly); - ServiceDiscoveryFacade AddCurrentAssembly(); - } -} \ No newline at end of file diff --git a/src/JsonApiDotNetCore/Graph/ResourceIdMapper.cs b/src/JsonApiDotNetCore/Graph/ResourceIdMapper.cs deleted file mode 100644 index 4cafa76366..0000000000 --- a/src/JsonApiDotNetCore/Graph/ResourceIdMapper.cs +++ /dev/null @@ -1,27 +0,0 @@ -namespace JsonApiDotNetCore.Graph -{ - /// - /// Provides an interface for formatting relationship identifiers from the navigation property name - /// - public interface IRelatedIdMapper - { - /// - /// Get the internal property name for the database mapped identifier property - /// - /// - /// - /// - /// DefaultRelatedIdMapper.GetRelatedIdPropertyName("Article"); - /// // "ArticleId" - /// - /// - string GetRelatedIdPropertyName(string propertyName); - } - - /// - public sealed class DefaultRelatedIdMapper : IRelatedIdMapper - { - /// - public string GetRelatedIdPropertyName(string propertyName) => propertyName + "Id"; - } -} diff --git a/src/JsonApiDotNetCore/Graph/ResourceNameFormatter.cs b/src/JsonApiDotNetCore/Graph/ResourceNameFormatter.cs deleted file mode 100644 index 36cf2234e8..0000000000 --- a/src/JsonApiDotNetCore/Graph/ResourceNameFormatter.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System; -using System.Reflection; -using Humanizer; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Models; -using Newtonsoft.Json.Serialization; - -namespace JsonApiDotNetCore.Graph -{ - internal sealed class ResourceNameFormatter - { - private readonly NamingStrategy _namingStrategy; - - public ResourceNameFormatter(IJsonApiOptions options) - { - _namingStrategy = options.SerializerContractResolver.NamingStrategy; - } - - /// - /// Gets the publicly visible resource name from the internal type name. - /// - public string FormatResourceName(Type type) - { - try - { - // check the class definition first - // [Resource("models"] public class Model : Identifiable { /* ... */ } - if (type.GetCustomAttribute(typeof(ResourceAttribute)) is ResourceAttribute attribute) - { - return attribute.ResourceName; - } - - return _namingStrategy.GetPropertyName(type.Name.Pluralize(), false); - } - catch (InvalidOperationException exception) - { - throw new InvalidOperationException($"Cannot define multiple {nameof(ResourceAttribute)}s on type '{type}'.", exception); - } - } - } -} diff --git a/src/JsonApiDotNetCore/Hooks/Execution/DiffableEntityHashSet.cs b/src/JsonApiDotNetCore/Hooks/Execution/DiffableEntityHashSet.cs deleted file mode 100644 index 62f2d3c16a..0000000000 --- a/src/JsonApiDotNetCore/Hooks/Execution/DiffableEntityHashSet.cs +++ /dev/null @@ -1,122 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using System.Linq.Expressions; -using System.Reflection; -using JsonApiDotNetCore.Extensions; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Serialization; - -namespace JsonApiDotNetCore.Hooks -{ - /// - /// A wrapper class that contains information about the resources that are updated by the request. - /// Contains the resources from the request and the corresponding database values. - /// - /// Also contains information about updated relationships through - /// implementation of IRelationshipsDictionary> - /// - public interface IDiffableEntityHashSet : IEntityHashSet where TResource : class, IIdentifiable - { - /// - /// Iterates over diffs, which is the affected entity from the request - /// with their associated current value from the database. - /// - IEnumerable> GetDiffs(); - - } - - /// - public sealed class DiffableEntityHashSet : EntityHashSet, IDiffableEntityHashSet where TResource : class, IIdentifiable - { - private readonly HashSet _databaseValues; - private readonly bool _databaseValuesLoaded; - private readonly Dictionary> _updatedAttributes; - - public DiffableEntityHashSet(HashSet requestEntities, - HashSet databaseEntities, - Dictionary> relationships, - Dictionary> updatedAttributes) - : base(requestEntities, relationships) - { - _databaseValues = databaseEntities; - _databaseValuesLoaded |= _databaseValues != null; - _updatedAttributes = updatedAttributes; - } - - /// - /// Used internally by the ResourceHookExecutor to make live a bit easier with generics - /// - internal DiffableEntityHashSet(IEnumerable requestEntities, - IEnumerable databaseEntities, - Dictionary relationships, - ITargetedFields targetedFields) - : this((HashSet)requestEntities, (HashSet)databaseEntities, TypeHelper.ConvertRelationshipDictionary(relationships), - TypeHelper.ConvertAttributeDictionary(targetedFields.Attributes, (HashSet)requestEntities)) - { } - - - /// - public IEnumerable> GetDiffs() - { - if (!_databaseValuesLoaded) ThrowNoDbValuesError(); - - foreach (var entity in this) - { - TResource currentValueInDatabase = _databaseValues.Single(e => entity.StringId == e.StringId); - yield return new EntityDiffPair(entity, currentValueInDatabase); - } - } - - /// - public new HashSet GetAffected(Expression> navigationAction) - { - var propertyInfo = TypeHelper.ParseNavigationExpression(navigationAction); - var propertyType = propertyInfo.PropertyType; - if (propertyType.IsOrImplementsInterface(typeof(IEnumerable))) - { - propertyType = TypeHelper.TryGetCollectionElementType(propertyType); - } - - if (propertyType.IsOrImplementsInterface(typeof(IIdentifiable))) - { - // 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)) - { - return entities; - } - return new HashSet(); - } - - private void ThrowNoDbValuesError() - { - throw new MemberAccessException($"Cannot iterate over the diffs if the ${nameof(LoadDatabaseValuesAttribute)} option is set to false"); - } - } - - /// - /// A wrapper that contains an entity that is affected by the request, - /// matched to its current database value - /// - public sealed class EntityDiffPair where TResource : class, IIdentifiable - { - public EntityDiffPair(TResource entity, TResource databaseValue) - { - Entity = entity; - DatabaseValue = databaseValue; - } - - /// - /// The resource from the request matching the resource from the database. - /// - public TResource Entity { get; } - /// - /// The resource from the database matching the resource from the request. - /// - public TResource DatabaseValue { get; } - } -} diff --git a/src/JsonApiDotNetCore/Hooks/IResourceHookContainer.cs b/src/JsonApiDotNetCore/Hooks/IResourceHookContainer.cs deleted file mode 100644 index 0c712f516f..0000000000 --- a/src/JsonApiDotNetCore/Hooks/IResourceHookContainer.cs +++ /dev/null @@ -1,238 +0,0 @@ -using System.Collections.Generic; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Services; - -namespace JsonApiDotNetCore.Hooks -{ - /// - /// Not meant for public usage. Used internally in the - /// - public interface IResourceHookContainer { } - - /// - /// Implement this interface to implement business logic hooks on . - /// - public interface IResourceHookContainer - : IReadHookContainer, IDeleteHookContainer, ICreateHookContainer, - IUpdateHookContainer, IOnReturnHookContainer, IResourceHookContainer - where TResource : class, IIdentifiable { } - - /// - /// Read hooks container - /// - public interface IReadHookContainer where TResource : class, IIdentifiable - { - /// - /// Implement this hook to run custom logic in the - /// layer just before reading entities 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 - 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 . - /// - /// The unique set of affected entities. - /// 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, - /// or if they were included as a relationship - void AfterRead(HashSet entities, ResourcePipeline pipeline, bool isIncluded = false); - } - - /// - /// Create hooks container - /// - public interface ICreateHookContainer where TResource : class, IIdentifiable - { - /// - /// Implement this hook to run custom logic in the - /// layer just before creation of entities of type . - /// - /// 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. - /// - /// If new relationships are to be created with the to-be-created entities, - /// 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. - /// An enum indicating from where the hook was triggered. - IEnumerable BeforeCreate(IEntityHashSet entities, ResourcePipeline pipeline); - - /// - /// Implement this hook to run custom logic in the - /// layer just after creation of entities of type . - /// - /// If relationships were created with the created entities, 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. - /// An enum indicating from where the hook was triggered. - void AfterCreate(HashSet entities, ResourcePipeline pipeline); - } - - /// - /// update hooks container - /// - public interface IUpdateHookContainer where TResource : class, IIdentifiable - { - /// - /// Implement this hook to run custom logic in the - /// layer just before updating entities of type . - /// - /// For the pipeline, the - /// will typically contain one entity. - /// - /// The returned may be a subset - /// 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. - /// - /// If new relationships are to be created with the to-be-updated entities, - /// 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. - /// - /// If by the creation of these relationships, any other relationships (eg - /// in the case of an already populated one-to-one relationship) are implicitly - /// affected, the - /// hook is fired for these. - /// - /// The transformed entity set - /// The affected entities. - /// An enum indicating from where the hook was triggered. - IEnumerable BeforeUpdate(IDiffableEntityHashSet entities, ResourcePipeline pipeline); - - /// - /// Implement this hook to run custom logic in the - /// layer just before updating relationships to entities of type . - /// - /// This hook is fired when a relationship is created to entities 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 - /// for that particular Person. - /// - /// 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 - /// - /// - /// 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); - - /// - /// Implement this hook to run custom logic in the - /// layer just after updating entities of type . - /// - /// If relationships were updated with the updated entities, 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. - /// An enum indicating from where the hook was triggered. - void AfterUpdate(HashSet entities, ResourcePipeline pipeline); - - /// - /// Implement this hook to run custom logic in the layer - /// just after a relationship was updated. - /// - /// Relationship helper. - /// An enum indicating from where the hook was triggered. - void AfterUpdateRelationship(IRelationshipsDictionary entitiesByRelationship, ResourcePipeline pipeline); - - /// - /// Implement this hook to run custom logic in the - /// layer just before implicitly updating relationships to entities of type . - /// - /// This hook is fired when a relationship to entities 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 - /// when this hook is fired. - /// - /// - /// The transformed set of ids - /// A helper that groups the entities by the affected relationship - /// An enum indicating from where the hook was triggered. - void BeforeImplicitUpdateRelationship(IRelationshipsDictionary entitiesByRelationship, ResourcePipeline pipeline); - } - - /// - /// Delete hooks container - /// - public interface IDeleteHookContainer where TResource : class, IIdentifiable - { - /// - /// Implement this hook to run custom logic in the - /// layer just before deleting entities of type . - /// - /// For the pipeline, - /// will typically contain one entity. - /// - /// The returned may be a subset - /// of , in which case the operation of the - /// pipeline will not be executed for the omitted entities. - /// - /// If by the deletion of these entities any other entities 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. - /// - /// The transformed entity set - /// The unique set of affected entities. - /// An enum indicating from where the hook was triggered. - IEnumerable BeforeDelete(IEntityHashSet entities, ResourcePipeline pipeline); - - /// - /// Implement this hook to run custom logic in the - /// layer just after deletion of entities of type . - /// - /// The unique set of affected entities. - /// 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); - } - - /// - /// On return hook container - /// - public interface IOnReturnHookContainer where TResource : class, IIdentifiable - { - /// - /// Implement this hook to transform the result data just before returning - /// the entities of type from the - /// layer - /// - /// The returned may be a subset - /// of and may contain changes in properties - /// of the encapsulated entities. - /// - /// - /// The transformed entity set - /// The unique set of affected entities - /// An enum indicating from where the hook was triggered. - IEnumerable OnReturn(HashSet entities, ResourcePipeline pipeline); - } -} diff --git a/src/JsonApiDotNetCore/Hooks/IResourceHookExecutor.cs b/src/JsonApiDotNetCore/Hooks/IResourceHookExecutor.cs deleted file mode 100644 index 399f98bc69..0000000000 --- a/src/JsonApiDotNetCore/Hooks/IResourceHookExecutor.cs +++ /dev/null @@ -1,171 +0,0 @@ -using System.Collections.Generic; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Services; - -namespace JsonApiDotNetCore.Hooks -{ - /// - /// Transient service responsible for executing Resource Hooks as defined - /// in . see methods in - /// , and - /// for more information. - /// - /// Uses for traversal of nested entity data structures. - /// Uses for retrieving meta data about hooks, - /// fetching database values and performing other recurring internal operations. - /// - public interface IResourceHookExecutor : IReadHookExecutor, IUpdateHookExecutor, ICreateHookExecutor, IDeleteHookExecutor, IOnReturnHookExecutor { } - - 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 . - /// - /// Fires the - /// hook where T = for values in parameter . - /// - /// Fires the - /// hook for any related (nested) entity for values within parameter - /// - /// The transformed set - /// Target entities 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; - /// - /// Executes the After Cycle by firing the appropriate hooks if they are implemented. - /// - /// Fires the - /// hook where T = for values in parameter . - /// - /// Fires the - /// hook for any related (nested) entity for values within parameter - /// - /// Target entities 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; - } - - 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 . - /// - /// Fires the - /// 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. - /// - /// The transformed set - /// Target entities 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; - - /// - /// Executes the After Cycle by firing the appropriate hooks if they are implemented. - /// - /// Fires the - /// hook where T = for values in parameter . - /// - /// Target entities 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; - } - - /// - /// Wrapper interface for all Before execution methods. - /// - public interface IReadHookExecutor - { - /// - /// Executes the Before Cycle by firing the appropriate hooks if they are implemented. - /// - /// Fires the - /// hook where T = for the requested - /// entities 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 - 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 . - /// - /// Target entities 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; - } - - /// - /// Wrapper interface for all After execution methods. - /// - 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 . - /// - /// Fires the - /// hook where T = for values in parameter . - /// - /// Fires the - /// hook for any related (nested) entity 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 - /// had this relationship populated, then this update will indirectly affect - /// the existing relationship value. - /// - /// The transformed set - /// Target entities 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; - /// - /// Executes the After Cycle by firing the appropriate hooks if they are implemented. - /// - /// Fires the - /// hook where T = for values in parameter . - /// - /// Fires the - /// hook for any related (nested) entity for values within parameter - /// - /// Target entities 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; - } - - /// - /// Wrapper interface for all On execution methods. - /// - 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 . - /// - /// The transformed set - /// Target entities 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; - } -} diff --git a/src/JsonApiDotNetCore/Hooks/Discovery/HooksDiscovery.cs b/src/JsonApiDotNetCore/Hooks/Internal/Discovery/HooksDiscovery.cs similarity index 90% rename from src/JsonApiDotNetCore/Hooks/Discovery/HooksDiscovery.cs rename to src/JsonApiDotNetCore/Hooks/Internal/Discovery/HooksDiscovery.cs index 86166600e4..0c87bbfa2b 100644 --- a/src/JsonApiDotNetCore/Hooks/Discovery/HooksDiscovery.cs +++ b/src/JsonApiDotNetCore/Hooks/Internal/Discovery/HooksDiscovery.cs @@ -1,11 +1,12 @@ using System; using System.Collections.Generic; using System.Linq; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Hooks.Internal.Execution; +using JsonApiDotNetCore.Resources; using Microsoft.Extensions.DependencyInjection; -namespace JsonApiDotNetCore.Hooks +namespace JsonApiDotNetCore.Hooks.Internal.Discovery { /// /// The default implementation for IHooksDiscovery @@ -21,7 +22,7 @@ public class HooksDiscovery : IHooksDiscovery where TResou ResourceHook.BeforeDelete }; - /// + /// public ResourceHook[] ImplementedHooks { get; private set; } public ResourceHook[] DatabaseValuesEnabledHooks { get; private set; } public ResourceHook[] DatabaseValuesDisabledHooks { get; private set; } @@ -69,7 +70,7 @@ private void DiscoverImplementedHooks(Type containerType) { if (!_databaseValuesAttributeAllowed.Contains(hook)) { - throw new JsonApiSetupException($"{nameof(LoadDatabaseValuesAttribute)} cannot be used on hook" + + throw new InvalidConfigurationException($"{nameof(LoadDatabaseValuesAttribute)} cannot be used on hook" + $"{hook:G} in resource definition {containerType.Name}"); } var targetList = attr.Value ? databaseValuesEnabledHooks : databaseValuesDisabledHooks; diff --git a/src/JsonApiDotNetCore/Hooks/Discovery/IHooksDiscovery.cs b/src/JsonApiDotNetCore/Hooks/Internal/Discovery/IHooksDiscovery.cs similarity index 80% rename from src/JsonApiDotNetCore/Hooks/Discovery/IHooksDiscovery.cs rename to src/JsonApiDotNetCore/Hooks/Internal/Discovery/IHooksDiscovery.cs index 9db0d2a0ca..9473ddbb24 100644 --- a/src/JsonApiDotNetCore/Hooks/Discovery/IHooksDiscovery.cs +++ b/src/JsonApiDotNetCore/Hooks/Internal/Discovery/IHooksDiscovery.cs @@ -1,19 +1,17 @@ -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Hooks.Internal.Execution; +using JsonApiDotNetCore.Resources; -namespace JsonApiDotNetCore.Hooks +namespace JsonApiDotNetCore.Hooks.Internal.Discovery { - /// /// 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 +22,4 @@ public interface IHooksDiscovery ResourceHook[] DatabaseValuesEnabledHooks { get; } ResourceHook[] DatabaseValuesDisabledHooks { get; } } - } diff --git a/src/JsonApiDotNetCore/Hooks/Discovery/LoadDatabaseValuesAttribute.cs b/src/JsonApiDotNetCore/Hooks/Internal/Discovery/LoadDatabaseValuesAttribute.cs similarity index 84% rename from src/JsonApiDotNetCore/Hooks/Discovery/LoadDatabaseValuesAttribute.cs rename to src/JsonApiDotNetCore/Hooks/Internal/Discovery/LoadDatabaseValuesAttribute.cs index 3caf4b5ef6..09a5393e3b 100644 --- a/src/JsonApiDotNetCore/Hooks/Discovery/LoadDatabaseValuesAttribute.cs +++ b/src/JsonApiDotNetCore/Hooks/Internal/Discovery/LoadDatabaseValuesAttribute.cs @@ -1,5 +1,6 @@ using System; -namespace JsonApiDotNetCore.Hooks + +namespace JsonApiDotNetCore.Hooks.Internal.Discovery { [AttributeUsage(AttributeTargets.Method)] public sealed class LoadDatabaseValuesAttribute : Attribute diff --git a/src/JsonApiDotNetCore/Hooks/Internal/Execution/DiffableResourceHashSet.cs b/src/JsonApiDotNetCore/Hooks/Internal/Execution/DiffableResourceHashSet.cs new file mode 100644 index 0000000000..d555e0d412 --- /dev/null +++ b/src/JsonApiDotNetCore/Hooks/Internal/Execution/DiffableResourceHashSet.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using JsonApiDotNetCore.Hooks.Internal.Discovery; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Hooks.Internal.Execution +{ + public sealed class DiffableResourceHashSet : ResourceHashSet, IDiffableResourceHashSet where TResource : class, IIdentifiable + { + private readonly HashSet _databaseValues; + private readonly bool _databaseValuesLoaded; + private readonly Dictionary> _updatedAttributes; + + public DiffableResourceHashSet(HashSet requestResources, + HashSet databaseResources, + Dictionary> relationships, + Dictionary> updatedAttributes) + : base(requestResources, relationships) + { + _databaseValues = databaseResources; + _databaseValuesLoaded |= _databaseValues != null; + _updatedAttributes = updatedAttributes; + } + + /// + /// Used internally by the ResourceHookExecutor to make live a bit easier with generics + /// + internal DiffableResourceHashSet(IEnumerable requestResources, + IEnumerable databaseResources, + Dictionary relationships, + ITargetedFields targetedFields) + : this((HashSet)requestResources, (HashSet)databaseResources, TypeHelper.ConvertRelationshipDictionary(relationships), + TypeHelper.ConvertAttributeDictionary(targetedFields.Attributes, (HashSet)requestResources)) + { } + + + /// + public IEnumerable> GetDiffs() + { + if (!_databaseValuesLoaded) ThrowNoDbValuesError(); + + foreach (var resource in this) + { + TResource currentValueInDatabase = _databaseValues.Single(e => resource.StringId == e.StringId); + yield return new ResourceDiffPair(resource, currentValueInDatabase); + } + } + + /// + public new HashSet GetAffected(Expression> navigationAction) + { + var propertyInfo = TypeHelper.ParseNavigationExpression(navigationAction); + var propertyType = propertyInfo.PropertyType; + if (TypeHelper.IsOrImplementsInterface(propertyType, typeof(IEnumerable))) + { + propertyType = TypeHelper.TryGetCollectionElementType(propertyType); + } + + if (TypeHelper.IsOrImplementsInterface(propertyType, typeof(IIdentifiable))) + { + // the navigation action references a relationship. Redirect the call to the relationship dictionary. + return base.GetAffected(navigationAction); + } + else if (_updatedAttributes.TryGetValue(propertyInfo, out HashSet resources)) + { + return resources; + } + return new HashSet(); + } + + private void ThrowNoDbValuesError() + { + throw new MemberAccessException($"Cannot iterate over the diffs if the ${nameof(LoadDatabaseValuesAttribute)} option is set to false"); + } + } +} diff --git a/src/JsonApiDotNetCore/Hooks/Execution/HookExecutorHelper.cs b/src/JsonApiDotNetCore/Hooks/Internal/Execution/HookExecutorHelper.cs similarity index 56% rename from src/JsonApiDotNetCore/Hooks/Execution/HookExecutorHelper.cs rename to src/JsonApiDotNetCore/Hooks/Internal/Execution/HookExecutorHelper.cs index 1af718f817..03c8ffa8d5 100644 --- a/src/JsonApiDotNetCore/Hooks/Execution/HookExecutorHelper.cs +++ b/src/JsonApiDotNetCore/Hooks/Internal/Execution/HookExecutorHelper.cs @@ -3,48 +3,50 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; -using JsonApiDotNetCore.Data; -using JsonApiDotNetCore.Internal.Generics; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Extensions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Hooks.Internal.Discovery; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Repositories; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; using LeftType = System.Type; using RightType = System.Type; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Configuration; -namespace JsonApiDotNetCore.Hooks +namespace JsonApiDotNetCore.Hooks.Internal.Execution { - /// + /// 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; @@ -54,7 +56,7 @@ public IResourceHookContainer GetResourceHookContainer(RightType rightType, Reso if (hook == ResourceHook.None) { CheckForTargetHookExistence(); - targetHooks = _targetedHooksForRelatedEntities; + targetHooks = _targetedHooksForRelatedResources; } else { @@ -63,41 +65,41 @@ 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; } - /// + /// public IResourceHookContainer GetResourceHookContainer(ResourceHook hook = ResourceHook.None) where TResource : class, IIdentifiable { 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(); - var ids = cast.Select(TypeHelper.GetResourceTypedId).CopyToList(idType); + .MakeGenericMethod(resourceTypeForRepository, idType); + var cast = ((IEnumerable)resources).Cast(); + var ids = TypeHelper.CopyToList(cast.Select(TypeHelper.GetResourceTypedId), 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), TypeHelper.CopyToList(values, 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)) @@ -105,38 +107,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 = IncludeChainConverter.FromRelationshipChains(chains); } - return query.ToList(); + + var repository = GetRepository(); + return repository.GetAsync(queryLayer).Result; } private IResourceReadRepository GetRepository() where TResource : class, IIdentifiable @@ -145,11 +157,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; @@ -158,31 +170,35 @@ 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); + + foreach (var item in dbRightResourceListCast) + { + ((IList)affected).Add(item); + } } } } @@ -191,12 +207,12 @@ public Dictionary LoadImplicitlyAffected( } private bool IsHasManyThrough(KeyValuePair kvp, - out IEnumerable entities, + out IEnumerable resources, out RelationshipAttribute attr) { attr = kvp.Key; - entities = (kvp.Value); - return (kvp.Key is HasManyThroughAttribute); + resources = kvp.Value; + return kvp.Key is HasManyThroughAttribute; } } } diff --git a/src/JsonApiDotNetCore/Hooks/Internal/Execution/IByAffectedRelationships.cs b/src/JsonApiDotNetCore/Hooks/Internal/Execution/IByAffectedRelationships.cs new file mode 100644 index 0000000000..689ac13260 --- /dev/null +++ b/src/JsonApiDotNetCore/Hooks/Internal/Execution/IByAffectedRelationships.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Hooks.Internal.Execution +{ + /// + /// An interface that is implemented to expose a relationship dictionary on another class. + /// + public interface IByAffectedRelationships : + IRelationshipGetters where TRightResource : class, IIdentifiable + { + /// + /// Gets a dictionary of affected resources grouped by affected relationships. + /// + Dictionary> AffectedRelationships { get; } + } +} \ No newline at end of file diff --git a/src/JsonApiDotNetCore/Hooks/Internal/Execution/IDiffableResourceHashSet.cs b/src/JsonApiDotNetCore/Hooks/Internal/Execution/IDiffableResourceHashSet.cs new file mode 100644 index 0000000000..1a7d98fc14 --- /dev/null +++ b/src/JsonApiDotNetCore/Hooks/Internal/Execution/IDiffableResourceHashSet.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.Hooks.Internal.Execution +{ + /// + /// A wrapper class that contains information about the resources that are updated by the request. + /// Contains the resources from the request and the corresponding database values. + /// + /// Also contains information about updated relationships through + /// implementation of IRelationshipsDictionary> + /// + public interface IDiffableResourceHashSet : IResourceHashSet where TResource : class, IIdentifiable + { + /// + /// Iterates over diffs, which is the affected resource from the request + /// with their associated current value from the database. + /// + IEnumerable> GetDiffs(); + + } +} \ No newline at end of file diff --git a/src/JsonApiDotNetCore/Hooks/Execution/IHookExecutorHelper.cs b/src/JsonApiDotNetCore/Hooks/Internal/Execution/IHookExecutorHelper.cs similarity index 63% rename from src/JsonApiDotNetCore/Hooks/Execution/IHookExecutorHelper.cs rename to src/JsonApiDotNetCore/Hooks/Internal/Execution/IHookExecutorHelper.cs index 046d12609c..b04ef1aae5 100644 --- a/src/JsonApiDotNetCore/Hooks/Execution/IHookExecutorHelper.cs +++ b/src/JsonApiDotNetCore/Hooks/Internal/Execution/IHookExecutorHelper.cs @@ -1,9 +1,10 @@ using System; using System.Collections; using System.Collections.Generic; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Hooks +namespace JsonApiDotNetCore.Hooks.Internal.Execution { /// /// A helper class for retrieving meta data about hooks, @@ -21,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 @@ -34,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/Internal/Execution/IRelationshipGetters.cs b/src/JsonApiDotNetCore/Hooks/Internal/Execution/IRelationshipGetters.cs new file mode 100644 index 0000000000..ca95ddef87 --- /dev/null +++ b/src/JsonApiDotNetCore/Hooks/Internal/Execution/IRelationshipGetters.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Hooks.Internal.Execution +{ + /// + /// 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 resources that have an affected relationship to type + /// + Dictionary> GetByRelationship() where TRightResource : class, IIdentifiable; + /// + /// Gets a dictionary of all resources that have an affected relationship to type + /// + Dictionary> GetByRelationship(Type relatedResourceType); + /// + /// Gets a collection of all the resources for the property within + /// has been affected by the request + /// + HashSet GetAffected(Expression> navigationAction); + } +} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/Execution/IRelationshipsDictionary.cs b/src/JsonApiDotNetCore/Hooks/Internal/Execution/IRelationshipsDictionary.cs new file mode 100644 index 0000000000..1ddd5cbc36 --- /dev/null +++ b/src/JsonApiDotNetCore/Hooks/Internal/Execution/IRelationshipsDictionary.cs @@ -0,0 +1,7 @@ +namespace JsonApiDotNetCore.Hooks.Internal.Execution +{ + /// + /// A dummy interface used internally by the hook executor. + /// + public interface IRelationshipsDictionary { } +} \ No newline at end of file diff --git a/src/JsonApiDotNetCore/Hooks/Internal/Execution/IResourceHashSet.cs b/src/JsonApiDotNetCore/Hooks/Internal/Execution/IResourceHashSet.cs new file mode 100644 index 0000000000..8863ac4686 --- /dev/null +++ b/src/JsonApiDotNetCore/Hooks/Internal/Execution/IResourceHashSet.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.Hooks.Internal.Execution +{ + /// + /// Basically a enumerable of of resources that were affected by the request. + /// + /// Also contains information about updated relationships through + /// implementation of IAffectedRelationshipsDictionary> + /// + public interface IResourceHashSet : IByAffectedRelationships, IReadOnlyCollection where TResource : class, IIdentifiable { } +} \ No newline at end of file diff --git a/src/JsonApiDotNetCore/Hooks/Execution/RelationshipsDictionary.cs b/src/JsonApiDotNetCore/Hooks/Internal/Execution/RelationshipsDictionary.cs similarity index 51% rename from src/JsonApiDotNetCore/Hooks/Execution/RelationshipsDictionary.cs rename to src/JsonApiDotNetCore/Hooks/Internal/Execution/RelationshipsDictionary.cs index 5ff77c35aa..f624b93aad 100644 --- a/src/JsonApiDotNetCore/Hooks/Execution/RelationshipsDictionary.cs +++ b/src/JsonApiDotNetCore/Hooks/Internal/Execution/RelationshipsDictionary.cs @@ -3,30 +3,13 @@ using System.Collections.Generic; using System.Linq; using System.Linq.Expressions; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Hooks +namespace JsonApiDotNetCore.Hooks.Internal.Execution { /// - /// A dummy interface used internally by the hook executor. - /// - public interface IRelationshipsDictionary { } - - /// - /// An interface that is implemented to expose a relationship dictionary on another class. - /// - public interface IByAffectedRelationships : - IRelationshipGetters where TRightResource : class, IIdentifiable - { - /// - /// Gets a dictionary of affected resources grouped by affected relationships. - /// - Dictionary> AffectedRelationships { get; } - } - - /// - /// 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, @@ -34,27 +17,6 @@ public interface IRelationshipsDictionary : IRelationshipsDictionary where TRightResource : class, IIdentifiable { } - /// - /// A helper class that provides insights in which relationships have been updated for which entities. - /// - public interface IRelationshipGetters where TLeftResource : class, IIdentifiable - { - /// - /// Gets a dictionary of all entities 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 - /// - Dictionary> GetByRelationship(Type relatedResourceType); - /// - /// Gets a collection of all the entities for the property within - /// has been affected by the request - /// - /// - HashSet GetAffected(Expression> navigationAction); - } - /// /// Implementation of IAffectedRelationships{TRightResource} @@ -67,13 +29,13 @@ public class RelationshipsDictionary : IRelationshipsDictionary where TResource : class, IIdentifiable { /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// Relationships. public RelationshipsDictionary(Dictionary> relationships) : base(relationships) { } /// - /// Used internally by the ResourceHookExecutor to make live a bit easier with generics + /// Used internally by the ResourceHookExecutor to make life a bit easier with generics /// internal RelationshipsDictionary(Dictionary relationships) : this(TypeHelper.ConvertRelationshipDictionary(relationships)) { } @@ -94,7 +56,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/Internal/Execution/ResourceDiffPair.cs b/src/JsonApiDotNetCore/Hooks/Internal/Execution/ResourceDiffPair.cs new file mode 100644 index 0000000000..a627c5d38b --- /dev/null +++ b/src/JsonApiDotNetCore/Hooks/Internal/Execution/ResourceDiffPair.cs @@ -0,0 +1,26 @@ +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.Hooks.Internal.Execution +{ + /// + /// A wrapper that contains a resource that is affected by the request, + /// matched to its current database value + /// + public sealed class ResourceDiffPair where TResource : class, IIdentifiable + { + public ResourceDiffPair(TResource resource, TResource databaseValue) + { + Resource = resource; + DatabaseValue = databaseValue; + } + + /// + /// The resource from the request matching the resource from the database. + /// + public TResource Resource { get; } + /// + /// The resource from the database matching the resource from the request. + /// + public TResource DatabaseValue { get; } + } +} \ No newline at end of file diff --git a/src/JsonApiDotNetCore/Hooks/Execution/EntityHashSet.cs b/src/JsonApiDotNetCore/Hooks/Internal/Execution/ResourceHashSet.cs similarity index 64% rename from src/JsonApiDotNetCore/Hooks/Execution/EntityHashSet.cs rename to src/JsonApiDotNetCore/Hooks/Internal/Execution/ResourceHashSet.cs index a29b30e391..9b61f57d36 100644 --- a/src/JsonApiDotNetCore/Hooks/Execution/EntityHashSet.cs +++ b/src/JsonApiDotNetCore/Hooks/Internal/Execution/ResourceHashSet.cs @@ -1,20 +1,12 @@ -using System.Collections.Generic; -using JsonApiDotNetCore.Models; -using System.Collections; -using JsonApiDotNetCore.Internal; using System; +using System.Collections; +using System.Collections.Generic; using System.Linq.Expressions; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Hooks +namespace JsonApiDotNetCore.Hooks.Internal.Execution { - /// - /// Basically a enumerable of of resources that were affected by the request. - /// - /// Also contains information about updated relationships through - /// implementation of IAffectedRelationshipsDictionary> - /// - public interface IEntityHashSet : IByAffectedRelationships, IReadOnlyCollection where TResource : class, IIdentifiable { } - /// /// Implementation of IResourceHashSet{TResource}. /// @@ -23,15 +15,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); } @@ -39,9 +31,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/ResourceHookEnum.cs b/src/JsonApiDotNetCore/Hooks/Internal/Execution/ResourceHook.cs similarity index 90% rename from src/JsonApiDotNetCore/Hooks/Execution/ResourceHookEnum.cs rename to src/JsonApiDotNetCore/Hooks/Internal/Execution/ResourceHook.cs index 9a22e527b1..f60978586f 100644 --- a/src/JsonApiDotNetCore/Hooks/Execution/ResourceHookEnum.cs +++ b/src/JsonApiDotNetCore/Hooks/Internal/Execution/ResourceHook.cs @@ -1,4 +1,4 @@ -namespace JsonApiDotNetCore.Hooks +namespace JsonApiDotNetCore.Hooks.Internal.Execution { /// diff --git a/src/JsonApiDotNetCore/Hooks/Execution/ResourcePipelineEnum.cs b/src/JsonApiDotNetCore/Hooks/Internal/Execution/ResourcePipeline.cs similarity index 65% rename from src/JsonApiDotNetCore/Hooks/Execution/ResourcePipelineEnum.cs rename to src/JsonApiDotNetCore/Hooks/Internal/Execution/ResourcePipeline.cs index 3f952c8f52..ffeb61a07f 100644 --- a/src/JsonApiDotNetCore/Hooks/Execution/ResourcePipelineEnum.cs +++ b/src/JsonApiDotNetCore/Hooks/Internal/Execution/ResourcePipeline.cs @@ -1,8 +1,10 @@ -namespace JsonApiDotNetCore.Hooks +using JsonApiDotNetCore.Services; + +namespace JsonApiDotNetCore.Hooks.Internal.Execution { /// /// 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/Internal/ICreateHookContainer.cs b/src/JsonApiDotNetCore/Hooks/Internal/ICreateHookContainer.cs new file mode 100644 index 0000000000..4a0369617b --- /dev/null +++ b/src/JsonApiDotNetCore/Hooks/Internal/ICreateHookContainer.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Hooks.Internal.Execution; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Services; + +namespace JsonApiDotNetCore.Hooks.Internal +{ + /// + /// Create hooks container + /// + public interface ICreateHookContainer where TResource : class, IIdentifiable + { + /// + /// Implement this hook to run custom logic in the + /// layer just before creation of resources of type . + /// + /// 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 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 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 resource set + /// The unique set of affected resources. + /// An enum indicating from where the hook was triggered. + IEnumerable BeforeCreate(IResourceHashSet resources, ResourcePipeline pipeline); + + /// + /// Implement this hook to run custom logic in the + /// layer just after creation of resources of type . + /// + /// 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 resource set + /// The unique set of affected resources. + /// An enum indicating from where the hook was triggered. + void AfterCreate(HashSet resources, ResourcePipeline pipeline); + } +} \ No newline at end of file diff --git a/src/JsonApiDotNetCore/Hooks/Internal/ICreateHookExecutor.cs b/src/JsonApiDotNetCore/Hooks/Internal/ICreateHookExecutor.cs new file mode 100644 index 0000000000..e2f90560fb --- /dev/null +++ b/src/JsonApiDotNetCore/Hooks/Internal/ICreateHookExecutor.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Hooks.Internal.Execution; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Services; + +namespace JsonApiDotNetCore.Hooks.Internal +{ + 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 . + /// + /// Fires the + /// hook where T = for values in parameter . + /// + /// Fires the + /// hook for any secondary (nested) resource for values within parameter + /// + /// The transformed set + /// Target resources for the Before cycle. + /// An enum indicating from where the hook was triggered. + /// 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 . + /// + /// Fires the + /// hook for any secondary (nested) resource for values within parameter + /// + /// Target resources for the Before cycle. + /// An enum indicating from where the hook was triggered. + /// The type of the root resources + void AfterCreate(IEnumerable resources, ResourcePipeline pipeline) where TResource : class, IIdentifiable; + } +} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/IDeleteHookContainer.cs b/src/JsonApiDotNetCore/Hooks/Internal/IDeleteHookContainer.cs new file mode 100644 index 0000000000..9f9a8c41d5 --- /dev/null +++ b/src/JsonApiDotNetCore/Hooks/Internal/IDeleteHookContainer.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Hooks.Internal.Execution; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Services; + +namespace JsonApiDotNetCore.Hooks.Internal +{ + /// + /// Delete hooks container + /// + public interface IDeleteHookContainer where TResource : class, IIdentifiable + { + /// + /// Implement this hook to run custom logic in the + /// layer just before deleting resources of type . + /// + /// For the pipeline, + /// 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 resources. + /// + /// 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 resources. + /// + /// The transformed resource set + /// The unique set of affected resources. + /// An enum indicating from where the hook was triggered. + IEnumerable BeforeDelete(IResourceHashSet resources, ResourcePipeline pipeline); + + /// + /// Implement this hook to run custom logic in the + /// layer just after deletion of resources of type . + /// + /// 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 resources, ResourcePipeline pipeline, bool succeeded); + } +} \ No newline at end of file diff --git a/src/JsonApiDotNetCore/Hooks/Internal/IDeleteHookExecutor.cs b/src/JsonApiDotNetCore/Hooks/Internal/IDeleteHookExecutor.cs new file mode 100644 index 0000000000..205d2cacc6 --- /dev/null +++ b/src/JsonApiDotNetCore/Hooks/Internal/IDeleteHookExecutor.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Hooks.Internal.Execution; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Services; + +namespace JsonApiDotNetCore.Hooks.Internal +{ + 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 . + /// + /// Fires the + /// hook where T = for values in parameter . + /// + /// Fires the + /// 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 resources for the Before cycle. + /// An enum indicating from where the hook was triggered. + /// 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 . + /// + /// 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 resources + void AfterDelete(IEnumerable resources, ResourcePipeline pipeline, bool succeeded) where TResource : class, IIdentifiable; + } +} \ No newline at end of file diff --git a/src/JsonApiDotNetCore/Hooks/Internal/IOnReturnHookContainer.cs b/src/JsonApiDotNetCore/Hooks/Internal/IOnReturnHookContainer.cs new file mode 100644 index 0000000000..61db0db85f --- /dev/null +++ b/src/JsonApiDotNetCore/Hooks/Internal/IOnReturnHookContainer.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Hooks.Internal.Execution; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Services; + +namespace JsonApiDotNetCore.Hooks.Internal +{ + /// + /// On return hook container + /// + public interface IOnReturnHookContainer where TResource : class, IIdentifiable + { + /// + /// Implement this hook to transform the result data just before returning + /// the resources of type from the + /// layer + /// + /// The returned may be a subset + /// of and may contain changes in properties + /// of the encapsulated resources. + /// + /// + /// The transformed resource set + /// The unique set of affected resources + /// An enum indicating from where the hook was triggered. + IEnumerable OnReturn(HashSet resources, ResourcePipeline pipeline); + } +} \ No newline at end of file diff --git a/src/JsonApiDotNetCore/Hooks/Internal/IOnReturnHookExecutor.cs b/src/JsonApiDotNetCore/Hooks/Internal/IOnReturnHookExecutor.cs new file mode 100644 index 0000000000..3bea4012fa --- /dev/null +++ b/src/JsonApiDotNetCore/Hooks/Internal/IOnReturnHookExecutor.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Hooks.Internal.Execution; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.Hooks.Internal +{ + /// + /// Wrapper interface for all On execution methods. + /// + public interface IOnReturnHookExecutor + { + /// + /// Executes the On Cycle by firing the appropriate hooks if they are implemented. + /// + /// Fires the for every unique + /// resource type occurring in parameter . + /// + /// The transformed set + /// Target resources for the Before cycle. + /// An enum indicating from where the hook was triggered. + /// The type of the root resources + IEnumerable OnReturn(IEnumerable resources, ResourcePipeline pipeline) where TResource : class, IIdentifiable; + } +} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/IReadHookContainer.cs b/src/JsonApiDotNetCore/Hooks/Internal/IReadHookContainer.cs new file mode 100644 index 0000000000..9913f8f2d4 --- /dev/null +++ b/src/JsonApiDotNetCore/Hooks/Internal/IReadHookContainer.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Hooks.Internal.Execution; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Services; + +namespace JsonApiDotNetCore.Hooks.Internal +{ + /// + /// Read hooks container + /// + public interface IReadHookContainer where TResource : class, IIdentifiable + { + /// + /// 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 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 resources of type . + /// + /// The unique set of affected resources. + /// An enum indicating from where the hook was triggered. + /// 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 resources, ResourcePipeline pipeline, bool isIncluded = false); + } +} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/IReadHookExecutor.cs b/src/JsonApiDotNetCore/Hooks/Internal/IReadHookExecutor.cs new file mode 100644 index 0000000000..0abbbe583f --- /dev/null +++ b/src/JsonApiDotNetCore/Hooks/Internal/IReadHookExecutor.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Hooks.Internal.Execution; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Services; + +namespace JsonApiDotNetCore.Hooks.Internal +{ + /// + /// Wrapper interface for all Before execution methods. + /// + public interface IReadHookExecutor + { + /// + /// Executes the Before Cycle by firing the appropriate hooks if they are implemented. + /// + /// Fires the + /// hook where T = for the requested + /// resources as well as any related relationship. + /// + /// An enum indicating from where the hook was triggered. + /// 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 + /// resource type occurring in parameter . + /// + /// Target resources for the Before cycle. + /// An enum indicating from where the hook was triggered. + /// The type of the root resources + void AfterRead(IEnumerable resources, ResourcePipeline pipeline) where TResource : class, IIdentifiable; + } +} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/IResourceHookContainer.cs b/src/JsonApiDotNetCore/Hooks/Internal/IResourceHookContainer.cs new file mode 100644 index 0000000000..60e0913fa4 --- /dev/null +++ b/src/JsonApiDotNetCore/Hooks/Internal/IResourceHookContainer.cs @@ -0,0 +1,17 @@ +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.Hooks.Internal +{ + /// + /// Not meant for public usage. Used internally in the + /// + public interface IResourceHookContainer { } + + /// + /// Implement this interface to implement business logic hooks on . + /// + public interface IResourceHookContainer + : IReadHookContainer, IDeleteHookContainer, ICreateHookContainer, + IUpdateHookContainer, IOnReturnHookContainer, IResourceHookContainer + where TResource : class, IIdentifiable { } +} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/IResourceHookExecutor.cs b/src/JsonApiDotNetCore/Hooks/Internal/IResourceHookExecutor.cs new file mode 100644 index 0000000000..3faa9f7798 --- /dev/null +++ b/src/JsonApiDotNetCore/Hooks/Internal/IResourceHookExecutor.cs @@ -0,0 +1,18 @@ +using JsonApiDotNetCore.Hooks.Internal.Execution; +using JsonApiDotNetCore.Hooks.Internal.Traversal; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.Hooks.Internal +{ + /// + /// Transient service responsible for executing Resource Hooks as defined + /// in . see methods in + /// , and + /// for more information. + /// + /// Uses for traversal of nested resource data structures. + /// Uses for retrieving meta data about hooks, + /// fetching database values and performing other recurring internal operations. + /// + public interface IResourceHookExecutor : IReadHookExecutor, IUpdateHookExecutor, ICreateHookExecutor, IDeleteHookExecutor, IOnReturnHookExecutor { } +} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/IUpdateHookContainer.cs b/src/JsonApiDotNetCore/Hooks/Internal/IUpdateHookContainer.cs new file mode 100644 index 0000000000..f0aa20df5a --- /dev/null +++ b/src/JsonApiDotNetCore/Hooks/Internal/IUpdateHookContainer.cs @@ -0,0 +1,103 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Hooks.Internal.Execution; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Services; + +namespace JsonApiDotNetCore.Hooks.Internal +{ + /// + /// update hooks container + /// + public interface IUpdateHookContainer where TResource : class, IIdentifiable + { + /// + /// Implement this hook to run custom logic in the + /// layer just before updating resources of type . + /// + /// For the pipeline, the + /// will typically contain one resource. + /// + /// The returned may be a subset + /// of the property in parameter , + /// 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-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. + /// + /// If by the creation of these relationships, any other relationships (eg + /// in the case of an already populated one-to-one relationship) are implicitly + /// affected, the + /// hook is fired for these. + /// + /// The transformed resource set + /// The affected resources. + /// An enum indicating from where the hook was triggered. + IEnumerable BeforeUpdate(IDiffableResourceHashSet resources, ResourcePipeline pipeline); + + /// + /// 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 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 + /// for that particular Person. + /// + /// The returned may be a subset + /// of , in which case the operation of the + /// 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 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 resources of type . + /// + /// 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 resources. + /// An enum indicating from where the hook was triggered. + void AfterUpdate(HashSet resources, ResourcePipeline pipeline); + + /// + /// Implement this hook to run custom logic in the layer + /// just after a relationship was updated. + /// + /// Relationship helper. + /// An enum indicating from where the hook was triggered. + void AfterUpdateRelationship(IRelationshipsDictionary resourcesByRelationship, ResourcePipeline pipeline); + + /// + /// 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 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 + /// when this hook is fired. + /// + /// + /// The transformed set of ids + /// A helper that groups the resources by the affected relationship + /// An enum indicating from where the hook was triggered. + void BeforeImplicitUpdateRelationship(IRelationshipsDictionary resourcesByRelationship, ResourcePipeline pipeline); + } +} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/IUpdateHookExecutor.cs b/src/JsonApiDotNetCore/Hooks/Internal/IUpdateHookExecutor.cs new file mode 100644 index 0000000000..ba1320a03e --- /dev/null +++ b/src/JsonApiDotNetCore/Hooks/Internal/IUpdateHookExecutor.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Hooks.Internal.Execution; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Services; + +namespace JsonApiDotNetCore.Hooks.Internal +{ + /// + /// Wrapper interface for all After execution methods. + /// + 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 . + /// + /// Fires the + /// hook where T = for values in parameter . + /// + /// Fires the + /// hook for any secondary (nested) resource for values within parameter + /// + /// Fires the + /// 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 resources for the Before cycle. + /// An enum indicating from where the hook was triggered. + /// 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 . + /// + /// Fires the + /// hook for any secondary (nested) resource for values within parameter + /// + /// Target resources for the Before cycle. + /// An enum indicating from where the hook was triggered. + /// The type of the root resources + void AfterUpdate(IEnumerable resources, ResourcePipeline pipeline) where TResource : class, IIdentifiable; + } +} \ No newline at end of file diff --git a/src/JsonApiDotNetCore/Hooks/Internal/IdentifiableComparer.cs b/src/JsonApiDotNetCore/Hooks/Internal/IdentifiableComparer.cs new file mode 100644 index 0000000000..4130afa92f --- /dev/null +++ b/src/JsonApiDotNetCore/Hooks/Internal/IdentifiableComparer.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.Hooks.Internal +{ + /// + /// Compares `IIdentifiable` with each other based on ID + /// + internal sealed class IdentifiableComparer : IEqualityComparer + { + public static readonly IdentifiableComparer Instance = new IdentifiableComparer(); + + private IdentifiableComparer() + { + } + + public bool Equals(IIdentifiable x, IIdentifiable y) + { + if (ReferenceEquals(x, y)) + { + return true; + } + + if (x is null || y is null || x.GetType() != y.GetType()) + { + return false; + } + + return x.StringId == y.StringId; + } + + public int GetHashCode(IIdentifiable obj) + { + return obj.StringId != null ? obj.StringId.GetHashCode() : 0; + } + } +} diff --git a/src/JsonApiDotNetCore/Hooks/ResourceHookExecutor.cs b/src/JsonApiDotNetCore/Hooks/Internal/ResourceHookExecutor.cs similarity index 63% rename from src/JsonApiDotNetCore/Hooks/ResourceHookExecutor.cs rename to src/JsonApiDotNetCore/Hooks/Internal/ResourceHookExecutor.cs index a1b5bf71b8..1c0721a256 100644 --- a/src/JsonApiDotNetCore/Hooks/ResourceHookExecutor.cs +++ b/src/JsonApiDotNetCore/Hooks/Internal/ResourceHookExecutor.cs @@ -3,23 +3,24 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Hooks.Internal.Execution; +using JsonApiDotNetCore.Hooks.Internal.Traversal; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; using LeftType = System.Type; using RightType = System.Type; -using JsonApiDotNetCore.Extensions; -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Serialization; -using JsonApiDotNetCore.Query; -namespace JsonApiDotNetCore.Hooks +namespace JsonApiDotNetCore.Hooks.Internal { - /// + /// 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; @@ -28,71 +29,80 @@ 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; } - /// + /// public void BeforeRead(ResourcePipeline pipeline, string stringId = null) where TResource : class, IIdentifiable { 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(IncludeChainConverter.GetRelationshipChains)) + { + 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. @@ -105,49 +115,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), @@ -155,12 +165,12 @@ public void AfterCreate(IEnumerable entities, ResourcePipe (nextContainer, nextNode) => FireAfterUpdateRelationship(nextContainer, nextNode, pipeline)); } - /// - 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), @@ -168,12 +178,12 @@ public void AfterUpdate(IEnumerable entities, ResourcePipe (nextContainer, nextNode) => FireAfterUpdateRelationship(nextContainer, nextNode, pipeline)); } - /// - 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); } } @@ -182,14 +192,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; } @@ -197,13 +207,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); } @@ -232,7 +242,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) @@ -243,35 +253,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 - // RelationshipAttributes that occured in the previous layer + // these are the resources of the current node grouped by + // RelationshipAttributes that occurred 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); } @@ -279,70 +289,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); - return inversableRelationshipAttributes.ToDictionary(kvp => _resourceGraph.GetInverse(kvp.Key), kvp => kvp.Value); + // currently cannot fire hooks for resources resolved through inverse relationships. + var inversableRelationshipAttributes = resourcesByRelationship.Where(kvp => kvp.Key.InverseNavigation != null); + return inversableRelationshipAttributes.ToDictionary(kvp => _resourceGraph.GetInverseRelationship(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 inverse = implicitAffected.ToDictionary(kvp => _resourceGraph.GetInverseRelationship(kvp.Key), kvp => kvp.Value); + var resourcesByRelationship = CreateRelationshipHelper(resourceTypeToInclude, inverse); CallHook(container, ResourceHook.BeforeImplicitUpdateRelationship, new object[] { resourcesByRelationship, pipeline, }); } @@ -389,80 +399,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 = TypeHelper.CopyToList(prevLayerRelationships[key].Cast().Select(resource => dbValues.Single(dbResource => dbResource.StringId == resource.StringId)), 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 - // resource in the previouslayer to a resource in the current (nested) layer. + Dictionary currentResourcesGrouped = node.RelationshipsFromPreviousLayer.GetRightResources(); + // the relationships attributes in currentResourcesGrouped will be pointing from a + // resource in the previous layer 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/Internal/Traversal/ChildNode.cs similarity index 77% rename from src/JsonApiDotNetCore/Hooks/Traversal/ChildNode.cs rename to src/JsonApiDotNetCore/Hooks/Internal/Traversal/ChildNode.cs index 3e971e3fb9..49df8d702c 100644 --- a/src/JsonApiDotNetCore/Hooks/Traversal/ChildNode.cs +++ b/src/JsonApiDotNetCore/Hooks/Internal/Traversal/ChildNode.cs @@ -1,18 +1,16 @@ using System.Collections; using System.Collections.Generic; using System.Linq; -using JsonApiDotNetCore.Extensions; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Resources; using RightType = System.Type; -namespace JsonApiDotNetCore.Hooks +namespace JsonApiDotNetCore.Hooks.Internal.Traversal { /// /// 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 +18,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(relationshipGroup => relationshipGroup.RightResources)); } } @@ -46,7 +44,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,20 +53,20 @@ 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); if (currentValue is IEnumerable relationshipCollection) { var intersection = relationshipCollection.Intersect(unique, _comparer); - IEnumerable typedCollection = intersection.CopyToTypedCollection(relationshipCollection.GetType()); + IEnumerable typedCollection = TypeHelper.CopyToTypedCollection(intersection, relationshipCollection.GetType()); proxy.SetValue(left, typedCollection, resourceFactory); } else if (currentValue is IIdentifiable relationshipSingle) diff --git a/src/JsonApiDotNetCore/Hooks/Internal/Traversal/IRelationshipGroup.cs b/src/JsonApiDotNetCore/Hooks/Internal/Traversal/IRelationshipGroup.cs new file mode 100644 index 0000000000..42b20ced84 --- /dev/null +++ b/src/JsonApiDotNetCore/Hooks/Internal/Traversal/IRelationshipGroup.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.Hooks.Internal.Traversal +{ + internal interface IRelationshipGroup + { + RelationshipProxy Proxy { get; } + HashSet LeftResources { get; } + } +} \ No newline at end of file diff --git a/src/JsonApiDotNetCore/Hooks/Internal/Traversal/IRelationshipsFromPreviousLayer.cs b/src/JsonApiDotNetCore/Hooks/Internal/Traversal/IRelationshipsFromPreviousLayer.cs new file mode 100644 index 0000000000..ed7b6fdf00 --- /dev/null +++ b/src/JsonApiDotNetCore/Hooks/Internal/Traversal/IRelationshipsFromPreviousLayer.cs @@ -0,0 +1,23 @@ +using System.Collections; +using System.Collections.Generic; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Hooks.Internal.Traversal +{ + /// + /// A helper class for mapping relationships between a current and previous layer + /// + internal interface IRelationshipsFromPreviousLayer + { + /// + /// Grouped by relationship to the previous layer, gets all the resources of the current layer + /// + /// The right side resources. + Dictionary GetRightResources(); + /// + /// Grouped by relationship to the previous layer, gets all the resources of the previous layer + /// + /// The right side resources. + Dictionary GetLeftResources(); + } +} \ No newline at end of file diff --git a/src/JsonApiDotNetCore/Hooks/Traversal/IEntityNode.cs b/src/JsonApiDotNetCore/Hooks/Internal/Traversal/IResourceNode.cs similarity index 76% rename from src/JsonApiDotNetCore/Hooks/Traversal/IEntityNode.cs rename to src/JsonApiDotNetCore/Hooks/Internal/Traversal/IResourceNode.cs index 71d305227c..5ae502336c 100644 --- a/src/JsonApiDotNetCore/Hooks/Traversal/IEntityNode.cs +++ b/src/JsonApiDotNetCore/Hooks/Internal/Traversal/IResourceNode.cs @@ -1,22 +1,22 @@ using System.Collections; -using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Resources; using RightType = System.Type; -namespace JsonApiDotNetCore.Hooks +namespace JsonApiDotNetCore.Hooks.Internal.Traversal { /// /// 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/Internal/Traversal/ITraversalHelper.cs similarity index 63% rename from src/JsonApiDotNetCore/Hooks/Traversal/ITraversalHelper.cs rename to src/JsonApiDotNetCore/Hooks/Internal/Traversal/ITraversalHelper.cs index 2a93cbb4b0..d3e2e2ba6a 100644 --- a/src/JsonApiDotNetCore/Hooks/Traversal/ITraversalHelper.cs +++ b/src/JsonApiDotNetCore/Hooks/Internal/Traversal/ITraversalHelper.cs @@ -1,30 +1,26 @@ using System.Collections.Generic; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Resources; -namespace JsonApiDotNetCore.Hooks +namespace JsonApiDotNetCore.Hooks.Internal.Traversal { internal interface ITraversalHelper { /// /// Crates the next layer /// - /// - /// - 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/Internal/Traversal/NodeLayer.cs b/src/JsonApiDotNetCore/Hooks/Internal/Traversal/NodeLayer.cs new file mode 100644 index 0000000000..711ddd28fb --- /dev/null +++ b/src/JsonApiDotNetCore/Hooks/Internal/Traversal/NodeLayer.cs @@ -0,0 +1,36 @@ +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.Hooks.Internal.Traversal +{ + /// + /// 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 + { + private readonly List _collection; + + public bool AnyResources() + { + return _collection.Any(n => n.UniqueResources.Cast().Any()); + } + + public NodeLayer(List nodes) + { + _collection = nodes; + } + + public IEnumerator GetEnumerator() + { + return _collection.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + } +} \ No newline at end of file diff --git a/src/JsonApiDotNetCore/Hooks/Internal/Traversal/RelationshipGroup.cs b/src/JsonApiDotNetCore/Hooks/Internal/Traversal/RelationshipGroup.cs new file mode 100644 index 0000000000..12ac8382cd --- /dev/null +++ b/src/JsonApiDotNetCore/Hooks/Internal/Traversal/RelationshipGroup.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.Hooks.Internal.Traversal +{ + internal sealed class RelationshipGroup : IRelationshipGroup where TRight : class, IIdentifiable + { + public RelationshipProxy Proxy { get; } + public HashSet LeftResources { get; } + public HashSet RightResources { get; internal set; } + public RelationshipGroup(RelationshipProxy proxy, HashSet leftResources, HashSet rightResources) + { + Proxy = proxy; + LeftResources = leftResources; + RightResources = rightResources; + } + } +} diff --git a/src/JsonApiDotNetCore/Hooks/Traversal/RelationshipProxy.cs b/src/JsonApiDotNetCore/Hooks/Internal/Traversal/RelationshipProxy.cs similarity index 54% rename from src/JsonApiDotNetCore/Hooks/Traversal/RelationshipProxy.cs rename to src/JsonApiDotNetCore/Hooks/Internal/Traversal/RelationshipProxy.cs index f0babe9dd7..ab701882e5 100644 --- a/src/JsonApiDotNetCore/Hooks/Traversal/RelationshipProxy.cs +++ b/src/JsonApiDotNetCore/Hooks/Internal/Traversal/RelationshipProxy.cs @@ -1,11 +1,10 @@ using System; using System.Collections; using System.Collections.Generic; -using JsonApiDotNetCore.Extensions; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Hooks +namespace JsonApiDotNetCore.Hooks.Internal.Traversal { /// /// A class used internally for resource hook execution. Not intended for developer use. @@ -13,20 +12,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; @@ -40,77 +39,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 = TypeHelper.CopyToList((IEnumerable)value, RightType); + foreach (var throughResource in throughResources) { - if (((IList)rightEntities).Contains(hasManyThrough.RightProperty.GetValue(joinEntity))) + if (rightResources.Contains(hasManyThrough.RightProperty.GetValue(throughResource))) { - filteredList.Add(joinEntity); + filteredList.Add(throughResource); } } - var collectionValue = filteredList.CopyToTypedCollection(hasManyThrough.ThroughProperty.PropertyType); - hasManyThrough.ThroughProperty.SetValue(entity, collectionValue); + var collectionValue = TypeHelper.CopyToTypedCollection(filteredList, hasManyThrough.ThroughProperty.PropertyType); + 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/Internal/Traversal/RelationshipsFromPreviousLayer.cs similarity index 53% rename from src/JsonApiDotNetCore/Hooks/Traversal/RelationshipsFromPreviousLayer.cs rename to src/JsonApiDotNetCore/Hooks/Internal/Traversal/RelationshipsFromPreviousLayer.cs index 386cf75e9f..d5bd5e416a 100644 --- a/src/JsonApiDotNetCore/Hooks/Traversal/RelationshipsFromPreviousLayer.cs +++ b/src/JsonApiDotNetCore/Hooks/Internal/Traversal/RelationshipsFromPreviousLayer.cs @@ -1,27 +1,11 @@ using System.Collections; using System.Collections.Generic; using System.Linq; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Hooks +namespace JsonApiDotNetCore.Hooks.Internal.Traversal { - /// - /// A helper class for mapping relationships between a current and previous layer - /// - internal interface IRelationshipsFromPreviousLayer - { - /// - /// Grouped by relationship to the previous layer, gets all the entities of the current layer - /// - /// The right side entities. - Dictionary GetRightEntities(); - /// - /// Grouped by relationship to the previous layer, gets all the entities of the previous layer - /// - /// The right side entities. - Dictionary GetLeftEntities(); - } - internal sealed class RelationshipsFromPreviousLayer : IRelationshipsFromPreviousLayer, IEnumerable> where TRightResource : class, IIdentifiable { private readonly IEnumerable> _collection; @@ -31,16 +15,16 @@ 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/Internal/Traversal/RootNode.cs similarity index 66% rename from src/JsonApiDotNetCore/Hooks/Traversal/RootNode.cs rename to src/JsonApiDotNetCore/Hooks/Internal/Traversal/RootNode.cs index 3e8f368a41..3c3103fd52 100644 --- a/src/JsonApiDotNetCore/Hooks/Traversal/RootNode.cs +++ b/src/JsonApiDotNetCore/Hooks/Internal/Traversal/RootNode.cs @@ -2,37 +2,37 @@ using System.Collections; using System.Collections.Generic; using System.Linq; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Hooks +namespace JsonApiDotNetCore.Hooks.Internal.Traversal { /// - /// 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); } /// @@ -40,28 +40,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/Internal/Traversal/TraversalHelper.cs similarity index 59% rename from src/JsonApiDotNetCore/Hooks/Traversal/TraversalHelper.cs rename to src/JsonApiDotNetCore/Hooks/Internal/Traversal/TraversalHelper.cs index 46da95ef28..bf6a1234c5 100644 --- a/src/JsonApiDotNetCore/Hooks/Traversal/TraversalHelper.cs +++ b/src/JsonApiDotNetCore/Hooks/Internal/Traversal/TraversalHelper.cs @@ -3,22 +3,20 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; -using JsonApiDotNetCore.Extensions; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Serialization; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; using RightType = System.Type; using LeftType = System.Type; -namespace JsonApiDotNetCore.Hooks +namespace JsonApiDotNetCore.Hooks.Internal.Traversal { /// /// 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 @@ -27,10 +25,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. @@ -50,16 +48,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); } /// @@ -67,7 +65,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 }); } @@ -77,11 +75,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 @@ -105,7 +103,6 @@ public NodeLayer CreateNextLayer(IEnumerable nodes) return CreateNodeInstance(nextNodeType, populatedRelationships.ToArray(), relationshipsToPreviousLayer); }).ToList(); - // wrap the child nodes in a EntityChildLayer return new NodeLayer(nextNodes); } @@ -119,53 +116,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 }); + var list = TypeHelper.CopyToList(kvp.Value, type); + processResourcesMethod.MakeGenericMethod(type).Invoke(this, new object[] { list }); } - return (leftEntitiesGrouped, rightEntitiesGrouped); + return (leftResourcesGrouped, rightResourcesGrouped); } /// @@ -180,17 +177,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; } /// @@ -216,78 +213,76 @@ 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 private RightType GetRightTypeFromRelationship(RelationshipAttribute attr) { - if (attr is HasManyThroughAttribute throughAttr && throughAttr.ThroughType.IsOrImplementsInterface(typeof(IIdentifiable))) + if (attr is HasManyThroughAttribute throughAttr && TypeHelper.IsOrImplementsInterface(throughAttr.ThroughType, typeof(IIdentifiable))) { return throughAttr.ThroughType; } 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); } /// @@ -295,48 +290,18 @@ private INode CreateNodeInstance(RightType nodeType, RelationshipProxy[] relatio /// private IRelationshipsFromPreviousLayer CreateRelationshipsFromInstance(RightType nodeType, IEnumerable relationshipsFromPrev) { - var cast = relationshipsFromPrev.CopyToList(relationshipsFromPrev.First().GetType()); + var cast = TypeHelper.CopyToList(relationshipsFromPrev, relationshipsFromPrev.First().GetType()); return (IRelationshipsFromPreviousLayer)TypeHelper.CreateInstanceOfOpenType(typeof(RelationshipsFromPreviousLayer<>), nodeType, cast); } /// /// 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, TypeHelper.CopyToList(rightResources, thisLayerType)); return (IRelationshipGroup)TypeHelper.CreateInstanceOfOpenType(typeof(RelationshipGroup<>), - thisLayerType, proxy, new HashSet(leftEntities), rightEntitiesHashed); - } - } - - /// - /// A helper class that represents all entities in the current layer that - /// are being traversed for which hooks will be executed (see IResourceHookExecutor) - /// - internal sealed class NodeLayer : IEnumerable - { - private readonly List _collection; - - public bool AnyEntities() - { - return _collection.Any(n => n.UniqueEntities.Cast().Any()); - } - - public NodeLayer(List nodes) - { - _collection = nodes; - } - - public IEnumerator GetEnumerator() - { - return _collection.GetEnumerator(); - } - - IEnumerator IEnumerable.GetEnumerator() - { - return GetEnumerator(); + thisLayerType, proxy, new HashSet(leftResources), rightResourcesHashed); } } } - diff --git a/src/JsonApiDotNetCore/Hooks/Traversal/RelationshipGroup.cs b/src/JsonApiDotNetCore/Hooks/Traversal/RelationshipGroup.cs deleted file mode 100644 index c3febd7a3b..0000000000 --- a/src/JsonApiDotNetCore/Hooks/Traversal/RelationshipGroup.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Collections.Generic; -using JsonApiDotNetCore.Models; - -namespace JsonApiDotNetCore.Hooks -{ - internal interface IRelationshipGroup - { - RelationshipProxy Proxy { get; } - HashSet LeftEntities { 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) - { - Proxy = proxy; - LeftEntities = leftEntities; - RightEntities = rightEntities; - } - } -} diff --git a/src/JsonApiDotNetCore/Internal/Contracts/IResourceGraphExplorer.cs b/src/JsonApiDotNetCore/Internal/Contracts/IResourceGraphExplorer.cs deleted file mode 100644 index 9a21c6a726..0000000000 --- a/src/JsonApiDotNetCore/Internal/Contracts/IResourceGraphExplorer.cs +++ /dev/null @@ -1,60 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq.Expressions; -using JsonApiDotNetCore.Models; - -namespace JsonApiDotNetCore.Internal.Contracts -{ - /// - /// Responsible for retrieving the exposed resource fields (attributes and - /// relationships) of registered resources in the resource resourceGraph. - /// - public interface IResourceGraph : IResourceContextProvider - { - /// - /// Gets all fields (attributes and relationships) for - /// that are targeted by the selector. If no selector is provided, all - /// exposed fields are returned. - /// - /// 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; - /// - /// Gets all attributes for - /// that are targeted by the selector. If no selector is provided, all - /// exposed fields are returned. - /// - /// The resource for which to retrieve attributes - /// Should be of the form: (TResource e) => new { e.Attribute1, e.Attribute2 } - List GetAttributes(Expression> selector = null) where TResource : IIdentifiable; - /// - /// Gets all relationships for - /// that are targeted by the selector. If no selector is provided, all - /// exposed fields are returned. - /// - /// The resource for which to retrieve relationships - /// Should be of the form: (TResource e) => new { e.Relationship1, e.Relationship2 } - List GetRelationships(Expression> selector = null) where TResource : IIdentifiable; - /// - /// Gets all exposed fields (attributes and relationships) for type - /// - /// The resource type. Must extend IIdentifiable. - List GetFields(Type type); - /// - /// Gets all exposed attributes for type - /// - /// The resource type. Must extend IIdentifiable. - List GetAttributes(Type type); - /// - /// Gets all exposed relationships for type - /// - /// The resource type. Must extend IIdentifiable. - List GetRelationships(Type type); - /// - /// Traverses the resource resourceGraph for the inverse relationship of the provided - /// ; - /// - /// - RelationshipAttribute GetInverse(RelationshipAttribute relationship); - } -} diff --git a/src/JsonApiDotNetCore/Internal/Exceptions/JsonApiSetupException.cs b/src/JsonApiDotNetCore/Internal/Exceptions/JsonApiSetupException.cs deleted file mode 100644 index 5ab6400b65..0000000000 --- a/src/JsonApiDotNetCore/Internal/Exceptions/JsonApiSetupException.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System; - -namespace JsonApiDotNetCore.Internal -{ - public sealed class JsonApiSetupException : Exception - { - public JsonApiSetupException(string message) - : base(message) { } - - public JsonApiSetupException(string message, Exception innerException) - : base(message, innerException) { } - } -} diff --git a/src/JsonApiDotNetCore/Internal/Generics/GenericServiceFactory.cs b/src/JsonApiDotNetCore/Internal/Generics/GenericServiceFactory.cs deleted file mode 100644 index acd3f88b06..0000000000 --- a/src/JsonApiDotNetCore/Internal/Generics/GenericServiceFactory.cs +++ /dev/null @@ -1,56 +0,0 @@ -using JsonApiDotNetCore.Services; -using System; - -namespace JsonApiDotNetCore.Internal.Generics -{ - /// - /// Used to generate a generic operations processor when the types - /// are not known until runtime. The typical use case would be for - /// accessing relationship data or resolving operations processors. - /// - public interface IGenericServiceFactory - { - /// - /// Constructs the generic type and locates the service, then casts to TInterface - /// - /// - /// - /// Get<IGenericProcessor>(typeof(GenericProcessor<>), typeof(TResource)); - /// - /// - TInterface Get(Type openGenericType, Type resourceType); - - /// - /// Constructs the generic type and locates the service, then casts to TInterface - /// - /// - /// - /// Get<IGenericProcessor>(typeof(GenericProcessor<,>), typeof(TResource), typeof(TId)); - /// - /// - TInterface Get(Type openGenericType, Type resourceType, Type keyType); - } - - public sealed class GenericServiceFactory : IGenericServiceFactory - { - private readonly IServiceProvider _serviceProvider; - - public GenericServiceFactory(IScopedServiceProvider serviceProvider) - { - _serviceProvider = serviceProvider; - } - - public TInterface Get(Type openGenericType, Type resourceType) - => GetInternal(openGenericType, resourceType); - - public TInterface Get(Type openGenericType, Type resourceType, Type keyType) - => GetInternal(openGenericType, resourceType, keyType); - - private TInterface GetInternal(Type openGenericType, params Type[] types) - { - var concreteType = openGenericType.MakeGenericType(types); - - return (TInterface)_serviceProvider.GetService(concreteType); - } - } -} diff --git a/src/JsonApiDotNetCore/Internal/IResourceFactory.cs b/src/JsonApiDotNetCore/Internal/IResourceFactory.cs deleted file mode 100644 index 67ea569592..0000000000 --- a/src/JsonApiDotNetCore/Internal/IResourceFactory.cs +++ /dev/null @@ -1,90 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq.Expressions; -using System.Reflection; -using JsonApiDotNetCore.Extensions; -using Microsoft.Extensions.DependencyInjection; - -namespace JsonApiDotNetCore.Internal -{ - public interface IResourceFactory - { - public object CreateInstance(Type resourceType); - public TResource CreateInstance(); - public NewExpression CreateNewExpression(Type resourceType); - } - - internal sealed class DefaultResourceFactory : IResourceFactory - { - private readonly IServiceProvider _serviceProvider; - - public DefaultResourceFactory(IServiceProvider serviceProvider) - { - _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); - } - - public object CreateInstance(Type resourceType) - { - if (resourceType == null) - { - throw new ArgumentNullException(nameof(resourceType)); - } - - return InnerCreateInstance(resourceType, _serviceProvider); - } - - public TResource CreateInstance() - { - return (TResource) InnerCreateInstance(typeof(TResource), _serviceProvider); - } - - private static object InnerCreateInstance(Type type, IServiceProvider serviceProvider) - { - bool hasSingleConstructorWithoutParameters = type.HasSingleConstructorWithoutParameters(); - - try - { - return hasSingleConstructorWithoutParameters - ? Activator.CreateInstance(type) - : ActivatorUtilities.CreateInstance(serviceProvider, type); - } - catch (Exception exception) - { - throw new InvalidOperationException(hasSingleConstructorWithoutParameters - ? $"Failed to create an instance of '{type.FullName}' using its default constructor." - : $"Failed to create an instance of '{type.FullName}' using injected constructor parameters.", - exception); - } - } - - public NewExpression CreateNewExpression(Type resourceType) - { - if (resourceType.HasSingleConstructorWithoutParameters()) - { - return Expression.New(resourceType); - } - - List constructorArguments = new List(); - - var longestConstructor = resourceType.GetLongestConstructor(); - foreach (ParameterInfo constructorParameter in longestConstructor.GetParameters()) - { - try - { - object constructorArgument = - ActivatorUtilities.GetServiceOrCreateInstance(_serviceProvider, constructorParameter.ParameterType); - - constructorArguments.Add(Expression.Constant(constructorArgument)); - } - catch (Exception exception) - { - throw new InvalidOperationException( - $"Failed to create an instance of '{resourceType.FullName}': Parameter '{constructorParameter.Name}' could not be resolved.", - exception); - } - } - - return Expression.New(longestConstructor, constructorArguments); - } - } -} diff --git a/src/JsonApiDotNetCore/Internal/IdentifiableComparer.cs b/src/JsonApiDotNetCore/Internal/IdentifiableComparer.cs deleted file mode 100644 index fbec8f67cb..0000000000 --- a/src/JsonApiDotNetCore/Internal/IdentifiableComparer.cs +++ /dev/null @@ -1,22 +0,0 @@ -using JsonApiDotNetCore.Models; -using System.Collections.Generic; - -namespace JsonApiDotNetCore.Internal -{ - /// - /// Compares `IIdentifiable` with each other based on ID - /// - public sealed class IdentifiableComparer : IEqualityComparer - { - internal static readonly IdentifiableComparer Instance = new IdentifiableComparer(); - - public bool Equals(IIdentifiable x, IIdentifiable y) - { - return x.StringId == y.StringId; - } - public int GetHashCode(IIdentifiable obj) - { - return obj.StringId.GetHashCode(); - } - } -} diff --git a/src/JsonApiDotNetCore/Internal/InverseRelationships.cs b/src/JsonApiDotNetCore/Internal/InverseRelationships.cs deleted file mode 100644 index ebef063067..0000000000 --- a/src/JsonApiDotNetCore/Internal/InverseRelationships.cs +++ /dev/null @@ -1,68 +0,0 @@ -using JsonApiDotNetCore.Data; -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Models; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata; - -namespace JsonApiDotNetCore.Internal -{ - /// - /// Responsible for populating the RelationshipAttribute InverseNavigation property. - /// - /// This service is instantiated in the configure phase of the application. - /// - /// When using a data access layer different from EF Core, and when using ResourceHooks - /// that depend on the inverse navigation property (BeforeImplicitUpdateRelationship), - /// you will need to override this service, or pass along the inverseNavigationProperty in - /// the RelationshipAttribute. - /// - public interface IInverseRelationships - { - /// - /// This method is called upon startup by JsonApiDotNetCore. It should - /// deal with resolving the inverse relationships. - /// - void Resolve(); - - } - - /// - public class InverseRelationships : IInverseRelationships - { - private readonly IResourceContextProvider _provider; - private readonly IDbContextResolver _resolver; - - public InverseRelationships(IResourceContextProvider provider, IDbContextResolver resolver = null) - { - _provider = provider; - _resolver = resolver; - } - - /// - public void Resolve() - { - if (EntityFrameworkCoreIsEnabled()) - { - DbContext context = _resolver.GetContext(); - - foreach (ResourceContext ce in _provider.GetResourceContexts()) - { - IEntityType meta = context.Model.FindEntityType(ce.ResourceType); - if (meta == null) continue; - foreach (var attr in ce.Relationships) - { - if (attr is HasManyThroughAttribute) continue; - INavigation inverseNavigation = meta.FindNavigation(attr.PropertyInfo.Name)?.FindInverse(); - attr.InverseNavigation = inverseNavigation?.Name; - } - } - } - } - - /// - /// 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; - } -} 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 61a342b14e..0000000000 --- a/src/JsonApiDotNetCore/Internal/Query/BaseQueryContext.cs +++ /dev/null @@ -1,31 +0,0 @@ -using JsonApiDotNetCore.Models; - -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.PropertyInfo.Name}.{Attribute.PropertyInfo.Name}"; - - return Attribute.PropertyInfo.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/Internal/ResourceContext.cs b/src/JsonApiDotNetCore/Internal/ResourceContext.cs deleted file mode 100644 index 2ebf4c1d64..0000000000 --- a/src/JsonApiDotNetCore/Internal/ResourceContext.cs +++ /dev/null @@ -1,79 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Models.Links; - -namespace JsonApiDotNetCore.Internal -{ - public class ResourceContext - { - /// - /// The exposed resource name - /// - public string ResourceName { get; set; } - - /// - /// The data model type - /// - public Type ResourceType { get; set; } - - /// - /// The identity member type - /// - public Type IdentityType { get; set; } - - /// - /// The concrete type. - /// We store this so that we don't need to re-compute the generic type. - /// - public Type ResourceDefinitionType { get; set; } - - /// - /// Exposed resource attributes. - /// See https://jsonapi.org/format/#document-resource-object-attributes. - /// - public List Attributes { get; set; } - - /// - /// Exposed resource relationships. - /// See https://jsonapi.org/format/#document-resource-object-relationships - /// - public List Relationships { get; set; } - - /// - /// Related entities that are not exposed as resource relationships. - /// - public List EagerLoads { get; set; } - - private List _fields; - public List Fields { get { return _fields ??= Attributes.Cast().Concat(Relationships).ToList(); } } - - /// - /// Configures which links to show in the - /// object for this resource. If set to , - /// the configuration will be read from . - /// Defaults to . - /// - public Link TopLevelLinks { get; internal set; } = Link.NotConfigured; - - /// - /// Configures which links to show in the - /// object for this resource. If set to , - /// the configuration will be read from . - /// Defaults to . - /// - public Link ResourceLinks { get; internal set; } = Link.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 - /// be read from or - /// . Defaults to . - /// - public Link RelationshipLinks { get; internal set; } = Link.NotConfigured; - - } -} diff --git a/src/JsonApiDotNetCore/Internal/ResourceGraph.cs b/src/JsonApiDotNetCore/Internal/ResourceGraph.cs deleted file mode 100644 index ee056ad4c2..0000000000 --- a/src/JsonApiDotNetCore/Internal/ResourceGraph.cs +++ /dev/null @@ -1,149 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Linq.Expressions; -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Models; - -namespace JsonApiDotNetCore.Internal -{ - /// - /// keeps track of all the models/resources defined in JADNC - /// - public class ResourceGraph : IResourceGraph - { - private List Resources { get; } - - public ResourceGraph(List resources) - { - Resources = resources; - } - - /// - public IEnumerable GetResourceContexts() => Resources; - /// - public ResourceContext GetResourceContext(string resourceName) - => Resources.SingleOrDefault(e => e.ResourceName == resourceName); - /// - public ResourceContext GetResourceContext(Type resourceType) - => Resources.SingleOrDefault(e => e.ResourceType == resourceType); - /// - public ResourceContext GetResourceContext() where TResource : class, IIdentifiable - => GetResourceContext(typeof(TResource)); - /// - public List GetFields(Expression> selector = null) where T : IIdentifiable - { - return Getter(selector).ToList(); - } - /// - public List GetAttributes(Expression> selector = null) where T : IIdentifiable - { - return Getter(selector, FieldFilterType.Attribute).Cast().ToList(); - } - /// - public List GetRelationships(Expression> selector = null) where T : IIdentifiable - { - return Getter(selector, FieldFilterType.Relationship).Cast().ToList(); - } - /// - public List GetFields(Type type) - { - return GetResourceContext(type).Fields.ToList(); - } - /// - public List GetAttributes(Type type) - { - return GetResourceContext(type).Attributes.ToList(); - } - /// - public List GetRelationships(Type type) - { - return GetResourceContext(type).Relationships.ToList(); - } - /// - public RelationshipAttribute GetInverse(RelationshipAttribute relationship) - { - if (relationship.InverseNavigation == null) return null; - return GetResourceContext(relationship.RightType) - .Relationships - .SingleOrDefault(r => r.PropertyInfo.Name == relationship.InverseNavigation); - } - - private IEnumerable Getter(Expression> selector = null, FieldFilterType type = FieldFilterType.None) where T : IIdentifiable - { - IEnumerable available; - if (type == FieldFilterType.Attribute) - available = GetResourceContext(typeof(T)).Attributes; - else if (type == FieldFilterType.Relationship) - available = GetResourceContext(typeof(T)).Relationships; - else - available = GetResourceContext(typeof(T)).Fields; - - if (selector == null) - return available; - - var targeted = new List(); - - var selectorBody = RemoveConvert(selector.Body); - - if (selectorBody is MemberExpression memberExpression) - { // model => model.Field1 - try - { - targeted.Add(available.Single(f => f.PropertyName == memberExpression.Member.Name)); - return targeted; - } - catch (InvalidOperationException) - { - ThrowNotExposedError(memberExpression.Member.Name, type); - } - } - - if (selectorBody is NewExpression newExpression) - { // model => new { model.Field1, model.Field2 } - string memberName = null; - try - { - if (newExpression.Members == null) - return targeted; - - foreach (var member in newExpression.Members) - { - memberName = member.Name; - targeted.Add(available.Single(f => f.PropertyName == memberName)); - } - return targeted; - } - catch (InvalidOperationException) - { - ThrowNotExposedError(memberName, type); - } - } - - throw new ArgumentException( - $"The expression '{selector}' should select a single property or select multiple properties into an anonymous type. " + - $"For example: 'article => article.Title' or 'article => new {{ article.Title, article.PageCount }}'."); - } - - private static Expression RemoveConvert(Expression expression) - => expression is UnaryExpression unaryExpression - && unaryExpression.NodeType == ExpressionType.Convert - ? RemoveConvert(unaryExpression.Operand) - : expression; - - private void ThrowNotExposedError(string memberName, FieldFilterType type) - { - throw new ArgumentException($"{memberName} is not an json:api exposed {type:g}."); - } - - /// - /// internally used only by . - /// - private enum FieldFilterType - { - None, - Attribute, - Relationship - } - } -} diff --git a/src/JsonApiDotNetCore/Internal/ValidationResults.cs b/src/JsonApiDotNetCore/Internal/ValidationResults.cs deleted file mode 100644 index d13b5c65da..0000000000 --- a/src/JsonApiDotNetCore/Internal/ValidationResults.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCore.Internal -{ - public sealed class ValidationResult - { - public ValidationResult(LogLevel logLevel, string message) - { - LogLevel = logLevel; - Message = message; - } - - public LogLevel LogLevel { get; } - public string Message { get; } - } -} diff --git a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj index 9a1e0c0104..092743c5cb 100644 --- a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj +++ b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj @@ -1,4 +1,4 @@ - + 4.0.0 $(NetCoreAppVersion) @@ -28,17 +28,4 @@ - - - - true - true - bin\Release\netstandard2.0\JsonApiDotNetCore.xml - - - - - diff --git a/src/JsonApiDotNetCore/Middleware/ConvertEmptyActionResultFilter.cs b/src/JsonApiDotNetCore/Middleware/ConvertEmptyActionResultFilter.cs index 328ed90491..4fbb3a644c 100644 --- a/src/JsonApiDotNetCore/Middleware/ConvertEmptyActionResultFilter.cs +++ b/src/JsonApiDotNetCore/Middleware/ConvertEmptyActionResultFilter.cs @@ -1,10 +1,14 @@ -using JsonApiDotNetCore.Extensions; +using System; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.Infrastructure; namespace JsonApiDotNetCore.Middleware { + /// + /// Transforms s without parameters for correct internal handling. + /// For example: return NotFound() -> return NotFound(null) + /// public sealed class ConvertEmptyActionResultFilter : IAlwaysRunResultFilter { public void OnResultExecuted(ResultExecutedContext context) @@ -13,6 +17,8 @@ public void OnResultExecuted(ResultExecutedContext context) public void OnResultExecuting(ResultExecutingContext context) { + if (context == null) throw new ArgumentNullException(nameof(context)); + if (!context.HttpContext.IsJsonApiRequest()) { return; diff --git a/src/JsonApiDotNetCore/Middleware/DefaultTypeMatchFilter.cs b/src/JsonApiDotNetCore/Middleware/DefaultTypeMatchFilter.cs deleted file mode 100644 index 2ddd341372..0000000000 --- a/src/JsonApiDotNetCore/Middleware/DefaultTypeMatchFilter.cs +++ /dev/null @@ -1,48 +0,0 @@ -using System.Linq; -using System.Net.Http; -using JsonApiDotNetCore.Exceptions; -using JsonApiDotNetCore.Extensions; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Internal.Contracts; -using Microsoft.AspNetCore.Mvc.Filters; - -namespace JsonApiDotNetCore.Middleware -{ - /// - /// Action filter used to verify the incoming type matches the target type, else return a 409 - /// - public sealed class DefaultTypeMatchFilter : IActionFilter - { - private readonly IResourceContextProvider _provider; - - public DefaultTypeMatchFilter(IResourceContextProvider provider) - { - _provider = provider; - } - - public void OnActionExecuting(ActionExecutingContext context) - { - if (!context.HttpContext.IsJsonApiRequest()) - { - return; - } - - var request = context.HttpContext.Request; - if (request.Method == "PATCH" || request.Method == "POST") - { - var deserializedType = context.ActionArguments.FirstOrDefault().Value?.GetType(); - var targetType = context.ActionDescriptor.Parameters.FirstOrDefault()?.ParameterType; - - if (deserializedType != null && targetType != null && deserializedType != targetType) - { - ResourceContext resourceFromEndpoint = _provider.GetResourceContext(targetType); - ResourceContext resourceFromBody = _provider.GetResourceContext(deserializedType); - - throw new ResourceTypeMismatchException(new HttpMethod(request.Method), request.Path, resourceFromEndpoint, resourceFromBody); - } - } - } - - public void OnActionExecuted(ActionExecutedContext context) { /* noop */ } - } -} diff --git a/src/JsonApiDotNetCore/Middleware/EndpointKind.cs b/src/JsonApiDotNetCore/Middleware/EndpointKind.cs new file mode 100644 index 0000000000..ea5b9339c9 --- /dev/null +++ b/src/JsonApiDotNetCore/Middleware/EndpointKind.cs @@ -0,0 +1,20 @@ +namespace JsonApiDotNetCore.Middleware +{ + 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/Middleware/DefaultExceptionHandler.cs b/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs similarity index 72% rename from src/JsonApiDotNetCore/Middleware/DefaultExceptionHandler.cs rename to src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs index 877166aafe..c0f3e2f6e0 100644 --- a/src/JsonApiDotNetCore/Middleware/DefaultExceptionHandler.cs +++ b/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs @@ -2,25 +2,30 @@ using System.Diagnostics; using System.Net; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Exceptions; -using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Serialization.Objects; using Microsoft.Extensions.Logging; 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(); + if (loggerFactory == null) throw new ArgumentNullException(nameof(loggerFactory)); + + _options = options ?? throw new ArgumentNullException(nameof(options)); + _logger = loggerFactory.CreateLogger(); } public ErrorDocument HandleException(Exception exception) { + if (exception == null) throw new ArgumentNullException(nameof(exception)); + Exception demystified = exception.Demystify(); LogException(demystified); @@ -38,6 +43,8 @@ private void LogException(Exception exception) protected virtual LogLevel GetLogLevel(Exception exception) { + if (exception == null) throw new ArgumentNullException(nameof(exception)); + if (exception is JsonApiException || exception is InvalidModelStateException) { return LogLevel.Information; @@ -48,6 +55,8 @@ protected virtual LogLevel GetLogLevel(Exception exception) protected virtual string GetLogMessage(Exception exception) { + if (exception == null) throw new ArgumentNullException(nameof(exception)); + return exception is JsonApiException jsonApiException ? jsonApiException.Error.Title : exception.Message; @@ -55,6 +64,8 @@ protected virtual string GetLogMessage(Exception exception) protected virtual ErrorDocument CreateErrorDocument(Exception exception) { + if (exception == null) throw new ArgumentNullException(nameof(exception)); + if (exception is InvalidModelStateException modelStateException) { return new ErrorDocument(modelStateException.Errors); diff --git a/src/JsonApiDotNetCore/Middleware/HeaderConstants.cs b/src/JsonApiDotNetCore/Middleware/HeaderConstants.cs index 7868140198..910cb1c17e 100644 --- a/src/JsonApiDotNetCore/Middleware/HeaderConstants.cs +++ b/src/JsonApiDotNetCore/Middleware/HeaderConstants.cs @@ -1,4 +1,4 @@ -namespace JsonApiDotNetCore +namespace JsonApiDotNetCore.Middleware { public static class HeaderConstants { diff --git a/src/JsonApiDotNetCore/Middleware/HttpContextExtensions.cs b/src/JsonApiDotNetCore/Middleware/HttpContextExtensions.cs new file mode 100644 index 0000000000..a2647de0d5 --- /dev/null +++ b/src/JsonApiDotNetCore/Middleware/HttpContextExtensions.cs @@ -0,0 +1,46 @@ +using System; +using Microsoft.AspNetCore.Http; + +namespace JsonApiDotNetCore.Middleware +{ + public static class HttpContextExtensions + { + /// + /// Indicates whether the currently executing HTTP request is being handled by JsonApiDotNetCore. + /// + public static bool IsJsonApiRequest(this HttpContext httpContext) + { + if (httpContext == null) throw new ArgumentNullException(nameof(httpContext)); + + string value = httpContext.Items["IsJsonApiRequest"] as string; + return value == bool.TrueString; + } + + internal static void RegisterJsonApiRequest(this HttpContext httpContext) + { + if (httpContext == null) throw new ArgumentNullException(nameof(httpContext)); + + httpContext.Items["IsJsonApiRequest"] = bool.TrueString; + } + + internal static void DisableValidator(this HttpContext httpContext, string propertyName, string model) + { + if (httpContext == null) throw new ArgumentNullException(nameof(httpContext)); + if (propertyName == null) throw new ArgumentNullException(nameof(propertyName)); + if (model == null) throw new ArgumentNullException(nameof(model)); + + var itemKey = $"JsonApiDotNetCore_DisableValidation_{model}_{propertyName}"; + httpContext.Items[itemKey] = true; + } + + internal static bool IsValidatorDisabled(this HttpContext httpContext, string propertyName, string model) + { + if (httpContext == null) throw new ArgumentNullException(nameof(httpContext)); + if (propertyName == null) throw new ArgumentNullException(nameof(propertyName)); + if (model == null) throw new ArgumentNullException(nameof(model)); + + return httpContext.Items.ContainsKey($"JsonApiDotNetCore_DisableValidation_{model}_{propertyName}") || + httpContext.Items.ContainsKey($"JsonApiDotNetCore_DisableValidation_{model}_Relation"); + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/IControllerResourceMapping.cs b/src/JsonApiDotNetCore/Middleware/IControllerResourceMapping.cs similarity index 52% rename from src/JsonApiDotNetCore/Internal/IControllerResourceMapping.cs rename to src/JsonApiDotNetCore/Middleware/IControllerResourceMapping.cs index 47a078c94c..de48544e79 100644 --- a/src/JsonApiDotNetCore/Internal/IControllerResourceMapping.cs +++ b/src/JsonApiDotNetCore/Middleware/IControllerResourceMapping.cs @@ -1,14 +1,14 @@ using System; -namespace JsonApiDotNetCore.Internal +namespace JsonApiDotNetCore.Middleware { /// - /// Registry of which resource is associated with which controller. + /// Registry of which resource type is associated with which controller. /// public interface IControllerResourceMapping { /// - /// Get the associated resource with the controller with the provided controller name + /// Get the associated resource type for the provided controller name. /// Type GetAssociatedResource(string controllerName); } diff --git a/src/JsonApiDotNetCore/Middleware/IExceptionHandler.cs b/src/JsonApiDotNetCore/Middleware/IExceptionHandler.cs index 3b3a55d10c..2521794c08 100644 --- a/src/JsonApiDotNetCore/Middleware/IExceptionHandler.cs +++ b/src/JsonApiDotNetCore/Middleware/IExceptionHandler.cs @@ -1,5 +1,5 @@ using System; -using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCore.Serialization.Objects; namespace JsonApiDotNetCore.Middleware { diff --git a/src/JsonApiDotNetCore/Middleware/IJsonApiExceptionFilterProvider.cs b/src/JsonApiDotNetCore/Middleware/IJsonApiExceptionFilterProvider.cs index 6400fa3a50..3343c9084e 100644 --- a/src/JsonApiDotNetCore/Middleware/IJsonApiExceptionFilterProvider.cs +++ b/src/JsonApiDotNetCore/Middleware/IJsonApiExceptionFilterProvider.cs @@ -1,20 +1,14 @@ -using System; +using System; 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 { Type Get(); } - - /// - public class JsonApiExceptionFilterProvider : IJsonApiExceptionFilterProvider - { - public Type Get() => typeof(DefaultExceptionFilter); - } } diff --git a/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs b/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs new file mode 100644 index 0000000000..b24729309f --- /dev/null +++ b/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs @@ -0,0 +1,61 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Middleware +{ + /// + /// Metadata associated with the json:api request that is currently being processed. + /// + public interface IJsonApiRequest + { + /// + /// 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; } + + /// + /// 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; } + + /// + /// The primary (top-level) resource for this request. + /// This would be "blogs" in "/blogs", "/blogs/123" or "/blogs/123/author". + /// + ResourceContext PrimaryResource { get; } + + /// + /// 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". + /// + ResourceContext SecondaryResource { get; } + + /// + /// 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". + /// + RelationshipAttribute Relationship { get; } + + /// + /// 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/Internal/IJsonApiRoutingConvention.cs b/src/JsonApiDotNetCore/Middleware/IJsonApiRoutingConvention.cs similarity index 68% rename from src/JsonApiDotNetCore/Internal/IJsonApiRoutingConvention.cs rename to src/JsonApiDotNetCore/Middleware/IJsonApiRoutingConvention.cs index 00eed0b4c0..3d4df4d29c 100644 --- a/src/JsonApiDotNetCore/Internal/IJsonApiRoutingConvention.cs +++ b/src/JsonApiDotNetCore/Middleware/IJsonApiRoutingConvention.cs @@ -1,9 +1,9 @@ -using Microsoft.AspNetCore.Mvc.ApplicationModels; +using Microsoft.AspNetCore.Mvc.ApplicationModels; -namespace JsonApiDotNetCore.Internal +namespace JsonApiDotNetCore.Middleware { /// - /// 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/Middleware/IJsonApiTypeMatchFilterProvider.cs b/src/JsonApiDotNetCore/Middleware/IJsonApiTypeMatchFilterProvider.cs index 50d2476890..e889ad65d1 100644 --- a/src/JsonApiDotNetCore/Middleware/IJsonApiTypeMatchFilterProvider.cs +++ b/src/JsonApiDotNetCore/Middleware/IJsonApiTypeMatchFilterProvider.cs @@ -5,16 +5,10 @@ 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 { Type Get(); } - - /// - public class JsonApiTypeMatchFilterProvider : IJsonApiTypeMatchFilterProvider - { - public Type Get() => typeof(DefaultTypeMatchFilter); - } } diff --git a/src/JsonApiDotNetCore/Middleware/IQueryParameterActionFilter.cs b/src/JsonApiDotNetCore/Middleware/IQueryStringActionFilter.cs similarity index 51% rename from src/JsonApiDotNetCore/Middleware/IQueryParameterActionFilter.cs rename to src/JsonApiDotNetCore/Middleware/IQueryStringActionFilter.cs index 2c843d9d99..d591165542 100644 --- a/src/JsonApiDotNetCore/Middleware/IQueryParameterActionFilter.cs +++ b/src/JsonApiDotNetCore/Middleware/IQueryStringActionFilter.cs @@ -1,10 +1,13 @@ -using System.Threading.Tasks; +using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc.Filters; namespace JsonApiDotNetCore.Middleware { - public interface IQueryParameterActionFilter + /// + /// Extensibility point for processing request query strings. + /// + public interface IQueryStringActionFilter { Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next); } -} \ No newline at end of file +} diff --git a/src/JsonApiDotNetCore/Middleware/IncomingTypeMatchFilter.cs b/src/JsonApiDotNetCore/Middleware/IncomingTypeMatchFilter.cs new file mode 100644 index 0000000000..27608e43c5 --- /dev/null +++ b/src/JsonApiDotNetCore/Middleware/IncomingTypeMatchFilter.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections; +using System.Linq; +using System.Net.Http; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace JsonApiDotNetCore.Middleware +{ + /// + /// Action filter used to verify the incoming resource type matches the target type, else return a 409. + /// + public sealed class IncomingTypeMatchFilter : IActionFilter + { + private readonly IResourceContextProvider _provider; + private readonly IJsonApiRequest _jsonApiRequest; + + public IncomingTypeMatchFilter(IResourceContextProvider provider, IJsonApiRequest jsonApiRequest) + { + _provider = provider ?? throw new ArgumentNullException(nameof(provider)); + _jsonApiRequest = jsonApiRequest ?? throw new ArgumentNullException(nameof(provider)); + } + + public void OnActionExecuting(ActionExecutingContext context) + { + if (context == null) throw new ArgumentNullException(nameof(context)); + + if (!context.HttpContext.IsJsonApiRequest()) + { + return; + } + + var request = context.HttpContext.Request; + if (request.Method == HttpMethods.Patch || request.Method == HttpMethods.Post) + { + var deserializedType = GetDeserializedType(context); + var targetType = GetTargetType(); + + if (deserializedType != null && targetType != null && deserializedType != targetType) + { + ResourceContext resourceFromEndpoint = _provider.GetResourceContext(targetType); + ResourceContext resourceFromBody = _provider.GetResourceContext(deserializedType); + + throw new ResourceTypeMismatchException(new HttpMethod(request.Method), request.Path, resourceFromEndpoint, resourceFromBody); + } + } + } + + private Type GetDeserializedType(ActionExecutingContext context) + { + var deserializedValue = context.ActionArguments.LastOrDefault().Value; + if (deserializedValue is IList resourceCollection && resourceCollection.Count > 0) + { + return resourceCollection[0].GetType(); + } + + return deserializedValue?.GetType(); + } + + private Type GetTargetType() + { + if (_jsonApiRequest.Kind == EndpointKind.Primary) + { + return _jsonApiRequest.PrimaryResource.ResourceType; + } + + return _jsonApiRequest.SecondaryResource?.ResourceType; + } + + public void OnActionExecuted(ActionExecutedContext context) { /* noop */ } + } +} diff --git a/src/JsonApiDotNetCore/Middleware/DefaultExceptionFilter.cs b/src/JsonApiDotNetCore/Middleware/JsonApiExceptionFilter.cs similarity index 68% rename from src/JsonApiDotNetCore/Middleware/DefaultExceptionFilter.cs rename to src/JsonApiDotNetCore/Middleware/JsonApiExceptionFilter.cs index 78acd1ca8a..f389f2de69 100644 --- a/src/JsonApiDotNetCore/Middleware/DefaultExceptionFilter.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiExceptionFilter.cs @@ -1,4 +1,4 @@ -using JsonApiDotNetCore.Extensions; +using System; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; @@ -7,17 +7,19 @@ namespace JsonApiDotNetCore.Middleware /// /// Global exception filter that wraps any thrown error with a JsonApiException. /// - public class DefaultExceptionFilter : ActionFilterAttribute, IExceptionFilter + public sealed class JsonApiExceptionFilter : ActionFilterAttribute, IExceptionFilter { private readonly IExceptionHandler _exceptionHandler; - public DefaultExceptionFilter(IExceptionHandler exceptionHandler) + public JsonApiExceptionFilter(IExceptionHandler exceptionHandler) { - _exceptionHandler = exceptionHandler; + _exceptionHandler = exceptionHandler ?? throw new ArgumentNullException(nameof(exceptionHandler)); } public void OnException(ExceptionContext context) { + if (context == null) throw new ArgumentNullException(nameof(context)); + if (context.HttpContext.IsJsonApiRequest()) { var errorDocument = _exceptionHandler.HandleException(context.Exception); diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiExceptionFilterProvider.cs b/src/JsonApiDotNetCore/Middleware/JsonApiExceptionFilterProvider.cs new file mode 100644 index 0000000000..6289718f69 --- /dev/null +++ b/src/JsonApiDotNetCore/Middleware/JsonApiExceptionFilterProvider.cs @@ -0,0 +1,10 @@ +using System; + +namespace JsonApiDotNetCore.Middleware +{ + /// + public sealed class JsonApiExceptionFilterProvider : IJsonApiExceptionFilterProvider + { + public Type Get() => typeof(JsonApiExceptionFilter); + } +} diff --git a/src/JsonApiDotNetCore/Formatters/JsonApiInputFormatter.cs b/src/JsonApiDotNetCore/Middleware/JsonApiInputFormatter.cs similarity index 63% rename from src/JsonApiDotNetCore/Formatters/JsonApiInputFormatter.cs rename to src/JsonApiDotNetCore/Middleware/JsonApiInputFormatter.cs index 815f5a75d2..4db839cc19 100644 --- a/src/JsonApiDotNetCore/Formatters/JsonApiInputFormatter.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiInputFormatter.cs @@ -1,23 +1,27 @@ using System; using System.Threading.Tasks; -using JsonApiDotNetCore.Extensions; +using JsonApiDotNetCore.Serialization; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.Extensions.DependencyInjection; -namespace JsonApiDotNetCore.Formatters +namespace JsonApiDotNetCore.Middleware { + /// + /// Extensibility point for reading incoming HTTP request. + /// public sealed class JsonApiInputFormatter : IInputFormatter { public bool CanRead(InputFormatterContext context) { - if (context == null) - throw new ArgumentNullException(nameof(context)); + if (context == null) throw new ArgumentNullException(nameof(context)); return context.HttpContext.IsJsonApiRequest(); } public async Task ReadAsync(InputFormatterContext context) { + if (context == null) throw new ArgumentNullException(nameof(context)); + var reader = context.HttpContext.RequestServices.GetService(); return await reader.ReadAsync(context); } diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs b/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs index 8aaa401dca..853386a7e8 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs @@ -1,16 +1,15 @@ -using System.Collections.Generic; +using System; 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; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Extensions; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Managers.Contracts; -using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization; +using JsonApiDotNetCore.Serialization.Objects; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; @@ -20,7 +19,7 @@ namespace JsonApiDotNetCore.Middleware { /// - /// Intercepts HTTP requests to populate injected instance for json:api requests. + /// Intercepts HTTP requests to populate injected instance for json:api requests. /// public sealed class JsonApiMiddleware { @@ -34,13 +33,19 @@ public JsonApiMiddleware(RequestDelegate next) public async Task Invoke(HttpContext httpContext, IControllerResourceMapping controllerResourceMapping, IJsonApiOptions options, - ICurrentRequest currentRequest, - IResourceGraph resourceGraph) + IJsonApiRequest request, + IResourceContextProvider resourceContextProvider) { + if (httpContext == null) throw new ArgumentNullException(nameof(httpContext)); + if (controllerResourceMapping == null) throw new ArgumentNullException(nameof(controllerResourceMapping)); + if (options == null) throw new ArgumentNullException(nameof(options)); + if (request == null) throw new ArgumentNullException(nameof(request)); + if (resourceContextProvider == null) throw new ArgumentNullException(nameof(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,25 +53,28 @@ public async Task Invoke(HttpContext httpContext, return; } - SetupCurrentRequest(currentRequest, resourceContext, routeValues, options, httpContext.Request); + SetupRequest((JsonApiRequest)request, primaryResourceContext, routeValues, options, resourceContextProvider, httpContext.Request); - httpContext.SetJsonApiRequest(); + httpContext.RegisterJsonApiRequest(); } 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) + if (controllerName != null) { - return null; + var resourceType = controllerResourceMapping.GetAssociatedResource(controllerName); + if (resourceType != null) + { + return resourceContextProvider.GetResourceContext(resourceType); + } } - var resourceType = controllerResourceMapping.GetAssociatedResource(controllerName); - return resourceGraph.GetResourceContext(resourceType); + return null; } private static async Task ValidateContentTypeHeaderAsync(HttpContext httpContext, JsonSerializerSettings serializerSettings) @@ -149,24 +157,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 SetupRequest(JsonApiRequest request, 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)) + request.IsReadOnly = httpRequest.Method == HttpMethod.Get.Method; + request.Kind = EndpointKind.Primary; + request.PrimaryResource = primaryResourceContext; + request.PrimaryId = GetPrimaryRequestId(routeValues); + request.BasePath = GetBasePath(primaryResourceContext.ResourceName, options, httpRequest); + + var relationshipName = GetRelationshipNameForSecondaryRequest(routeValues); + if (relationshipName != null) { - currentRequest.RequestRelationship = - resourceContext.Relationships.SingleOrDefault(relationship => - relationship.PublicRelationshipName == (string) relationshipName); + request.Kind = IsRouteForRelationship(routeValues) ? EndpointKind.Relationship : EndpointKind.Secondary; + + var requestRelationship = + primaryResourceContext.Relationships.SingleOrDefault(relationship => + relationship.PublicName == relationshipName); + + if (requestRelationship != null) + { + request.Relationship = requestRelationship; + request.SecondaryResource = resourceContextProvider.GetResourceContext(requestRelationship.RightType); + } } + + request.IsCollection = request.PrimaryId == null || request.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 +195,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 +233,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", StringComparison.Ordinal); } } } diff --git a/src/JsonApiDotNetCore/Formatters/JsonApiOutputFormatter.cs b/src/JsonApiDotNetCore/Middleware/JsonApiOutputFormatter.cs similarity index 63% rename from src/JsonApiDotNetCore/Formatters/JsonApiOutputFormatter.cs rename to src/JsonApiDotNetCore/Middleware/JsonApiOutputFormatter.cs index def858fd3a..89ab8da53b 100644 --- a/src/JsonApiDotNetCore/Formatters/JsonApiOutputFormatter.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiOutputFormatter.cs @@ -1,23 +1,27 @@ using System; using System.Threading.Tasks; -using JsonApiDotNetCore.Extensions; +using JsonApiDotNetCore.Serialization; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.Extensions.DependencyInjection; -namespace JsonApiDotNetCore.Formatters +namespace JsonApiDotNetCore.Middleware { + /// + /// Extensibility point for writing outgoing HTTP response. + /// public sealed class JsonApiOutputFormatter : IOutputFormatter { public bool CanWriteResult(OutputFormatterCanWriteContext context) { - if (context == null) - throw new ArgumentNullException(nameof(context)); + if (context == null) throw new ArgumentNullException(nameof(context)); return context.HttpContext.IsJsonApiRequest(); } public async Task WriteAsync(OutputFormatterWriteContext context) { + if (context == null) throw new ArgumentNullException(nameof(context)); + var writer = context.HttpContext.RequestServices.GetService(); await writer.WriteAsync(context); } diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs b/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs new file mode 100644 index 0000000000..5080c5f9e2 --- /dev/null +++ b/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs @@ -0,0 +1,33 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Middleware +{ + /// + public sealed class JsonApiRequest : IJsonApiRequest + { + /// + public EndpointKind Kind { get; set; } + + /// + public string BasePath { get; set; } + + /// + 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/Internal/DefaultRoutingConvention.cs b/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs similarity index 61% rename from src/JsonApiDotNetCore/Internal/DefaultRoutingConvention.cs rename to src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs index 9a327ebff2..0264c15797 100644 --- a/src/JsonApiDotNetCore/Internal/DefaultRoutingConvention.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs @@ -4,59 +4,57 @@ using System.Reflection; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Extensions; -using JsonApiDotNetCore.Graph; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Controllers.Annotations; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Resources; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ApplicationModels; -using Newtonsoft.Json.Serialization; -namespace JsonApiDotNetCore.Internal +namespace JsonApiDotNetCore.Middleware { /// /// 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 + /// // when using kebab-case casing convention: + /// 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; + _options = options ?? throw new ArgumentNullException(nameof(options)); _formatter = new ResourceNameFormatter(options); } - /// + /// public Type GetAssociatedResource(string controllerName) { + if (controllerName == null) throw new ArgumentNullException(nameof(controllerName)); + _registeredResources.TryGetValue(controllerName, out Type type); return type; } - /// + /// public void Apply(ApplicationModel application) { + if (application == null) throw new ArgumentNullException(nameof(application)); + foreach (var controller in application.Controllers) { var resourceType = GetResourceTypeFromController(controller.ControllerType); @@ -64,12 +62,12 @@ public void Apply(ApplicationModel application) if (resourceType != null) _registeredResources.Add(controller.ControllerName, resourceType); - if (RoutingConventionDisabled(controller) == false) + if (!RoutingConventionDisabled(controller)) continue; var template = TemplateFromResource(controller) ?? TemplateFromController(controller); if (template == null) - throw new JsonApiSetupException($"Controllers with overlapping route templates detected: {controller.ControllerType.FullName}"); + throw new InvalidConfigurationException($"Controllers with overlapping route templates detected: {controller.ControllerType.FullName}"); controller.Selectors[0].AttributeRouteModel = new AttributeRouteModel { Template = template }; } @@ -82,7 +80,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)); } /// @@ -124,30 +122,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 => TypeHelper.IsOrImplementsInterface(t, 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/Middleware/JsonApiTypeMatchFilterProvider.cs b/src/JsonApiDotNetCore/Middleware/JsonApiTypeMatchFilterProvider.cs new file mode 100644 index 0000000000..f6cbb7a743 --- /dev/null +++ b/src/JsonApiDotNetCore/Middleware/JsonApiTypeMatchFilterProvider.cs @@ -0,0 +1,10 @@ +using System; + +namespace JsonApiDotNetCore.Middleware +{ + /// + public class JsonApiTypeMatchFilterProvider : IJsonApiTypeMatchFilterProvider + { + public Type Get() => typeof(IncomingTypeMatchFilter); + } +} diff --git a/src/JsonApiDotNetCore/Middleware/QueryParameterFilter.cs b/src/JsonApiDotNetCore/Middleware/QueryParameterFilter.cs deleted file mode 100644 index a3f5e5bdf7..0000000000 --- a/src/JsonApiDotNetCore/Middleware/QueryParameterFilter.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Reflection; -using System.Threading.Tasks; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Services; -using Microsoft.AspNetCore.Mvc.Filters; - -namespace JsonApiDotNetCore.Middleware -{ - public sealed class QueryParameterActionFilter : IAsyncActionFilter, IQueryParameterActionFilter - { - private readonly IQueryParameterParser _queryParser; - public QueryParameterActionFilter(IQueryParameterParser queryParser) => _queryParser = queryParser; - - public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) - { - DisableQueryAttribute disableQueryAttribute = context.Controller.GetType().GetCustomAttribute(); - - _queryParser.Parse(disableQueryAttribute); - await next(); - } - } -} diff --git a/src/JsonApiDotNetCore/Middleware/QueryStringActionFilter.cs b/src/JsonApiDotNetCore/Middleware/QueryStringActionFilter.cs new file mode 100644 index 0000000000..5ed52517f3 --- /dev/null +++ b/src/JsonApiDotNetCore/Middleware/QueryStringActionFilter.cs @@ -0,0 +1,30 @@ +using System; +using System.Reflection; +using System.Threading.Tasks; +using JsonApiDotNetCore.Controllers.Annotations; +using JsonApiDotNetCore.QueryStrings; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace JsonApiDotNetCore.Middleware +{ + public sealed class QueryStringActionFilter : IAsyncActionFilter, IQueryStringActionFilter + { + private readonly IQueryStringReader _queryStringReader; + + public QueryStringActionFilter(IQueryStringReader queryStringReader) + { + _queryStringReader = queryStringReader ?? throw new ArgumentNullException(nameof(queryStringReader)); + } + + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + if (context == null) throw new ArgumentNullException(nameof(context)); + if (next == null) throw new ArgumentNullException(nameof(next)); + + DisableQueryStringAttribute disableQueryStringAttribute = context.Controller.GetType().GetCustomAttribute(); + + _queryStringReader.ReadAll(disableQueryStringAttribute); + await next(); + } + } +} diff --git a/src/JsonApiDotNetCore/Middleware/TraceLogWriter.cs b/src/JsonApiDotNetCore/Middleware/TraceLogWriter.cs new file mode 100644 index 0000000000..6e03c521dc --- /dev/null +++ b/src/JsonApiDotNetCore/Middleware/TraceLogWriter.cs @@ -0,0 +1,143 @@ +using System; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Text; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; + +namespace JsonApiDotNetCore.Middleware +{ + internal sealed class TraceLogWriter + { + private readonly ILogger _logger; + + private bool IsEnabled => _logger.IsEnabled(LogLevel.Trace); + + public TraceLogWriter(ILoggerFactory loggerFactory) + { + _logger = loggerFactory.CreateLogger(typeof(T)); + } + + public void LogMethodStart(object parameters = null, [CallerMemberName] string memberName = "") + { + if (IsEnabled) + { + string message = FormatMessage(memberName, parameters); + WriteMessageToLog(message); + } + } + + public void LogMessage(Func messageFactory) + { + if (IsEnabled) + { + string message = messageFactory(); + WriteMessageToLog(message); + } + } + + private static string FormatMessage(string memberName, object parameters) + { + var builder = new StringBuilder(); + + builder.Append("Entering "); + builder.Append(memberName); + builder.Append("("); + WriteProperties(builder, parameters); + builder.Append(")"); + + return builder.ToString(); + } + + private static void WriteProperties(StringBuilder builder, object propertyContainer) + { + if (propertyContainer != null) + { + bool isFirstMember = true; + foreach (var property in propertyContainer.GetType().GetProperties()) + { + if (isFirstMember) + { + isFirstMember = false; + } + else + { + builder.Append(", "); + } + + WriteProperty(builder, property, propertyContainer); + } + } + } + + private static void WriteProperty(StringBuilder builder, PropertyInfo property, object instance) + { + builder.Append(property.Name); + builder.Append(": "); + + var value = property.GetValue(instance); + if (value == null) + { + builder.Append("null"); + } + else if (value is string stringValue) + { + builder.Append("\""); + builder.Append(stringValue); + builder.Append("\""); + } + else + { + WriteObject(builder, value); + } + } + + private static void WriteObject(StringBuilder builder, object value) + { + if (HasToStringOverload(value.GetType())) + { + builder.Append(value); + } + else + { + var text = SerializeObject(value); + builder.Append(text); + } + } + + private static bool HasToStringOverload(Type type) + { + if (type != null) + { + var toStringMethod = type.GetMethod("ToString", Array.Empty()); + if (toStringMethod != null && toStringMethod.DeclaringType != typeof(object)) + { + return true; + } + } + + return false; + } + + private static string SerializeObject(object value) + { + try + { + // It turns out setting ReferenceLoopHandling to something other than Error only takes longer to fail. + // This is because Newtonsoft.Json always tries to serialize the first element in a graph. And with + // EF Core models, that one is often recursive, resulting in either StackOverflowException or OutOfMemoryException. + return JsonConvert.SerializeObject(value, Formatting.Indented); + } + catch (JsonSerializationException) + { + // Never crash as a result of logging, this is best-effort only. + return "object"; + } + } + + private void WriteMessageToLog(string message) + { + _logger.LogTrace(message); + } + } +} diff --git a/src/JsonApiDotNetCore/Models/Annotation/AttrAttribute.cs b/src/JsonApiDotNetCore/Models/Annotation/AttrAttribute.cs deleted file mode 100644 index cd701f64eb..0000000000 --- a/src/JsonApiDotNetCore/Models/Annotation/AttrAttribute.cs +++ /dev/null @@ -1,131 +0,0 @@ -using System; -using System.Reflection; -using JsonApiDotNetCore.Internal; - -namespace JsonApiDotNetCore.Models -{ - [AttributeUsage(AttributeTargets.Property)] - public sealed class AttrAttribute : Attribute, IResourceField - { - /// - /// Exposes a resource property as a json:api attribute using the configured casing convention and capabilities. - /// - /// - /// - /// public class Author : Identifiable - /// { - /// [Attr] - /// public string Name { get; set; } - /// } - /// - /// - public AttrAttribute() - { - } - - /// - /// Exposes a resource property as a json:api attribute with an explicit name, using configured capabilities. - /// - public AttrAttribute(string 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; - } - - /// - /// Exposes a resource property as a json:api attribute using the configured casing convention and an explicit set of capabilities. - /// - /// - /// - /// public class Author : Identifiable - /// { - /// [Attr(AttrCapabilities.AllowFilter | AttrCapabilities.AllowSort)] - /// public string Name { get; set; } - /// } - /// - /// - public AttrAttribute(AttrCapabilities capabilities) - { - HasExplicitCapabilities = true; - Capabilities = capabilities; - } - - /// - /// Exposes a resource property as a json:api attribute with an explicit name and capabilities. - /// - 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 - /// provided object. - /// - public object GetValue(object entity) - { - if (entity == null) - { - throw new ArgumentNullException(nameof(entity)); - } - - if (PropertyInfo.GetMethod == null) - { - throw new InvalidOperationException($"Property '{PropertyInfo.DeclaringType?.Name}.{PropertyInfo.Name}' is write-only."); - } - - return PropertyInfo.GetValue(entity); - } - - /// - /// Sets the value of the attribute on the given object. - /// - public void SetValue(object entity, object newValue) - { - if (entity == null) - { - throw new ArgumentNullException(nameof(entity)); - } - - if (PropertyInfo.SetMethod == null) - { - throw new InvalidOperationException( - $"Property '{PropertyInfo.DeclaringType?.Name}.{PropertyInfo.Name}' is read-only."); - } - - var convertedValue = TypeHelper.ConvertType(newValue, PropertyInfo.PropertyType); - PropertyInfo.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/HasManyAttribute.cs b/src/JsonApiDotNetCore/Models/Annotation/HasManyAttribute.cs deleted file mode 100644 index 540f0dba00..0000000000 --- a/src/JsonApiDotNetCore/Models/Annotation/HasManyAttribute.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System; -using JsonApiDotNetCore.Models.Links; - -namespace JsonApiDotNetCore.Models -{ - [AttributeUsage(AttributeTargets.Property)] - public class HasManyAttribute : RelationshipAttribute - { - /// - /// Create a HasMany relational link to another entity - /// - /// - /// The relationship name as exposed by the API - /// Which links are available. Defaults to - /// Whether or not this relationship can be included using the ?include=public-name query string - /// - /// - /// - /// - /// public class Author : Identifiable - /// { - /// [HasMany("articles"] - /// public List<Article> Articles { get; set; } - /// } - /// - /// - /// - public HasManyAttribute(string publicName = null, Link relationshipLinks = Link.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 deleted file mode 100644 index d4ad24c2f7..0000000000 --- a/src/JsonApiDotNetCore/Models/Annotation/HasManyThroughAttribute.cs +++ /dev/null @@ -1,212 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using JsonApiDotNetCore.Extensions; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Models.Links; - -namespace JsonApiDotNetCore.Models -{ - /// - /// Create a HasMany relationship through a many-to-many join relationship. - /// This type can only be applied on types that implement ICollection. - /// - /// - /// - /// 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 - /// to map this to a database relationship. - /// - /// [NotMapped] - /// [HasManyThrough("tags", nameof(ArticleTags))] - /// public ICollection<Tag> Tags { get; set; } - /// public ICollection<ArticleTag> 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. - /// - /// - /// In the `[HasManyThrough("tags", nameof(ArticleTags))]` example - /// this would be "ArticleTags". - /// - internal string ThroughPropertyName { get; } - - /// - /// The join type. - /// - /// - /// In the `[HasManyThrough("tags", nameof(ArticleTags))]` example - /// this would be `ArticleTag`. - /// - public Type ThroughType { get; internal set; } - - /// - /// The navigation property back to the parent resource from the join type. - /// - /// - /// - /// In the `[HasManyThrough("tags", nameof(ArticleTags))]` example - /// this would point to the `Article.ArticleTags.Article` property - /// - /// - /// public Article Article { get; set; } - /// - /// - /// - public PropertyInfo LeftProperty { get; internal set; } - - /// - /// The id property back to the parent resource from the join type. - /// - /// - /// - /// In the `[HasManyThrough("tags", nameof(ArticleTags))]` example - /// this would point to the `Article.ArticleTags.ArticleId` property - /// - /// - /// public int ArticleId { get; set; } - /// - /// - /// - public PropertyInfo LeftIdProperty { get; internal set; } - - /// - /// The navigation property to the related resource from the join type. - /// - /// - /// - /// In the `[HasManyThrough("tags", nameof(ArticleTags))]` example - /// this would point to the `Article.ArticleTags.Tag` property - /// - /// - /// public Tag Tag { get; set; } - /// - /// - /// - public PropertyInfo RightProperty { get; internal set; } - - /// - /// The id property to the related resource from the join type. - /// - /// - /// - /// In the `[HasManyThrough("tags", nameof(ArticleTags))]` example - /// this would point to the `Article.ArticleTags.TagId` property - /// - /// - /// public int TagId { get; set; } - /// - /// - /// - public PropertyInfo RightIdProperty { get; internal set; } - - /// - /// The join entity property on the parent resource. - /// - /// - /// - /// In the `[HasManyThrough("tags", nameof(ArticleTags))]` example - /// this would point to the `Article.ArticleTags` property - /// - /// - /// public ICollection<ArticleTags> ArticleTags { get; set; } - /// - /// - /// - public PropertyInfo ThroughProperty { get; internal set; } - - /// - /// - /// "ArticleTags.Tag" - /// - public override string RelationshipPath => $"{ThroughProperty.Name}.{RightProperty.Name}"; - } -} diff --git a/src/JsonApiDotNetCore/Models/Annotation/HasOneAttribute.cs b/src/JsonApiDotNetCore/Models/Annotation/HasOneAttribute.cs deleted file mode 100644 index 9afed2438e..0000000000 --- a/src/JsonApiDotNetCore/Models/Annotation/HasOneAttribute.cs +++ /dev/null @@ -1,67 +0,0 @@ -using System; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Models.Links; - -namespace JsonApiDotNetCore.Models -{ - [AttributeUsage(AttributeTargets.Property)] - public sealed class HasOneAttribute : RelationshipAttribute - { - /// - /// Create a HasOne relational link to another entity - /// - /// - /// 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. - /// Whether or not this relationship can be included using the ?include=public-name query string - /// The foreign key property name. Defaults to "{RelationshipName}Id" - /// - /// - /// Using an alternative foreign key: - /// - /// - /// public class Article : Identifiable - /// { - /// [HasOne("author", withForeignKey: nameof(AuthorKey)] - /// public Author Author { get; set; } - /// public int AuthorKey { get; set; } - /// } - /// - /// - public HasOneAttribute(string publicName = null, Link links = Link.NotConfigured, bool canInclude = true, string withForeignKey = null, string inverseNavigationProperty = null) - : base(publicName, links, canInclude) - { - _explicitIdentifiablePropertyName = withForeignKey; - 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; - 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 deleted file mode 100644 index 251f569b43..0000000000 --- a/src/JsonApiDotNetCore/Models/Annotation/LinksAttribute.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System; -using JsonApiDotNetCore.Internal; - -namespace JsonApiDotNetCore.Models.Links -{ - [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) - { - if (topLevelLinks == Link.Related) - throw new JsonApiSetupException($"{Link.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 (relationshipLinks == Link.Paging) - throw new JsonApiSetupException($"{Link.Paging:g} not allowed for argument {nameof(relationshipLinks)}"); - - TopLevelLinks = topLevelLinks; - ResourceLinks = resourceLinks; - RelationshipLinks = relationshipLinks; - } - - /// - /// Configures which links to show in the - /// object for this resource. - /// - public Link TopLevelLinks { get; } - - /// - /// Configures which links to show in the - /// object for this resource. - /// - public Link 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; } - } -} diff --git a/src/JsonApiDotNetCore/Models/Annotation/RelationshipAttribute.cs b/src/JsonApiDotNetCore/Models/Annotation/RelationshipAttribute.cs deleted file mode 100644 index d5a742ead3..0000000000 --- a/src/JsonApiDotNetCore/Models/Annotation/RelationshipAttribute.cs +++ /dev/null @@ -1,106 +0,0 @@ -using System; -using System.Reflection; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Models.Links; - -namespace JsonApiDotNetCore.Models -{ - public abstract class RelationshipAttribute : Attribute, IResourceField - { - 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. - /// - public PropertyInfo PropertyInfo { get; internal set; } - - /// - /// 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 - /// - /// - public Type RightType { get; internal set; } - - /// - /// The parent entity type. This is the type of the class in which this attribute was used. - /// - public Type LeftType { get; internal set; } - - /// - /// Configures which links to show in the - /// object for this relationship. - /// - public Link RelationshipLinks { get; } - public bool CanInclude { get; } - - /// - /// Gets the value of the resource property this attributes was declared on. - /// - public virtual object GetValue(object entity) - { - return PropertyInfo.GetValue(entity); - } - - /// - /// Sets the value of the resource property this attributes was declared on. - /// - public virtual void SetValue(object entity, object newValue, IResourceFactory resourceFactory) - { - PropertyInfo.SetValue(entity, newValue); - } - - public override string ToString() - { - return base.ToString() + ":" + PublicRelationshipName; - } - - public override bool Equals(object obj) - { - if (obj == null || GetType() != obj.GetType()) - { - return false; - } - - var other = (RelationshipAttribute) obj; - - return PublicRelationshipName == other.PublicRelationshipName && LeftType == other.LeftType && - RightType == other.RightType; - } - - public override int GetHashCode() - { - return HashCode.Combine(PublicRelationshipName, 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/IHasMeta.cs b/src/JsonApiDotNetCore/Models/IHasMeta.cs deleted file mode 100644 index f1605bf790..0000000000 --- a/src/JsonApiDotNetCore/Models/IHasMeta.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System.Collections.Generic; - -namespace JsonApiDotNetCore.Models -{ - public interface IHasMeta - { - Dictionary GetMeta(); - } -} 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/JsonApiDocuments/IIdentifiable.cs b/src/JsonApiDotNetCore/Models/JsonApiDocuments/IIdentifiable.cs deleted file mode 100644 index 93d7f859da..0000000000 --- a/src/JsonApiDotNetCore/Models/JsonApiDocuments/IIdentifiable.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace JsonApiDotNetCore.Models -{ - public interface IIdentifiable - { - string StringId { get; set; } - } - - public interface IIdentifiable : IIdentifiable - { - T Id { get; set; } - } -} diff --git a/src/JsonApiDotNetCore/Models/JsonApiDocuments/Identifiable.cs b/src/JsonApiDotNetCore/Models/JsonApiDocuments/Identifiable.cs deleted file mode 100644 index f559cf9aa3..0000000000 --- a/src/JsonApiDotNetCore/Models/JsonApiDocuments/Identifiable.cs +++ /dev/null @@ -1,67 +0,0 @@ -using System; -using System.ComponentModel.DataAnnotations.Schema; -using JsonApiDotNetCore.Internal; - -namespace JsonApiDotNetCore.Models -{ - public abstract class Identifiable : Identifiable - { } - - public abstract class Identifiable : IIdentifiable - { - /// - /// The resource identifier - /// - public virtual T Id { get; set; } - - /// - /// The string representation of the `Id`. - /// - /// This is used in serialization and deserialization. - /// The getters should handle the conversion - /// from `typeof(T)` to a string and the setter vice versa. - /// - /// To override this behavior, you can either implement the - /// interface directly or override - /// `GetStringId` and `GetTypedId` methods. - /// - [NotMapped] - public string StringId - { - get => GetStringId(Id); - set => Id = GetTypedId(value); - } - - /// - /// Convert the provided resource identifier to a string. - /// - protected virtual string GetStringId(object value) - { - if(value == null) - return string.Empty; // todo; investigate why not using null, because null would make more sense in serialization - - var type = typeof(T); - var stringValue = value.ToString(); - - if (type == typeof(Guid)) - { - var guid = Guid.Parse(stringValue); - return guid == Guid.Empty ? string.Empty : stringValue; - } - - return stringValue == "0" - ? string.Empty - : stringValue; - } - - /// - /// Convert a string to a typed resource identifier. - /// - protected virtual T GetTypedId(string value) - { - if (value == null) - return default; - return (T)TypeHelper.ConvertType(value, typeof(T)); - } - } -} diff --git a/src/JsonApiDotNetCore/Models/JsonApiDocuments/ResourceObjectComparer.cs b/src/JsonApiDotNetCore/Models/JsonApiDocuments/ResourceObjectComparer.cs deleted file mode 100644 index 442a918687..0000000000 --- a/src/JsonApiDotNetCore/Models/JsonApiDocuments/ResourceObjectComparer.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.Collections.Generic; -using JsonApiDotNetCore.Models; - -namespace JsonApiDotNetCore.Builders -{ - internal sealed class ResourceObjectComparer : IEqualityComparer - { - public bool Equals(ResourceObject x, ResourceObject y) - { - return x.Id.Equals(y.Id) && x.Type.Equals(y.Type); - } - - public int GetHashCode(ResourceObject ro) - { - return ro.GetHashCode(); - } - } -} diff --git a/src/JsonApiDotNetCore/Models/JsonApiDocuments/TopLevelLinks.cs b/src/JsonApiDotNetCore/Models/JsonApiDocuments/TopLevelLinks.cs deleted file mode 100644 index adb2ddbc6d..0000000000 --- a/src/JsonApiDotNetCore/Models/JsonApiDocuments/TopLevelLinks.cs +++ /dev/null @@ -1,51 +0,0 @@ -using Newtonsoft.Json; - -namespace JsonApiDotNetCore.Models.Links -{ - /// - /// see links section in https://jsonapi.org/format/#document-top-level - /// - public sealed class TopLevelLinks - { - [JsonProperty("self")] - public string Self { get; set; } - - [JsonProperty("next")] - public string Next { get; set; } - - [JsonProperty("prev")] - public string Prev { get; set; } - - [JsonProperty("first")] - public string First { get; set; } - - [JsonProperty("last")] - public string Last { get; set; } - - // http://www.newtonsoft.com/json/help/html/ConditionalProperties.htm - public bool ShouldSerializeSelf() - { - return (!string.IsNullOrEmpty(Self)); - } - - public bool ShouldSerializeFirst() - { - return (!string.IsNullOrEmpty(First)); - } - - public bool ShouldSerializeNext() - { - return (!string.IsNullOrEmpty(Next)); - } - - public bool ShouldSerializePrev() - { - return (!string.IsNullOrEmpty(Prev)); - } - - public bool ShouldSerializeLast() - { - return (!string.IsNullOrEmpty(Last)); - } - } -} diff --git a/src/JsonApiDotNetCore/Models/ResourceAttribute.cs b/src/JsonApiDotNetCore/Models/ResourceAttribute.cs deleted file mode 100644 index 2f43e830a2..0000000000 --- a/src/JsonApiDotNetCore/Models/ResourceAttribute.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; - -namespace JsonApiDotNetCore.Models -{ - [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Interface)] - public sealed class ResourceAttribute : Attribute - { - public ResourceAttribute(string resourceName) - { - ResourceName = resourceName; - } - - public string ResourceName { get; } - } -} diff --git a/src/JsonApiDotNetCore/Models/ResourceDefinition.cs b/src/JsonApiDotNetCore/Models/ResourceDefinition.cs deleted file mode 100644 index 7a519d3b19..0000000000 --- a/src/JsonApiDotNetCore/Models/ResourceDefinition.cs +++ /dev/null @@ -1,173 +0,0 @@ -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Internal.Query; -using JsonApiDotNetCore.Hooks; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Linq.Expressions; - -namespace JsonApiDotNetCore.Models -{ - public interface IResourceDefinition - { - List GetAllowedAttributes(); - List GetAllowedRelationships(); - object GetCustomQueryFilter(string key); - List<(AttrAttribute Attribute, SortDirection SortDirection)> DefaultSort(); - } - - /// - /// 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. - /// - /// The resource type - public class ResourceDefinition : IResourceDefinition, IResourceHookContainer where TResource : class, IIdentifiable - { - private readonly IResourceGraph _resourceGraph; - private List _allowedAttributes; - private List _allowedRelationships; - public ResourceDefinition(IResourceGraph resourceGraph) - { - var resourceContext = resourceGraph.GetResourceContext(typeof(TResource)); - _allowedAttributes = resourceContext.Attributes; - _allowedRelationships = resourceContext.Relationships; - _resourceGraph = resourceGraph; - } - - public List GetAllowedRelationships() => _allowedRelationships; - public List GetAllowedAttributes() => _allowedAttributes; - - /// - /// 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. - /// - /// Should be of the form: (TResource e) => new { e.Attribute1, e.Attribute2, e.Relationship1, e.Relationship2 } - public void HideFields(Expression> selector) - { - 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(); - } - - /// - /// 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. - /// - /// - /// - /// 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. - /// - /// - /// - /// - /// 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: - /// - /// protected override QueryFilters GetQueryFilters() - /// => new QueryFilters { - /// { "is-active", FilterIsActive } - /// }; - /// - /// private IQueryable<Model> FilterIsActive(IQueryable<Model> query, string value) - /// { - /// // some complex logic goes here... - /// return query.Where(x => x.IsActive == computedValue); - /// } - /// - /// - public virtual QueryFilters GetQueryFilters() => null; - - public object GetCustomQueryFilter(string key) - { - var customFilters = GetQueryFilters(); - if (customFilters != null && customFilters.TryGetValue(key, out var query)) - return query; - return null; - } - - /// - 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; } - - - /// - /// This is an alias type intended to simplify the implementation's - /// method signature. - /// See for usage details. - /// - public sealed class QueryFilters : Dictionary, FilterQuery, IQueryable>> { } - - /// - /// Define the default sort order if no sort key is provided. - /// - /// - /// A list of properties and the direction they should be sorted. - /// - /// - /// - /// public override PropertySortOrder GetDefaultSortOrder() - /// => new PropertySortOrder { - /// (t => t.Prop1, SortDirection.Ascending), - /// (t => t.Prop2, SortDirection.Descending), - /// }; - /// - /// - public virtual PropertySortOrder GetDefaultSortOrder() => null; - - public List<(AttrAttribute Attribute, SortDirection SortDirection)> DefaultSort() - { - 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 order; - } - - 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> Attribute, SortDirection SortDirection)> { } - } -} diff --git a/src/JsonApiDotNetCore/Properties/AssemblyInfo.cs b/src/JsonApiDotNetCore/Properties/AssemblyInfo.cs index 7c21e6f218..aea73b3126 100644 --- a/src/JsonApiDotNetCore/Properties/AssemblyInfo.cs +++ b/src/JsonApiDotNetCore/Properties/AssemblyInfo.cs @@ -1,3 +1,6 @@ -using System.Runtime.CompilerServices; - +using System.Runtime.CompilerServices; + +[assembly:InternalsVisibleTo("Benchmarks")] [assembly: InternalsVisibleTo("IntegrationTests")] +[assembly:InternalsVisibleTo("JsonApiDotNetCoreExampleTests")] +[assembly:InternalsVisibleTo("UnitTests")] diff --git a/src/JsonApiDotNetCore/Queries/ExpressionInScope.cs b/src/JsonApiDotNetCore/Queries/ExpressionInScope.cs new file mode 100644 index 0000000000..b24257a3fc --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/ExpressionInScope.cs @@ -0,0 +1,26 @@ +using System; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; + +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; } + 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/Queries/Expressions/CollectionNotEmptyExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/CollectionNotEmptyExpression.cs new file mode 100644 index 0000000000..2642874112 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Expressions/CollectionNotEmptyExpression.cs @@ -0,0 +1,28 @@ +using System; +using JsonApiDotNetCore.Queries.Internal.Parsing; + +namespace JsonApiDotNetCore.Queries.Expressions +{ + /// + /// Represents the "has" filter function, resulting from text such as: has(articles) + /// + 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/Queries/Expressions/ComparisonExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/ComparisonExpression.cs new file mode 100644 index 0000000000..c017c38afe --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Expressions/ComparisonExpression.cs @@ -0,0 +1,32 @@ +using System; +using Humanizer; + +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; } + 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/Queries/Expressions/ComparisonOperator.cs b/src/JsonApiDotNetCore/Queries/Expressions/ComparisonOperator.cs new file mode 100644 index 0000000000..fbe12f1b0c --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Expressions/ComparisonOperator.cs @@ -0,0 +1,11 @@ +namespace JsonApiDotNetCore.Queries.Expressions +{ + public enum ComparisonOperator + { + Equals, + GreaterThan, + GreaterOrEqual, + LessThan, + LessOrEqual + } +} diff --git a/src/JsonApiDotNetCore/Queries/Expressions/CountExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/CountExpression.cs new file mode 100644 index 0000000000..80473bf3c7 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Expressions/CountExpression.cs @@ -0,0 +1,28 @@ +using System; +using JsonApiDotNetCore.Queries.Internal.Parsing; + +namespace JsonApiDotNetCore.Queries.Expressions +{ + /// + /// Represents the "count" function, resulting from text such as: count(articles) + /// + 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/Queries/Expressions/EqualsAnyOfExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/EqualsAnyOfExpression.cs new file mode 100644 index 0000000000..18671bb8b0 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Expressions/EqualsAnyOfExpression.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using JsonApiDotNetCore.Queries.Internal.Parsing; + +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; } + 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/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/Queries/Expressions/IncludeChainConverter.cs b/src/JsonApiDotNetCore/Queries/Expressions/IncludeChainConverter.cs new file mode 100644 index 0000000000..b46759f85f --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Expressions/IncludeChainConverter.cs @@ -0,0 +1,160 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Queries.Expressions +{ + /// + /// 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. + /// + /// + /// 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; + } + + /// + /// 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(IReadOnlyCollection 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(IReadOnlyCollection 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()).ToArray(); + } + + 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()).ToArray(); + return new IncludeElementExpression(_relationship, elementChildren); + } + } + } +} diff --git a/src/JsonApiDotNetCore/Queries/Expressions/IncludeElementExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/IncludeElementExpression.cs new file mode 100644 index 0000000000..c8ca8c51f1 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Expressions/IncludeElementExpression.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Queries.Expressions +{ + /// + /// Represents an element in . + /// + 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/Queries/Expressions/IncludeExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/IncludeExpression.cs new file mode 100644 index 0000000000..8f2a879952 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Expressions/IncludeExpression.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace JsonApiDotNetCore.Queries.Expressions +{ + /// + /// Represents an inclusion tree, resulting from text such as: owner,articles.revisions + /// + public class IncludeExpression : QueryExpression + { + public IReadOnlyCollection Elements { get; } + + public static readonly IncludeExpression Empty = new IncludeExpression(); + + private IncludeExpression() + { + Elements = Array.Empty(); + } + + public IncludeExpression(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.VisitInclude(this, argument); + } + + public override string ToString() + { + var chains = IncludeChainConverter.GetRelationshipChains(this); + return string.Join(",", chains.Select(child => child.ToString())); + } + } +} diff --git a/src/JsonApiDotNetCore/Queries/Expressions/LiteralConstantExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/LiteralConstantExpression.cs new file mode 100644 index 0000000000..c625f507f2 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Expressions/LiteralConstantExpression.cs @@ -0,0 +1,28 @@ +using System; + +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; } + + 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/Queries/Expressions/LogicalExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/LogicalExpression.cs new file mode 100644 index 0000000000..c97855dc8a --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Expressions/LogicalExpression.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Humanizer; + +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; } + 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/Queries/Expressions/LogicalOperator.cs b/src/JsonApiDotNetCore/Queries/Expressions/LogicalOperator.cs new file mode 100644 index 0000000000..3514820f86 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Expressions/LogicalOperator.cs @@ -0,0 +1,8 @@ +namespace JsonApiDotNetCore.Queries.Expressions +{ + public enum LogicalOperator + { + And, + Or + } +} diff --git a/src/JsonApiDotNetCore/Queries/Expressions/MatchTextExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/MatchTextExpression.cs new file mode 100644 index 0000000000..915dcc16d5 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Expressions/MatchTextExpression.cs @@ -0,0 +1,41 @@ +using System; +using System.Text; +using Humanizer; + +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; } + 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/Queries/Expressions/NotExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/NotExpression.cs new file mode 100644 index 0000000000..14216c0dce --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Expressions/NotExpression.cs @@ -0,0 +1,28 @@ +using System; +using JsonApiDotNetCore.Queries.Internal.Parsing; + +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; } + + 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/Queries/Expressions/NullConstantExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/NullConstantExpression.cs new file mode 100644 index 0000000000..3e69ec907c --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Expressions/NullConstantExpression.cs @@ -0,0 +1,20 @@ +using JsonApiDotNetCore.Queries.Internal.Parsing; + +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) + { + return visitor.VisitNullConstant(this, argument); + } + + public override string ToString() + { + return Keywords.Null; + } + } +} diff --git a/src/JsonApiDotNetCore/Queries/Expressions/PaginationElementQueryStringValueExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/PaginationElementQueryStringValueExpression.cs new file mode 100644 index 0000000000..8d08968d05 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Expressions/PaginationElementQueryStringValueExpression.cs @@ -0,0 +1,27 @@ +namespace JsonApiDotNetCore.Queries.Expressions +{ + /// + /// Represents an element in . + /// + 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/Queries/Expressions/PaginationExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/PaginationExpression.cs new file mode 100644 index 0000000000..3e43d5c429 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Expressions/PaginationExpression.cs @@ -0,0 +1,30 @@ +using System; +using JsonApiDotNetCore.Configuration; + +namespace JsonApiDotNetCore.Queries.Expressions +{ + /// + /// Represents a pagination, produced from . + /// + 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/Queries/Expressions/PaginationQueryStringValueExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/PaginationQueryStringValueExpression.cs new file mode 100644 index 0000000000..848ad6fe12 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Expressions/PaginationQueryStringValueExpression.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +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; } + + 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/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/Queries/Expressions/QueryExpressionVisitor.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs new file mode 100644 index 0000000000..cfe9ad5c73 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs @@ -0,0 +1,118 @@ +namespace JsonApiDotNetCore.Queries.Expressions +{ + /// + /// Implements the visitor design pattern that enables traversing a tree. + /// + public abstract 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 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/Queries/Expressions/QueryStringParameterScopeExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryStringParameterScopeExpression.cs new file mode 100644 index 0000000000..7f21d21e90 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryStringParameterScopeExpression.cs @@ -0,0 +1,29 @@ +using System; + +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; } + 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/Queries/Expressions/QueryableHandlerExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryableHandlerExpression.cs new file mode 100644 index 0000000000..153630a4e0 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryableHandlerExpression.cs @@ -0,0 +1,39 @@ +using System; +using System.Linq; +using JsonApiDotNetCore.Resources; +using Microsoft.Extensions.Primitives; + +namespace JsonApiDotNetCore.Queries.Expressions +{ + /// + /// Holds a expression, used for custom query string handlers from s. + /// + 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/Queries/Expressions/ResourceFieldChainExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/ResourceFieldChainExpression.cs new file mode 100644 index 0000000000..7ef9442241 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Expressions/ResourceFieldChainExpression.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using JsonApiDotNetCore.Resources.Annotations; + +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; } + + 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/Queries/Expressions/SortElementExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/SortElementExpression.cs new file mode 100644 index 0000000000..a7c24fca21 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Expressions/SortElementExpression.cs @@ -0,0 +1,53 @@ +using System; +using System.Text; + +namespace JsonApiDotNetCore.Queries.Expressions +{ + /// + /// Represents an element in . + /// + 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/Queries/Expressions/SortExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/SortExpression.cs new file mode 100644 index 0000000000..65584c1cd1 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Expressions/SortExpression.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace JsonApiDotNetCore.Queries.Expressions +{ + /// + /// Represents a sorting, resulting from text such as: lastName,-lastModifiedAt + /// + 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/Queries/Expressions/SparseFieldSetExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs new file mode 100644 index 0000000000..4e3ae04e73 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Queries.Expressions +{ + /// + /// Represents a sparse fieldset, resulting from text such as: firstName,lastName + /// + 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/Queries/Expressions/SparseFieldSetExpressionExtensions.cs b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpressionExtensions.cs new file mode 100644 index 0000000000..6d3eaecb20 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpressionExtensions.cs @@ -0,0 +1,85 @@ +using System; +using System.Linq; +using System.Linq.Expressions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Queries.Expressions +{ + public static class SparseFieldSetExpressionExtensions + { + public static SparseFieldSetExpression Including(this SparseFieldSetExpression sparseFieldSet, + Expression> attributeSelector, IResourceGraph resourceGraph) + where TResource : class, 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 : class, 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/Queries/Expressions/TextMatchKind.cs b/src/JsonApiDotNetCore/Queries/Expressions/TextMatchKind.cs new file mode 100644 index 0000000000..e51436b252 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Expressions/TextMatchKind.cs @@ -0,0 +1,9 @@ +namespace JsonApiDotNetCore.Queries.Expressions +{ + public enum TextMatchKind + { + Contains, + StartsWith, + EndsWith + } +} diff --git a/src/JsonApiDotNetCore/Queries/IPaginationContext.cs b/src/JsonApiDotNetCore/Queries/IPaginationContext.cs new file mode 100644 index 0000000000..9db3a24c9f --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/IPaginationContext.cs @@ -0,0 +1,34 @@ +using JsonApiDotNetCore.Configuration; + +namespace JsonApiDotNetCore.Queries +{ + /// + /// 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/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..dd2657e6a2 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources.Annotations; + +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); + + /// + /// Wraps a layer for a secondary endpoint into a primary layer, rewriting top-level includes. + /// + QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer, ResourceContext primaryResourceContext, + TId primaryId, RelationshipAttribute secondaryRelationship); + + /// + /// Gets the secondary projection for a relationship endpoint. + /// + IDictionary GetSecondaryProjectionForRelationshipEndpoint( + ResourceContext secondaryResourceContext); + } +} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/FieldChainRequirements.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/FieldChainRequirements.cs new file mode 100644 index 0000000000..443c63a3c0 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/FieldChainRequirements.cs @@ -0,0 +1,34 @@ +using System; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Queries.Internal.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/Queries/Internal/Parsing/FilterParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs new file mode 100644 index 0000000000..0f2904fc9f --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs @@ -0,0 +1,322 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Humanizer; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Queries.Internal.Parsing +{ + public class FilterParser : QueryExpressionParser + { + private readonly IResourceFactory _resourceFactory; + private readonly Action _validateSingleFieldCallback; + private ResourceContext _resourceContextInScope; + + public FilterParser(IResourceContextProvider resourceContextProvider, IResourceFactory resourceFactory, + Action validateSingleFieldCallback = null) + : base(resourceContextProvider) + { + _resourceFactory = resourceFactory ?? throw new ArgumentNullException(nameof(resourceFactory)); + _validateSingleFieldCallback = validateSingleFieldCallback; + } + + public FilterExpression Parse(string source, ResourceContext resourceContextInScope) + { + Tokenize(source); + _resourceContextInScope = resourceContextInScope ?? throw new ArgumentNullException(nameof(resourceContextInScope)); + + 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); + + // Allow equality comparison of a HasOne relationship with null. + 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. + OnResolveFieldChain(leftChain.ToString(), FieldChainRequirements.EndsInAttribute); + } + + PropertyInfo leftProperty = leftChain.Fields.Last().Property; + if (leftProperty.Name == nameof(Identifiable.Id) && rightTerm is LiteralConstantExpression rightConstant) + { + string id = DeObfuscateStringId(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 = DeObfuscateStringId(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."); + } + + 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/Queries/Internal/Parsing/IncludeParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/IncludeParser.cs new file mode 100644 index 0000000000..5510509c97 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/IncludeParser.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Queries.Internal.Parsing +{ + public class IncludeParser : QueryExpressionParser + { + private readonly Action _validateSingleRelationshipCallback; + private ResourceContext _resourceContextInScope; + + public IncludeParser(IResourceContextProvider resourceContextProvider, + Action validateSingleRelationshipCallback = null) + : base(resourceContextProvider) + { + _validateSingleRelationshipCallback = validateSingleRelationshipCallback; + } + + public IncludeExpression Parse(string source, ResourceContext resourceContextInScope, int? maximumDepth) + { + _resourceContextInScope = resourceContextInScope ?? throw new ArgumentNullException(nameof(resourceContextInScope)); + Tokenize(source); + + var expression = ParseInclude(maximumDepth); + + AssertTokenStackIsEmpty(); + + return expression; + } + + protected IncludeExpression ParseInclude(int? maximumDepth) + { + 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); + } + + ValidateMaximumIncludeDepth(maximumDepth, chains); + + return IncludeChainConverter.FromRelationshipChains(chains); + } + + private static void ValidateMaximumIncludeDepth(int? maximumDepth, IReadOnlyCollection 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/Queries/Internal/Parsing/Keywords.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/Keywords.cs new file mode 100644 index 0000000000..c1933152e1 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/Keywords.cs @@ -0,0 +1,21 @@ +namespace JsonApiDotNetCore.Queries.Internal.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/Queries/Internal/Parsing/PaginationParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/PaginationParser.cs new file mode 100644 index 0000000000..765f62e7b1 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/PaginationParser.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Queries.Internal.Parsing +{ + public class PaginationParser : QueryExpressionParser + { + private readonly Action _validateSingleFieldCallback; + private ResourceContext _resourceContextInScope; + + public PaginationParser(IResourceContextProvider resourceContextProvider, + Action validateSingleFieldCallback = null) + : base(resourceContextProvider) + { + _validateSingleFieldCallback = validateSingleFieldCallback; + } + + public PaginationQueryStringValueExpression Parse(string source, ResourceContext resourceContextInScope) + { + _resourceContextInScope = resourceContextInScope ?? throw new ArgumentNullException(nameof(resourceContextInScope)); + Tokenize(source); + + 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; + } + + protected override IReadOnlyCollection OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) + { + return ChainResolver.ResolveToManyChain(_resourceContextInScope, path, _validateSingleFieldCallback); + } + } +} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryExpressionParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryExpressionParser.cs new file mode 100644 index 0000000000..277d18f62d --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryExpressionParser.cs @@ -0,0 +1,95 @@ +using System.Collections.Generic; +using System.Linq; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Queries.Internal.Parsing +{ + /// + /// The base class for parsing query string parameters, using the Recursive Descent algorithm. + /// + /// + /// 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 QueryExpressionParser(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) + { + 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 = OnResolveFieldChain(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/Queries/Internal/Parsing/QueryParseException.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryParseException.cs new file mode 100644 index 0000000000..33a556e6c2 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryParseException.cs @@ -0,0 +1,11 @@ +using System; + +namespace JsonApiDotNetCore.Queries.Internal.Parsing +{ + public sealed class QueryParseException : Exception + { + public QueryParseException(string message) : base(message) + { + } + } +} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryStringParameterScopeParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryStringParameterScopeParser.cs new file mode 100644 index 0000000000..39a00a3098 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryStringParameterScopeParser.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Queries.Internal.Parsing +{ + public class QueryStringParameterScopeParser : QueryExpressionParser + { + 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(string source, ResourceContext resourceContextInScope) + { + _resourceContextInScope = resourceContextInScope ?? throw new ArgumentNullException(nameof(resourceContextInScope)); + Tokenize(source); + + var expression = ParseQueryStringParameterScope(); + + AssertTokenStackIsEmpty(); + + return expression; + } + + protected QueryStringParameterScopeExpression ParseQueryStringParameterScope() + { + 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); + } + + protected override IReadOnlyCollection OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) + { + 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); + } + + 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/Queries/Internal/Parsing/QueryTokenizer.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryTokenizer.cs new file mode 100644 index 0000000000..25e7659001 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryTokenizer.cs @@ -0,0 +1,139 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Text; + +namespace JsonApiDotNetCore.Queries.Internal.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 = ProduceTokenFromTextBuffer(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 = ProduceTokenFromTextBuffer(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 = ProduceTokenFromTextBuffer(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 ProduceTokenFromTextBuffer(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/Queries/Internal/Parsing/ResourceFieldChainResolver.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldChainResolver.cs new file mode 100644 index 0000000000..a28236f0e1 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldChainResolver.cs @@ -0,0 +1,263 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Queries.Internal.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; + + 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/Queries/Internal/Parsing/SortParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/SortParser.cs new file mode 100644 index 0000000000..d8beac4d8f --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/SortParser.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Queries.Internal.Parsing +{ + public class SortParser : QueryExpressionParser + { + private readonly Action _validateSingleFieldCallback; + private ResourceContext _resourceContextInScope; + + public SortParser(IResourceContextProvider resourceContextProvider, + Action validateSingleFieldCallback = null) + : base(resourceContextProvider) + { + _validateSingleFieldCallback = validateSingleFieldCallback; + } + + public SortExpression Parse(string source, ResourceContext resourceContextInScope) + { + _resourceContextInScope = resourceContextInScope ?? throw new ArgumentNullException(nameof(resourceContextInScope)); + Tokenize(source); + + 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); + } + + 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/Queries/Internal/Parsing/SparseFieldSetParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldSetParser.cs new file mode 100644 index 0000000000..e2e205ebf8 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldSetParser.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Queries.Internal.Parsing +{ + public class SparseFieldSetParser : QueryExpressionParser + { + private readonly Action _validateSingleAttributeCallback; + private ResourceContext _resourceContextInScope; + + public SparseFieldSetParser(IResourceContextProvider resourceContextProvider, Action validateSingleAttributeCallback = null) + : base(resourceContextProvider) + { + _validateSingleAttributeCallback = validateSingleAttributeCallback; + } + + public SparseFieldSetExpression Parse(string source, ResourceContext resourceContextInScope) + { + _resourceContextInScope = resourceContextInScope ?? throw new ArgumentNullException(nameof(resourceContextInScope)); + Tokenize(source); + + 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); + } + + 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/Queries/Internal/Parsing/Token.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/Token.cs new file mode 100644 index 0000000000..93995233d2 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/Token.cs @@ -0,0 +1,19 @@ +namespace JsonApiDotNetCore.Queries.Internal.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/Queries/Internal/Parsing/TokenKind.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/TokenKind.cs new file mode 100644 index 0000000000..3658c82f18 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/TokenKind.cs @@ -0,0 +1,15 @@ +namespace JsonApiDotNetCore.Queries.Internal.Parsing +{ + public enum TokenKind + { + OpenParen, + CloseParen, + OpenBracket, + CloseBracket, + Comma, + Colon, + Minus, + Text, + QuotedText + } +} diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs new file mode 100644 index 0000000000..a9061e9380 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs @@ -0,0 +1,325 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Queries.Internal +{ + /// + 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); + topLayer.Include = 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) + { + Filter = GetFilter(expressionsInTopScope, resourceContext), + Sort = GetSort(expressionsInTopScope, resourceContext), + Pagination = ((JsonApiOptions)_options).DisableTopPagination ? null : topPagination, + Projection = GetSparseFieldSetProjection(expressionsInTopScope, resourceContext) + }; + } + + private IncludeExpression ComposeChildren(QueryLayer topLayer, ExpressionInScope[] constraints) + { + 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, ICollection parentRelationshipChain, ExpressionInScope[] constraints) + { + includeElements = GetIncludeElements(includeElements, parentLayer.ResourceContext) ?? Array.Empty(); + + var updatesInChildren = new Dictionary>(); + + foreach (var includeElement in includeElements) + { + parentLayer.Projection ??= new Dictionary(); + + if (!parentLayer.Projection.ContainsKey(includeElement.Relationship)) + { + var relationshipChain = new List(parentRelationshipChain) + { + includeElement.Relationship + }; + + var expressionsInCurrentScope = constraints + .Where(c => c.Scope != null && c.Scope.Fields.SequenceEqual(relationshipChain)) + .Select(expressionInScope => expressionInScope.Expression) + .ToArray(); + + var resourceContext = + _resourceContextProvider.GetResourceContext(includeElement.Relationship.RightType); + + var child = new QueryLayer(resourceContext) + { + Filter = GetFilter(expressionsInCurrentScope, resourceContext), + Sort = GetSort(expressionsInCurrentScope, resourceContext), + Pagination = ((JsonApiOptions) _options).DisableChildrenPagination + ? null + : GetPagination(expressionsInCurrentScope, resourceContext), + Projection = GetSparseFieldSetProjection(expressionsInCurrentScope, resourceContext) + }; + + parentLayer.Projection.Add(includeElement.Relationship, child); + + if (includeElement.Children.Any()) + { + var updatedChildren = ProcessIncludeSet(includeElement.Children, child, relationshipChain, constraints); + + 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; + } + + /// + public QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer, ResourceContext primaryResourceContext, TId primaryId, RelationshipAttribute secondaryRelationship) + { + var innerInclude = secondaryLayer.Include; + secondaryLayer.Include = null; + + var primaryIdAttribute = primaryResourceContext.Attributes.Single(x => x.Property.Name == nameof(Identifiable.Id)); + var sparseFieldSet = new SparseFieldSetExpression(new[] { primaryIdAttribute }); + + var primaryProjection = GetSparseFieldSetProjection(new[] { sparseFieldSet }, primaryResourceContext) ?? new Dictionary(); + primaryProjection[secondaryRelationship] = secondaryLayer; + primaryProjection[primaryIdAttribute] = null; + + return new QueryLayer(primaryResourceContext) + { + Include = RewriteIncludeForSecondaryEndpoint(innerInclude, secondaryRelationship), + Filter = CreateFilterById(primaryId, primaryResourceContext), + Projection = primaryProjection + }; + } + + private IncludeExpression RewriteIncludeForSecondaryEndpoint(IncludeExpression relativeInclude, RelationshipAttribute secondaryRelationship) + { + var parentElement = relativeInclude != null + ? new IncludeElementExpression(secondaryRelationship, relativeInclude.Elements) + : new IncludeElementExpression(secondaryRelationship); + + return new IncludeExpression(new[] {parentElement}); + } + + private FilterExpression CreateFilterById(TId id, ResourceContext resourceContext) + { + var primaryIdAttribute = resourceContext.Attributes.Single(a => a.Property.Name == nameof(Identifiable.Id)); + + return new ComparisonExpression(ComparisonOperator.Equals, + new ResourceFieldChainExpression(primaryIdAttribute), new LiteralConstantExpression(id.ToString())); + } + + public IDictionary GetSecondaryProjectionForRelationshipEndpoint(ResourceContext secondaryResourceContext) + { + var secondaryIdAttribute = secondaryResourceContext.Attributes.Single(a => a.Property.Name == nameof(Identifiable.Id)); + var sparseFieldSet = new SparseFieldSetExpression(new[] { secondaryIdAttribute }); + + var secondaryProjection = GetSparseFieldSetProjection(new[] { sparseFieldSet }, secondaryResourceContext) ?? new Dictionary(); + secondaryProjection[secondaryIdAttribute] = null; + + return secondaryProjection; + } + + protected virtual IReadOnlyCollection GetIncludeElements(IReadOnlyCollection includeElements, ResourceContext resourceContext) + { + if (resourceContext == null) throw new ArgumentNullException(nameof(resourceContext)); + + var resourceDefinition = _resourceDefinitionProvider.Get(resourceContext.ResourceType); + if (resourceDefinition != null) + { + includeElements = resourceDefinition.OnApplyIncludes(includeElements); + } + + return includeElements; + } + + protected virtual FilterExpression GetFilter(IReadOnlyCollection expressionsInScope, ResourceContext resourceContext) + { + if (expressionsInScope == null) throw new ArgumentNullException(nameof(expressionsInScope)); + if (resourceContext == null) throw new ArgumentNullException(nameof(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(IReadOnlyCollection expressionsInScope, ResourceContext resourceContext) + { + if (expressionsInScope == null) throw new ArgumentNullException(nameof(expressionsInScope)); + if (resourceContext == null) throw new ArgumentNullException(nameof(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(IReadOnlyCollection expressionsInScope, ResourceContext resourceContext) + { + if (expressionsInScope == null) throw new ArgumentNullException(nameof(expressionsInScope)); + if (resourceContext == null) throw new ArgumentNullException(nameof(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(IReadOnlyCollection expressionsInScope, ResourceContext resourceContext) + { + if (expressionsInScope == null) throw new ArgumentNullException(nameof(expressionsInScope)); + if (resourceContext == null) throw new ArgumentNullException(nameof(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()) + { + 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/Queries/Internal/QueryableBuilding/IncludeClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/IncludeClauseBuilder.cs new file mode 100644 index 0000000000..09a6f42655 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/IncludeClauseBuilder.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources.Annotations; +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding +{ + /// + /// Transforms into calls. + /// + 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 IncludeChainConverter.GetRelationshipChains(expression)) + { + 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/Queries/Internal/QueryableBuilding/LambdaParameterNameFactory.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaParameterNameFactory.cs new file mode 100644 index 0000000000..341dd2a23e --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaParameterNameFactory.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using Humanizer; + +namespace JsonApiDotNetCore.Queries.Internal.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/Queries/Internal/QueryableBuilding/LambdaParameterNameScope.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaParameterNameScope.cs new file mode 100644 index 0000000000..e443997ca1 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaParameterNameScope.cs @@ -0,0 +1,22 @@ +using System; + +namespace JsonApiDotNetCore.Queries.Internal.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/Queries/Internal/QueryableBuilding/LambdaScope.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScope.cs new file mode 100644 index 0000000000..8555fffc43 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScope.cs @@ -0,0 +1,47 @@ +using System; +using System.Linq.Expressions; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Queries.Internal.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; + + public ParameterExpression Parameter { get; } + public Expression Accessor { get; } + public HasManyThroughAttribute HasManyThrough { get; } + + public LambdaScope(LambdaParameterNameFactory nameFactory, Type elementType, Expression accessorExpression, HasManyThroughAttribute hasManyThrough) + { + if (nameFactory == null) throw new ArgumentNullException(nameof(nameFactory)); + if (elementType == null) throw new ArgumentNullException(nameof(elementType)); + + _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/Queries/Internal/QueryableBuilding/LambdaScopeFactory.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScopeFactory.cs new file mode 100644 index 0000000000..b0ae0467b8 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScopeFactory.cs @@ -0,0 +1,28 @@ +using System; +using System.Linq.Expressions; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Queries.Internal.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/Queries/Internal/QueryableBuilding/OrderClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/OrderClauseBuilder.cs new file mode 100644 index 0000000000..2d7d72331e --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/OrderClauseBuilder.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding +{ + /// + /// Transforms into calls. + /// + 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).ToArray(); + + return CreatePropertyExpressionFromComponents(LambdaScope.Accessor, components); + } + } +} diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryClauseBuilder.cs new file mode 100644 index 0000000000..701a867812 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryClauseBuilder.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding +{ + /// + /// Base class for transforming trees into system trees. + /// + 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) + { + foreach (var item in collectionExpression.Type.GetInterfaces().SelectMany(i => i.GetProperties())) + { + properties.Add(item); + } + } + + 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).ToArray(); + + return CreatePropertyExpressionFromComponents(source, components); + } + + protected static MemberExpression CreatePropertyExpressionFromComponents(Expression source, IReadOnlyCollection 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/Queries/Internal/QueryableBuilding/QueryableBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryableBuilder.cs new file mode 100644 index 0000000000..ac64c6c15e --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryableBuilder.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding +{ + /// + /// Drives conversion from into system trees. + /// + 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) + { + if (layer == null) throw new ArgumentNullException(nameof(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/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs new file mode 100644 index 0000000000..3d45eafdb0 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs @@ -0,0 +1,236 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding +{ + /// + /// Transforms into calls. + /// + 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 = TypeHelper.ToConcreteCollectionType(collectionProperty.PropertyType); + + 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/Queries/Internal/QueryableBuilding/SkipTakeClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SkipTakeClauseBuilder.cs new file mode 100644 index 0000000000..96c94a9cb6 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SkipTakeClauseBuilder.cs @@ -0,0 +1,62 @@ +using System; +using System.Linq; +using System.Linq.Expressions; +using JsonApiDotNetCore.Queries.Expressions; + +namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding +{ + /// + /// Transforms into and calls. + /// + 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/Queries/Internal/QueryableBuilding/WhereClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/WhereClauseBuilder.cs new file mode 100644 index 0000000000..9ed848053f --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/WhereClauseBuilder.cs @@ -0,0 +1,288 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding +{ + /// + /// Transforms into calls. + /// + 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).ToArray(); + + return CreatePropertyExpressionFromComponents(LambdaScope.Accessor, components); + } + } +} diff --git a/src/JsonApiDotNetCore/Queries/PaginationContext.cs b/src/JsonApiDotNetCore/Queries/PaginationContext.cs new file mode 100644 index 0000000000..fbdd8ad453 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/PaginationContext.cs @@ -0,0 +1,23 @@ +using System; +using JsonApiDotNetCore.Configuration; + +namespace JsonApiDotNetCore.Queries +{ + /// + 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/Queries/QueryLayer.cs b/src/JsonApiDotNetCore/Queries/QueryLayer.cs new file mode 100644 index 0000000000..74d8d5f043 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/QueryLayer.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Queries +{ + /// + /// A nested data structure that contains constraints per resource type. + /// + 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/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 a2f1bb6425..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; - -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 => a.Is(target)) - : _requestResource.Attributes.FirstOrDefault(attr => attr.Is(target)); - - 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 => r.Is(propertyName)); - 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/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 0de79a3d17..0000000000 --- a/src/JsonApiDotNetCore/QueryParameterServices/Contracts/IIncludeService.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Collections.Generic; -using JsonApiDotNetCore.Models; - -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/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 99e5232598..0000000000 --- a/src/JsonApiDotNetCore/QueryParameterServices/Contracts/ISparseFieldsService.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Collections.Generic; -using JsonApiDotNetCore.Models; - -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/DefaultsService.cs b/src/JsonApiDotNetCore/QueryParameterServices/DefaultsService.cs deleted file mode 100644 index aa8c597884..0000000000 --- a/src/JsonApiDotNetCore/QueryParameterServices/DefaultsService.cs +++ /dev/null @@ -1,49 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Exceptions; -using Microsoft.Extensions.Primitives; -using Newtonsoft.Json; - -namespace JsonApiDotNetCore.Query -{ - /// - public class DefaultsService : QueryParameterService, IDefaultsService - { - private readonly IJsonApiOptions _options; - - public DefaultsService(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) - { - return parameterName == "defaults"; - } - - /// - public virtual void Parse(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."); - } - - SerializerDefaultValueHandling = result ? DefaultValueHandling.Include : DefaultValueHandling.Ignore; - } - } -} diff --git a/src/JsonApiDotNetCore/QueryParameterServices/FilterService.cs b/src/JsonApiDotNetCore/QueryParameterServices/FilterService.cs deleted file mode 100644 index ae9fbfaa15..0000000000 --- a/src/JsonApiDotNetCore/QueryParameterServices/FilterService.cs +++ /dev/null @@ -1,140 +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 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.PublicRelationshipName}.{attribute.PublicAttributeName}' 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."); - } - - 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 f00bd9680b..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; -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.PublicRelationshipName == 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/NullsService.cs b/src/JsonApiDotNetCore/QueryParameterServices/NullsService.cs deleted file mode 100644 index df3f06a14e..0000000000 --- a/src/JsonApiDotNetCore/QueryParameterServices/NullsService.cs +++ /dev/null @@ -1,49 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Exceptions; -using Microsoft.Extensions.Primitives; -using Newtonsoft.Json; - -namespace JsonApiDotNetCore.Query -{ - /// - public class NullsService : QueryParameterService, INullsService - { - private readonly IJsonApiOptions _options; - - public NullsService(IJsonApiOptions options) - { - SerializerNullValueHandling = options.SerializerSettings.NullValueHandling; - _options = options; - } - - /// - public NullValueHandling SerializerNullValueHandling { get; private set; } - - /// - public bool IsEnabled(DisableQueryAttribute disableQueryAttribute) - { - return _options.AllowQueryStringOverrideForSerializerNullValueHandling && - !disableQueryAttribute.ContainsParameter(StandardQueryStringParameters.Nulls); - } - - /// - public bool CanParse(string parameterName) - { - return parameterName == "nulls"; - } - - /// - public virtual void Parse(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."); - } - - SerializerNullValueHandling = result ? NullValueHandling.Include : NullValueHandling.Ignore; - } - } -} 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 e5a4a76cf7..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.PublicAttributeName, 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.PublicAttributeName}' 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 bafa6976d4..0000000000 --- a/src/JsonApiDotNetCore/QueryParameterServices/SparseFieldsService.cs +++ /dev/null @@ -1,183 +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 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.PropertyInfo.Name)); - - foreach (var pair in _selectedRelationshipFields) - { - string pathPrefix = pair.Key.RelationshipPath + "."; - properties.AddRange(pair.Value.Select(x => pathPrefix + x.PropertyInfo.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 => a.Is(navigation))) - { - 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 => a.Is(navigation)); - 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 => a.Is(field)); - 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}'."); - } - - if (attr.PropertyInfo.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 => a.Is(field)); - 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.PropertyInfo.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/QueryParameterServices/Contracts/IDefaultsService.cs b/src/JsonApiDotNetCore/QueryStrings/IDefaultsQueryStringParameterReader.cs similarity index 51% rename from src/JsonApiDotNetCore/QueryParameterServices/Contracts/IDefaultsService.cs rename to src/JsonApiDotNetCore/QueryStrings/IDefaultsQueryStringParameterReader.cs index 0d69326296..176bf69bda 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/Contracts/IDefaultsService.cs +++ b/src/JsonApiDotNetCore/QueryStrings/IDefaultsQueryStringParameterReader.cs @@ -1,14 +1,14 @@ using Newtonsoft.Json; -namespace JsonApiDotNetCore.Query +namespace JsonApiDotNetCore.QueryStrings { /// - /// Query parameter service responsible for url queries of the form ?defaults=false + /// Reads the 'defaults' query string parameter. /// - public interface IDefaultsService : IQueryParameterService + public interface IDefaultsQueryStringParameterReader : IQueryStringParameterReader { /// - /// Contains the effective value of default configuration and query string override, after parsing has occured. + /// Contains the effective value of default configuration and query string override, after parsing has occurred. /// 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/QueryParameterServices/Contracts/INullsService.cs b/src/JsonApiDotNetCore/QueryStrings/INullsQueryStringParameterReader.cs similarity index 51% rename from src/JsonApiDotNetCore/QueryParameterServices/Contracts/INullsService.cs rename to src/JsonApiDotNetCore/QueryStrings/INullsQueryStringParameterReader.cs index 038eaa7153..e1885925e5 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/Contracts/INullsService.cs +++ b/src/JsonApiDotNetCore/QueryStrings/INullsQueryStringParameterReader.cs @@ -1,14 +1,14 @@ using Newtonsoft.Json; -namespace JsonApiDotNetCore.Query +namespace JsonApiDotNetCore.QueryStrings { /// - /// Query parameter service responsible for url queries of the form ?nulls=false + /// Reads the 'nulls' query string parameter. /// - public interface INullsService : IQueryParameterService + public interface INullsQueryStringParameterReader : IQueryStringParameterReader { /// - /// Contains the effective value of default configuration and query string override, after parsing has occured. + /// Contains the effective value of default configuration and query string override, after parsing has occurred. /// 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/QueryStrings/IQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/IQueryStringParameterReader.cs new file mode 100644 index 0000000000..b07399b98c --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/IQueryStringParameterReader.cs @@ -0,0 +1,26 @@ +using JsonApiDotNetCore.Controllers.Annotations; +using Microsoft.Extensions.Primitives; + +namespace JsonApiDotNetCore.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(DisableQueryStringAttribute disableQueryStringAttribute); + + /// + /// 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/QueryStrings/IQueryStringReader.cs b/src/JsonApiDotNetCore/QueryStrings/IQueryStringReader.cs new file mode 100644 index 0000000000..c26b14bd1e --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/IQueryStringReader.cs @@ -0,0 +1,18 @@ +using JsonApiDotNetCore.Controllers.Annotations; + +namespace JsonApiDotNetCore.QueryStrings +{ + /// + /// Reads and processes the various query string parameters for a HTTP request. + /// + 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(DisableQueryStringAttribute disableQueryStringAttribute); + } +} diff --git a/src/JsonApiDotNetCore/QueryParameterServices/Common/IRequestQueryStringAccessor.cs b/src/JsonApiDotNetCore/QueryStrings/IRequestQueryStringAccessor.cs similarity index 54% rename from src/JsonApiDotNetCore/QueryParameterServices/Common/IRequestQueryStringAccessor.cs rename to src/JsonApiDotNetCore/QueryStrings/IRequestQueryStringAccessor.cs index 8ec146a292..c94d5dd533 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/Common/IRequestQueryStringAccessor.cs +++ b/src/JsonApiDotNetCore/QueryStrings/IRequestQueryStringAccessor.cs @@ -1,7 +1,10 @@ using Microsoft.AspNetCore.Http; -namespace JsonApiDotNetCore.QueryParameterServices.Common +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..d2531b6918 --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/IResourceDefinitionQueryableParameterReader.cs @@ -0,0 +1,13 @@ +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Resources; + +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/QueryStrings/Internal/DefaultsQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/DefaultsQueryStringParameterReader.cs new file mode 100644 index 0000000000..776b9559cb --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/DefaultsQueryStringParameterReader.cs @@ -0,0 +1,52 @@ +using System; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers.Annotations; +using JsonApiDotNetCore.Errors; +using Microsoft.Extensions.Primitives; +using Newtonsoft.Json; + +namespace JsonApiDotNetCore.QueryStrings.Internal +{ + /// + public class DefaultsQueryStringParameterReader : IDefaultsQueryStringParameterReader + { + private readonly IJsonApiOptions _options; + + /// + public DefaultValueHandling SerializerDefaultValueHandling { get; private set; } + + public DefaultsQueryStringParameterReader(IJsonApiOptions options) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + SerializerDefaultValueHandling = options.SerializerSettings.DefaultValueHandling; + } + + /// + public virtual bool IsEnabled(DisableQueryStringAttribute disableQueryStringAttribute) + { + if (disableQueryStringAttribute == null) throw new ArgumentNullException(nameof(disableQueryStringAttribute)); + + return _options.AllowQueryStringOverrideForSerializerDefaultValueHandling && + !disableQueryStringAttribute.ContainsParameter(StandardQueryStringParameters.Defaults); + } + + /// + public virtual bool CanRead(string parameterName) + { + return parameterName == "defaults"; + } + + /// + public virtual void Read(string parameterName, StringValues parameterValue) + { + if (!bool.TryParse(parameterValue, out var result)) + { + throw new InvalidQueryStringParameterException(parameterName, + "The specified defaults is invalid.", + $"The value '{parameterValue}' must be 'true' or 'false'."); + } + + SerializerDefaultValueHandling = result ? DefaultValueHandling.Include : DefaultValueHandling.Ignore; + } + } +} diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/FilterQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/FilterQueryStringParameterReader.cs new file mode 100644 index 0000000000..87bb7f3554 --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/FilterQueryStringParameterReader.cs @@ -0,0 +1,154 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers.Annotations; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Internal.Parsing; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using Microsoft.Extensions.Primitives; + +namespace JsonApiDotNetCore.QueryStrings.Internal +{ + public class FilterQueryStringParameterReader : QueryStringParameterReader, IFilterQueryStringParameterReader + { + private static readonly LegacyFilterNotationConverter _legacyConverter = new LegacyFilterNotationConverter(); + + 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; + + public FilterQueryStringParameterReader(IJsonApiRequest request, + IResourceContextProvider resourceContextProvider, IResourceFactory resourceFactory, IJsonApiOptions options) + : base(request, resourceContextProvider) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + _scopeParser = new QueryStringParameterScopeParser(resourceContextProvider, FieldChainRequirements.EndsInToMany); + _filterParser = new FilterParser(resourceContextProvider, resourceFactory, ValidateSingleField); + } + + protected 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 virtual bool IsEnabled(DisableQueryStringAttribute disableQueryStringAttribute) + { + if (disableQueryStringAttribute == null) throw new ArgumentNullException(nameof(disableQueryStringAttribute)); + + return !disableQueryStringAttribute.ContainsParameter(StandardQueryStringParameters.Filter); + } + + /// + public virtual bool CanRead(string parameterName) + { + var isNested = parameterName.StartsWith("filter[", StringComparison.Ordinal) && parameterName.EndsWith("]", StringComparison.Ordinal); + return parameterName == "filter" || isNested; + } + + /// + public virtual 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 parameterScope = _scopeParser.Parse(parameterName, RequestResource); + + if (parameterScope.Scope == null) + { + AssertIsCollectionRequest(); + } + + return parameterScope.Scope; + } + + private FilterExpression GetFilter(string parameterValue, ResourceFieldChainExpression scope) + { + ResourceContext resourceContextInScope = GetResourceContextForScope(scope); + return _filterParser.Parse(parameterValue, resourceContextInScope); + } + + 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 virtual IReadOnlyCollection GetConstraints() + { + return EnumerateFiltersInScopes().ToArray(); + } + + 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/QueryStrings/Internal/IncludeQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/IncludeQueryStringParameterReader.cs new file mode 100644 index 0000000000..c6d2767ad6 --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/IncludeQueryStringParameterReader.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers.Annotations; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Internal.Parsing; +using JsonApiDotNetCore.Resources.Annotations; +using Microsoft.Extensions.Primitives; + +namespace JsonApiDotNetCore.QueryStrings.Internal +{ + public class IncludeQueryStringParameterReader : QueryStringParameterReader, IIncludeQueryStringParameterReader + { + private readonly IJsonApiOptions _options; + private readonly IncludeParser _includeParser; + + private IncludeExpression _includeExpression; + private string _lastParameterName; + + public IncludeQueryStringParameterReader(IJsonApiRequest request, IResourceContextProvider resourceContextProvider, IJsonApiOptions options) + : base(request, resourceContextProvider) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + _includeParser = new IncludeParser(resourceContextProvider, ValidateSingleRelationship); + } + + protected 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 virtual bool IsEnabled(DisableQueryStringAttribute disableQueryStringAttribute) + { + if (disableQueryStringAttribute == null) throw new ArgumentNullException(nameof(disableQueryStringAttribute)); + + return !disableQueryStringAttribute.ContainsParameter(StandardQueryStringParameters.Include); + } + + /// + public virtual bool CanRead(string parameterName) + { + return parameterName == "include"; + } + + /// + public virtual 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) + { + return _includeParser.Parse(parameterValue, RequestResource, _options.MaximumIncludeDepth); + } + + /// + public virtual IReadOnlyCollection GetConstraints() + { + var expressionInScope = _includeExpression != null + ? new ExpressionInScope(null, _includeExpression) + : new ExpressionInScope(null, IncludeExpression.Empty); + + return new[] {expressionInScope}; + } + } +} diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/LegacyFilterNotationConverter.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/LegacyFilterNotationConverter.cs new file mode 100644 index 0000000000..6234625424 --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/LegacyFilterNotationConverter.cs @@ -0,0 +1,121 @@ +using System; +using System.Collections.Generic; +using JsonApiDotNetCore.Queries.Internal.Parsing; + +namespace JsonApiDotNetCore.QueryStrings.Internal +{ + 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 (parameterName == null) throw new ArgumentNullException(nameof(parameterName)); + if (parameterValue == null) throw new ArgumentNullException(nameof(parameterValue)); + + if (parameterValue.StartsWith(ExpressionPrefix, StringComparison.Ordinal)) + { + string expression = parameterValue.Substring(ExpressionPrefix.Length); + return (parameterName, expression); + } + + var attributeName = ExtractAttributeName(parameterName); + + foreach (var (prefix, keyword) in _prefixConversionTable) + { + if (parameterValue.StartsWith(prefix, StringComparison.Ordinal)) + { + var value = parameterValue.Substring(prefix.Length); + string escapedValue = EscapeQuotes(value); + string expression = $"{keyword}({attributeName},'{escapedValue}')"; + + return (OutputParameterName, expression); + } + } + + if (parameterValue.StartsWith(NotEqualsPrefix, StringComparison.Ordinal)) + { + 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, StringComparison.Ordinal)) + { + 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, StringComparison.Ordinal)) + { + 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, StringComparison.Ordinal) && parameterName.EndsWith(ParameterNameSuffix, StringComparison.Ordinal)) + { + 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/QueryStrings/Internal/NullsQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/NullsQueryStringParameterReader.cs new file mode 100644 index 0000000000..f181f5777b --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/NullsQueryStringParameterReader.cs @@ -0,0 +1,52 @@ +using System; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers.Annotations; +using JsonApiDotNetCore.Errors; +using Microsoft.Extensions.Primitives; +using Newtonsoft.Json; + +namespace JsonApiDotNetCore.QueryStrings.Internal +{ + /// + public class NullsQueryStringParameterReader : INullsQueryStringParameterReader + { + private readonly IJsonApiOptions _options; + + /// + public NullValueHandling SerializerNullValueHandling { get; private set; } + + public NullsQueryStringParameterReader(IJsonApiOptions options) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + SerializerNullValueHandling = options.SerializerSettings.NullValueHandling; + } + + /// + public virtual bool IsEnabled(DisableQueryStringAttribute disableQueryStringAttribute) + { + if (disableQueryStringAttribute == null) throw new ArgumentNullException(nameof(disableQueryStringAttribute)); + + return _options.AllowQueryStringOverrideForSerializerNullValueHandling && + !disableQueryStringAttribute.ContainsParameter(StandardQueryStringParameters.Nulls); + } + + /// + public virtual bool CanRead(string parameterName) + { + return parameterName == "nulls"; + } + + /// + public virtual void Read(string parameterName, StringValues parameterValue) + { + if (!bool.TryParse(parameterValue, out var result)) + { + throw new InvalidQueryStringParameterException(parameterName, + "The specified nulls is invalid.", + $"The value '{parameterValue}' must be 'true' or 'false'."); + } + + SerializerNullValueHandling = result ? NullValueHandling.Include : NullValueHandling.Ignore; + } + } +} diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/PaginationQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/PaginationQueryStringParameterReader.cs new file mode 100644 index 0000000000..b7b8148971 --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/PaginationQueryStringParameterReader.cs @@ -0,0 +1,203 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers.Annotations; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Internal.Parsing; +using Microsoft.Extensions.Primitives; + +namespace JsonApiDotNetCore.QueryStrings.Internal +{ + public class PaginationQueryStringParameterReader : QueryStringParameterReader, IPaginationQueryStringParameterReader + { + private const string PageSizeParameterName = "page[size]"; + private const string PageNumberParameterName = "page[number]"; + + private readonly IJsonApiOptions _options; + private readonly PaginationParser _paginationParser; + + private PaginationQueryStringValueExpression _pageSizeConstraint; + private PaginationQueryStringValueExpression _pageNumberConstraint; + + public PaginationQueryStringParameterReader(IJsonApiRequest request, IResourceContextProvider resourceContextProvider, IJsonApiOptions options) + : base(request, resourceContextProvider) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + _paginationParser = new PaginationParser(resourceContextProvider); + } + + /// + public virtual bool IsEnabled(DisableQueryStringAttribute disableQueryStringAttribute) + { + if (disableQueryStringAttribute == null) throw new ArgumentNullException(nameof(disableQueryStringAttribute)); + + return !disableQueryStringAttribute.ContainsParameter(StandardQueryStringParameters.Page); + } + + /// + public virtual bool CanRead(string parameterName) + { + return parameterName == PageSizeParameterName || parameterName == PageNumberParameterName; + } + + /// + public virtual 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) + { + return _paginationParser.Parse(parameterValue, RequestResource); + } + + protected virtual 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."); + } + } + + protected virtual 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 virtual 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().ToArray(); + } + + 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/QueryStrings/Internal/QueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/QueryStringParameterReader.cs new file mode 100644 index 0000000000..453565eab5 --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/QueryStringParameterReader.cs @@ -0,0 +1,51 @@ +using System; +using System.Linq; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Internal.Parsing; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.QueryStrings.Internal +{ + public abstract class QueryStringParameterReader + { + private readonly IResourceContextProvider _resourceContextProvider; + private readonly bool _isCollectionRequest; + + protected ResourceContext RequestResource { get; } + + protected QueryStringParameterReader(IJsonApiRequest request, IResourceContextProvider resourceContextProvider) + { + if (request == null) + { + throw new ArgumentNullException(nameof(request)); + } + + _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); + _isCollectionRequest = request.IsCollection; + RequestResource = request.SecondaryResource ?? request.PrimaryResource; + } + + 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/QueryStrings/Internal/QueryStringReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/QueryStringReader.cs new file mode 100644 index 0000000000..1716e451ca --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/QueryStringReader.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers.Annotations; +using JsonApiDotNetCore.Errors; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCore.QueryStrings.Internal +{ + /// + 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) + { + if (loggerFactory == null) throw new ArgumentNullException(nameof(loggerFactory)); + + _options = options ?? throw new ArgumentNullException(nameof(options)); + _queryStringAccessor = queryStringAccessor ?? throw new ArgumentNullException(nameof(queryStringAccessor)); + _parameterReaders = parameterReaders ?? throw new ArgumentNullException(nameof(parameterReaders)); + + _logger = loggerFactory.CreateLogger(); + } + + /// + public virtual void ReadAll(DisableQueryStringAttribute disableQueryStringAttribute) + { + disableQueryStringAttribute ??= DisableQueryStringAttribute.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(disableQueryStringAttribute)) + { + 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/QueryStrings/Internal/RequestQueryStringAccessor.cs similarity index 70% rename from src/JsonApiDotNetCore/QueryParameterServices/Common/RequestQueryStringAccessor.cs rename to src/JsonApiDotNetCore/QueryStrings/Internal/RequestQueryStringAccessor.cs index 974e79bc5e..5c2fd09c35 100644 --- a/src/JsonApiDotNetCore/QueryParameterServices/Common/RequestQueryStringAccessor.cs +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/RequestQueryStringAccessor.cs @@ -1,7 +1,9 @@ +using System; using Microsoft.AspNetCore.Http; -namespace JsonApiDotNetCore.QueryParameterServices.Common +namespace JsonApiDotNetCore.QueryStrings.Internal { + /// internal sealed class RequestQueryStringAccessor : IRequestQueryStringAccessor { private readonly IHttpContextAccessor _httpContextAccessor; @@ -11,7 +13,7 @@ internal sealed class RequestQueryStringAccessor : IRequestQueryStringAccessor public RequestQueryStringAccessor(IHttpContextAccessor httpContextAccessor) { - _httpContextAccessor = httpContextAccessor; + _httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor)); } } } diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/ResourceDefinitionQueryableParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/ResourceDefinitionQueryableParameterReader.cs new file mode 100644 index 0000000000..acce8a5148 --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/ResourceDefinitionQueryableParameterReader.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using JsonApiDotNetCore.Controllers.Annotations; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources; +using Microsoft.Extensions.Primitives; + +namespace JsonApiDotNetCore.QueryStrings.Internal +{ + /// + public class ResourceDefinitionQueryableParameterReader : IResourceDefinitionQueryableParameterReader + { + private readonly IJsonApiRequest _request; + private readonly IResourceDefinitionProvider _resourceDefinitionProvider; + private readonly List _constraints = new List(); + + public ResourceDefinitionQueryableParameterReader(IJsonApiRequest request, IResourceDefinitionProvider resourceDefinitionProvider) + { + _request = request ?? throw new ArgumentNullException(nameof(request)); + _resourceDefinitionProvider = resourceDefinitionProvider ?? throw new ArgumentNullException(nameof(resourceDefinitionProvider)); + } + + /// + public virtual bool IsEnabled(DisableQueryStringAttribute disableQueryStringAttribute) + { + return true; + } + + /// + public virtual bool CanRead(string parameterName) + { + var queryableHandler = GetQueryableHandler(parameterName); + return queryableHandler != null; + } + + /// + public virtual 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 (_request.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 = _request.PrimaryResource.ResourceType; + var resourceDefinition = _resourceDefinitionProvider.Get(resourceType); + return resourceDefinition?.GetQueryableHandlerForQueryStringParameter(parameterName); + } + + /// + public virtual IReadOnlyCollection GetConstraints() + { + return _constraints; + } + } +} diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/SortQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/SortQueryStringParameterReader.cs new file mode 100644 index 0000000000..514f2ba235 --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/SortQueryStringParameterReader.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers.Annotations; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Internal.Parsing; +using JsonApiDotNetCore.Resources.Annotations; +using Microsoft.Extensions.Primitives; + +namespace JsonApiDotNetCore.QueryStrings.Internal +{ + public class SortQueryStringParameterReader : QueryStringParameterReader, ISortQueryStringParameterReader + { + private readonly QueryStringParameterScopeParser _scopeParser; + private readonly SortParser _sortParser; + private readonly List _constraints = new List(); + private string _lastParameterName; + + public SortQueryStringParameterReader(IJsonApiRequest request, IResourceContextProvider resourceContextProvider) + : base(request, resourceContextProvider) + { + _scopeParser = new QueryStringParameterScopeParser(resourceContextProvider, FieldChainRequirements.EndsInToMany); + _sortParser = new SortParser(resourceContextProvider, ValidateSingleField); + } + + protected 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 virtual bool IsEnabled(DisableQueryStringAttribute disableQueryStringAttribute) + { + if (disableQueryStringAttribute == null) throw new ArgumentNullException(nameof(disableQueryStringAttribute)); + + return !disableQueryStringAttribute.ContainsParameter(StandardQueryStringParameters.Sort); + } + + /// + public virtual bool CanRead(string parameterName) + { + var isNested = parameterName.StartsWith("sort[", StringComparison.Ordinal) && parameterName.EndsWith("]", StringComparison.Ordinal); + return parameterName == "sort" || isNested; + } + + /// + public virtual 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 parameterScope = _scopeParser.Parse(parameterName, RequestResource); + + if (parameterScope.Scope == null) + { + AssertIsCollectionRequest(); + } + + return parameterScope.Scope; + } + + private SortExpression GetSort(string parameterValue, ResourceFieldChainExpression scope) + { + ResourceContext resourceContextInScope = GetResourceContextForScope(scope); + return _sortParser.Parse(parameterValue, resourceContextInScope); + } + + /// + public virtual IReadOnlyCollection GetConstraints() + { + return _constraints; + } + } +} diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/SparseFieldSetQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/SparseFieldSetQueryStringParameterReader.cs new file mode 100644 index 0000000000..686c890d3c --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/SparseFieldSetQueryStringParameterReader.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers.Annotations; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Internal.Parsing; +using JsonApiDotNetCore.Resources.Annotations; +using Microsoft.Extensions.Primitives; + +namespace JsonApiDotNetCore.QueryStrings.Internal +{ + public class SparseFieldSetQueryStringParameterReader : QueryStringParameterReader, ISparseFieldSetQueryStringParameterReader + { + private readonly QueryStringParameterScopeParser _scopeParser; + private readonly SparseFieldSetParser _sparseFieldSetParser; + private readonly List _constraints = new List(); + private string _lastParameterName; + + public SparseFieldSetQueryStringParameterReader(IJsonApiRequest request, IResourceContextProvider resourceContextProvider) + : base(request, resourceContextProvider) + { + _sparseFieldSetParser = new SparseFieldSetParser(resourceContextProvider, ValidateSingleAttribute); + _scopeParser = new QueryStringParameterScopeParser(resourceContextProvider, FieldChainRequirements.IsRelationship); + } + + protected 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 virtual bool IsEnabled(DisableQueryStringAttribute disableQueryStringAttribute) + { + if (disableQueryStringAttribute == null) throw new ArgumentNullException(nameof(disableQueryStringAttribute)); + + return !disableQueryStringAttribute.ContainsParameter(StandardQueryStringParameters.Fields); + } + + /// + public virtual bool CanRead(string parameterName) + { + var isNested = parameterName.StartsWith("fields[", StringComparison.Ordinal) && parameterName.EndsWith("]", StringComparison.Ordinal); + return parameterName == "fields" || isNested; + } + + /// + public virtual 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 parameterScope = _scopeParser.Parse(parameterName, RequestResource); + return parameterScope.Scope; + } + + private SparseFieldSetExpression GetSparseFieldSet(string parameterValue, ResourceFieldChainExpression scope) + { + ResourceContext resourceContextInScope = GetResourceContextForScope(scope); + return _sparseFieldSetParser.Parse(parameterValue, resourceContextInScope); + } + + /// + public virtual IReadOnlyCollection GetConstraints() + { + return _constraints; + } + } +} diff --git a/src/JsonApiDotNetCore/Controllers/StandardQueryStringParameters.cs b/src/JsonApiDotNetCore/QueryStrings/StandardQueryStringParameters.cs similarity index 59% rename from src/JsonApiDotNetCore/Controllers/StandardQueryStringParameters.cs rename to src/JsonApiDotNetCore/QueryStrings/StandardQueryStringParameters.cs index 766b564d54..f488e823f6 100644 --- a/src/JsonApiDotNetCore/Controllers/StandardQueryStringParameters.cs +++ b/src/JsonApiDotNetCore/QueryStrings/StandardQueryStringParameters.cs @@ -1,7 +1,11 @@ using System; +using JsonApiDotNetCore.Controllers.Annotations; -namespace JsonApiDotNetCore.Controllers +namespace JsonApiDotNetCore.QueryStrings { + /// + /// Lists query string parameters used by . + /// [Flags] public enum StandardQueryStringParameters { diff --git a/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs b/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs new file mode 100644 index 0000000000..ebc1ec6498 --- /dev/null +++ b/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs @@ -0,0 +1,54 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using JsonApiDotNetCore.Resources; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; + +namespace JsonApiDotNetCore.Repositories +{ + public static class DbContextExtensions + { + /// + /// Determines whether or not EF is already tracking an entity of the same Type and Id + /// and returns that entity. + /// + internal static TEntity GetTrackedEntity(this DbContext context, TEntity entity) + where TEntity : class, IIdentifiable + { + if (entity == null) + throw new ArgumentNullException(nameof(entity)); + + var entityEntry = context.ChangeTracker + .Entries() + .FirstOrDefault(entry => + entry.Entity.GetType() == entity.GetType() && + ((IIdentifiable) entry.Entity).StringId == entity.StringId); + + return (TEntity) entityEntry?.Entity; + } + + /// + /// Gets the current transaction or creates a new one. + /// If a transaction already exists, commit, rollback and dispose + /// will not be called. It is assumed the creator of the original + /// transaction should be responsible for disposal. + /// + /// + /// + /// + /// using(var transaction = _context.GetCurrentOrCreateTransaction()) + /// { + /// // perform multiple operations on the context and then save... + /// _context.SaveChanges(); + /// } + /// + /// + public static async Task GetCurrentOrCreateTransactionAsync(this DbContext context) + { + if (context == null) throw new ArgumentNullException(nameof(context)); + + return await SafeTransactionProxy.GetOrCreateAsync(context.Database); + } + } +} diff --git a/src/JsonApiDotNetCore/Data/DbContextResolver.cs b/src/JsonApiDotNetCore/Repositories/DbContextResolver.cs similarity index 67% rename from src/JsonApiDotNetCore/Data/DbContextResolver.cs rename to src/JsonApiDotNetCore/Repositories/DbContextResolver.cs index a485ae22be..f0f6ea166f 100644 --- a/src/JsonApiDotNetCore/Data/DbContextResolver.cs +++ b/src/JsonApiDotNetCore/Repositories/DbContextResolver.cs @@ -1,7 +1,9 @@ +using System; using Microsoft.EntityFrameworkCore; -namespace JsonApiDotNetCore.Data +namespace JsonApiDotNetCore.Repositories { + /// public sealed class DbContextResolver : IDbContextResolver where TDbContext : DbContext { @@ -9,7 +11,7 @@ public sealed class DbContextResolver : IDbContextResolver public DbContextResolver(TDbContext context) { - _context = context; + _context = context ?? throw new ArgumentNullException(nameof(context)); } public DbContext GetContext() => _context; diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs new file mode 100644 index 0000000000..e7c081efa3 --- /dev/null +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -0,0 +1,419 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Internal.QueryableBuilding; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCore.Repositories +{ + /// + /// Implements the foundational Repository layer in the JsonApiDotNetCore architecture that uses Entity Framework Core. + /// + public class EntityFrameworkCoreRepository : IResourceRepository + where TResource : class, IIdentifiable + { + private readonly ITargetedFields _targetedFields; + private readonly DbContext _dbContext; + private readonly IResourceGraph _resourceGraph; + private readonly IGenericServiceFactory _genericServiceFactory; + private readonly IResourceFactory _resourceFactory; + private readonly IEnumerable _constraintProviders; + private readonly TraceLogWriter> _traceWriter; + + public EntityFrameworkCoreRepository( + ITargetedFields targetedFields, + IDbContextResolver contextResolver, + IResourceGraph resourceGraph, + IGenericServiceFactory genericServiceFactory, + IResourceFactory resourceFactory, + IEnumerable constraintProviders, + ILoggerFactory loggerFactory) + { + if (contextResolver == null) throw new ArgumentNullException(nameof(contextResolver)); + if (loggerFactory == null) throw new ArgumentNullException(nameof(loggerFactory)); + + _targetedFields = targetedFields ?? throw new ArgumentNullException(nameof(targetedFields)); + _resourceGraph = resourceGraph ?? throw new ArgumentNullException(nameof(resourceGraph)); + _genericServiceFactory = genericServiceFactory ?? throw new ArgumentNullException(nameof(genericServiceFactory)); + _resourceFactory = resourceFactory ?? throw new ArgumentNullException(nameof(resourceFactory)); + _constraintProviders = constraintProviders ?? throw new ArgumentNullException(nameof(constraintProviders)); + _dbContext = contextResolver.GetContext(); + _traceWriter = new TraceLogWriter>(loggerFactory); + } + + /// + public virtual async Task> GetAsync(QueryLayer layer) + { + _traceWriter.LogMethodStart(new {layer}); + if (layer == null) throw new ArgumentNullException(nameof(layer)); + + IQueryable query = ApplyQueryLayer(layer); + return await query.ToListAsync(); + } + + /// + public virtual async Task CountAsync(FilterExpression topFilter) + { + _traceWriter.LogMethodStart(new {topFilter}); + + var resourceContext = _resourceGraph.GetResourceContext(); + var layer = new QueryLayer(resourceContext) + { + Filter = topFilter + }; + + IQueryable query = ApplyQueryLayer(layer); + return await query.CountAsync(); + } + + protected virtual IQueryable ApplyQueryLayer(QueryLayer layer) + { + _traceWriter.LogMethodStart(new {layer}); + if (layer == null) throw new ArgumentNullException(nameof(layer)); + + IQueryable source = GetAll(); + + var queryableHandlers = _constraintProviders + .SelectMany(p => p.GetConstraints()) + .Where(expressionInScope => expressionInScope.Scope == null) + .Select(expressionInScope => expressionInScope.Expression) + .OfType() + .ToArray(); + + foreach (var queryableHandler in queryableHandlers) + { + source = queryableHandler.Apply(source); + } + + 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 resource) + { + _traceWriter.LogMethodStart(new {resource}); + if (resource == null) throw new ArgumentNullException(nameof(resource)); + + foreach (var relationshipAttr in _targetedFields.Relationships) + { + object trackedRelationshipValue = GetTrackedRelationshipValue(relationshipAttr, resource, out bool relationshipWasAlreadyTracked); + LoadInverseRelationships(trackedRelationshipValue, relationshipAttr); + if (relationshipWasAlreadyTracked || relationshipAttr is HasManyThroughAttribute) + // We only need to reassign the relationship value to the to-be-added + // resource when we're using a different instance of the relationship (because this different one + // was already tracked) than the one assigned to the to-be-created resource. + // Alternatively, even if we don't have to reassign anything because of already tracked + // entities, we still need to assign the "through" entities in the case of many-to-many. + relationshipAttr.SetValue(resource, trackedRelationshipValue, _resourceFactory); + } + + _dbContext.Set().Add(resource); + await _dbContext.SaveChangesAsync(); + + FlushFromCache(resource); + + // this ensures relationships get reloaded from the database if they have + // been requested. See https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/343 + DetachRelationships(resource); + } + + /// + /// Loads the inverse relationships to prevent foreign key constraints from being violated + /// to support implicit removes, see https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/502. + /// + /// Consider the following example: + /// person.todoItems = [t1,t2] is updated to [t3, t4]. If t3, and/or t4 was + /// already related to a other person, and these persons are NOT loaded into the + /// DbContext, then the query may cause a foreign key constraint. Loading + /// these "inverse relationships" into the DB context ensures EF core to take + /// this into account. + /// + /// + private void LoadInverseRelationships(object trackedRelationshipValue, RelationshipAttribute relationshipAttr) + { + if (relationshipAttr.InverseNavigation == null || trackedRelationshipValue == null) return; + if (relationshipAttr is HasOneAttribute hasOneAttr) + { + var relationEntry = _dbContext.Entry((IIdentifiable)trackedRelationshipValue); + if (IsHasOneRelationship(hasOneAttr.InverseNavigation, trackedRelationshipValue.GetType())) + relationEntry.Reference(hasOneAttr.InverseNavigation).Load(); + else + relationEntry.Collection(hasOneAttr.InverseNavigation).Load(); + } + else if (relationshipAttr is HasManyAttribute hasManyAttr && !(relationshipAttr is HasManyThroughAttribute)) + { + foreach (IIdentifiable relationshipValue in (IEnumerable)trackedRelationshipValue) + _dbContext.Entry(relationshipValue).Reference(hasManyAttr.InverseNavigation).Load(); + } + } + + private bool IsHasOneRelationship(string internalRelationshipName, Type type) + { + var relationshipAttr = _resourceGraph.GetRelationships(type).FirstOrDefault(r => r.Property.Name == internalRelationshipName); + if (relationshipAttr != null) + { + if (relationshipAttr is HasOneAttribute) + return true; + + return false; + } + // relationshipAttr is null when we don't put a [RelationshipAttribute] on the inverse navigation property. + // In this case we use reflection to figure out what kind of relationship is pointing back. + return !TypeHelper.IsOrImplementsInterface(type.GetProperty(internalRelationshipName).PropertyType, typeof(IEnumerable)); + } + + private void DetachRelationships(TResource resource) + { + foreach (var relationship in _targetedFields.Relationships) + { + var value = relationship.GetValue(resource); + if (value == null) + continue; + + if (value is IEnumerable collection) + { + foreach (IIdentifiable single in collection) + _dbContext.Entry(single).State = EntityState.Detached; + // detaching has many relationships is not sufficient to + // trigger a full reload of relationships: the navigation + // property actually needs to be nulled out, otherwise + // EF will still add duplicate instances to the collection + relationship.SetValue(resource, null, _resourceFactory); + } + else + { + _dbContext.Entry(value).State = EntityState.Detached; + } + } + } + + /// + public virtual async Task UpdateAsync(TResource requestResource, TResource databaseResource) + { + _traceWriter.LogMethodStart(new {requestResource, databaseResource}); + if (requestResource == null) throw new ArgumentNullException(nameof(requestResource)); + if (databaseResource == null) throw new ArgumentNullException(nameof(databaseResource)); + + foreach (var attribute in _targetedFields.Attributes) + attribute.SetValue(databaseResource, attribute.GetValue(requestResource)); + + foreach (var relationshipAttr in _targetedFields.Relationships) + { + // loads databasePerson.todoItems + LoadCurrentRelationships(databaseResource, relationshipAttr); + // trackedRelationshipValue is either equal to updatedPerson.todoItems, + // or replaced with the same set (same ids) of todoItems from the EF Core change tracker, + // which is the case if they were already tracked + object trackedRelationshipValue = GetTrackedRelationshipValue(relationshipAttr, requestResource, out _); + // loads into the db context any persons currently related + // to the todoItems in trackedRelationshipValue + LoadInverseRelationships(trackedRelationshipValue, relationshipAttr); + // assigns the updated relationship to the database resource + //AssignRelationshipValue(databaseResource, trackedRelationshipValue, relationshipAttr); + relationshipAttr.SetValue(databaseResource, trackedRelationshipValue, _resourceFactory); + } + + await _dbContext.SaveChangesAsync(); + } + + /// + /// Responsible for getting the relationship value for a given relationship + /// attribute of a given resource. It ensures that the relationship value + /// that it returns is attached to the database without reattaching duplicates instances + /// to the change tracker. It does so by checking if there already are + /// instances of the to-be-attached entities in the change tracker. + /// + private object GetTrackedRelationshipValue(RelationshipAttribute relationshipAttr, TResource resource, out bool wasAlreadyAttached) + { + wasAlreadyAttached = false; + if (relationshipAttr is HasOneAttribute hasOneAttr) + { + var relationshipValue = (IIdentifiable)hasOneAttr.GetValue(resource); + if (relationshipValue == null) + return null; + return GetTrackedHasOneRelationshipValue(relationshipValue, ref wasAlreadyAttached); + } + + IEnumerable relationshipValues = (IEnumerable)relationshipAttr.GetValue(resource); + if (relationshipValues == null) + return null; + + return GetTrackedManyRelationshipValue(relationshipValues, relationshipAttr, ref wasAlreadyAttached); + } + + // helper method used in GetTrackedRelationshipValue. See comments below. + private IEnumerable GetTrackedManyRelationshipValue(IEnumerable relationshipValues, RelationshipAttribute relationshipAttr, ref bool wasAlreadyAttached) + { + if (relationshipValues == null) return null; + bool newWasAlreadyAttached = false; + var trackedPointerCollection = TypeHelper.CopyToTypedCollection(relationshipValues.Select(pointer => + { + // convert each element in the value list to relationshipAttr.DependentType. + var tracked = AttachOrGetTracked(pointer); + if (tracked != null) newWasAlreadyAttached = true; + return Convert.ChangeType(tracked ?? pointer, relationshipAttr.RightType); + }), relationshipAttr.Property.PropertyType); + + if (newWasAlreadyAttached) wasAlreadyAttached = true; + return trackedPointerCollection; + } + + // helper method used in GetTrackedRelationshipValue. See comments there. + private IIdentifiable GetTrackedHasOneRelationshipValue(IIdentifiable relationshipValue, ref bool wasAlreadyAttached) + { + var tracked = AttachOrGetTracked(relationshipValue); + if (tracked != null) wasAlreadyAttached = true; + return tracked ?? relationshipValue; + } + + /// + public async Task UpdateRelationshipsAsync(object parent, RelationshipAttribute relationship, IReadOnlyCollection relationshipIds) + { + _traceWriter.LogMethodStart(new {parent, relationship, relationshipIds}); + if (parent == null) throw new ArgumentNullException(nameof(parent)); + if (relationship == null) throw new ArgumentNullException(nameof(relationship)); + if (relationshipIds == null) throw new ArgumentNullException(nameof(relationshipIds)); + + var typeToUpdate = relationship is HasManyThroughAttribute hasManyThrough + ? hasManyThrough.ThroughType + : relationship.RightType; + + var helper = _genericServiceFactory.Get(typeof(RepositoryRelationshipUpdateHelper<>), typeToUpdate); + await helper.UpdateRelationshipAsync((IIdentifiable)parent, relationship, relationshipIds); + + await _dbContext.SaveChangesAsync(); + } + + /// + public virtual async Task DeleteAsync(TId id) + { + _traceWriter.LogMethodStart(new {id}); + + var resourceToDelete = _resourceFactory.CreateInstance(); + resourceToDelete.Id = id; + + var resourceFromCache = _dbContext.GetTrackedEntity(resourceToDelete); + if (resourceFromCache != null) + { + resourceToDelete = resourceFromCache; + } + else + { + _dbContext.Attach(resourceToDelete); + } + + _dbContext.Remove(resourceToDelete); + + try + { + await _dbContext.SaveChangesAsync(); + return true; + } + catch (DbUpdateConcurrencyException) + { + return false; + } + } + + /// + public virtual void FlushFromCache(TResource resource) + { + _traceWriter.LogMethodStart(new {resource}); + if (resource == null) throw new ArgumentNullException(nameof(resource)); + + _dbContext.Entry(resource).State = EntityState.Detached; + } + + /// + /// Before assigning new relationship values (UpdateAsync), we need to + /// attach the current database values of the relationship to the dbContext, else + /// it will not perform a complete-replace which is required for + /// one-to-many and many-to-many. + /// + /// For example: a person `p1` has 2 todo-items: `t1` and `t2`. + /// If we want to update this todo-item set to `t3` and `t4`, simply assigning + /// `p1.todoItems = [t3, t4]` will result in EF Core adding them to the set, + /// resulting in `[t1 ... t4]`. Instead, we should first include `[t1, t2]`, + /// after which the reassignment `p1.todoItems = [t3, t4]` will actually + /// make EF Core perform a complete replace. This method does the loading of `[t1, t2]`. + /// + protected void LoadCurrentRelationships(TResource oldResource, RelationshipAttribute relationshipAttribute) + { + if (oldResource == null) throw new ArgumentNullException(nameof(oldResource)); + if (relationshipAttribute == null) throw new ArgumentNullException(nameof(relationshipAttribute)); + + if (relationshipAttribute is HasManyThroughAttribute throughAttribute) + { + _dbContext.Entry(oldResource).Collection(throughAttribute.ThroughProperty.Name).Load(); + } + else if (relationshipAttribute is HasManyAttribute hasManyAttribute) + { + _dbContext.Entry(oldResource).Collection(hasManyAttribute.Property.Name).Load(); + } + } + + /// + /// Given a IIdentifiable relationship value, verify if a resource of the underlying + /// type with the same ID is already attached to the dbContext, and if so, return it. + /// If not, attach the relationship value to the dbContext. + /// + /// useful article: https://stackoverflow.com/questions/30987806/dbset-attachentity-vs-dbcontext-entryentity-state-entitystate-modified + /// + private IIdentifiable AttachOrGetTracked(IIdentifiable relationshipValue) + { + var trackedEntity = _dbContext.GetTrackedEntity(relationshipValue); + + if (trackedEntity != null) + { + // there already was an instance of this type and ID tracked + // by EF Core. Reattaching will produce a conflict, so from now on we + // will use the already attached instance instead. This entry might + // contain updated fields as a result of business logic elsewhere in the application + return trackedEntity; + } + + // the relationship pointer is new to EF Core, but we are sure + // it exists in the database, so we attach it. In this case, as per + // the json:api spec, we can also safely assume that no fields of + // this resource were updated. + _dbContext.Entry(relationshipValue).State = EntityState.Unchanged; + return null; + } + } + + /// + /// Implements the foundational repository implementation that uses Entity Framework Core. + /// + public class EntityFrameworkCoreRepository : EntityFrameworkCoreRepository, IResourceRepository + where TResource : class, IIdentifiable + { + public EntityFrameworkCoreRepository( + ITargetedFields targetedFields, + IDbContextResolver contextResolver, + IResourceGraph resourceGraph, + IGenericServiceFactory genericServiceFactory, + IResourceFactory resourceFactory, + IEnumerable constraintProviders, + ILoggerFactory loggerFactory) + : base(targetedFields, contextResolver, resourceGraph, genericServiceFactory, resourceFactory, constraintProviders, loggerFactory) + { } + } +} diff --git a/src/JsonApiDotNetCore/Repositories/IDbContextResolver.cs b/src/JsonApiDotNetCore/Repositories/IDbContextResolver.cs new file mode 100644 index 0000000000..693724ed90 --- /dev/null +++ b/src/JsonApiDotNetCore/Repositories/IDbContextResolver.cs @@ -0,0 +1,12 @@ +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCore.Repositories +{ + /// + /// Provides a method to resolve a . + /// + public interface IDbContextResolver + { + DbContext GetContext(); + } +} diff --git a/src/JsonApiDotNetCore/Repositories/IRepositoryRelationshipUpdateHelper.cs b/src/JsonApiDotNetCore/Repositories/IRepositoryRelationshipUpdateHelper.cs new file mode 100644 index 0000000000..376644cb2e --- /dev/null +++ b/src/JsonApiDotNetCore/Repositories/IRepositoryRelationshipUpdateHelper.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Repositories +{ + /// + /// A special helper that processes updates of relationships + /// + /// + /// This service required to be able translate involved expressions into queries + /// instead of having them evaluated on the client side. In particular, for all three types of relationship + /// a lookup is performed based on an ID. Expressions that use IIdentifiable.StringId can never + /// be translated into queries because this property only exists at runtime after the query is performed. + /// We will have to build expression trees if we want to use IIdentifiable{TId}.TId, for which we minimally a + /// generic execution to DbContext.Set{T}(). + /// + public interface IRepositoryRelationshipUpdateHelper + { + /// + /// Processes updates of relationships + /// + Task UpdateRelationshipAsync(IIdentifiable parent, RelationshipAttribute relationship, IReadOnlyCollection relationshipIds); + } +} diff --git a/src/JsonApiDotNetCore/Repositories/IResourceReadRepository.cs b/src/JsonApiDotNetCore/Repositories/IResourceReadRepository.cs new file mode 100644 index 0000000000..9c04f4bcc5 --- /dev/null +++ b/src/JsonApiDotNetCore/Repositories/IResourceReadRepository.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.Repositories +{ + /// + public interface IResourceReadRepository + : IResourceReadRepository + where TResource : class, IIdentifiable + { } + + /// + /// Groups read operations. + /// + /// The resource type. + /// The resource identifier type. + public interface IResourceReadRepository + where TResource : class, IIdentifiable + { + /// + /// Executes a read query using the specified constraints and returns the collection of matching resources. + /// + Task> GetAsync(QueryLayer layer); + + /// + /// Executes a read query using the specified top-level filter and returns the top-level count of matching resources. + /// + Task CountAsync(FilterExpression topFilter); + } +} diff --git a/src/JsonApiDotNetCore/Repositories/IResourceRepository.cs b/src/JsonApiDotNetCore/Repositories/IResourceRepository.cs new file mode 100644 index 0000000000..81efe34e54 --- /dev/null +++ b/src/JsonApiDotNetCore/Repositories/IResourceRepository.cs @@ -0,0 +1,21 @@ +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.Repositories +{ + /// + public interface IResourceRepository + : IResourceRepository + where TResource : class, IIdentifiable + { } + + /// + /// Represents the foundational Resource Repository layer in the JsonApiDotNetCore architecture that provides data access to an underlying store. + /// + /// The resource type. + /// The resource identifier type. + public interface IResourceRepository + : IResourceReadRepository, + IResourceWriteRepository + where TResource : class, IIdentifiable + { } +} diff --git a/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs b/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs new file mode 100644 index 0000000000..301ba6ef7f --- /dev/null +++ b/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs @@ -0,0 +1,51 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Repositories +{ + /// + public interface IResourceWriteRepository + : IResourceWriteRepository + where TResource : class, IIdentifiable + { } + + /// + /// Groups write operations. + /// + /// The resource type. + /// The resource identifier type. + public interface IResourceWriteRepository + where TResource : class, IIdentifiable + { + /// + /// Creates a new resource in the underlying data store. + /// + Task CreateAsync(TResource resource); + + /// + /// Updates an existing resource in the underlying data store. + /// + /// The (partial) resource coming from the request body. + /// The resource as stored in the database before the update. + Task UpdateAsync(TResource requestResource, TResource databaseResource); + + /// + /// Updates relationships in the underlying data store. + /// + Task UpdateRelationshipsAsync(object parent, RelationshipAttribute relationship, IReadOnlyCollection relationshipIds); + + /// + /// Deletes a resource from the underlying data store. + /// + /// Identifier for the resource to delete. + /// true if the resource was deleted; false is the resource did not exist. + Task DeleteAsync(TId id); + + /// + /// Ensures that the next time this resource is requested, it is re-fetched from the underlying data store. + /// + void FlushFromCache(TResource resource); + } +} diff --git a/src/JsonApiDotNetCore/Internal/Generics/RepositoryRelationshipUpdateHelper.cs b/src/JsonApiDotNetCore/Repositories/RepositoryRelationshipUpdateHelper.cs similarity index 72% rename from src/JsonApiDotNetCore/Internal/Generics/RepositoryRelationshipUpdateHelper.cs rename to src/JsonApiDotNetCore/Repositories/RepositoryRelationshipUpdateHelper.cs index ccd734a9bd..d349abe17c 100644 --- a/src/JsonApiDotNetCore/Internal/Generics/RepositoryRelationshipUpdateHelper.cs +++ b/src/JsonApiDotNetCore/Repositories/RepositoryRelationshipUpdateHelper.cs @@ -4,33 +4,13 @@ using System.Linq; using System.Linq.Expressions; using System.Threading.Tasks; -using JsonApiDotNetCore.Data; -using JsonApiDotNetCore.Extensions; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; using Microsoft.EntityFrameworkCore; -namespace JsonApiDotNetCore.Internal.Generics +namespace JsonApiDotNetCore.Repositories { - /// - /// A special helper that processes updates of relationships - /// - /// - /// This service required to be able translate involved expressions into queries - /// instead of having them evaluated on the client side. In particular, for all three types of relationship - /// a lookup is performed based on an id. Expressions that use IIdentifiable.StringId can never - /// be translated into queries because this property only exists at runtime after the query is performed. - /// We will have to build expression trees if we want to use IIdentifiable{TId}.TId, for which we minimally a - /// generic execution to DbContext.Set{T}(). - /// - public interface IRepositoryRelationshipUpdateHelper - { - /// - /// Processes updates of relationships - /// - Task UpdateRelationshipAsync(IIdentifiable parent, RelationshipAttribute relationship, IEnumerable relationshipIds); - } - - /// + /// public class RepositoryRelationshipUpdateHelper : IRepositoryRelationshipUpdateHelper where TRelatedResource : class { private readonly IResourceFactory _resourceFactory; @@ -38,13 +18,19 @@ public class RepositoryRelationshipUpdateHelper : IRepositoryR public RepositoryRelationshipUpdateHelper(IDbContextResolver contextResolver, IResourceFactory resourceFactory) { - _resourceFactory = resourceFactory; + if (contextResolver == null) throw new ArgumentNullException(nameof(contextResolver)); + + _resourceFactory = resourceFactory ?? throw new ArgumentNullException(nameof(resourceFactory)); _context = contextResolver.GetContext(); } - /// - public virtual async Task UpdateRelationshipAsync(IIdentifiable parent, RelationshipAttribute relationship, IEnumerable relationshipIds) + /// + public virtual async Task UpdateRelationshipAsync(IIdentifiable parent, RelationshipAttribute relationship, IReadOnlyCollection relationshipIds) { + if (parent == null) throw new ArgumentNullException(nameof(parent)); + if (relationship == null) throw new ArgumentNullException(nameof(relationship)); + if (relationshipIds == null) throw new ArgumentNullException(nameof(relationshipIds)); + if (relationship is HasManyThroughAttribute hasManyThrough) await UpdateManyToManyAsync(parent, hasManyThrough, relationshipIds); else if (relationship is HasManyAttribute) @@ -53,7 +39,7 @@ public virtual async Task UpdateRelationshipAsync(IIdentifiable parent, Relation await UpdateOneToOneAsync(parent, relationship, relationshipIds); } - private async Task UpdateOneToOneAsync(IIdentifiable parent, RelationshipAttribute relationship, IEnumerable relationshipIds) + private async Task UpdateOneToOneAsync(IIdentifiable parent, RelationshipAttribute relationship, IReadOnlyCollection relationshipIds) { TRelatedResource value = null; if (relationshipIds.Any()) @@ -71,18 +57,18 @@ private async Task UpdateOneToOneAsync(IIdentifiable parent, RelationshipAttribu relationship.SetValue(parent, value, _resourceFactory); } - private async Task UpdateOneToManyAsync(IIdentifiable parent, RelationshipAttribute relationship, IEnumerable relationshipIds) + private async Task UpdateOneToManyAsync(IIdentifiable parent, RelationshipAttribute relationship, IReadOnlyCollection relationshipIds) { IEnumerable value; if (!relationshipIds.Any()) { - var collectionType = relationship.PropertyInfo.PropertyType.ToConcreteCollectionType(); + var collectionType = TypeHelper.ToConcreteCollectionType(relationship.Property.PropertyType); value = (IEnumerable)TypeHelper.CreateInstance(collectionType); } else { var idType = TypeHelper.GetIdType(relationship.RightType); - var typedIds = relationshipIds.CopyToList(idType, stringId => TypeHelper.ConvertType(stringId, idType)); + var typedIds = TypeHelper.CopyToList(relationshipIds, idType, stringId => TypeHelper.ConvertType(stringId, idType)); // [1, 2, 3] var target = Expression.Constant(typedIds); @@ -95,16 +81,16 @@ 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 = TypeHelper.CopyToTypedCollection(resultSet, relationship.Property.PropertyType); } relationship.SetValue(parent, value, _resourceFactory); } - private async Task UpdateManyToManyAsync(IIdentifiable parent, HasManyThroughAttribute relationship, IEnumerable relationshipIds) + private async Task UpdateManyToManyAsync(IIdentifiable parent, HasManyThroughAttribute relationship, IReadOnlyCollection relationshipIds) { // we need to create a transaction for the HasManyThrough case so we can get and remove any existing - // 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); @@ -136,7 +122,7 @@ private async Task UpdateManyToManyAsync(IIdentifiable parent, HasManyThroughAtt _context.AddRange(newLinks); await _context.SaveChangesAsync(); - transaction.Commit(); + await transaction.CommitAsync(); } } } diff --git a/src/JsonApiDotNetCore/Repositories/SafeTransactionProxy.cs b/src/JsonApiDotNetCore/Repositories/SafeTransactionProxy.cs new file mode 100644 index 0000000000..36c1e39b40 --- /dev/null +++ b/src/JsonApiDotNetCore/Repositories/SafeTransactionProxy.cs @@ -0,0 +1,68 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage; + +namespace JsonApiDotNetCore.Repositories +{ + /// + /// Gets the current transaction or creates a new one. + /// If a transaction already exists, commit, rollback and dispose + /// will not be called. It is assumed the creator of the original + /// transaction should be responsible for disposal. + /// + internal class SafeTransactionProxy : IDbContextTransaction + { + private readonly bool _shouldExecute; + private readonly IDbContextTransaction _transaction; + + private SafeTransactionProxy(IDbContextTransaction transaction, bool shouldExecute) + { + _transaction = transaction ?? throw new ArgumentNullException(nameof(transaction)); + _shouldExecute = shouldExecute; + } + + public static async Task GetOrCreateAsync(DatabaseFacade databaseFacade) + { + if (databaseFacade == null) throw new ArgumentNullException(nameof(databaseFacade)); + + return databaseFacade.CurrentTransaction != null + ? new SafeTransactionProxy(databaseFacade.CurrentTransaction, shouldExecute: false) + : new SafeTransactionProxy(await databaseFacade.BeginTransactionAsync(), shouldExecute: true); + } + + /// + public Guid TransactionId => _transaction.TransactionId; + + /// + public void Commit() => Proxy(t => t.Commit()); + + /// + public void Rollback() => Proxy(t => t.Rollback()); + + /// + public void Dispose() => Proxy(t => t.Dispose()); + + private void Proxy(Action action) + { + if(_shouldExecute) + action(_transaction); + } + + public Task CommitAsync(CancellationToken cancellationToken = default) + { + return _transaction.CommitAsync(cancellationToken); + } + + public Task RollbackAsync(CancellationToken cancellationToken = default) + { + return _transaction.RollbackAsync(cancellationToken); + } + + public ValueTask DisposeAsync() + { + return _transaction.DisposeAsync(); + } + } +} diff --git a/src/JsonApiDotNetCore/RequestServices/Contracts/ICurrentRequest.cs b/src/JsonApiDotNetCore/RequestServices/Contracts/ICurrentRequest.cs deleted file mode 100644 index 15eddf495e..0000000000 --- a/src/JsonApiDotNetCore/RequestServices/Contracts/ICurrentRequest.cs +++ /dev/null @@ -1,43 +0,0 @@ -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Models; - -namespace JsonApiDotNetCore.Managers.Contracts -{ - /// - /// Metadata associated to the current json:api request. - /// - public interface ICurrentRequest - { - /// - /// The request namespace. This may be an absolute or relative path - /// depending upon the configuration. - /// - /// - /// Absolute: https://example.com/api/v1 - /// - /// Relative: /api/v1 - /// - string BasePath { get; set; } - - /// - /// If the request is on the `{id}/relationships/{relationshipName}` route - /// - bool IsRelationshipPath { get; set; } - - /// - /// If is true, this property - /// is the relationship attribute associated with the targeted relationship - /// - RelationshipAttribute RequestRelationship { get; set; } - string BaseId { get; set; } - string RelationshipId { get; set; } - - /// - /// Sets the current context entity for this entire request - /// - /// - void SetRequestResource(ResourceContext currentResourceContext); - - ResourceContext GetRequestResource(); - } -} diff --git a/src/JsonApiDotNetCore/RequestServices/Contracts/ITargetedFields.cs b/src/JsonApiDotNetCore/RequestServices/Contracts/ITargetedFields.cs deleted file mode 100644 index 556ea57094..0000000000 --- a/src/JsonApiDotNetCore/RequestServices/Contracts/ITargetedFields.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.Collections.Generic; -using JsonApiDotNetCore.Models; - -namespace JsonApiDotNetCore.Serialization -{ - /// - /// Container to register which attributes and relationships are targeted by the current operation. - /// - public interface ITargetedFields - { - /// - /// List of attributes that are updated by a request - /// - List Attributes { get; set; } - /// - /// List of relationships that are updated by a request - /// - List Relationships { get; set; } - } -} diff --git a/src/JsonApiDotNetCore/RequestServices/CurrentRequest.cs b/src/JsonApiDotNetCore/RequestServices/CurrentRequest.cs deleted file mode 100644 index a5bbc31a1b..0000000000 --- a/src/JsonApiDotNetCore/RequestServices/CurrentRequest.cs +++ /dev/null @@ -1,30 +0,0 @@ -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Managers.Contracts; -using JsonApiDotNetCore.Models; - -namespace JsonApiDotNetCore.Managers -{ - internal sealed class CurrentRequest : ICurrentRequest - { - private ResourceContext _resourceContext; - 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; - } - } -} diff --git a/src/JsonApiDotNetCore/RequestServices/TargetedFields.cs b/src/JsonApiDotNetCore/RequestServices/TargetedFields.cs deleted file mode 100644 index d5e3d2919a..0000000000 --- a/src/JsonApiDotNetCore/RequestServices/TargetedFields.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.Collections.Generic; -using JsonApiDotNetCore.Models; - -namespace JsonApiDotNetCore.Serialization -{ - /// - public sealed class TargetedFields : ITargetedFields - { - /// - public List Attributes { get; set; } = new List(); - /// - public List Relationships { get; set; } = new List(); - } - -} diff --git a/src/JsonApiDotNetCore/Resources/Annotations/AttrAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/AttrAttribute.cs new file mode 100644 index 0000000000..4f379bcab8 --- /dev/null +++ b/src/JsonApiDotNetCore/Resources/Annotations/AttrAttribute.cs @@ -0,0 +1,74 @@ +using System; + +namespace JsonApiDotNetCore.Resources.Annotations +{ + /// + /// Used to expose a property on a resource class as a json:api attribute (https://jsonapi.org/format/#document-resource-object-attributes). + /// + [AttributeUsage(AttributeTargets.Property)] + public sealed class AttrAttribute : ResourceFieldAttribute + { + private AttrCapabilities? _capabilities; + + internal bool HasExplicitCapabilities => _capabilities != null; + + /// + /// The set of capabilities that are allowed to be performed on this attribute. + /// When not explicitly assigned, the configured default set of capabilities is used. + /// + /// + /// + /// public class Author : Identifiable + /// { + /// [Attr(Capabilities = AttrCapabilities.AllowFilter | AttrCapabilities.AllowSort)] + /// public string Name { get; set; } + /// } + /// + /// + public AttrCapabilities Capabilities + { + get => _capabilities ?? default; + set => _capabilities = value; + } + + /// + /// Get the value of the attribute for the given object. + /// Returns null if the attribute does not belong to the + /// provided object. + /// + public object GetValue(object resource) + { + if (resource == null) + { + throw new ArgumentNullException(nameof(resource)); + } + + if (Property.GetMethod == null) + { + throw new InvalidOperationException($"Property '{Property.DeclaringType?.Name}.{Property.Name}' is write-only."); + } + + return Property.GetValue(resource); + } + + /// + /// Sets the value of the attribute on the given object. + /// + public void SetValue(object resource, object newValue) + { + if (resource == null) + { + throw new ArgumentNullException(nameof(resource)); + } + + if (Property.SetMethod == null) + { + throw new InvalidOperationException( + $"Property '{Property.DeclaringType?.Name}.{Property.Name}' is read-only."); + } + + var convertedValue = TypeHelper.ConvertType(newValue, Property.PropertyType); + Property.SetValue(resource, convertedValue); + } + } +} diff --git a/src/JsonApiDotNetCore/Models/AttrCapabilities.cs b/src/JsonApiDotNetCore/Resources/Annotations/AttrCapabilities.cs similarity index 60% rename from src/JsonApiDotNetCore/Models/AttrCapabilities.cs rename to src/JsonApiDotNetCore/Resources/Annotations/AttrCapabilities.cs index 102ba3893a..7205f01241 100644 --- a/src/JsonApiDotNetCore/Models/AttrCapabilities.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/AttrCapabilities.cs @@ -1,33 +1,39 @@ using System; -namespace JsonApiDotNetCore.Models +namespace JsonApiDotNetCore.Resources.Annotations { /// - /// Indicates query string capabilities that can be performed on an . + /// Indicates capabilities that can be performed on an . /// [Flags] 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/Annotation/EagerLoadAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/EagerLoadAttribute.cs similarity index 90% rename from src/JsonApiDotNetCore/Models/Annotation/EagerLoadAttribute.cs rename to src/JsonApiDotNetCore/Resources/Annotations/EagerLoadAttribute.cs index e5ff487083..be5adc2339 100644 --- a/src/JsonApiDotNetCore/Models/Annotation/EagerLoadAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/EagerLoadAttribute.cs @@ -2,7 +2,7 @@ using System.Collections.Generic; using System.Reflection; -namespace JsonApiDotNetCore.Models +namespace JsonApiDotNetCore.Resources.Annotations { /// /// Used to unconditionally load a related entity that is not exposed as a json:api relationship. @@ -38,6 +38,6 @@ public sealed class EagerLoadAttribute : Attribute { public PropertyInfo Property { get; internal set; } - public IList Children { get; internal set; } + public IReadOnlyCollection Children { get; internal set; } } } diff --git a/src/JsonApiDotNetCore/Resources/Annotations/HasManyAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/HasManyAttribute.cs new file mode 100644 index 0000000000..1555a24f7f --- /dev/null +++ b/src/JsonApiDotNetCore/Resources/Annotations/HasManyAttribute.cs @@ -0,0 +1,28 @@ +using System; + +namespace JsonApiDotNetCore.Resources.Annotations +{ + /// + /// Used to expose a property on a resource class as a json:api to-many relationship (https://jsonapi.org/format/#document-resource-object-relationships). + /// + [AttributeUsage(AttributeTargets.Property)] + public class HasManyAttribute : RelationshipAttribute + { + /// + /// Creates a HasMany relational link to another resource. + /// + /// + /// Articles { get; set; } + /// } + /// ]]> + /// + public HasManyAttribute() + { + Links = LinkTypes.All; + } + } +} diff --git a/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs new file mode 100644 index 0000000000..409e4d639a --- /dev/null +++ b/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs @@ -0,0 +1,151 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +namespace JsonApiDotNetCore.Resources.Annotations +{ + /// + /// Used to expose a property on a resource class as a json:api to-many relationship (https://jsonapi.org/format/#document-resource-object-relationships) + /// through a many-to-many join relationship. + /// + /// + /// In the following example, we expose a relationship named "tags" + /// through the navigation property `ArticleTags`. + /// The `Tags` property is decorated with `NotMapped` so that EF does not try + /// to map this to a database relationship. + /// Tags { get; set; } + /// public ISet ArticleTags { get; set; } + /// } + /// + /// public class Tag : Identifiable + /// { + /// [Attr] + /// public string Name { get; set; } + /// } + /// + /// public sealed class ArticleTag + /// { + /// public int ArticleId { get; set; } + /// public Article Article { get; set; } + /// + /// public int TagId { get; set; } + /// public Tag Tag { get; set; } + /// } + /// ]]> + /// + [AttributeUsage(AttributeTargets.Property)] + public sealed class HasManyThroughAttribute : HasManyAttribute + { + /// + /// The name of the join property on the parent resource. + /// In the example described above, this would be "ArticleTags". + /// + internal string ThroughPropertyName { get; } + + /// + /// The join type. + /// In the example described above, this would be `ArticleTag`. + /// + public Type ThroughType { get; internal set; } + + /// + /// The navigation property back to the parent resource from the through type. + /// In the example described above, this would point to the `Article.ArticleTags.Article` property. + /// + public PropertyInfo LeftProperty { get; internal set; } + + /// + /// The ID property back to the parent resource from the through type. + /// In the example described above, this would point to the `Article.ArticleTags.ArticleId` property. + /// + public PropertyInfo LeftIdProperty { get; internal set; } + + /// + /// The navigation property to the related resource from the through type. + /// In the example described above, this would point to the `Article.ArticleTags.Tag` property. + /// + public PropertyInfo RightProperty { get; internal set; } + + /// + /// The ID property to the related resource from the through type. + /// In the example described above, this would point to the `Article.ArticleTags.TagId` property. + /// + public PropertyInfo RightIdProperty { get; internal set; } + + /// + /// The join resource property on the parent resource. + /// In the example described above, this would point to the `Article.ArticleTags` property. + /// + public PropertyInfo ThroughProperty { get; internal set; } + + /// + /// The internal navigation property path to the related resource. + /// In the example described above, this would contain "ArticleTags.Tag". + /// + public override string RelationshipPath => $"{ThroughProperty.Name}.{RightProperty.Name}"; + + /// + /// Creates a HasMany relationship through a many-to-many join relationship. + /// + /// The name of the navigation property that will be used to access the join relationship. + public HasManyThroughAttribute(string throughPropertyName) + { + ThroughPropertyName = throughPropertyName ?? throw new ArgumentNullException(nameof(throughPropertyName)); + } + + /// + /// Traverses through the provided resource and returns the value of the relationship on the other side of the through type. + /// In the example described above, this would be the value of "Articles.ArticleTags.Tag". + /// + public override object GetValue(object resource) + { + if (resource == null) throw new ArgumentNullException(nameof(resource)); + + IEnumerable throughResources = (IEnumerable)ThroughProperty.GetValue(resource) ?? Array.Empty(); + + IEnumerable rightResources = throughResources + .Cast() + .Select(rightResource => RightProperty.GetValue(rightResource)); + + return TypeHelper.CopyToTypedCollection(rightResources, Property.PropertyType); + } + + /// + /// Traverses through the provided resource and sets the value of the relationship on the other side of the through type. + /// In the example described above, this would be the value of "Articles.ArticleTags.Tag". + /// + public override void SetValue(object resource, object newValue, IResourceFactory resourceFactory) + { + if (resource == null) throw new ArgumentNullException(nameof(resource)); + if (resourceFactory == null) throw new ArgumentNullException(nameof(resourceFactory)); + + base.SetValue(resource, newValue, resourceFactory); + + if (newValue == null) + { + ThroughProperty.SetValue(resource, null); + } + else + { + List throughResources = new List(); + foreach (IIdentifiable identifiable in (IEnumerable)newValue) + { + object throughResource = resourceFactory.CreateInstance(ThroughType); + LeftProperty.SetValue(throughResource, resource); + RightProperty.SetValue(throughResource, identifiable); + throughResources.Add(throughResource); + } + + var typedCollection = TypeHelper.CopyToTypedCollection(throughResources, ThroughProperty.PropertyType); + ThroughProperty.SetValue(resource, typedCollection); + } + } + } +} diff --git a/src/JsonApiDotNetCore/Resources/Annotations/HasOneAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/HasOneAttribute.cs new file mode 100644 index 0000000000..d0e739aef9 --- /dev/null +++ b/src/JsonApiDotNetCore/Resources/Annotations/HasOneAttribute.cs @@ -0,0 +1,62 @@ +using System; +using JsonApiDotNetCore.Configuration; + +namespace JsonApiDotNetCore.Resources.Annotations +{ + /// + /// Used to expose a property on a resource class as a json:api to-one relationship (https://jsonapi.org/format/#document-resource-object-relationships). + /// + [AttributeUsage(AttributeTargets.Property)] + public sealed class HasOneAttribute : RelationshipAttribute + { + private string _identifiablePropertyName; + + /// + /// The foreign key property name. Defaults to "{RelationshipName}Id". + /// + /// + /// Using an alternative foreign key: + /// + /// public class Article : Identifiable + /// { + /// [HasOne(PublicName = "author", IdentifiablePropertyName = nameof(AuthorKey)] + /// public Author Author { get; set; } + /// public int AuthorKey { get; set; } + /// } + /// + /// + public string IdentifiablePropertyName + { + get => _identifiablePropertyName ?? JsonApiOptions.RelatedIdMapper.GetRelatedIdPropertyName(Property.Name); + set => _identifiablePropertyName = value; + } + + public HasOneAttribute() + { + Links = LinkTypes.NotConfigured; + } + + /// + public override void SetValue(object resource, object newValue, IResourceFactory resourceFactory) + { + if (resource == null) throw new ArgumentNullException(nameof(resource)); + if (resourceFactory == null) throw new ArgumentNullException(nameof(resourceFactory)); + + // If we're deleting the relationship (setting it to null), we set the foreignKey to null. + // We could also set the actual property to null, but then we would first need to load the + // current relationship, which requires an extra query. + + var propertyName = newValue == null ? IdentifiablePropertyName : Property.Name; + var resourceType = resource.GetType(); + + var propertyInfo = resourceType.GetProperty(propertyName); + if (propertyInfo == null) + { + // we can't set the FK to null because there isn't any. + propertyInfo = resourceType.GetProperty(RelationshipPath); + } + + propertyInfo.SetValue(resource, newValue); + } + } +} diff --git a/src/JsonApiDotNetCore/Resources/Annotations/IsRequiredAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/IsRequiredAttribute.cs new file mode 100644 index 0000000000..71fbd8faa9 --- /dev/null +++ b/src/JsonApiDotNetCore/Resources/Annotations/IsRequiredAttribute.cs @@ -0,0 +1,32 @@ +using System; +using System.ComponentModel.DataAnnotations; +using JsonApiDotNetCore.Middleware; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace JsonApiDotNetCore.Resources.Annotations +{ + /// + /// Used with model state validation as a replacement for the built-in to support partial updates. + /// + public sealed class IsRequiredAttribute : RequiredAttribute + { + private bool _isDisabled; + + /// + public override bool IsValid(object value) + { + return _isDisabled || base.IsValid(value); + } + + /// + protected override ValidationResult IsValid(object value, ValidationContext validationContext) + { + if (validationContext == null) throw new ArgumentNullException(nameof(validationContext)); + + var httpContextAccessor = (IHttpContextAccessor)validationContext.GetRequiredService(typeof(IHttpContextAccessor)); + _isDisabled = httpContextAccessor.HttpContext.IsValidatorDisabled(validationContext.MemberName, validationContext.ObjectType.Name); + return _isDisabled ? ValidationResult.Success : base.IsValid(value, validationContext); + } + } +} diff --git a/src/JsonApiDotNetCore/Models/JsonApiDocuments/Link.cs b/src/JsonApiDotNetCore/Resources/Annotations/LinkTypes.cs similarity index 73% rename from src/JsonApiDotNetCore/Models/JsonApiDocuments/Link.cs rename to src/JsonApiDotNetCore/Resources/Annotations/LinkTypes.cs index 5bba6273a0..d655a8b882 100644 --- a/src/JsonApiDotNetCore/Models/JsonApiDocuments/Link.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/LinkTypes.cs @@ -1,9 +1,9 @@ using System; -namespace JsonApiDotNetCore.Models.Links +namespace JsonApiDotNetCore.Resources.Annotations { [Flags] - public enum Link + public enum LinkTypes { Self = 1 << 0, Related = 1 << 1, diff --git a/src/JsonApiDotNetCore/Resources/Annotations/RelationshipAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/RelationshipAttribute.cs new file mode 100644 index 0000000000..b13f8dd852 --- /dev/null +++ b/src/JsonApiDotNetCore/Resources/Annotations/RelationshipAttribute.cs @@ -0,0 +1,108 @@ +using System; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; + +namespace JsonApiDotNetCore.Resources.Annotations +{ + /// + /// Used to expose a property on a resource class as a json:api relationship (https://jsonapi.org/format/#document-resource-object-relationships). + /// + public abstract class RelationshipAttribute : ResourceFieldAttribute + { + private LinkTypes _links; + + public string InverseNavigation { get; set; } + + /// + /// The internal navigation property path to the related resource. + /// + /// + /// In all cases except for relationships, this equals the property name. + /// + public virtual string RelationshipPath => Property.Name; + + /// + /// The child resource type. This does not necessarily match the navigation property type. + /// In the case of a relationship, this value will be the collection argument type. + /// + /// + /// Tags { get; set; } // Type => Tag + /// ]]> + /// + public Type RightType { get; internal set; } + + /// + /// The parent resource type. This is the type of the class in which this attribute was used. + /// + public Type LeftType { get; internal set; } + + /// + /// Configures which links to show in the object for this relationship. + /// When not explicitly assigned, the default value depends on the relationship type (see remarks). + /// + /// + /// This defaults to for and relationships. + /// This defaults to for relationships, which means that + /// the configuration in or is used. + /// + public LinkTypes Links + { + get => _links; + set + { + if (value == LinkTypes.Paging) + { + throw new InvalidConfigurationException($"{LinkTypes.Paging:g} not allowed for argument {nameof(value)}"); + } + + _links = value; + } + } + + /// + /// Whether or not this relationship can be included using the ?include=publicName query string parameter. + /// This is true by default. + /// + public bool CanInclude { get; set; } = true; + + /// + /// Gets the value of the resource property this attributes was declared on. + /// + public virtual object GetValue(object resource) + { + if (resource == null) throw new ArgumentNullException(nameof(resource)); + + return Property.GetValue(resource); + } + + /// + /// Sets the value of the resource property this attributes was declared on. + /// + public virtual void SetValue(object resource, object newValue, IResourceFactory resourceFactory) + { + if (resource == null) throw new ArgumentNullException(nameof(resource)); + if (resourceFactory == null) throw new ArgumentNullException(nameof(resourceFactory)); + + Property.SetValue(resource, newValue); + } + + public override bool Equals(object obj) + { + if (obj == null || GetType() != obj.GetType()) + { + return false; + } + + var other = (RelationshipAttribute) obj; + + return PublicName == other.PublicName && LeftType == other.LeftType && + RightType == other.RightType; + } + + public override int GetHashCode() + { + return HashCode.Combine(PublicName, LeftType, RightType); + } + } +} diff --git a/src/JsonApiDotNetCore/Resources/Annotations/ResourceAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/ResourceAttribute.cs new file mode 100644 index 0000000000..7b8a3a6ad0 --- /dev/null +++ b/src/JsonApiDotNetCore/Resources/Annotations/ResourceAttribute.cs @@ -0,0 +1,22 @@ +using System; + +namespace JsonApiDotNetCore.Resources.Annotations +{ + /// + /// When put on a resource class, overrides the convention-based resource name. + /// + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Interface)] + public sealed class ResourceAttribute : Attribute + { + /// + /// The publicly exposed name of this resource type. + /// When not explicitly assigned, the configured casing convention is applied on the pluralized resource class name. + /// + public string PublicName { get; } + + public ResourceAttribute(string publicName) + { + PublicName = publicName ?? throw new ArgumentNullException(nameof(publicName)); + } + } +} diff --git a/src/JsonApiDotNetCore/Resources/Annotations/ResourceFieldAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/ResourceFieldAttribute.cs new file mode 100644 index 0000000000..d9688ae52e --- /dev/null +++ b/src/JsonApiDotNetCore/Resources/Annotations/ResourceFieldAttribute.cs @@ -0,0 +1,41 @@ +using System; +using System.Reflection; + +namespace JsonApiDotNetCore.Resources.Annotations +{ + /// + /// Used to expose a property on a resource class as a json:api field (attribute or relationship). + /// See https://jsonapi.org/format/#document-resource-object-fields. + /// + public abstract class ResourceFieldAttribute : Attribute + { + private string _publicName; + + /// + /// The publicly exposed name of this json:api field. + /// When not explicitly assigned, the configured casing convention is applied on the property name. + /// + public string PublicName + { + get => _publicName; + set + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentException("Exposed name cannot be null, empty or contain only whitespace.", nameof(value)); + } + _publicName = value; + } + } + + /// + /// The resource property that this attribute is declared on. + /// + public PropertyInfo Property { get; internal set; } + + public override string ToString() + { + return PublicName ?? (Property != null ? Property.Name : base.ToString()); + } + } +} diff --git a/src/JsonApiDotNetCore/Resources/Annotations/ResourceLinksAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/ResourceLinksAttribute.cs new file mode 100644 index 0000000000..eff732d954 --- /dev/null +++ b/src/JsonApiDotNetCore/Resources/Annotations/ResourceLinksAttribute.cs @@ -0,0 +1,75 @@ +using System; +using JsonApiDotNetCore.Errors; + +namespace JsonApiDotNetCore.Resources.Annotations +{ + // TODO: There are no tests for this. + + /// + /// When put on a resource class, overrides global configuration for which links to render. + /// + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Interface)] + public sealed class ResourceLinksAttribute : Attribute + { + private LinkTypes _topLevelLinks = LinkTypes.NotConfigured; + private LinkTypes _resourceLinks = LinkTypes.NotConfigured; + private LinkTypes _relationshipLinks = LinkTypes.NotConfigured; + + /// + /// Configures which links to show in the + /// section for this resource. + /// Defaults to . + /// + public LinkTypes TopLevelLinks + { + get => _topLevelLinks; + set + { + if (value == LinkTypes.Related) + { + throw new InvalidConfigurationException($"{LinkTypes.Related:g} not allowed for argument {nameof(value)}"); + } + + _topLevelLinks = value; + } + } + + /// + /// Configures which links to show in the + /// section for this resource. + /// Defaults to . + /// + public LinkTypes ResourceLinks + { + get => _resourceLinks; + set + { + if (value == LinkTypes.Paging) + { + throw new InvalidConfigurationException($"{LinkTypes.Paging:g} not allowed for argument {nameof(value)}"); + } + + _resourceLinks = value; + } + } + + /// + /// Configures which links to show in the + /// for all relationships of the resource type on which this attribute was used. + /// Defaults to . + /// + public LinkTypes RelationshipLinks + { + get => _relationshipLinks; + set + { + if (value == LinkTypes.Paging) + { + throw new InvalidConfigurationException($"{LinkTypes.Paging:g} not allowed for argument {nameof(value)}"); + } + + _relationshipLinks = value; + } + } + } +} diff --git a/src/JsonApiDotNetCore/Resources/IHasMeta.cs b/src/JsonApiDotNetCore/Resources/IHasMeta.cs new file mode 100644 index 0000000000..a22408d78d --- /dev/null +++ b/src/JsonApiDotNetCore/Resources/IHasMeta.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; + +namespace JsonApiDotNetCore.Resources +{ + /// + /// When implemented by a class, indicates it provides json:api meta key/value pairs. + /// + public interface IHasMeta + { + IReadOnlyDictionary GetMeta(); + } +} diff --git a/src/JsonApiDotNetCore/Resources/IIdentifiable.cs b/src/JsonApiDotNetCore/Resources/IIdentifiable.cs new file mode 100644 index 0000000000..f8e8d9f613 --- /dev/null +++ b/src/JsonApiDotNetCore/Resources/IIdentifiable.cs @@ -0,0 +1,26 @@ +namespace JsonApiDotNetCore.Resources +{ + /// + /// When implemented by a class, indicates to JsonApiDotNetCore that the class represents a json:api resource. + /// Note that JsonApiDotNetCore also assumes that a property named 'Id' exists. + /// + public interface IIdentifiable + { + /// + /// The value for element 'id' in a json:api request or response. + /// + string StringId { get; set; } + } + + /// + /// When implemented by a class, indicates to JsonApiDotNetCore that the class represents a json:api resource. + /// + /// The resource identifier type. + public interface IIdentifiable : IIdentifiable + { + /// + /// The typed identifier as used by the underlying data store (usually numeric). + /// + TId Id { get; set; } + } +} diff --git a/src/JsonApiDotNetCore/Resources/IResourceChangeTracker.cs b/src/JsonApiDotNetCore/Resources/IResourceChangeTracker.cs new file mode 100644 index 0000000000..82aae71710 --- /dev/null +++ b/src/JsonApiDotNetCore/Resources/IResourceChangeTracker.cs @@ -0,0 +1,32 @@ +namespace JsonApiDotNetCore.Resources +{ + /// + /// Used to determine whether additional changes to a resource, not specified in a PATCH request, have been applied. + /// + public interface IResourceChangeTracker where TResource : class, IIdentifiable + { + /// + /// Sets the exposed resource attributes as stored in database, before applying changes. + /// + void SetInitiallyStoredAttributeValues(TResource resource); + + /// + /// Sets the subset of exposed attributes from the PATCH request. + /// + void SetRequestedAttributeValues(TResource resource); + + /// + /// Sets the exposed resource attributes as stored in database, after applying changes. + /// + void SetFinallyStoredAttributeValues(TResource resource); + + /// + /// Validates if any exposed resource attributes that were not in the PATCH request have been changed. + /// And validates if the values from the PATCH request are stored without modification. + /// + /// + /// true if the attribute values from the PATCH request were the only changes; false, otherwise. + /// + bool HasImplicitChanges(); + } +} diff --git a/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs b/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs new file mode 100644 index 0000000000..a192e103b0 --- /dev/null +++ b/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Queries.Expressions; + +namespace JsonApiDotNetCore.Resources +{ + /// + /// Used internally to track resource extensibility endpoints. Do not implement this interface directly. + /// + public interface IResourceDefinition + { + IReadOnlyCollection OnApplyIncludes(IReadOnlyCollection existingIncludes); + FilterExpression OnApplyFilter(FilterExpression existingFilter); + SortExpression OnApplySort(SortExpression existingSort); + PaginationExpression OnApplyPagination(PaginationExpression existingPagination); + SparseFieldSetExpression OnApplySparseFieldSet(SparseFieldSetExpression existingSparseFieldSet); + object GetQueryableHandlerForQueryStringParameter(string parameterName); + } +} diff --git a/src/JsonApiDotNetCore/Services/Contract/IResourceDefinitionProvider.cs b/src/JsonApiDotNetCore/Resources/IResourceDefinitionProvider.cs similarity index 79% rename from src/JsonApiDotNetCore/Services/Contract/IResourceDefinitionProvider.cs rename to src/JsonApiDotNetCore/Resources/IResourceDefinitionProvider.cs index 7f25d22470..07a17148a4 100644 --- a/src/JsonApiDotNetCore/Services/Contract/IResourceDefinitionProvider.cs +++ b/src/JsonApiDotNetCore/Resources/IResourceDefinitionProvider.cs @@ -1,7 +1,6 @@ -using System; -using JsonApiDotNetCore.Models; +using System; -namespace JsonApiDotNetCore.Query +namespace JsonApiDotNetCore.Resources { /// /// Retrieves a from the DI container. @@ -13,7 +12,6 @@ public interface IResourceDefinitionProvider /// /// Retrieves the resource definition associated to . /// - /// IResourceDefinition Get(Type resourceType); } -} \ No newline at end of file +} diff --git a/src/JsonApiDotNetCore/Resources/IResourceFactory.cs b/src/JsonApiDotNetCore/Resources/IResourceFactory.cs new file mode 100644 index 0000000000..1ed2356ff7 --- /dev/null +++ b/src/JsonApiDotNetCore/Resources/IResourceFactory.cs @@ -0,0 +1,26 @@ +using System; +using System.Linq.Expressions; + +namespace JsonApiDotNetCore.Resources +{ + /// + /// Creates object instances for resource classes, which may have injectable dependencies. + /// + public interface IResourceFactory + { + /// + /// Creates a new resource object instance. + /// + public object CreateInstance(Type resourceType); + + /// + /// Creates a new resource object instance. + /// + public TResource CreateInstance(); + + /// + /// Returns an expression tree that represents creating a new resource object instance. + /// + public NewExpression CreateNewExpression(Type resourceType); + } +} diff --git a/src/JsonApiDotNetCore/Resources/ITargetedFields.cs b/src/JsonApiDotNetCore/Resources/ITargetedFields.cs new file mode 100644 index 0000000000..03262e834d --- /dev/null +++ b/src/JsonApiDotNetCore/Resources/ITargetedFields.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Resources +{ + /// + /// Container to register which resource attributes and relationships are targeted by a request. + /// + public interface ITargetedFields + { + /// + /// List of attributes that are targeted by a request. + /// + IList Attributes { get; set; } + + /// + /// List of relationships that are targeted by a request. + /// + IList Relationships { get; set; } + } +} diff --git a/src/JsonApiDotNetCore/Resources/Identifiable.cs b/src/JsonApiDotNetCore/Resources/Identifiable.cs new file mode 100644 index 0000000000..b621869e80 --- /dev/null +++ b/src/JsonApiDotNetCore/Resources/Identifiable.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; + +namespace JsonApiDotNetCore.Resources +{ + /// + public abstract class Identifiable : Identifiable + { } + + /// + /// A convenient basic implementation of that provides conversion between and . + /// + /// The resource identifier type. + public abstract class Identifiable : IIdentifiable + { + /// + public virtual TId Id { get; set; } + + /// + [NotMapped] + public string StringId + { + get => GetStringId(Id); + set => Id = GetTypedId(value); + } + + /// + /// Converts an outgoing typed resource identifier to string format for use in a json:api response. + /// + protected virtual string GetStringId(TId value) + { + return EqualityComparer.Default.Equals(value, default) ? null : value.ToString(); + } + + /// + /// Converts an incoming 'id' element from a json:api request to the typed resource identifier. + /// + protected virtual TId GetTypedId(string value) + { + return string.IsNullOrEmpty(value) ? default : (TId)TypeHelper.ConvertType(value, typeof(TId)); + } + } +} diff --git a/src/JsonApiDotNetCore/RequestServices/DefaultResourceChangeTracker.cs b/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs similarity index 53% rename from src/JsonApiDotNetCore/RequestServices/DefaultResourceChangeTracker.cs rename to src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs index f61a7a4e07..9df7cd9dd4 100644 --- a/src/JsonApiDotNetCore/RequestServices/DefaultResourceChangeTracker.cs +++ b/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs @@ -1,43 +1,13 @@ +using System; using System.Collections.Generic; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Serialization; +using JsonApiDotNetCore.Resources.Annotations; using Newtonsoft.Json; -namespace JsonApiDotNetCore.RequestServices +namespace JsonApiDotNetCore.Resources { - /// - /// Used to determine whether additional changes to a resource, not specified in a PATCH request, have been applied. - /// - public interface IResourceChangeTracker where TResource : class, IIdentifiable - { - /// - /// Sets the exposed entity attributes as stored in database, before applying changes. - /// - void SetInitiallyStoredAttributeValues(TResource entity); - - /// - /// Sets the subset of exposed attributes from the PATCH request. - /// - void SetRequestedAttributeValues(TResource entity); - - /// - /// Sets the exposed entity attributes as stored in database, after applying changes. - /// - void SetFinallyStoredAttributeValues(TResource entity); - - /// - /// Validates if any exposed entity attributes that were not in the PATCH request have been changed. - /// And validates if the values from the PATCH request are stored without modification. - /// - /// - /// true if the attribute values from the PATCH request were the only changes; false, otherwise. - /// - 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; @@ -47,29 +17,38 @@ 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; - _contextProvider = contextProvider; - _targetedFields = targetedFields; + _options = options ?? throw new ArgumentNullException(nameof(options)); + _contextProvider = contextProvider ?? throw new ArgumentNullException(nameof(contextProvider)); + _targetedFields = targetedFields ?? throw new ArgumentNullException(nameof(targetedFields)); } - public void SetInitiallyStoredAttributeValues(TResource entity) + /// + public void SetInitiallyStoredAttributeValues(TResource resource) { + if (resource == null) throw new ArgumentNullException(nameof(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); + if (resource == null) throw new ArgumentNullException(nameof(resource)); + + _requestedAttributeValues = CreateAttributeDictionary(resource, _targetedFields.Attributes); } - public void SetFinallyStoredAttributeValues(TResource entity) + /// + public void SetFinallyStoredAttributeValues(TResource resource) { + if (resource == null) throw new ArgumentNullException(nameof(resource)); + var resourceContext = _contextProvider.GetResourceContext(); - _finallyStoredAttributeValues = CreateAttributeDictionary(entity, resourceContext.Attributes); + _finallyStoredAttributeValues = CreateAttributeDictionary(resource, resourceContext.Attributes); } private IDictionary CreateAttributeDictionary(TResource resource, @@ -81,12 +60,13 @@ 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; } + /// public bool HasImplicitChanges() { foreach (var key in _initiallyStoredAttributeValues.Keys) diff --git a/src/JsonApiDotNetCore/Resources/ResourceDefinition.cs b/src/JsonApiDotNetCore/Resources/ResourceDefinition.cs new file mode 100644 index 0000000000..ee446b9334 --- /dev/null +++ b/src/JsonApiDotNetCore/Resources/ResourceDefinition.cs @@ -0,0 +1,243 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Linq.Expressions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Hooks.Internal; +using JsonApiDotNetCore.Hooks.Internal.Execution; +using JsonApiDotNetCore.Queries.Expressions; +using Microsoft.Extensions.Primitives; + +namespace JsonApiDotNetCore.Resources +{ + /// + /// Provides a resource-specific extensibility point for API developers to be notified of various events and influence behavior using custom code. + /// It is intended to improve the developer 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. + /// + /// The resource type. + public class ResourceDefinition : IResourceDefinition, IResourceHookContainer where TResource : class, IIdentifiable + { + protected IResourceGraph ResourceGraph { get; } + + public ResourceDefinition(IResourceGraph resourceGraph) + { + ResourceGraph = resourceGraph ?? throw new ArgumentNullException(nameof(resourceGraph)); + } + + /// + /// This method is part of Resource Hooks, which is an experimental feature and subject to change in future versions. + public virtual void AfterCreate(HashSet resources, ResourcePipeline pipeline) { } + + /// + /// This method is part of Resource Hooks, which is an experimental feature and subject to change in future versions. + public virtual void AfterRead(HashSet resources, ResourcePipeline pipeline, bool isIncluded = false) { } + + /// + /// This method is part of Resource Hooks, which is an experimental feature and subject to change in future versions. + public virtual void AfterUpdate(HashSet resources, ResourcePipeline pipeline) { } + + /// + /// This method is part of Resource Hooks, which is an experimental feature and subject to change in future versions. + public virtual void AfterDelete(HashSet resources, ResourcePipeline pipeline, bool succeeded) { } + + /// + /// This method is part of Resource Hooks, which is an experimental feature and subject to change in future versions. + public virtual void AfterUpdateRelationship(IRelationshipsDictionary resourcesByRelationship, ResourcePipeline pipeline) { } + + /// + /// This method is part of Resource Hooks, which is an experimental feature and subject to change in future versions. + public virtual IEnumerable BeforeCreate(IResourceHashSet resources, ResourcePipeline pipeline) { return resources; } + + /// + /// This method is part of Resource Hooks, which is an experimental feature and subject to change in future versions. + public virtual void BeforeRead(ResourcePipeline pipeline, bool isIncluded = false, string stringId = null) { } + + /// + /// This method is part of Resource Hooks, which is an experimental feature and subject to change in future versions. + public virtual IEnumerable BeforeUpdate(IDiffableResourceHashSet resources, ResourcePipeline pipeline) { return resources; } + + /// + /// This method is part of Resource Hooks, which is an experimental feature and subject to change in future versions. + public virtual IEnumerable BeforeDelete(IResourceHashSet resources, ResourcePipeline pipeline) { return resources; } + + /// + /// This method is part of Resource Hooks, which is an experimental feature and subject to change in future versions. + public virtual IEnumerable BeforeUpdateRelationship(HashSet ids, IRelationshipsDictionary resourcesByRelationship, ResourcePipeline pipeline) { return ids; } + + /// + /// This method is part of Resource Hooks, which is an experimental feature and subject to change in future versions. + public virtual void BeforeImplicitUpdateRelationship(IRelationshipsDictionary resourcesByRelationship, ResourcePipeline pipeline) { } + + /// + /// This method is part of Resource Hooks, which is an experimental feature and subject to change in future versions. + 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. + /// + /// + /// 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) + { + return existingFilter; + } + + /// + /// 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. + /// + /// + /// 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. + /// + /// + /// model.CreatedAt, ListSortDirection.Ascending), + /// (model => model.Password, ListSortDirection.Descending) + /// }); + /// ]]> + /// + protected SortExpression CreateSortExpressionFromLambda(PropertySortOrder keySelectors) + { + if (keySelectors == null) + { + throw new ArgumentNullException(nameof(keySelectors)); + } + + 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); + } + + /// + /// Enables to extend, replace or remove pagination that is being applied on a set of this resource type. + /// + /// + /// 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; + } + + /// + /// 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. + /// + /// + /// 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 discard the existing sparse fieldset and select all viewable 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. + /// + /// + /// 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); + /// } + /// ]]> + /// + protected virtual QueryStringParameterHandlers OnRegisterQueryableHandlersForQueryStringParameters() + { + return new QueryStringParameterHandlers(); + } + + public object GetQueryableHandlerForQueryStringParameter(string parameterName) + { + if (parameterName == null) throw new ArgumentNullException(nameof(parameterName)); + + var handlers = OnRegisterQueryableHandlersForQueryStringParameters(); + return handlers != null && handlers.ContainsKey(parameterName) ? handlers[parameterName] : 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. + /// + public sealed class QueryStringParameterHandlers : Dictionary, StringValues, IQueryable>> + { + } + } +} diff --git a/src/JsonApiDotNetCore/Resources/ResourceDefinitionProvider.cs b/src/JsonApiDotNetCore/Resources/ResourceDefinitionProvider.cs new file mode 100644 index 0000000000..74e41aa304 --- /dev/null +++ b/src/JsonApiDotNetCore/Resources/ResourceDefinitionProvider.cs @@ -0,0 +1,27 @@ +using System; +using JsonApiDotNetCore.Configuration; + +namespace JsonApiDotNetCore.Resources +{ + /// + internal sealed class ResourceDefinitionProvider : IResourceDefinitionProvider + { + private readonly IResourceGraph _resourceContextProvider; + private readonly IRequestScopedServiceProvider _serviceProvider; + + public ResourceDefinitionProvider(IResourceGraph resourceContextProvider, IRequestScopedServiceProvider serviceProvider) + { + _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); + _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + } + + /// + public IResourceDefinition Get(Type resourceType) + { + if (resourceType == null) throw new ArgumentNullException(nameof(resourceType)); + + var resourceContext = _resourceContextProvider.GetResourceContext(resourceType); + return (IResourceDefinition)_serviceProvider.GetService(resourceContext.ResourceDefinitionType); + } + } +} diff --git a/src/JsonApiDotNetCore/Resources/ResourceFactory.cs b/src/JsonApiDotNetCore/Resources/ResourceFactory.cs new file mode 100644 index 0000000000..5352ea4bd7 --- /dev/null +++ b/src/JsonApiDotNetCore/Resources/ResourceFactory.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using JsonApiDotNetCore.Queries.Internal.QueryableBuilding; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace JsonApiDotNetCore.Resources +{ + /// + internal sealed class ResourceFactory : IResourceFactory + { + private readonly IServiceProvider _serviceProvider; + + public ResourceFactory(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + } + + /// + public object CreateInstance(Type resourceType) + { + if (resourceType == null) + { + throw new ArgumentNullException(nameof(resourceType)); + } + + return InnerCreateInstance(resourceType, _serviceProvider); + } + + /// + public TResource CreateInstance() + { + return (TResource) InnerCreateInstance(typeof(TResource), _serviceProvider); + } + + private static object InnerCreateInstance(Type type, IServiceProvider serviceProvider) + { + bool hasSingleConstructorWithoutParameters = HasSingleConstructorWithoutParameters(type); + + try + { + return hasSingleConstructorWithoutParameters + ? Activator.CreateInstance(type) + : ActivatorUtilities.CreateInstance(serviceProvider, type); + } + catch (Exception exception) + { + throw new InvalidOperationException(hasSingleConstructorWithoutParameters + ? $"Failed to create an instance of '{type.FullName}' using its default constructor." + : $"Failed to create an instance of '{type.FullName}' using injected constructor parameters.", + exception); + } + } + + /// + public NewExpression CreateNewExpression(Type resourceType) + { + if (resourceType == null) throw new ArgumentNullException(nameof(resourceType)); + + if (HasSingleConstructorWithoutParameters(resourceType)) + { + return Expression.New(resourceType); + } + + List constructorArguments = new List(); + + var longestConstructor = GetLongestConstructor(resourceType); + foreach (ParameterInfo constructorParameter in longestConstructor.GetParameters()) + { + try + { + object constructorArgument = + ActivatorUtilities.GetServiceOrCreateInstance(_serviceProvider, constructorParameter.ParameterType); + + var argumentExpression = typeof(DbContext).Assembly.GetName().Version.Major >= 5 + // Workaround for https://github.com/dotnet/efcore/issues/20502 to not fail on injected DbContext in EF Core 5. + ? CreateTupleAccessExpressionForConstant(constructorArgument, constructorArgument.GetType()) + : Expression.Constant(constructorArgument); + + constructorArguments.Add(argumentExpression); + } + catch (Exception exception) + { + throw new InvalidOperationException( + $"Failed to create an instance of '{resourceType.FullName}': Parameter '{constructorParameter.Name}' could not be resolved.", + exception); + } + } + + return Expression.New(longestConstructor, constructorArguments); + } + + private static Expression CreateTupleAccessExpressionForConstant(object value, Type type) + { + 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 bool HasSingleConstructorWithoutParameters(Type type) + { + ConstructorInfo[] constructors = type.GetConstructors().Where(c => !c.IsStatic).ToArray(); + + return constructors.Length == 1 && constructors[0].GetParameters().Length == 0; + } + + private static ConstructorInfo GetLongestConstructor(Type type) + { + ConstructorInfo[] constructors = type.GetConstructors().Where(c => !c.IsStatic).ToArray(); + + ConstructorInfo bestMatch = constructors[0]; + int maxParameterLength = constructors[0].GetParameters().Length; + + for (int index = 1; index < constructors.Length; index++) + { + var constructor = constructors[index]; + int length = constructor.GetParameters().Length; + if (length > maxParameterLength) + { + bestMatch = constructor; + maxParameterLength = length; + } + } + + return bestMatch; + } + } +} diff --git a/src/JsonApiDotNetCore/Resources/TargetedFields.cs b/src/JsonApiDotNetCore/Resources/TargetedFields.cs new file mode 100644 index 0000000000..6784b8b9c8 --- /dev/null +++ b/src/JsonApiDotNetCore/Resources/TargetedFields.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Resources +{ + /// + public sealed class TargetedFields : ITargetedFields + { + /// + public IList Attributes { get; set; } = new List(); + + /// + public IList Relationships { get; set; } = new List(); + } +} diff --git a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs new file mode 100644 index 0000000000..cbe4b793c8 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs @@ -0,0 +1,263 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Client.Internal; +using JsonApiDotNetCore.Serialization.Objects; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace JsonApiDotNetCore.Serialization +{ + /// + /// Abstract base class for deserialization. Deserializes JSON content into s + /// and constructs instances of the resource(s) in the document body. + /// + public abstract class BaseDeserializer + { + protected IResourceContextProvider ResourceContextProvider { get; } + protected IResourceFactory ResourceFactory{ get; } + protected Document Document { get; set; } + + protected BaseDeserializer(IResourceContextProvider resourceContextProvider, IResourceFactory resourceFactory) + { + ResourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); + ResourceFactory = resourceFactory ?? throw new ArgumentNullException(nameof(resourceFactory)); + } + + /// + /// This method is called each time a is constructed + /// from the serialized content, which is used to do additional processing + /// depending on the type of deserializer. + /// + /// + /// See the implementation of this method in + /// and for examples. + /// + /// 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 resource, ResourceFieldAttribute field, RelationshipEntry data = null); + + protected object DeserializeBody(string body) + { + if (body == null) throw new ArgumentNullException(nameof(body)); + + var bodyJToken = LoadJToken(body); + Document = bodyJToken.ToObject(); + if (Document.IsManyData) + { + if (Document.ManyData.Count == 0) + return Array.Empty(); + + return Document.ManyData.Select(ParseResourceObject).ToArray(); + } + + if (Document.SingleData == null) return null; + return ParseResourceObject(Document.SingleData); + } + + /// + /// Sets the attributes on a parsed resource. + /// + /// The parsed resource. + /// Attributes and their values, as in the serialized content. + /// Exposed attributes for . + protected virtual IIdentifiable SetAttributes(IIdentifiable resource, IDictionary attributeValues, IReadOnlyCollection attributes) + { + if (resource == null) throw new ArgumentNullException(nameof(resource)); + if (attributes == null) throw new ArgumentNullException(nameof(attributes)); + + if (attributeValues == null || attributeValues.Count == 0) + return resource; + + foreach (var attr in attributes) + { + if (attributeValues.TryGetValue(attr.PublicName, out object newValue)) + { + var convertedValue = ConvertAttrValue(newValue, attr.Property.PropertyType); + attr.SetValue(resource, convertedValue); + AfterProcessField(resource, attr); + } + } + + return resource; + } + + /// + /// Sets the relationships on a parsed resource. + /// + /// The parsed resource. + /// Relationships and their values, as in the serialized content. + /// Exposed relationships for . + protected virtual IIdentifiable SetRelationships(IIdentifiable resource, IDictionary relationshipValues, IReadOnlyCollection relationshipAttributes) + { + if (resource == null) throw new ArgumentNullException(nameof(resource)); + if (relationshipAttributes == null) throw new ArgumentNullException(nameof(relationshipAttributes)); + + if (relationshipValues == null || relationshipValues.Count == 0) + return resource; + + var resourceProperties = resource.GetType().GetProperties(); + foreach (var attr in relationshipAttributes) + { + if (!relationshipValues.TryGetValue(attr.PublicName, out RelationshipEntry relationshipData) || !relationshipData.IsPopulated) + continue; + + if (attr is HasOneAttribute hasOneAttribute) + SetHasOneRelationship(resource, resourceProperties, hasOneAttribute, relationshipData); + else + SetHasManyRelationship(resource, (HasManyAttribute)attr, relationshipData); + } + return resource; + } + + private JToken LoadJToken(string body) + { + JToken jToken; + using (JsonReader jsonReader = new JsonTextReader(new StringReader(body))) + { + // https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/509 + jsonReader.DateParseHandling = DateParseHandling.None; + jToken = JToken.Load(jsonReader); + } + return jToken; + } + + /// + /// Creates an instance of the referenced type in + /// and sets its attributes and relationships. + /// + /// The parsed resource. + private IIdentifiable ParseResourceObject(ResourceObject data) + { + var resourceContext = ResourceContextProvider.GetResourceContext(data.Type); + if (resourceContext == null) + { + throw new InvalidRequestBodyException("Payload includes unknown resource type.", + $"The resource '{data.Type}' is not registered on the resource graph. " + + "If you are using Entity Framework Core, make sure the DbSet matches the expected resource name. " + + "If you have manually registered the resource, check that the call to Add correctly sets the public name.", null); + } + + var resource = (IIdentifiable)ResourceFactory.CreateInstance(resourceContext.ResourceType); + + resource = SetAttributes(resource, data.Attributes, resourceContext.Attributes); + resource = SetRelationships(resource, data.Relationships, resourceContext.Relationships); + + if (data.Id != null) + resource.StringId = data.Id; + + return resource; + } + + /// + /// Sets a HasOne relationship on a parsed resource. If present, also + /// populates the foreign key. + /// + private void SetHasOneRelationship(IIdentifiable resource, + PropertyInfo[] resourceProperties, + HasOneAttribute attr, + RelationshipEntry relationshipData) + { + var rio = (ResourceIdentifierObject)relationshipData.Data; + 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 = resourceProperties.FirstOrDefault(p => p.Name == attr.IdentifiablePropertyName); + + if (foreignKeyProperty != null) + // there is a FK from the current resource pointing to the related object, + // i.e. we're populating the relationship from the dependent side. + SetForeignKey(resource, foreignKeyProperty, attr, relatedId); + + 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(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 resource, PropertyInfo foreignKey, HasOneAttribute attr, string id) + { + bool foreignKeyPropertyIsNullableType = Nullable.GetUnderlyingType(foreignKey.PropertyType) != null + || foreignKey.PropertyType == typeof(string); + if (id == null && !foreignKeyPropertyIsNullableType) + { + // this happens when a non-optional relationship is deliberately set to null. + // For a server deserializer, it should be mapped to a BadRequest HTTP error code. + throw new FormatException($"Cannot set required relationship identifier '{attr.IdentifiablePropertyName}' to null because it is a non-nullable type."); + } + + var typedId = TypeHelper.ConvertStringIdToTypedId(attr.Property.PropertyType, id, ResourceFactory); + foreignKey.SetValue(resource, typedId); + } + + /// + /// Sets the principal side of a HasOne relationship, which means no + /// foreign key is involved. + /// + private void SetNavigation(IIdentifiable resource, HasOneAttribute attr, string relatedId) + { + if (relatedId == null) + { + attr.SetValue(resource, null, ResourceFactory); + } + else + { + var relatedInstance = (IIdentifiable)ResourceFactory.CreateInstance(attr.RightType); + relatedInstance.StringId = relatedId; + attr.SetValue(resource, relatedInstance, ResourceFactory); + } + } + + /// + /// Sets a HasMany relationship. + /// + private void SetHasManyRelationship( + IIdentifiable resource, + HasManyAttribute attr, + RelationshipEntry relationshipData) + { + if (relationshipData.Data != null) + { // if the relationship is set to null, no need to set the navigation property to null: this is the default value. + var relatedResources = relationshipData.ManyData.Select(rio => + { + var relatedInstance = (IIdentifiable)ResourceFactory.CreateInstance(attr.RightType); + relatedInstance.StringId = rio.Id; + return relatedInstance; + }); + + var convertedCollection = TypeHelper.CopyToTypedCollection(relatedResources, attr.Property.PropertyType); + attr.SetValue(resource, convertedCollection, ResourceFactory); + } + + AfterProcessField(resource, attr, relationshipData); + } + + private object ConvertAttrValue(object newValue, Type targetType) + { + if (newValue is JContainer jObject) + // the attribute value is a complex type that needs additional deserialization + return DeserializeComplexType(jObject, targetType); + + // the attribute value is a native C# type. + var convertedValue = TypeHelper.ConvertType(newValue, targetType); + return convertedValue; + } + + private object DeserializeComplexType(JContainer obj, Type targetType) + { + return obj.ToObject(targetType); + } + } +} diff --git a/src/JsonApiDotNetCore/Serialization/BaseSerializer.cs b/src/JsonApiDotNetCore/Serialization/BaseSerializer.cs new file mode 100644 index 0000000000..da61a4b188 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/BaseSerializer.cs @@ -0,0 +1,76 @@ +using System; +using System.Collections.Generic; +using System.IO; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Building; +using JsonApiDotNetCore.Serialization.Objects; +using Newtonsoft.Json; + +namespace JsonApiDotNetCore.Serialization +{ + /// + /// Abstract base class for serialization. + /// Uses to convert resources into s and wraps them in a . + /// + public abstract class BaseSerializer + { + protected IResourceObjectBuilder ResourceObjectBuilder { get; } + + protected BaseSerializer(IResourceObjectBuilder resourceObjectBuilder) + { + ResourceObjectBuilder = resourceObjectBuilder ?? throw new ArgumentNullException(nameof(resourceObjectBuilder)); + } + + /// + /// Builds a for . + /// Adds the attributes and relationships that are enlisted in and . + /// + /// Resource to build a 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 resource, IReadOnlyCollection attributes, IReadOnlyCollection relationships) + { + if (resource == null) + return new Document(); + + return new Document { Data = ResourceObjectBuilder.Build(resource, attributes, relationships) }; + } + + /// + /// Builds a for . + /// Adds the attributes and relationships that are enlisted in and . + /// + /// Resource to build a for. + /// Attributes to include in the building process. + /// Relationships to include in the building process. + /// The resource object that was built. + protected Document Build(IReadOnlyCollection resources, IReadOnlyCollection attributes, IReadOnlyCollection relationships) + { + if (resources == null) throw new ArgumentNullException(nameof(resources)); + + var data = new List(); + foreach (IIdentifiable resource in resources) + data.Add(ResourceObjectBuilder.Build(resource, attributes, relationships)); + + return new Document { Data = data }; + } + + protected string SerializeObject(object value, JsonSerializerSettings defaultSettings, Action changeSerializer = null) + { + if (defaultSettings == null) throw new ArgumentNullException(nameof(defaultSettings)); + + JsonSerializer serializer = JsonSerializer.CreateDefault(defaultSettings); + changeSerializer?.Invoke(serializer); + + using var stringWriter = new StringWriter(); + using (var jsonWriter = new JsonTextWriter(stringWriter)) + { + serializer.Serialize(jsonWriter, value); + } + + return stringWriter.ToString(); + } + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Building/IIncludedResourceObjectBuilder.cs b/src/JsonApiDotNetCore/Serialization/Building/IIncludedResourceObjectBuilder.cs new file mode 100644 index 0000000000..88a1b21afc --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Building/IIncludedResourceObjectBuilder.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Serialization.Building +{ + public interface IIncludedResourceObjectBuilder + { + /// + /// Gets the list of resource objects representing the included resources. + /// + IList Build(); + + /// + /// Extracts the included resources from using the + /// (arbitrarily deeply nested) included relationships in . + /// + void IncludeRelationshipChain(IReadOnlyCollection inclusionChain, IIdentifiable rootResource); + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Server/Contracts/ILinkBuilder.cs b/src/JsonApiDotNetCore/Serialization/Building/ILinkBuilder.cs similarity index 77% rename from src/JsonApiDotNetCore/Serialization/Server/Contracts/ILinkBuilder.cs rename to src/JsonApiDotNetCore/Serialization/Building/ILinkBuilder.cs index 6a0b59042f..ee54290c92 100644 --- a/src/JsonApiDotNetCore/Serialization/Server/Contracts/ILinkBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Building/ILinkBuilder.cs @@ -1,7 +1,8 @@ -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Models.Links; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Serialization.Server.Builders +namespace JsonApiDotNetCore.Serialization.Building { /// /// Builds resource object links and relationship object links. @@ -19,8 +20,6 @@ public interface ILinkBuilder /// /// Builds the links object that is included in the values of the . /// - /// - /// RelationshipLinks GetRelationshipLinks(RelationshipAttribute relationship, IIdentifiable parent); } } diff --git a/src/JsonApiDotNetCore/Serialization/Server/Contracts/IMetaBuilder.cs b/src/JsonApiDotNetCore/Serialization/Building/IMetaBuilder.cs similarity index 51% rename from src/JsonApiDotNetCore/Serialization/Server/Contracts/IMetaBuilder.cs rename to src/JsonApiDotNetCore/Serialization/Building/IMetaBuilder.cs index 5e18f930a5..c939d9d139 100644 --- a/src/JsonApiDotNetCore/Serialization/Server/Contracts/IMetaBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Building/IMetaBuilder.cs @@ -1,27 +1,27 @@ using System.Collections.Generic; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Resources; -namespace JsonApiDotNetCore.Serialization.Server.Builders +namespace JsonApiDotNetCore.Serialization.Building { /// - /// Builds the top-level meta data object. This builder is generic to allow for - /// different top-level meta data object depending on the associated resource of the request. + /// Builds the top-level meta object. This builder is generic to allow for + /// different top-level meta objects depending on the associated resource of the request. /// - /// Associated resource for which to build the meta data + /// Associated resource for which to build the meta element. public interface IMetaBuilder where TResource : class, IIdentifiable { /// - /// Adds a key-value pair to the top-level meta data object + /// Adds a key-value pair to the top-level meta object. /// void Add(string key, object value); /// /// Joins the new dictionary with the current one. In the event of a key collision, - /// the new value will override the old. + /// the new value will overwrite the old one. /// - void Add(Dictionary values); + void Add(IReadOnlyDictionary values); /// /// Builds the top-level meta data object. /// - Dictionary GetMeta(); + IDictionary GetMeta(); } -} \ No newline at end of file +} diff --git a/src/JsonApiDotNetCore/Serialization/Building/IResourceObjectBuilder.cs b/src/JsonApiDotNetCore/Serialization/Building/IResourceObjectBuilder.cs new file mode 100644 index 0000000000..4dea18abdb --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Building/IResourceObjectBuilder.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Serialization.Building +{ + /// + /// Responsible for converting resources into s + /// given a collection of attributes and relationships. + /// + public interface IResourceObjectBuilder + { + /// + /// Converts into a . + /// Adds the attributes and relationships that are enlisted in and . + /// + /// Resource to build a for. + /// Attributes to include in the building process. + /// Relationships to include in the building process. + /// The resource object that was built. + ResourceObject Build(IIdentifiable resource, IReadOnlyCollection attributes, IReadOnlyCollection relationships); + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Server/Contracts/IResourceObjectBuilderSettingsProvider.cs b/src/JsonApiDotNetCore/Serialization/Building/IResourceObjectBuilderSettingsProvider.cs similarity index 62% rename from src/JsonApiDotNetCore/Serialization/Server/Contracts/IResourceObjectBuilderSettingsProvider.cs rename to src/JsonApiDotNetCore/Serialization/Building/IResourceObjectBuilderSettingsProvider.cs index 5da12fe37a..5edc8801d4 100644 --- a/src/JsonApiDotNetCore/Serialization/Server/Contracts/IResourceObjectBuilderSettingsProvider.cs +++ b/src/JsonApiDotNetCore/Serialization/Building/IResourceObjectBuilderSettingsProvider.cs @@ -1,12 +1,12 @@ -namespace JsonApiDotNetCore.Serialization.Server +namespace JsonApiDotNetCore.Serialization.Building { /// - /// Service that provides the server serializer with + /// Service that provides the server serializer with . /// public interface IResourceObjectBuilderSettingsProvider { /// - /// Gets the behaviour for the serializer it is injected in. + /// Gets the behavior for the serializer it is injected in. /// ResourceObjectBuilderSettings Get(); } diff --git a/src/JsonApiDotNetCore/Serialization/Server/Builders/IncludedResourceObjectBuilder.cs b/src/JsonApiDotNetCore/Serialization/Building/IncludedResourceObjectBuilder.cs similarity index 65% rename from src/JsonApiDotNetCore/Serialization/Server/Builders/IncludedResourceObjectBuilder.cs rename to src/JsonApiDotNetCore/Serialization/Building/IncludedResourceObjectBuilder.cs index 5e1608fd3f..fe25ee0550 100644 --- a/src/JsonApiDotNetCore/Serialization/Server/Builders/IncludedResourceObjectBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Building/IncludedResourceObjectBuilder.cs @@ -1,13 +1,14 @@ +using System; using System.Collections; using System.Collections.Generic; using System.Linq; -using JsonApiDotNetCore.Builders; -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Serialization.Server.Builders +namespace JsonApiDotNetCore.Serialization.Building { - /// public class IncludedResourceObjectBuilder : ResourceObjectBuilder, IIncludedResourceObjectBuilder { private readonly HashSet _included; @@ -16,17 +17,17 @@ public class IncludedResourceObjectBuilder : ResourceObjectBuilder, IIncludedRes public IncludedResourceObjectBuilder(IFieldsToSerialize fieldsToSerialize, ILinkBuilder linkBuilder, - IResourceContextProvider provider, + IResourceContextProvider resourceContextProvider, IResourceObjectBuilderSettingsProvider settingsProvider) - : base(provider, settingsProvider.Get()) + : base(resourceContextProvider, settingsProvider.Get()) { - _included = new HashSet(new ResourceObjectComparer()); - _fieldsToSerialize = fieldsToSerialize; - _linkBuilder = linkBuilder; + _included = new HashSet(ResourceIdentifierObjectComparer.Instance); + _fieldsToSerialize = fieldsToSerialize ?? throw new ArgumentNullException(nameof(fieldsToSerialize)); + _linkBuilder = linkBuilder ?? throw new ArgumentNullException(nameof(linkBuilder)); } - /// - public List Build() + /// + public IList Build() { if (_included.Any()) { @@ -41,20 +42,23 @@ public List Build() } resourceObject.Links = _linkBuilder.GetResourceLinks(resourceObject.Type, resourceObject.Id); } - return _included.ToList(); + return _included.ToArray(); } return null; } - /// - public void IncludeRelationshipChain(List inclusionChain, IIdentifiable rootEntity) + /// + public void IncludeRelationshipChain(IReadOnlyCollection inclusionChain, IIdentifiable rootResource) { - // We dont have to build a resource object for the root entity because + if (inclusionChain == null) throw new ArgumentNullException(nameof(inclusionChain)); + if (rootResource == null) throw new ArgumentNullException(nameof(rootResource)); + + // 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); } @@ -77,11 +81,11 @@ 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)) - relationshipsObject[nextRelationshipName] = (relationshipEntry = GetRelationshipData(nextRelationship, parent)); + relationshipsObject[nextRelationshipName] = relationshipEntry = GetRelationshipData(nextRelationship, parent); relationshipEntry.Data = GetRelatedResourceLinkage(nextRelationship, parent); @@ -92,7 +96,7 @@ private void ProcessRelationship(RelationshipAttribute originRelationship, IIden } } - private List ShiftChain(List chain) + private List ShiftChain(IReadOnlyCollection chain) { var chainRemainder = chain.ToList(); chainRemainder.RemoveAt(0); @@ -100,32 +104,29 @@ private List ShiftChain(List chain } /// - /// We only need a empty relationship object entry here. It will be populated in the + /// We only need an empty relationship object entry here. It will be populated in the /// 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) }; + if (relationship == null) throw new ArgumentNullException(nameof(relationship)); + if (resource == null) throw new ArgumentNullException(nameof(resource)); + + return new RelationshipEntry { Links = _linkBuilder.GetRelationshipLinks(relationship, resource) }; } /// /// Gets the resource object for by searching the included list. - /// If it was not already build, it is constructed and added to the included list. + /// If it was not already built, it is constructed and added to the inclusion list. /// - /// - /// - /// private ResourceObject GetOrBuildResourceObject(IIdentifiable parent, RelationshipAttribute relationship) { var type = parent.GetType(); - var resourceName = _provider.GetResourceContext(type).ResourceName; + var resourceName = ResourceContextProvider.GetResourceContext(type).ResourceName; 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/Building/LinkBuilder.cs similarity index 55% rename from src/JsonApiDotNetCore/Serialization/Server/Builders/LinkBuilder.cs rename to src/JsonApiDotNetCore/Serialization/Building/LinkBuilder.cs index 9cd27888a9..b3d58fe84c 100644 --- a/src/JsonApiDotNetCore/Serialization/Server/Builders/LinkBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Building/LinkBuilder.cs @@ -3,50 +3,49 @@ using System.Linq; using System.Text; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Managers.Contracts; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Models.Links; -using JsonApiDotNetCore.Query; -using JsonApiDotNetCore.QueryParameterServices.Common; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.QueryStrings; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Objects; using Microsoft.AspNetCore.Http; -namespace JsonApiDotNetCore.Serialization.Server.Builders +namespace JsonApiDotNetCore.Serialization.Building { public class LinkBuilder : ILinkBuilder { private readonly IResourceContextProvider _provider; private readonly IRequestQueryStringAccessor _queryStringAccessor; - private readonly ILinksConfiguration _options; - private readonly ICurrentRequest _currentRequest; - private readonly IPageService _pageService; + private readonly IJsonApiOptions _options; + private readonly IJsonApiRequest _request; + private readonly IPaginationContext _paginationContext; - public LinkBuilder(ILinksConfiguration options, - ICurrentRequest currentRequest, - IPageService pageService, + public LinkBuilder(IJsonApiOptions options, + IJsonApiRequest request, + IPaginationContext paginationContext, IResourceContextProvider provider, IRequestQueryStringAccessor queryStringAccessor) { - _options = options; - _currentRequest = currentRequest; - _pageService = pageService; - _provider = provider; - _queryStringAccessor = queryStringAccessor; + _options = options ?? throw new ArgumentNullException(nameof(options)); + _request = request ?? throw new ArgumentNullException(nameof(request)); + _paginationContext = paginationContext ?? throw new ArgumentNullException(nameof(paginationContext)); + _provider = provider ?? throw new ArgumentNullException(nameof(provider)); + _queryStringAccessor = queryStringAccessor ?? throw new ArgumentNullException(nameof(queryStringAccessor)); } - /// + /// public TopLevelLinks GetTopLevelLinks() { - ResourceContext resourceContext = _currentRequest.GetRequestResource(); + ResourceContext resourceContext = _request.PrimaryResource; TopLevelLinks topLevelLinks = null; - if (ShouldAddTopLevelLink(resourceContext, Link.Self)) + if (ShouldAddTopLevelLink(resourceContext, LinkTypes.Self)) { topLevelLinks = new TopLevelLinks { Self = GetSelfTopLevelLink(resourceContext) }; } - if (ShouldAddTopLevelLink(resourceContext, Link.Paging) && _pageService.CanPaginate) + if (ShouldAddTopLevelLink(resourceContext, LinkTypes.Paging) && _paginationContext.PageSize != null) { SetPageLinks(resourceContext, topLevelLinks ??= new TopLevelLinks()); } @@ -57,11 +56,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, LinkTypes link) { - if (resourceContext.TopLevelLinks != Link.NotConfigured) + if (resourceContext.TopLevelLinks != LinkTypes.NotConfigured) { return resourceContext.TopLevelLinks.HasFlag(link); } @@ -71,63 +70,58 @@ 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); } } private string GetSelfTopLevelLink(ResourceContext resourceContext) { var builder = new StringBuilder(); - builder.Append(_currentRequest.BasePath); + builder.Append(_request.BasePath); builder.Append("/"); builder.Append(resourceContext.ResourceName); - string resourceId = _currentRequest.BaseId; + string resourceId = _request.PrimaryId; if (resourceId != null) { builder.Append("/"); builder.Append(resourceId); } - if (_currentRequest.RequestRelationship != null) + if (_request.Relationship != null) { builder.Append("/"); - builder.Append(_currentRequest.RequestRelationship.PublicRelationshipName); + builder.Append(_request.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(); parameters["page[number]"] = pageOffset.ToString(); }); - return $"{_currentRequest.BasePath}/{resourceContext.ResourceName}" + queryString; + return $"{_request.BasePath}/{resourceContext.ResourceName}" + queryString; } private string BuildQueryString(Action> updateAction) @@ -136,15 +130,22 @@ 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) { + if (resourceName == null) throw new ArgumentNullException(nameof(resourceName)); + if (id == null) throw new ArgumentNullException(nameof(id)); + var resourceContext = _provider.GetResourceContext(resourceName); - if (ShouldAddResourceLink(resourceContext, Link.Self)) + if (ShouldAddResourceLink(resourceContext, LinkTypes.Self)) { return new ResourceLinks { Self = GetSelfResourceLink(resourceName, id) }; } @@ -152,18 +153,21 @@ public ResourceLinks GetResourceLinks(string resourceName, string id) return null; } - /// + /// public RelationshipLinks GetRelationshipLinks(RelationshipAttribute relationship, IIdentifiable parent) { + if (relationship == null) throw new ArgumentNullException(nameof(relationship)); + if (parent == null) throw new ArgumentNullException(nameof(parent)); + var parentResourceContext = _provider.GetResourceContext(parent.GetType()); - var childNavigation = relationship.PublicRelationshipName; + var childNavigation = relationship.PublicName; RelationshipLinks links = null; - if (ShouldAddRelationshipLink(parentResourceContext, relationship, Link.Related)) + if (ShouldAddRelationshipLink(parentResourceContext, relationship, LinkTypes.Related)) { links = new RelationshipLinks { Related = GetRelatedRelationshipLink(parentResourceContext.ResourceName, parent.StringId, childNavigation) }; } - if (ShouldAddRelationshipLink(parentResourceContext, relationship, Link.Self)) + if (ShouldAddRelationshipLink(parentResourceContext, relationship, LinkTypes.Self)) { links ??= new RelationshipLinks(); links.Self = GetSelfRelationshipLink(parentResourceContext.ResourceName, parent.StringId, childNavigation); @@ -175,27 +179,27 @@ public RelationshipLinks GetRelationshipLinks(RelationshipAttribute relationship private string GetSelfRelationshipLink(string parent, string parentId, string navigation) { - return $"{_currentRequest.BasePath}/{parent}/{parentId}/relationships/{navigation}"; + return $"{_request.BasePath}/{parent}/{parentId}/relationships/{navigation}"; } private string GetSelfResourceLink(string resource, string resourceId) { - return $"{_currentRequest.BasePath}/{resource}/{resourceId}"; + return $"{_request.BasePath}/{resource}/{resourceId}"; } private string GetRelatedRelationshipLink(string parent, string parentId, string navigation) { - return $"{_currentRequest.BasePath}/{parent}/{parentId}/{navigation}"; + return $"{_request.BasePath}/{parent}/{parentId}/{navigation}"; } /// /// 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, LinkTypes link) { - if (resourceContext.ResourceLinks != Link.NotConfigured) + if (resourceContext.ResourceLinks != LinkTypes.NotConfigured) { return resourceContext.ResourceLinks.HasFlag(link); } @@ -206,15 +210,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, LinkTypes link) { - if (relationship.RelationshipLinks != Link.NotConfigured) + if (relationship.Links != LinkTypes.NotConfigured) { - return relationship.RelationshipLinks.HasFlag(link); + return relationship.Links.HasFlag(link); } - if (resourceContext.RelationshipLinks != Link.NotConfigured) + if (resourceContext.RelationshipLinks != LinkTypes.NotConfigured) { return resourceContext.RelationshipLinks.HasFlag(link); } diff --git a/src/JsonApiDotNetCore/Serialization/Building/MetaBuilder.cs b/src/JsonApiDotNetCore/Serialization/Building/MetaBuilder.cs new file mode 100644 index 0000000000..f73bb728b6 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Building/MetaBuilder.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.Serialization.Building +{ + /// + public class MetaBuilder : IMetaBuilder where TResource : class, IIdentifiable + { + private Dictionary _meta = new Dictionary(); + private readonly IPaginationContext _paginationContext; + private readonly IJsonApiOptions _options; + private readonly IRequestMeta _requestMeta; + private readonly IHasMeta _resourceMeta; + + public MetaBuilder(IPaginationContext paginationContext, IJsonApiOptions options, IRequestMeta requestMeta = null, + ResourceDefinition resourceDefinition = null) + { + _paginationContext = paginationContext ?? throw new ArgumentNullException(nameof(paginationContext)); + _options = options ?? throw new ArgumentNullException(nameof(options)); + _requestMeta = requestMeta; + _resourceMeta = resourceDefinition as IHasMeta; + } + + /// + public void Add(string key, object value) + { + if (key == null) throw new ArgumentNullException(nameof(key)); + + _meta[key] = value ?? throw new ArgumentNullException(nameof(value)); + } + + /// + public void Add(IReadOnlyDictionary values) + { + if (values == null) throw new ArgumentNullException(nameof(values)); + + _meta = values.Keys.Union(_meta.Keys) + .ToDictionary(key => key, + key => values.ContainsKey(key) ? values[key] : _meta[key]); + } + + /// + public IDictionary GetMeta() + { + 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()); + } + + return _meta.Any() ? _meta : null; + } + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Building/ResourceIdentifierObjectComparer.cs b/src/JsonApiDotNetCore/Serialization/Building/ResourceIdentifierObjectComparer.cs new file mode 100644 index 0000000000..7820f874f0 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Building/ResourceIdentifierObjectComparer.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Serialization.Building +{ + internal sealed class ResourceIdentifierObjectComparer : IEqualityComparer + { + public static readonly ResourceIdentifierObjectComparer Instance = new ResourceIdentifierObjectComparer(); + + private ResourceIdentifierObjectComparer() + { + } + + public bool Equals(ResourceIdentifierObject x, ResourceIdentifierObject y) + { + if (ReferenceEquals(x, y)) + { + return true; + } + + if (x is null || y is null || x.GetType() != y.GetType()) + { + return false; + } + + return x.Id == y.Id && x.Type == y.Type; + } + + public int GetHashCode(ResourceIdentifierObject obj) + { + return obj.GetHashCode(); + } + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilder.cs b/src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilder.cs new file mode 100644 index 0000000000..31c9f2555d --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilder.cs @@ -0,0 +1,165 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Objects; +using Newtonsoft.Json; + +namespace JsonApiDotNetCore.Serialization.Building +{ + /// + public class ResourceObjectBuilder : IResourceObjectBuilder + { + protected IResourceContextProvider ResourceContextProvider { get; } + private readonly ResourceObjectBuilderSettings _settings; + + public ResourceObjectBuilder(IResourceContextProvider resourceContextProvider, ResourceObjectBuilderSettings settings) + { + ResourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); + _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + } + + /// + public ResourceObject Build(IIdentifiable resource, IReadOnlyCollection attributes = null, IReadOnlyCollection relationships = null) + { + if (resource == null) throw new ArgumentNullException(nameof(resource)); + + var resourceContext = ResourceContextProvider.GetResourceContext(resource.GetType()); + + // populating the top-level "type" and "id" members. + 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 != nameof(Identifiable.Id)).ToArray()).Any()) + ProcessAttributes(resource, attributes, ro); + + // populating the top-level "relationship" member of a resource object. + if (relationships != null) + ProcessRelationships(resource, relationships, ro); + + return ro; + } + + /// + /// Builds the entries of the "relationships + /// objects". The default behavior is to just construct a resource linkage + /// with the "data" field populated with "single" or "many" data. + /// Depending on the requirements of the implementation (server or client serializer), + /// this may be overridden. + /// + protected virtual RelationshipEntry GetRelationshipData(RelationshipAttribute relationship, IIdentifiable resource) + { + if (relationship == null) throw new ArgumentNullException(nameof(relationship)); + if (resource == null) throw new ArgumentNullException(nameof(resource)); + + return new RelationshipEntry { Data = GetRelatedResourceLinkage(relationship, resource) }; + } + + /// + /// Gets the value for the property. + /// + protected object GetRelatedResourceLinkage(RelationshipAttribute relationship, IIdentifiable resource) + { + if (relationship == null) throw new ArgumentNullException(nameof(relationship)); + if (resource == null) throw new ArgumentNullException(nameof(resource)); + + return relationship is HasOneAttribute hasOne + ? (object) GetRelatedResourceLinkageForHasOne(hasOne, resource) + : GetRelatedResourceLinkageForHasMany((HasManyAttribute) relationship, resource); + } + + /// + /// Builds a for a HasOne relationship. + /// + private ResourceIdentifierObject GetRelatedResourceLinkageForHasOne(HasOneAttribute relationship, IIdentifiable resource) + { + var relatedResource = (IIdentifiable)relationship.GetValue(resource); + if (relatedResource == null && IsRequiredToOneRelationship(relationship, resource)) + throw new NotSupportedException("Cannot serialize a required to one relationship that is not populated but was included in the set of relationships to be serialized."); + + if (relatedResource != null) + return GetResourceIdentifier(relatedResource); + + return null; + } + + /// + /// Builds the s for a HasMany relationship. + /// + private List GetRelatedResourceLinkageForHasMany(HasManyAttribute relationship, IIdentifiable resource) + { + var relatedResources = (IEnumerable)relationship.GetValue(resource); + var manyData = new List(); + if (relatedResources != null) + foreach (IIdentifiable relatedResource in relatedResources) + manyData.Add(GetResourceIdentifier(relatedResource)); + + return manyData; + } + + /// + /// Creates a from . + /// + private ResourceIdentifierObject GetResourceIdentifier(IIdentifiable resource) + { + var resourceName = ResourceContextProvider.GetResourceContext(resource.GetType()).ResourceName; + return new ResourceIdentifierObject + { + Type = resourceName, + 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 resource) + { + var foreignKey = resource.GetType().GetProperty(attr.IdentifiablePropertyName); + if (foreignKey != null && Nullable.GetUnderlyingType(foreignKey.PropertyType) == null) + return true; + + return false; + } + + /// + /// Puts the relationships of the resource into the resource object. + /// + private void ProcessRelationships(IIdentifiable resource, IEnumerable relationships, ResourceObject ro) + { + foreach (var rel in relationships) + { + var relData = GetRelationshipData(rel, resource); + if (relData != null) + (ro.Relationships ??= new Dictionary()).Add(rel.PublicName, relData); + } + } + + /// + /// Puts the attributes of the resource into the resource object. + /// + private void ProcessAttributes(IIdentifiable resource, IEnumerable attributes, ResourceObject ro) + { + ro.Attributes = new Dictionary(); + foreach (var attr in attributes) + { + object value = attr.GetValue(resource); + + if (_settings.SerializerNullValueHandling == NullValueHandling.Ignore && value == null) + { + return; + } + + if (_settings.SerializerDefaultValueHandling == DefaultValueHandling.Ignore && value == TypeHelper.GetDefaultValue(attr.Property.PropertyType)) + { + return; + } + + ro.Attributes.Add(attr.PublicName, value); + } + } + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Common/ResourceObjectBuilderSettings.cs b/src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilderSettings.cs similarity index 89% rename from src/JsonApiDotNetCore/Serialization/Common/ResourceObjectBuilderSettings.cs rename to src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilderSettings.cs index 2eceb3809d..1d00ab813f 100644 --- a/src/JsonApiDotNetCore/Serialization/Common/ResourceObjectBuilderSettings.cs +++ b/src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilderSettings.cs @@ -1,7 +1,7 @@ -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Serialization.Objects; using Newtonsoft.Json; -namespace JsonApiDotNetCore.Serialization +namespace JsonApiDotNetCore.Serialization.Building { /// /// Options used to configure how fields of a model get serialized into diff --git a/src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilderSettingsProvider.cs b/src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilderSettingsProvider.cs new file mode 100644 index 0000000000..8b317c3e3e --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilderSettingsProvider.cs @@ -0,0 +1,28 @@ +using System; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.QueryStrings; + +namespace JsonApiDotNetCore.Serialization.Building +{ + /// + /// This implementation of the behavior provider reads the defaults/nulls query string parameters that + /// can, if provided, override the settings in . + /// + public sealed class ResourceObjectBuilderSettingsProvider : IResourceObjectBuilderSettingsProvider + { + private readonly IDefaultsQueryStringParameterReader _defaultsReader; + private readonly INullsQueryStringParameterReader _nullsReader; + + public ResourceObjectBuilderSettingsProvider(IDefaultsQueryStringParameterReader defaultsReader, INullsQueryStringParameterReader nullsReader) + { + _defaultsReader = defaultsReader ?? throw new ArgumentNullException(nameof(defaultsReader)); + _nullsReader = nullsReader ?? throw new ArgumentNullException(nameof(nullsReader)); + } + + /// + public ResourceObjectBuilderSettings Get() + { + return new ResourceObjectBuilderSettings(_nullsReader.SerializerNullValueHandling, _defaultsReader.SerializerDefaultValueHandling); + } + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Building/ResponseResourceObjectBuilder.cs b/src/JsonApiDotNetCore/Serialization/Building/ResponseResourceObjectBuilder.cs new file mode 100644 index 0000000000..e7a1344b53 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Building/ResponseResourceObjectBuilder.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.QueryStrings; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Serialization.Building +{ + public class ResponseResourceObjectBuilder : ResourceObjectBuilder + { + private readonly IIncludedResourceObjectBuilder _includedBuilder; + private readonly IEnumerable _constraintProviders; + private readonly ILinkBuilder _linkBuilder; + private RelationshipAttribute _requestRelationship; + + public ResponseResourceObjectBuilder(ILinkBuilder linkBuilder, + IIncludedResourceObjectBuilder includedBuilder, + IEnumerable constraintProviders, + IResourceContextProvider resourceContextProvider, + IResourceObjectBuilderSettingsProvider settingsProvider) + : base(resourceContextProvider, settingsProvider.Get()) + { + _linkBuilder = linkBuilder ?? throw new ArgumentNullException(nameof(linkBuilder)); + _includedBuilder = includedBuilder ?? throw new ArgumentNullException(nameof(includedBuilder)); + _constraintProviders = constraintProviders ?? throw new ArgumentNullException(nameof(constraintProviders)); + } + + public RelationshipEntry Build(IIdentifiable resource, RelationshipAttribute requestRelationship) + { + if (resource == null) throw new ArgumentNullException(nameof(resource)); + + _requestRelationship = requestRelationship ?? throw new ArgumentNullException(nameof(requestRelationship)); + return GetRelationshipData(requestRelationship, resource); + } + + /// + /// Builds the values of the relationships object on a resource object. + /// The server serializer only populates the "data" member when the relationship is included, + /// and adds links unless these are turned off. This means that if a relationship is not included + /// 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 resource) + { + if (relationship == null) throw new ArgumentNullException(nameof(relationship)); + if (resource == null) throw new ArgumentNullException(nameof(resource)); + + RelationshipEntry relationshipEntry = null; + List> relationshipChains = null; + if (Equals(relationship, _requestRelationship) || ShouldInclude(relationship, out relationshipChains)) + { + relationshipEntry = base.GetRelationshipData(relationship, resource); + if (relationshipChains != null && relationshipEntry.HasResource) + foreach (var chain in relationshipChains) + // traverses (recursively) and extracts all (nested) related resources for the current inclusion chain. + _includedBuilder.IncludeRelationshipChain(chain, resource); + } + + 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; + + // if neither "links" nor "data" was populated, return null, which will omit this entry from the output. + // (see the NullValueHandling settings on ) + return relationshipEntry; + } + + /// + /// Inspects the included relationship chains (see + /// to see if should be included or not. + /// + private bool ShouldInclude(RelationshipAttribute relationship, out List> inclusionChain) + { + var chains = _constraintProviders + .SelectMany(p => p.GetConstraints()) + .Select(expressionInScope => expressionInScope.Expression) + .OfType() + .SelectMany(IncludeChainConverter.GetRelationshipChains) + .ToArray(); + + inclusionChain = new List>(); + + foreach (var chain in chains) + { + if (chain.Fields.First().Equals(relationship)) + { + inclusionChain.Add(chain.Fields.Cast().ToArray()); + } + } + + return inclusionChain.Any(); + } + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Client/DeserializedResponse.cs b/src/JsonApiDotNetCore/Serialization/Client/DeserializedResponse.cs deleted file mode 100644 index 6bae9a9370..0000000000 --- a/src/JsonApiDotNetCore/Serialization/Client/DeserializedResponse.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Collections.Generic; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Models.Links; - -namespace JsonApiDotNetCore.Serialization.Client -{ - /// Base class for "single data" and "many data" deserialized responses. - /// TODO: Currently and - /// information is ignored by the serializer. This is out of scope for now because - /// it is not considered mission critical for v4. - public abstract class DeserializedResponseBase - { - public TopLevelLinks Links { get; set; } - public Dictionary Meta { get; set; } - public object Errors { get; set; } - public object JsonApi { get; set; } - } - - /// - /// Represents a deserialized document with "single data". - /// - /// Type of the resource in the primary data - public sealed class DeserializedSingleResponse : DeserializedResponseBase where TResource : class, IIdentifiable - { - public TResource Data { get; set; } - } - - /// - /// Represents a deserialized document with "many data". - /// - /// Type of the resource(s) in the primary data - public sealed class DeserializedListResponse : DeserializedResponseBase where TResource : class, IIdentifiable - { - public List Data { get; set; } - } -} diff --git a/src/JsonApiDotNetCore/Serialization/Client/IResponseDeserializer.cs b/src/JsonApiDotNetCore/Serialization/Client/IResponseDeserializer.cs deleted file mode 100644 index c40a07e8a2..0000000000 --- a/src/JsonApiDotNetCore/Serialization/Client/IResponseDeserializer.cs +++ /dev/null @@ -1,26 +0,0 @@ -using JsonApiDotNetCore.Models; - -namespace JsonApiDotNetCore.Serialization.Client -{ - /// - /// Client deserializer. Currently not used internally in JsonApiDotNetCore, - /// except for in the tests. Exposed publicly to make testing easier or to implement - /// server-to-server communication. - /// - public interface IResponseDeserializer - { - /// - /// Deserializes a response with a single resource (or null) as data. - /// - /// The type of the resources in the primary data - /// The JSON to be deserialized - DeserializedSingleResponse DeserializeSingle(string body) where TResource : class, IIdentifiable; - - /// - /// Deserializes a response with a (empty) list of resources as data. - /// - /// The type of the resources in the primary data - /// The JSON to be deserialized - DeserializedListResponse DeserializeList(string body) where TResource : class, IIdentifiable; - } -} diff --git a/src/JsonApiDotNetCore/Serialization/Client/Internal/DeserializedResponseBase.cs b/src/JsonApiDotNetCore/Serialization/Client/Internal/DeserializedResponseBase.cs new file mode 100644 index 0000000000..c2831e327e --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Client/Internal/DeserializedResponseBase.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Serialization.Client.Internal +{ + /// Base class for "single data" and "many data" deserialized responses. + /// TODO: Currently and + /// information is ignored by the serializer. This is out of scope for now because + /// it is not considered mission critical for v4. + public abstract class DeserializedResponseBase + { + public TopLevelLinks Links { get; set; } + public IDictionary Meta { get; set; } + public object Errors { get; set; } + public object JsonApi { get; set; } + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Client/IRequestSerializer.cs b/src/JsonApiDotNetCore/Serialization/Client/Internal/IRequestSerializer.cs similarity index 54% rename from src/JsonApiDotNetCore/Serialization/Client/IRequestSerializer.cs rename to src/JsonApiDotNetCore/Serialization/Client/Internal/IRequestSerializer.cs index 244b30f70e..eabca7e593 100644 --- a/src/JsonApiDotNetCore/Serialization/Client/IRequestSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/Client/Internal/IRequestSerializer.cs @@ -1,39 +1,40 @@ -using System.Collections; using System.Collections.Generic; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Internal.Contracts; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Serialization.Client +namespace JsonApiDotNetCore.Serialization.Client.Internal { /// - /// Interface for client serializer that can be used to register with the DI, for usage in + /// Interface for client serializer that can be used to register with the DI container, for usage in /// custom services or repositories. /// 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 collection of resources. /// - /// Entities to serialize /// The serialized content - string Serialize(IEnumerable entities); + string Serialize(IReadOnlyCollection 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; } + public IReadOnlyCollection 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; } + public IReadOnlyCollection RelationshipsToSerialize { set; } } } diff --git a/src/JsonApiDotNetCore/Serialization/Client/Internal/IResponseDeserializer.cs b/src/JsonApiDotNetCore/Serialization/Client/Internal/IResponseDeserializer.cs new file mode 100644 index 0000000000..c4ede45737 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Client/Internal/IResponseDeserializer.cs @@ -0,0 +1,26 @@ +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.Serialization.Client.Internal +{ + /// + /// Client deserializer. Currently not used internally in JsonApiDotNetCore, + /// except for in the tests. Exposed publicly to make testing easier or to implement + /// server-to-server communication. + /// + public interface IResponseDeserializer + { + /// + /// Deserializes a response with a single resource (or null) as data. + /// + /// The type of the resources in the primary data. + /// The JSON to be deserialized. + SingleResponse DeserializeSingle(string body) where TResource : class, IIdentifiable; + + /// + /// Deserializes a response with an (empty) collection of resources as data. + /// + /// The type of the resources in the primary data. + /// The JSON to be deserialized. + ManyResponse DeserializeMany(string body) where TResource : class, IIdentifiable; + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Client/Internal/ManyResponse.cs b/src/JsonApiDotNetCore/Serialization/Client/Internal/ManyResponse.cs new file mode 100644 index 0000000000..954b65e167 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Client/Internal/ManyResponse.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.Serialization.Client.Internal +{ + /// + /// Represents a deserialized document with "many data". + /// + /// Type of the resource(s) in the primary data. + public sealed class ManyResponse : DeserializedResponseBase where TResource : class, IIdentifiable + { + public IReadOnlyCollection Data { get; set; } + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Client/Internal/RequestSerializer.cs b/src/JsonApiDotNetCore/Serialization/Client/Internal/RequestSerializer.cs new file mode 100644 index 0000000000..2e0cf5f461 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Client/Internal/RequestSerializer.cs @@ -0,0 +1,112 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Building; +using JsonApiDotNetCore.Serialization.Objects; +using Newtonsoft.Json; + +namespace JsonApiDotNetCore.Serialization.Client.Internal +{ + /// + /// Client serializer implementation of . + /// + public class RequestSerializer : BaseSerializer, IRequestSerializer + { + private Type _currentTargetedResource; + private readonly IResourceGraph _resourceGraph; + private readonly JsonSerializerSettings _jsonSerializerSettings = new JsonSerializerSettings(); + + public RequestSerializer(IResourceGraph resourceGraph, + IResourceObjectBuilder resourceObjectBuilder) + : base(resourceObjectBuilder) + { + _resourceGraph = resourceGraph ?? throw new ArgumentNullException(nameof(resourceGraph)); + } + + /// + public string Serialize(IIdentifiable resource) + { + if (resource == null) + { + var empty = Build((IIdentifiable) null, Array.Empty(), Array.Empty()); + return SerializeObject(empty, _jsonSerializerSettings); + } + + _currentTargetedResource = resource.GetType(); + var document = Build(resource, GetAttributesToSerialize(resource), GetRelationshipsToSerialize(resource)); + _currentTargetedResource = null; + + return SerializeObject(document, _jsonSerializerSettings); + } + + /// + public string Serialize(IReadOnlyCollection resources) + { + if (resources == null) throw new ArgumentNullException(nameof(resources)); + + IIdentifiable firstResource = resources.FirstOrDefault(); + + Document document; + if (firstResource == null) + { + document = Build(resources, Array.Empty(), Array.Empty()); + } + else + { + _currentTargetedResource = firstResource.GetType(); + var attributes = GetAttributesToSerialize(firstResource); + var relationships = GetRelationshipsToSerialize(firstResource); + + document = Build(resources, attributes, relationships); + _currentTargetedResource = null; + } + + return SerializeObject(document, _jsonSerializerSettings); + } + + /// + public IReadOnlyCollection AttributesToSerialize { private get; set; } + + /// + public IReadOnlyCollection RelationshipsToSerialize { private get; set; } + + /// + /// By default, the client serializer includes all attributes in the result, + /// unless a list of allowed attributes was supplied using the + /// method. For any related resources, attributes are never exposed. + /// + private IReadOnlyCollection GetAttributesToSerialize(IIdentifiable resource) + { + 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. + return new List(); + + if (AttributesToSerialize == null) + return _resourceGraph.GetAttributes(currentResourceType); + + return AttributesToSerialize; + } + + /// + /// By default, the client serializer does not include any relationships + /// for resources in the primary data unless explicitly included using + /// . + /// + private IReadOnlyCollection GetRelationshipsToSerialize(IIdentifiable resource) + { + var currentResourceType = resource.GetType(); + // only allow relationship attributes to be serialized if they were set using + // + // and the current resource is a primary entry. + if (RelationshipsToSerialize == null) + return _resourceGraph.GetRelationships(currentResourceType); + + return RelationshipsToSerialize; + } + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Client/ResponseDeserializer.cs b/src/JsonApiDotNetCore/Serialization/Client/Internal/ResponseDeserializer.cs similarity index 52% rename from src/JsonApiDotNetCore/Serialization/Client/ResponseDeserializer.cs rename to src/JsonApiDotNetCore/Serialization/Client/Internal/ResponseDeserializer.cs index c3210eb131..277cd0b2c0 100644 --- a/src/JsonApiDotNetCore/Serialization/Client/ResponseDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/Client/Internal/ResponseDeserializer.cs @@ -1,43 +1,47 @@ using System; using System.Collections.Generic; using System.Linq; -using JsonApiDotNetCore.Extensions; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Serialization.Client +namespace JsonApiDotNetCore.Serialization.Client.Internal { /// - /// Client deserializer implementation of the + /// Client deserializer implementation of the . /// - public class ResponseDeserializer : BaseDocumentParser, IResponseDeserializer + public class ResponseDeserializer : BaseDeserializer, IResponseDeserializer { - public ResponseDeserializer(IResourceContextProvider contextProvider, IResourceFactory resourceFactory) : base(contextProvider, resourceFactory) { } + public ResponseDeserializer(IResourceContextProvider resourceContextProvider, IResourceFactory resourceFactory) : base(resourceContextProvider, resourceFactory) { } - /// - public DeserializedSingleResponse DeserializeSingle(string body) where TResource : class, IIdentifiable + /// + public SingleResponse DeserializeSingle(string body) where TResource : class, IIdentifiable { - var entity = Deserialize(body); - return new DeserializedSingleResponse + if (body == null) throw new ArgumentNullException(nameof(body)); + + var resource = DeserializeBody(body); + return new SingleResponse { - Links = _document.Links, - Meta = _document.Meta, - Data = (TResource) entity, + Links = Document.Links, + Meta = Document.Meta, + Data = (TResource) resource, JsonApi = null, Errors = null }; } - /// - public DeserializedListResponse DeserializeList(string body) where TResource : class, IIdentifiable + /// + public ManyResponse DeserializeMany(string body) where TResource : class, IIdentifiable { - var entities = Deserialize(body); - return new DeserializedListResponse + if (body == null) throw new ArgumentNullException(nameof(body)); + + var resources = DeserializeBody(body); + return new ManyResponse { - Links = _document.Links, - Meta = _document.Meta, - Data = ((ICollection) entities)?.Cast().ToList(), + Links = Document.Links, + Meta = Document.Meta, + Data = ((ICollection) resources)?.Cast().ToArray(), JsonApi = null, Errors = null }; @@ -48,46 +52,49 @@ 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 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) + /// 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 resource, ResourceFieldAttribute field, RelationshipEntry data = null) { + if (resource == null) throw new ArgumentNullException(nameof(resource)); + if (field == null) throw new ArgumentNullException(nameof(field)); + // Client deserializers do not need additional processing for attributes. if (field is AttrAttribute) return; // if the included property is empty or absent, there is no additional data to be parsed. - if (_document.Included == null || _document.Included.Count == 0) + if (Document.Included == null || Document.Included.Count == 0) return; if (field is HasOneAttribute hasOneAttr) { // 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.PropertyInfo.PropertyType); - hasManyAttr.SetValue(entity, values, _resourceFactory); + var values = TypeHelper.CopyToTypedCollection(items, hasManyAttr.Property.PropertyType); + hasManyAttr.SetValue(resource, values, ResourceFactory); } } /// - /// Searches for and parses the included relationship + /// Searches for and parses the included relationship. /// private IIdentifiable ParseIncludedRelationship(RelationshipAttribute relationshipAttr, ResourceIdentifierObject relatedResourceIdentifier) { - var relatedInstance = (IIdentifiable)_resourceFactory.CreateInstance(relationshipAttr.RightType); + var relatedInstance = (IIdentifiable)ResourceFactory.CreateInstance(relationshipAttr.RightType); relatedInstance.StringId = relatedResourceIdentifier.Id; var includedResource = GetLinkedResource(relatedResourceIdentifier); if (includedResource == null) return relatedInstance; - var resourceContext = _contextProvider.GetResourceContext(relatedResourceIdentifier.Type); + var resourceContext = ResourceContextProvider.GetResourceContext(relatedResourceIdentifier.Type); if (resourceContext == null) throw new InvalidOperationException($"Included type '{relationshipAttr.RightType}' is not a registered json:api resource."); @@ -100,11 +107,11 @@ private ResourceObject GetLinkedResource(ResourceIdentifierObject relatedResourc { try { - return _document.Included.SingleOrDefault(r => r.Type == relatedResourceIdentifier.Type && r.Id == relatedResourceIdentifier.Id); + return Document.Included.SingleOrDefault(r => r.Type == relatedResourceIdentifier.Type && r.Id == relatedResourceIdentifier.Id); } catch (InvalidOperationException e) { - throw new InvalidOperationException("A compound document MUST NOT include more than one resource object for each type and id pair." + throw new InvalidOperationException("A compound document MUST NOT include more than one resource object for each type and ID pair." + $"The duplicate pair was '{relatedResourceIdentifier.Type}, {relatedResourceIdentifier.Id}'", e); } } diff --git a/src/JsonApiDotNetCore/Serialization/Client/Internal/SingleResponse.cs b/src/JsonApiDotNetCore/Serialization/Client/Internal/SingleResponse.cs new file mode 100644 index 0000000000..562317f2bf --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Client/Internal/SingleResponse.cs @@ -0,0 +1,13 @@ +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.Serialization.Client.Internal +{ + /// + /// Represents a deserialized document with "single data". + /// + /// Type of the resource in the primary data. + public sealed class SingleResponse : DeserializedResponseBase where TResource : class, IIdentifiable + { + public TResource Data { get; set; } + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Client/RequestSerializer.cs b/src/JsonApiDotNetCore/Serialization/Client/RequestSerializer.cs deleted file mode 100644 index fff4639d73..0000000000 --- a/src/JsonApiDotNetCore/Serialization/Client/RequestSerializer.cs +++ /dev/null @@ -1,109 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Models; -using Newtonsoft.Json; - -namespace JsonApiDotNetCore.Serialization.Client -{ - /// - /// Client serializer implementation of - /// - public class RequestSerializer : BaseDocumentBuilder, IRequestSerializer - { - private Type _currentTargetedResource; - private readonly IResourceGraph _resourceGraph; - private readonly JsonSerializerSettings _jsonSerializerSettings = new JsonSerializerSettings(); - - public RequestSerializer(IResourceGraph resourceGraph, - IResourceObjectBuilder resourceObjectBuilder) - : base(resourceObjectBuilder) - { - _resourceGraph = resourceGraph; - } - - /// - public string Serialize(IIdentifiable entity) - { - if (entity == 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 = null; - - return SerializeObject(document, _jsonSerializerSettings); - } - - /// - public string Serialize(IEnumerable entities) - { - IIdentifiable entity = null; - foreach (IIdentifiable item in entities) - { - entity = item; - break; - } - - if (entity == null) - { - var result = Build(entities, 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 = null; - return SerializeObject(document, _jsonSerializerSettings); - } - - /// - public IEnumerable AttributesToSerialize { private get; set; } - - /// - public IEnumerable RelationshipsToSerialize { private get; set; } - - /// - /// By default, the client serializer includes all attributes in the result, - /// unless a list of allowed attributes was supplied using the - /// method. For any related resources, attributes are never exposed. - /// - private List GetAttributesToSerialize(IIdentifiable entity) - { - var currentResourceType = entity.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. - return new List(); - - if (AttributesToSerialize == null) - return _resourceGraph.GetAttributes(currentResourceType); - - return AttributesToSerialize.ToList(); - } - - /// - /// By default, the client serializer does not include any relationships - /// for entities in the primary data unless explicitly included using - /// . - /// - private List GetRelationshipsToSerialize(IIdentifiable entity) - { - var currentResourceType = entity.GetType(); - // only allow relationship attributes to be serialized if they were set using - // - // and the current is a main entry in the primary data. - if (RelationshipsToSerialize == null) - return _resourceGraph.GetRelationships(currentResourceType); - - return RelationshipsToSerialize.ToList(); - } - } -} diff --git a/src/JsonApiDotNetCore/Serialization/Common/BaseDocumentParser.cs b/src/JsonApiDotNetCore/Serialization/Common/BaseDocumentParser.cs deleted file mode 100644 index b4626bf884..0000000000 --- a/src/JsonApiDotNetCore/Serialization/Common/BaseDocumentParser.cs +++ /dev/null @@ -1,264 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Reflection; -using JsonApiDotNetCore.Exceptions; -using JsonApiDotNetCore.Extensions; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Serialization.Client; -using JsonApiDotNetCore.Serialization.Server; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; - -namespace JsonApiDotNetCore.Serialization -{ - /// - /// Abstract base class for deserialization. Deserializes JSON content into s - /// And constructs instances of the resource(s) in the document body. - /// - public abstract class BaseDocumentParser - { - protected readonly IResourceContextProvider _contextProvider; - protected readonly IResourceFactory _resourceFactory; - protected Document _document; - - protected BaseDocumentParser(IResourceContextProvider contextProvider, IResourceFactory resourceFactory) - { - _contextProvider = contextProvider; - _resourceFactory = resourceFactory; - } - - /// - /// 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. - /// - /// - /// See the implementation of this method in - /// and for examples. - /// - /// 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 object Deserialize(string body) - { - var bodyJToken = LoadJToken(body); - _document = bodyJToken.ToObject(); - if (_document.IsManyData) - { - if (_document.ManyData.Count == 0) - return Array.Empty(); - - return _document.ManyData.Select(ParseResourceObject).ToList(); - } - - if (_document.SingleData == null) return null; - return ParseResourceObject(_document.SingleData); - } - - /// - /// Sets the attributes on a parsed entity. - /// - /// The parsed entity - /// Attributes and their values, as in the serialized content - /// Exposed attributes for - /// - protected IIdentifiable SetAttributes(IIdentifiable entity, Dictionary attributeValues, List attributes) - { - if (attributeValues == null || attributeValues.Count == 0) - return entity; - - foreach (var attr in attributes) - { - if (attributeValues.TryGetValue(attr.PublicAttributeName, out object newValue)) - { - var convertedValue = ConvertAttrValue(newValue, attr.PropertyInfo.PropertyType); - attr.SetValue(entity, convertedValue); - AfterProcessField(entity, attr); - } - } - - return entity; - } - /// - /// Sets the relationships on a parsed entity - /// - /// The parsed entity - /// Relationships and their values, as in the serialized content - /// Exposed relationships for - /// - protected IIdentifiable SetRelationships(IIdentifiable entity, Dictionary relationshipsValues, List relationshipAttributes) - { - if (relationshipsValues == null || relationshipsValues.Count == 0) - return entity; - - var entityProperties = entity.GetType().GetProperties(); - foreach (var attr in relationshipAttributes) - { - if (!relationshipsValues.TryGetValue(attr.PublicRelationshipName, out RelationshipEntry relationshipData) || !relationshipData.IsPopulated) - continue; - - if (attr is HasOneAttribute hasOneAttribute) - SetHasOneRelationship(entity, entityProperties, hasOneAttribute, relationshipData); - else - SetHasManyRelationship(entity, (HasManyAttribute)attr, relationshipData); - - } - return entity; - } - - private JToken LoadJToken(string body) - { - JToken jToken; - using (JsonReader jsonReader = new JsonTextReader(new StringReader(body))) - { - // https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/509 - jsonReader.DateParseHandling = DateParseHandling.None; - jToken = JToken.Load(jsonReader); - } - return jToken; - } - - /// - /// Creates an instance of the referenced type in - /// and sets its attributes and relationships - /// - /// - /// The parsed entity - private IIdentifiable ParseResourceObject(ResourceObject data) - { - var resourceContext = _contextProvider.GetResourceContext(data.Type); - if (resourceContext == null) - { - throw new InvalidRequestBodyException("Payload includes unknown resource type.", - $"The resource '{data.Type}' is not registered on the resource graph. " + - "If you are using Entity Framework, 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); - - entity = SetAttributes(entity, data.Attributes, resourceContext.Attributes); - entity = SetRelationships(entity, data.Relationships, resourceContext.Relationships); - - if (data.Id != null) - entity.StringId = data.Id; - - return entity; - } - - /// - /// Sets a HasOne relationship on a parsed entity. If present, also - /// populates the foreign key. - /// - /// - /// - /// - /// - private void SetHasOneRelationship(IIdentifiable entity, - PropertyInfo[] entityProperties, - HasOneAttribute attr, - RelationshipEntry relationshipData) - { - var rio = (ResourceIdentifierObject)relationshipData.Data; - 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); - - if (foreignKeyProperty != null) - // there is a FK from the current entity pointing to the related object, - // i.e. we're populating the relationship from the dependent side. - SetForeignKey(entity, foreignKeyProperty, attr, relatedId); - - SetNavigation(entity, 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); - } - - /// - /// 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) - { - bool foreignKeyPropertyIsNullableType = Nullable.GetUnderlyingType(foreignKey.PropertyType) != null - || foreignKey.PropertyType == typeof(string); - if (id == null && !foreignKeyPropertyIsNullableType) - { - // this happens when a non-optional relationship is deliberately set to null. - // For a server deserializer, it should be mapped to a BadRequest HTTP error code. - throw new FormatException($"Cannot set required relationship identifier '{attr.IdentifiablePropertyName}' to null because it is a non-nullable type."); - } - - var typedId = TypeHelper.ConvertStringIdToTypedId(attr.PropertyInfo.PropertyType, id, _resourceFactory); - foreignKey.SetValue(entity, 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) - { - if (relatedId == null) - { - attr.SetValue(entity, null, _resourceFactory); - } - else - { - var relatedInstance = (IIdentifiable)_resourceFactory.CreateInstance(attr.RightType); - relatedInstance.StringId = relatedId; - attr.SetValue(entity, relatedInstance, _resourceFactory); - } - } - - /// - /// Sets a HasMany relationship. - /// - private void SetHasManyRelationship( - IIdentifiable entity, - HasManyAttribute attr, - RelationshipEntry relationshipData) - { - if (relationshipData.Data != null) - { // if the relationship is set to null, no need to set the navigation property to null: this is the default value. - var relatedResources = relationshipData.ManyData.Select(rio => - { - var relatedInstance = (IIdentifiable)_resourceFactory.CreateInstance(attr.RightType); - relatedInstance.StringId = rio.Id; - return relatedInstance; - }); - - var convertedCollection = relatedResources.CopyToTypedCollection(attr.PropertyInfo.PropertyType); - attr.SetValue(entity, convertedCollection, _resourceFactory); - } - - AfterProcessField(entity, attr, relationshipData); - } - - private object ConvertAttrValue(object newValue, Type targetType) - { - if (newValue is JContainer jObject) - // the attribute value is a complex type that needs additional deserialization - return DeserializeComplexType(jObject, targetType); - - // the attribute value is a native C# type. - var convertedValue = TypeHelper.ConvertType(newValue, targetType); - return convertedValue; - } - - private object DeserializeComplexType(JContainer obj, Type targetType) - { - return obj.ToObject(targetType); - } - } -} diff --git a/src/JsonApiDotNetCore/Serialization/Common/DocumentBuilder.cs b/src/JsonApiDotNetCore/Serialization/Common/DocumentBuilder.cs deleted file mode 100644 index 896fadec3b..0000000000 --- a/src/JsonApiDotNetCore/Serialization/Common/DocumentBuilder.cs +++ /dev/null @@ -1,70 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.IO; -using JsonApiDotNetCore.Models; -using Newtonsoft.Json; - -namespace JsonApiDotNetCore.Serialization -{ - /// - /// Abstract base class for serialization. - /// Uses to convert entities in to s and wraps them in a . - /// - public abstract class BaseDocumentBuilder - { - protected readonly IResourceObjectBuilder _resourceObjectBuilder; - - protected BaseDocumentBuilder(IResourceObjectBuilder resourceObjectBuilder) - { - _resourceObjectBuilder = resourceObjectBuilder; - } - - /// - /// Builds a for . - /// Adds the attributes and relationships that are enlisted in and - /// - /// Entity 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) - { - if (entity == null) - return new Document(); - - return new Document { Data = _resourceObjectBuilder.Build(entity, attributes, relationships) }; - } - - /// - /// Builds a for . - /// Adds the attributes and relationships that are enlisted in and - /// - /// Entity 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) - { - var data = new List(); - foreach (IIdentifiable entity in entities) - data.Add(_resourceObjectBuilder.Build(entity, attributes, relationships)); - - return new Document { Data = data }; - } - - protected string SerializeObject(object value, JsonSerializerSettings defaultSettings, Action changeSerializer = null) - { - JsonSerializer serializer = JsonSerializer.CreateDefault(defaultSettings); - changeSerializer?.Invoke(serializer); - - using var stringWriter = new StringWriter(); - using (var jsonWriter = new JsonTextWriter(stringWriter)) - { - serializer.Serialize(jsonWriter, value); - } - - return stringWriter.ToString(); - } - } -} diff --git a/src/JsonApiDotNetCore/Serialization/Common/IResourceObjectBuilder.cs b/src/JsonApiDotNetCore/Serialization/Common/IResourceObjectBuilder.cs deleted file mode 100644 index 2575a1caf8..0000000000 --- a/src/JsonApiDotNetCore/Serialization/Common/IResourceObjectBuilder.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Collections.Generic; -using JsonApiDotNetCore.Models; - -namespace JsonApiDotNetCore.Serialization -{ - /// - /// Responsible for converting entities in to s - /// given a list of attributes and relationships. - /// - public interface IResourceObjectBuilder - { - /// - /// Converts into a . - /// Adds the attributes and relationships that are enlisted in and - /// - /// Entity 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); - } -} diff --git a/src/JsonApiDotNetCore/Serialization/Common/ResourceObjectBuilder.cs b/src/JsonApiDotNetCore/Serialization/Common/ResourceObjectBuilder.cs deleted file mode 100644 index bf45416449..0000000000 --- a/src/JsonApiDotNetCore/Serialization/Common/ResourceObjectBuilder.cs +++ /dev/null @@ -1,159 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Models; -using Newtonsoft.Json; - -namespace JsonApiDotNetCore.Serialization -{ - - /// - public class ResourceObjectBuilder : IResourceObjectBuilder - { - protected readonly IResourceContextProvider _provider; - private readonly ResourceObjectBuilderSettings _settings; - private const string _identifiablePropertyName = nameof(Identifiable.Id); - - public ResourceObjectBuilder(IResourceContextProvider provider, ResourceObjectBuilderSettings settings) - { - _provider = provider; - _settings = settings; - } - - /// - public ResourceObject Build(IIdentifiable entity, IEnumerable attributes = null, IEnumerable relationships = null) - { - var resourceContext = _provider.GetResourceContext(entity.GetType()); - - // populating the top-level "type" and "id" members. - 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()) - ProcessAttributes(entity, attributes, ro); - - // populating the top-level "relationship" member of a resource object. - if (relationships != null) - ProcessRelationships(entity, relationships, ro); - - return ro; - } - - /// - /// Builds the entries of the "relationships - /// objects" The default behaviour is to just construct a resource linkage - /// with the "data" field populated with "single" or "many" data. - /// Depending on the requirements of the implementation (server or client serializer), - /// this may be overridden. - /// - protected virtual RelationshipEntry GetRelationshipData(RelationshipAttribute relationship, IIdentifiable entity) - { - return new RelationshipEntry { Data = GetRelatedResourceLinkage(relationship, entity) }; - } - - /// - /// Gets the value for the property. - /// - protected object GetRelatedResourceLinkage(RelationshipAttribute relationship, IIdentifiable entity) - { - if (relationship is HasOneAttribute hasOne) - return GetRelatedResourceLinkage(hasOne, entity); - - return GetRelatedResourceLinkage((HasManyAttribute)relationship, entity); - } - - /// - /// Builds a for a HasOne relationship - /// - private ResourceIdentifierObject GetRelatedResourceLinkage(HasOneAttribute relationship, IIdentifiable entity) - { - var relatedEntity = (IIdentifiable)relationship.GetValue(entity); - if (relatedEntity == null && IsRequiredToOneRelationship(relationship, entity)) - 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); - - return null; - } - - /// - /// Builds the s for a HasMany relationship - /// - private List GetRelatedResourceLinkage(HasManyAttribute relationship, IIdentifiable entity) - { - var relatedEntities = (IEnumerable)relationship.GetValue(entity); - var manyData = new List(); - if (relatedEntities != null) - foreach (IIdentifiable relatedEntity in relatedEntities) - manyData.Add(GetResourceIdentifier(relatedEntity)); - - return manyData; - } - - /// - /// Creates a from . - /// - private ResourceIdentifierObject GetResourceIdentifier(IIdentifiable entity) - { - var resourceName = _provider.GetResourceContext(entity.GetType()).ResourceName; - return new ResourceIdentifierObject - { - Type = resourceName, - Id = entity.StringId - }; - } - - /// - /// Checks if the to-one relationship is required by checking if the foreign key is nullable. - /// - private bool IsRequiredToOneRelationship(HasOneAttribute attr, IIdentifiable entity) - { - var foreignKey = entity.GetType().GetProperty(attr.IdentifiablePropertyName); - if (foreignKey != null && Nullable.GetUnderlyingType(foreignKey.PropertyType) == null) - return true; - - return false; - } - - /// - /// Puts the relationships of the entity into the resource object. - /// - private void ProcessRelationships(IIdentifiable entity, IEnumerable relationships, ResourceObject ro) - { - foreach (var rel in relationships) - { - var relData = GetRelationshipData(rel, entity); - if (relData != null) - (ro.Relationships ??= new Dictionary()).Add(rel.PublicRelationshipName, relData); - } - } - - /// - /// Puts the attributes of the entity into the resource object. - /// - private void ProcessAttributes(IIdentifiable entity, IEnumerable attributes, ResourceObject ro) - { - ro.Attributes = new Dictionary(); - foreach (var attr in attributes) - { - object value = attr.GetValue(entity); - - if (_settings.SerializerNullValueHandling == NullValueHandling.Ignore && value == null) - { - return; - } - - if (_settings.SerializerDefaultValueHandling == DefaultValueHandling.Ignore && value == attr.PropertyInfo.PropertyType.GetDefaultValue()) - { - return; - } - - ro.Attributes.Add(attr.PublicAttributeName, value); - } - } - } -} diff --git a/src/JsonApiDotNetCore/Serialization/FieldsToSerialize.cs b/src/JsonApiDotNetCore/Serialization/FieldsToSerialize.cs new file mode 100644 index 0000000000..5f5a113430 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/FieldsToSerialize.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Serialization +{ + /// + public class FieldsToSerialize : IFieldsToSerialize + { + private readonly IResourceGraph _resourceGraph; + private readonly IEnumerable _constraintProviders; + private readonly IResourceDefinitionProvider _resourceDefinitionProvider; + + public FieldsToSerialize( + IResourceGraph resourceGraph, + IEnumerable constraintProviders, + IResourceDefinitionProvider resourceDefinitionProvider) + { + _resourceGraph = resourceGraph ?? throw new ArgumentNullException(nameof(resourceGraph)); + _constraintProviders = constraintProviders ?? throw new ArgumentNullException(nameof(constraintProviders)); + _resourceDefinitionProvider = resourceDefinitionProvider ?? throw new ArgumentNullException(nameof(resourceDefinitionProvider)); + } + + /// + public IReadOnlyCollection GetAttributes(Type resourceType, RelationshipAttribute relationship = null) + { + if (resourceType == null) throw new ArgumentNullException(nameof(resourceType)); + + 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 = GetViewableAttributes(resourceType); + } + + var resourceDefinition = _resourceDefinitionProvider.Get(resourceType); + if (resourceDefinition != null) + { + var inputExpression = sparseFieldSetAttributes.Any() ? new SparseFieldSetExpression(sparseFieldSetAttributes) : null; + var outputExpression = resourceDefinition.OnApplySparseFieldSet(inputExpression); + + 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 + /// if it should be serialized. This is because completely hiding a relationship + /// 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 IReadOnlyCollection GetRelationships(Type type) + { + if (type == null) throw new ArgumentNullException(nameof(type)); + + return _resourceGraph.GetRelationships(type); + } + } +} diff --git a/src/JsonApiDotNetCore/Serialization/IFieldsToSerialize.cs b/src/JsonApiDotNetCore/Serialization/IFieldsToSerialize.cs new file mode 100644 index 0000000000..0c41e74abd --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/IFieldsToSerialize.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Serialization +{ + /// + /// Responsible for getting the set of fields that are to be included for a + /// given type in the serialization result. Typically combines various sources + /// of information, like application-wide and request-wide sparse fieldsets. + /// + public interface IFieldsToSerialize + { + /// + /// Gets the collection of attributes that are to be serialized for resources of type . + /// If is non-null, it will consider the allowed collection of attributes + /// as an included relationship. + /// + IReadOnlyCollection GetAttributes(Type resourceType, RelationshipAttribute relationship = null); + + /// + /// Gets the collection of relationships that are to be serialized for resources of type . + /// + IReadOnlyCollection GetRelationships(Type type); + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Server/Contracts/IJsonApiDeserializer.cs b/src/JsonApiDotNetCore/Serialization/IJsonApiDeserializer.cs similarity index 50% rename from src/JsonApiDotNetCore/Serialization/Server/Contracts/IJsonApiDeserializer.cs rename to src/JsonApiDotNetCore/Serialization/IJsonApiDeserializer.cs index 680391a755..eeebb47d95 100644 --- a/src/JsonApiDotNetCore/Serialization/Server/Contracts/IJsonApiDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/IJsonApiDeserializer.cs @@ -1,6 +1,6 @@ -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Serialization.Server +namespace JsonApiDotNetCore.Serialization { /// /// Deserializer used internally in JsonApiDotNetCore to deserialize requests. @@ -8,11 +8,11 @@ namespace JsonApiDotNetCore.Serialization.Server public interface IJsonApiDeserializer { /// - /// Deserializes JSON in to a and constructs entities + /// Deserializes JSON into a and constructs resources /// from . /// - /// The JSON to be deserialized - /// The entities constructed from the content + /// The JSON to be deserialized. + /// The resources constructed from the content. object Deserialize(string body); } } diff --git a/src/JsonApiDotNetCore/Formatters/IJsonApiReader.cs b/src/JsonApiDotNetCore/Serialization/IJsonApiReader.cs similarity index 62% rename from src/JsonApiDotNetCore/Formatters/IJsonApiReader.cs rename to src/JsonApiDotNetCore/Serialization/IJsonApiReader.cs index 948a0eac03..37fc344c0e 100644 --- a/src/JsonApiDotNetCore/Formatters/IJsonApiReader.cs +++ b/src/JsonApiDotNetCore/Serialization/IJsonApiReader.cs @@ -1,11 +1,11 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc.Formatters; -namespace JsonApiDotNetCore.Formatters +namespace JsonApiDotNetCore.Serialization { /// - /// The deserializer of the body, used in .NET core internally - /// to process `FromBody` + /// The deserializer of the body, used in ASP.NET Core internally + /// to process `FromBody`. /// public interface IJsonApiReader { diff --git a/src/JsonApiDotNetCore/Serialization/Server/Contracts/IJsonApiSerializer.cs b/src/JsonApiDotNetCore/Serialization/IJsonApiSerializer.cs similarity index 69% rename from src/JsonApiDotNetCore/Serialization/Server/Contracts/IJsonApiSerializer.cs rename to src/JsonApiDotNetCore/Serialization/IJsonApiSerializer.cs index 6d056940c0..29d09a2c36 100644 --- a/src/JsonApiDotNetCore/Serialization/Server/Contracts/IJsonApiSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/IJsonApiSerializer.cs @@ -1,4 +1,4 @@ -namespace JsonApiDotNetCore.Serialization.Server +namespace JsonApiDotNetCore.Serialization { /// /// 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 collection of resources. /// string Serialize(object content); } -} \ No newline at end of file +} diff --git a/src/JsonApiDotNetCore/Serialization/Server/Contracts/IJsonApiSerializerFactory.cs b/src/JsonApiDotNetCore/Serialization/IJsonApiSerializerFactory.cs similarity index 81% rename from src/JsonApiDotNetCore/Serialization/Server/Contracts/IJsonApiSerializerFactory.cs rename to src/JsonApiDotNetCore/Serialization/IJsonApiSerializerFactory.cs index def323437a..75d7f928af 100644 --- a/src/JsonApiDotNetCore/Serialization/Server/Contracts/IJsonApiSerializerFactory.cs +++ b/src/JsonApiDotNetCore/Serialization/IJsonApiSerializerFactory.cs @@ -1,4 +1,4 @@ -namespace JsonApiDotNetCore.Serialization.Server +namespace JsonApiDotNetCore.Serialization { public interface IJsonApiSerializerFactory { diff --git a/src/JsonApiDotNetCore/Formatters/IJsonApiWriter.cs b/src/JsonApiDotNetCore/Serialization/IJsonApiWriter.cs similarity index 81% rename from src/JsonApiDotNetCore/Formatters/IJsonApiWriter.cs rename to src/JsonApiDotNetCore/Serialization/IJsonApiWriter.cs index ce8b7da6a4..0f8287801a 100644 --- a/src/JsonApiDotNetCore/Formatters/IJsonApiWriter.cs +++ b/src/JsonApiDotNetCore/Serialization/IJsonApiWriter.cs @@ -1,7 +1,7 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc.Formatters; -namespace JsonApiDotNetCore.Formatters +namespace JsonApiDotNetCore.Serialization { public interface IJsonApiWriter { diff --git a/src/JsonApiDotNetCore/Services/Contract/IRequestMeta.cs b/src/JsonApiDotNetCore/Serialization/IRequestMeta.cs similarity index 65% rename from src/JsonApiDotNetCore/Services/Contract/IRequestMeta.cs rename to src/JsonApiDotNetCore/Serialization/IRequestMeta.cs index 3083acdfe1..6fa34c0d9a 100644 --- a/src/JsonApiDotNetCore/Services/Contract/IRequestMeta.cs +++ b/src/JsonApiDotNetCore/Serialization/IRequestMeta.cs @@ -1,7 +1,8 @@ using System.Collections.Generic; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Services +namespace JsonApiDotNetCore.Serialization { /// /// Service to add global top-level metadata to a . @@ -10,6 +11,6 @@ namespace JsonApiDotNetCore.Services /// public interface IRequestMeta { - Dictionary GetMeta(); + IReadOnlyDictionary GetMeta(); } -} \ No newline at end of file +} diff --git a/src/JsonApiDotNetCore/Serialization/Server/Contracts/IResponseSerializer.cs b/src/JsonApiDotNetCore/Serialization/IResponseSerializer.cs similarity index 76% rename from src/JsonApiDotNetCore/Serialization/Server/Contracts/IResponseSerializer.cs rename to src/JsonApiDotNetCore/Serialization/IResponseSerializer.cs index f69e1ce096..31635d5e36 100644 --- a/src/JsonApiDotNetCore/Serialization/Server/Contracts/IResponseSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/IResponseSerializer.cs @@ -1,6 +1,6 @@ -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Serialization.Server +namespace JsonApiDotNetCore.Serialization { internal interface IResponseSerializer { diff --git a/src/JsonApiDotNetCore/Formatters/JsonApiReader.cs b/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs similarity index 74% rename from src/JsonApiDotNetCore/Formatters/JsonApiReader.cs rename to src/JsonApiDotNetCore/Serialization/JsonApiReader.cs index 3cab898881..12e8c95bd0 100644 --- a/src/JsonApiDotNetCore/Formatters/JsonApiReader.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs @@ -2,30 +2,32 @@ using System.Collections; using System.IO; using System.Threading.Tasks; -using JsonApiDotNetCore.Exceptions; -using JsonApiDotNetCore.Managers.Contracts; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Serialization.Server; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCore.Formatters +namespace JsonApiDotNetCore.Serialization { /// public class JsonApiReader : IJsonApiReader { private readonly IJsonApiDeserializer _deserializer; - private readonly ICurrentRequest _currentRequest; - private readonly ILogger _logger; + private readonly IJsonApiRequest _request; + private readonly TraceLogWriter _traceWriter; public JsonApiReader(IJsonApiDeserializer deserializer, - ICurrentRequest currentRequest, + IJsonApiRequest request, ILoggerFactory loggerFactory) { - _deserializer = deserializer; - _currentRequest = currentRequest; - _logger = loggerFactory.CreateLogger(); + if (loggerFactory == null) throw new ArgumentNullException(nameof(loggerFactory)); + + _deserializer = deserializer ?? throw new ArgumentNullException(nameof(deserializer)); + _request = request ?? throw new ArgumentNullException(nameof(request)); + _traceWriter = new TraceLogWriter(loggerFactory); } public async Task ReadAsync(InputFormatterContext context) @@ -42,7 +44,7 @@ public async Task ReadAsync(InputFormatterContext context) string body = await GetRequestBody(context.HttpContext.Request.Body); string url = context.HttpContext.Request.GetEncodedUrl(); - _logger.LogTrace($"Received request at '{url}' with body: <<{body}>>"); + _traceWriter.LogMessage(() => $"Received request at '{url}' with body: <<{body}>>"); object model; try @@ -59,21 +61,26 @@ public async Task ReadAsync(InputFormatterContext context) throw new InvalidRequestBodyException(null, null, body, exception); } + ValidatePatchRequestIncludesId(context, model, body); + + return await InputFormatterResult.SuccessAsync(model); + } + + private void ValidatePatchRequestIncludesId(InputFormatterContext context, object model, string body) + { if (context.HttpContext.Request.Method == "PATCH") { bool hasMissingId = model is IList list ? HasMissingId(list) : HasMissingId(model); if (hasMissingId) { - throw new InvalidRequestBodyException("Payload must include id attribute.", null, body); + throw new InvalidRequestBodyException("Payload must include 'id' element.", null, body); } - if (!_currentRequest.IsRelationshipPath && TryGetId(model, out var bodyId) && bodyId != _currentRequest.BaseId) + if (_request.Kind == EndpointKind.Primary && TryGetId(model, out var bodyId) && bodyId != _request.PrimaryId) { - throw new ResourceIdMismatchException(bodyId, _currentRequest.BaseId, context.HttpContext.Request.GetDisplayUrl()); + throw new ResourceIdMismatchException(bodyId, _request.PrimaryId, context.HttpContext.Request.GetDisplayUrl()); } } - - return await InputFormatterResult.SuccessAsync(model); } /// Checks if the deserialized payload has an ID included diff --git a/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs b/src/JsonApiDotNetCore/Serialization/JsonApiWriter.cs similarity index 81% rename from src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs rename to src/JsonApiDotNetCore/Serialization/JsonApiWriter.cs index e9f1c95f6f..bc0c5b44b1 100644 --- a/src/JsonApiDotNetCore/Formatters/JsonApiWriter.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonApiWriter.cs @@ -4,34 +4,34 @@ using System.Net.Http; using System.Text; using System.Threading.Tasks; -using JsonApiDotNetCore.Exceptions; +using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Models.JsonApiDocuments; -using JsonApiDotNetCore.Serialization.Server; +using JsonApiDotNetCore.Serialization.Objects; using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.Extensions.Logging; using Newtonsoft.Json; -namespace JsonApiDotNetCore.Formatters +namespace JsonApiDotNetCore.Serialization { /// - /// Formats the response data used https://docs.microsoft.com/en-us/aspnet/core/web-api/advanced/formatting?view=aspnetcore-3.0. + /// Formats the response data used (see https://docs.microsoft.com/en-us/aspnet/core/web-api/advanced/formatting?view=aspnetcore-3.0). /// It was intended to have as little dependencies as possible in formatting layer for greater extensibility. /// public class JsonApiWriter : IJsonApiWriter { private readonly IJsonApiSerializer _serializer; private readonly IExceptionHandler _exceptionHandler; - private readonly ILogger _logger; + private readonly TraceLogWriter _traceWriter; public JsonApiWriter(IJsonApiSerializer serializer, IExceptionHandler exceptionHandler, ILoggerFactory loggerFactory) { - _serializer = serializer; - _exceptionHandler = exceptionHandler; + if (loggerFactory == null) throw new ArgumentNullException(nameof(loggerFactory)); - _logger = loggerFactory.CreateLogger(); + _serializer = serializer ?? throw new ArgumentNullException(nameof(serializer)); + _exceptionHandler = exceptionHandler ?? throw new ArgumentNullException(nameof(exceptionHandler)); + _traceWriter = new TraceLogWriter(loggerFactory); } public async Task WriteAsync(OutputFormatterWriteContext context) @@ -64,7 +64,7 @@ public async Task WriteAsync(OutputFormatterWriteContext context) } var url = context.HttpContext.Request.GetEncodedUrl(); - _logger.LogTrace($"Sending {response.StatusCode} response for request at '{url}' with body: <<{responseContent}>>"); + _traceWriter.LogMessage(() => $"Sending {response.StatusCode} response for request at '{url}' with body: <<{responseContent}>>"); await writer.WriteAsync(responseContent); await writer.FlushAsync(); diff --git a/src/JsonApiDotNetCore/Extensions/JsonSerializerExtensions.cs b/src/JsonApiDotNetCore/Serialization/JsonSerializerExtensions.cs similarity index 98% rename from src/JsonApiDotNetCore/Extensions/JsonSerializerExtensions.cs rename to src/JsonApiDotNetCore/Serialization/JsonSerializerExtensions.cs index 38dde88f63..e08b9c3ce0 100644 --- a/src/JsonApiDotNetCore/Extensions/JsonSerializerExtensions.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonSerializerExtensions.cs @@ -1,7 +1,7 @@ using Newtonsoft.Json; using Newtonsoft.Json.Serialization; -namespace JsonApiDotNetCore.Extensions +namespace JsonApiDotNetCore.Serialization { internal static class JsonSerializerExtensions { diff --git a/src/JsonApiDotNetCore/Models/JsonApiDocuments/Document.cs b/src/JsonApiDotNetCore/Serialization/Objects/Document.cs similarity index 81% rename from src/JsonApiDotNetCore/Models/JsonApiDocuments/Document.cs rename to src/JsonApiDotNetCore/Serialization/Objects/Document.cs index b42bb75766..0be16785de 100644 --- a/src/JsonApiDotNetCore/Models/JsonApiDocuments/Document.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/Document.cs @@ -1,8 +1,7 @@ -using System.Collections.Generic; -using JsonApiDotNetCore.Models.Links; +using System.Collections.Generic; using Newtonsoft.Json; -namespace JsonApiDotNetCore.Models +namespace JsonApiDotNetCore.Serialization.Objects { /// /// https://jsonapi.org/format/#document-structure @@ -13,7 +12,7 @@ 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; } + public IDictionary Meta { get; set; } /// /// see "links" in https://jsonapi.org/format/#document-top-level @@ -25,6 +24,6 @@ public sealed class Document : ExposableData /// see "included" in https://jsonapi.org/format/#document-top-level /// [JsonProperty("included", NullValueHandling = NullValueHandling.Ignore, Order = 1)] - public List Included { get; set; } + public IList Included { get; set; } } } diff --git a/src/JsonApiDotNetCore/Models/JsonApiDocuments/Error.cs b/src/JsonApiDotNetCore/Serialization/Objects/Error.cs similarity index 93% rename from src/JsonApiDotNetCore/Models/JsonApiDocuments/Error.cs rename to src/JsonApiDotNetCore/Serialization/Objects/Error.cs index b93c3c0790..23408a993c 100644 --- a/src/JsonApiDotNetCore/Models/JsonApiDocuments/Error.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/Error.cs @@ -3,11 +3,11 @@ using System.Net; using Newtonsoft.Json; -namespace JsonApiDotNetCore.Models.JsonApiDocuments +namespace JsonApiDotNetCore.Serialization.Objects { /// /// Provides additional information about a problem encountered while performing an operation. - /// Error objects MUST be returned as an array keyed by errors in the top level of a JSON:API document. + /// Error objects MUST be returned as an array keyed by errors in the top level of a json:api document. /// public sealed class Error { @@ -56,7 +56,7 @@ public string Status public string Title { get; set; } /// - /// A human-readable explanation specific to this occurrence of the problem. Like title, this field’s value can be localized. + /// A human-readable explanation specific to this occurrence of the problem. Like title, this field's value can be localized. /// [JsonProperty] public string Detail { get; set; } diff --git a/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorDocument.cs b/src/JsonApiDotNetCore/Serialization/Objects/ErrorDocument.cs similarity index 72% rename from src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorDocument.cs rename to src/JsonApiDotNetCore/Serialization/Objects/ErrorDocument.cs index 452b12adbc..34b89596e4 100644 --- a/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorDocument.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/ErrorDocument.cs @@ -1,25 +1,28 @@ +using System; using System.Collections.Generic; using System.Linq; using System.Net; -namespace JsonApiDotNetCore.Models.JsonApiDocuments +namespace JsonApiDotNetCore.Serialization.Objects { public sealed class ErrorDocument { public IReadOnlyList Errors { get; } public ErrorDocument() + : this(Array.Empty()) { - Errors = new List(); } public ErrorDocument(Error error) + : this(new[] {error}) { - Errors = new List {error}; } public ErrorDocument(IEnumerable errors) { + if (errors == null) throw new ArgumentNullException(nameof(errors)); + Errors = errors.ToList(); } @@ -28,9 +31,9 @@ public HttpStatusCode GetErrorStatusCode() var statusCodes = Errors .Select(e => (int)e.StatusCode) .Distinct() - .ToList(); + .ToArray(); - if (statusCodes.Count == 1) + if (statusCodes.Length == 1) return (HttpStatusCode)statusCodes[0]; var statusCode = int.Parse(statusCodes.Max().ToString()[0] + "00"); diff --git a/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorLinks.cs b/src/JsonApiDotNetCore/Serialization/Objects/ErrorLinks.cs similarity index 84% rename from src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorLinks.cs rename to src/JsonApiDotNetCore/Serialization/Objects/ErrorLinks.cs index a2c06ff1af..16be6392e1 100644 --- a/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorLinks.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/ErrorLinks.cs @@ -1,6 +1,6 @@ using Newtonsoft.Json; -namespace JsonApiDotNetCore.Models.JsonApiDocuments +namespace JsonApiDotNetCore.Serialization.Objects { public sealed class ErrorLinks { diff --git a/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorMeta.cs b/src/JsonApiDotNetCore/Serialization/Objects/ErrorMeta.cs similarity index 71% rename from src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorMeta.cs rename to src/JsonApiDotNetCore/Serialization/Objects/ErrorMeta.cs index 6d09bd627c..8a07774d71 100644 --- a/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorMeta.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/ErrorMeta.cs @@ -3,7 +3,7 @@ using System.Diagnostics; using Newtonsoft.Json; -namespace JsonApiDotNetCore.Models.JsonApiDocuments +namespace JsonApiDotNetCore.Serialization.Objects { /// /// A meta object containing non-standard meta-information about the error. @@ -11,7 +11,7 @@ namespace JsonApiDotNetCore.Models.JsonApiDocuments public sealed class ErrorMeta { [JsonExtensionData] - public Dictionary Data { get; } = new Dictionary(); + public IDictionary Data { get; } = new Dictionary(); public void IncludeExceptionStackTrace(Exception exception) { @@ -22,7 +22,7 @@ public void IncludeExceptionStackTrace(Exception exception) else { Data["StackTrace"] = exception.Demystify().ToString() - .Split(new[] { "\n" }, int.MaxValue, StringSplitOptions.RemoveEmptyEntries); + .Split("\n", int.MaxValue, StringSplitOptions.RemoveEmptyEntries); } } } diff --git a/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorSource.cs b/src/JsonApiDotNetCore/Serialization/Objects/ErrorSource.cs similarity index 71% rename from src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorSource.cs rename to src/JsonApiDotNetCore/Serialization/Objects/ErrorSource.cs index b5eb4c3f70..0e1686fbdf 100644 --- a/src/JsonApiDotNetCore/Models/JsonApiDocuments/ErrorSource.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/ErrorSource.cs @@ -1,11 +1,11 @@ using Newtonsoft.Json; -namespace JsonApiDotNetCore.Models.JsonApiDocuments +namespace JsonApiDotNetCore.Serialization.Objects { 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/ExposableData.cs b/src/JsonApiDotNetCore/Serialization/Objects/ExposableData.cs similarity index 72% rename from src/JsonApiDotNetCore/Models/JsonApiDocuments/ExposableData.cs rename to src/JsonApiDotNetCore/Serialization/Objects/ExposableData.cs index cf09483775..aa2b603406 100644 --- a/src/JsonApiDotNetCore/Models/JsonApiDocuments/ExposableData.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/ExposableData.cs @@ -1,14 +1,14 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using Newtonsoft.Json; using Newtonsoft.Json.Linq; -namespace JsonApiDotNetCore.Models +namespace JsonApiDotNetCore.Serialization.Objects { - public abstract class ExposableData where T : class + public abstract class ExposableData where TResource : class { /// - /// see "primary data" in https://jsonapi.org/format/#document-top-level. + /// See "primary data" in https://jsonapi.org/format/#document-top-level. /// [JsonProperty("data")] public object Data @@ -18,7 +18,7 @@ public object Data } /// - /// see https://www.newtonsoft.com/json/help/html/ConditionalProperties.htm + /// See https://www.newtonsoft.com/json/help/html/ConditionalProperties.htm. /// /// /// Moving this method to the derived class where it is needed only in the @@ -36,16 +36,16 @@ public bool ShouldSerializeData() /// Internally used for "single" primary data. /// [JsonIgnore] - public T SingleData { get; private set; } + public TResource SingleData { get; private set; } /// /// Internally used for "many" primary data. /// [JsonIgnore] - public List ManyData { get; private set; } + public IList ManyData { get; private set; } /// - /// Used to indicate if the document's primary data is "single" or "many". + /// Indicates if the document's primary data is "single" or "many". /// [JsonIgnore] public bool IsManyData { get; private set; } @@ -57,7 +57,11 @@ public bool ShouldSerializeData() /// internal bool IsPopulated { get; private set; } - internal bool HasResource => IsPopulated && ((IsManyData && ManyData.Any()) || SingleData != null); + internal bool HasResource => IsPopulated && !IsEmpty; + + private bool IsEmpty => !HasManyData && SingleData == null; + + private bool HasManyData => IsManyData && ManyData.Any(); /// /// Gets the "single" or "many" data depending on which one was @@ -77,16 +81,16 @@ protected void SetPrimaryData(object value) { IsPopulated = true; if (value is JObject jObject) - SingleData = jObject.ToObject(); - else if (value is T ro) + SingleData = jObject.ToObject(); + else if (value is TResource ro) SingleData = ro; else if (value != null) { IsManyData = true; if (value is JArray jArray) - ManyData = jArray.ToObject>(); + ManyData = jArray.ToObject>(); else - ManyData = (List)value; + ManyData = (List)value; } } } diff --git a/src/JsonApiDotNetCore/Models/JsonApiDocuments/RelationshipEntry.cs b/src/JsonApiDotNetCore/Serialization/Objects/RelationshipEntry.cs similarity index 77% rename from src/JsonApiDotNetCore/Models/JsonApiDocuments/RelationshipEntry.cs rename to src/JsonApiDotNetCore/Serialization/Objects/RelationshipEntry.cs index 5a19067f49..6228f83972 100644 --- a/src/JsonApiDotNetCore/Models/JsonApiDocuments/RelationshipEntry.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/RelationshipEntry.cs @@ -1,7 +1,6 @@ -using JsonApiDotNetCore.Models.Links; using Newtonsoft.Json; -namespace JsonApiDotNetCore.Models +namespace JsonApiDotNetCore.Serialization.Objects { public sealed class RelationshipEntry : ExposableData { diff --git a/src/JsonApiDotNetCore/Models/JsonApiDocuments/RelationshipLinks.cs b/src/JsonApiDotNetCore/Serialization/Objects/RelationshipLinks.cs similarity index 62% rename from src/JsonApiDotNetCore/Models/JsonApiDocuments/RelationshipLinks.cs rename to src/JsonApiDotNetCore/Serialization/Objects/RelationshipLinks.cs index e8076af318..fd747eea8e 100644 --- a/src/JsonApiDotNetCore/Models/JsonApiDocuments/RelationshipLinks.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/RelationshipLinks.cs @@ -1,17 +1,17 @@ using Newtonsoft.Json; -namespace JsonApiDotNetCore.Models.Links +namespace JsonApiDotNetCore.Serialization.Objects { public sealed class RelationshipLinks { /// - /// see "links" bulletin at https://jsonapi.org/format/#document-resource-object-relationships + /// See "links" bulletin at https://jsonapi.org/format/#document-resource-object-relationships. /// [JsonProperty("self", NullValueHandling = NullValueHandling.Ignore)] public string Self { get; set; } /// - /// https://jsonapi.org/format/#document-resource-object-related-resource-links + /// See https://jsonapi.org/format/#document-resource-object-related-resource-links. /// [JsonProperty("related", NullValueHandling = NullValueHandling.Ignore)] public string Related { get; set; } diff --git a/src/JsonApiDotNetCore/Models/JsonApiDocuments/ResourceIdentifierObject.cs b/src/JsonApiDotNetCore/Serialization/Objects/ResourceIdentifierObject.cs similarity index 91% rename from src/JsonApiDotNetCore/Models/JsonApiDocuments/ResourceIdentifierObject.cs rename to src/JsonApiDotNetCore/Serialization/Objects/ResourceIdentifierObject.cs index 939cae0820..af10bc0cab 100644 --- a/src/JsonApiDotNetCore/Models/JsonApiDocuments/ResourceIdentifierObject.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/ResourceIdentifierObject.cs @@ -1,10 +1,11 @@ using Newtonsoft.Json; -namespace JsonApiDotNetCore.Models +namespace JsonApiDotNetCore.Serialization.Objects { public class ResourceIdentifierObject { public ResourceIdentifierObject() { } + public ResourceIdentifierObject(string type, string id) { Type = type; diff --git a/src/JsonApiDotNetCore/Models/JsonApiDocuments/ResourceLinks.cs b/src/JsonApiDotNetCore/Serialization/Objects/ResourceLinks.cs similarity index 65% rename from src/JsonApiDotNetCore/Models/JsonApiDocuments/ResourceLinks.cs rename to src/JsonApiDotNetCore/Serialization/Objects/ResourceLinks.cs index 0ff07ad7e5..701e42a3f1 100644 --- a/src/JsonApiDotNetCore/Models/JsonApiDocuments/ResourceLinks.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/ResourceLinks.cs @@ -1,11 +1,11 @@ using Newtonsoft.Json; -namespace JsonApiDotNetCore.Models.Links +namespace JsonApiDotNetCore.Serialization.Objects { public sealed class ResourceLinks { /// - /// https://jsonapi.org/format/#document-resource-object-links + /// See https://jsonapi.org/format/#document-resource-object-links. /// [JsonProperty("self", NullValueHandling = NullValueHandling.Ignore)] public string Self { get; set; } diff --git a/src/JsonApiDotNetCore/Models/JsonApiDocuments/ResourceObject.cs b/src/JsonApiDotNetCore/Serialization/Objects/ResourceObject.cs similarity index 66% rename from src/JsonApiDotNetCore/Models/JsonApiDocuments/ResourceObject.cs rename to src/JsonApiDotNetCore/Serialization/Objects/ResourceObject.cs index e46525755b..787bfe292e 100644 --- a/src/JsonApiDotNetCore/Models/JsonApiDocuments/ResourceObject.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/ResourceObject.cs @@ -1,16 +1,15 @@ using System.Collections.Generic; -using JsonApiDotNetCore.Models.Links; using Newtonsoft.Json; -namespace JsonApiDotNetCore.Models +namespace JsonApiDotNetCore.Serialization.Objects { public sealed class ResourceObject : ResourceIdentifierObject { [JsonProperty("attributes", NullValueHandling = NullValueHandling.Ignore)] - public Dictionary Attributes { get; set; } + public IDictionary Attributes { get; set; } [JsonProperty("relationships", NullValueHandling = NullValueHandling.Ignore)] - public Dictionary Relationships { get; set; } + public IDictionary Relationships { get; set; } [JsonProperty("links", NullValueHandling = NullValueHandling.Ignore)] public ResourceLinks Links { get; set; } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/TopLevelLinks.cs b/src/JsonApiDotNetCore/Serialization/Objects/TopLevelLinks.cs new file mode 100644 index 0000000000..96adaf99e9 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Objects/TopLevelLinks.cs @@ -0,0 +1,32 @@ +using Newtonsoft.Json; + +namespace JsonApiDotNetCore.Serialization.Objects +{ + /// + /// See links section in https://jsonapi.org/format/#document-top-level. + /// + public sealed class TopLevelLinks + { + [JsonProperty("self")] + public string Self { get; set; } + + [JsonProperty("first")] + public string First { get; set; } + + [JsonProperty("last")] + public string Last { get; set; } + + [JsonProperty("prev")] + public string Prev { get; set; } + + [JsonProperty("next")] + public string Next { get; set; } + + // http://www.newtonsoft.com/json/help/html/ConditionalProperties.htm + public bool ShouldSerializeSelf() => !string.IsNullOrEmpty(Self); + public bool ShouldSerializeFirst() => !string.IsNullOrEmpty(First); + public bool ShouldSerializeLast() => !string.IsNullOrEmpty(Last); + public bool ShouldSerializePrev() => !string.IsNullOrEmpty(Prev); + public bool ShouldSerializeNext() => !string.IsNullOrEmpty(Next); + } +} diff --git a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs new file mode 100644 index 0000000000..e7b3734b2f --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Reflection; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.AspNetCore.Http; + +namespace JsonApiDotNetCore.Serialization +{ + /// + /// Server deserializer implementation of the . + /// + public class RequestDeserializer : BaseDeserializer, IJsonApiDeserializer + { + private readonly ITargetedFields _targetedFields; + private readonly IHttpContextAccessor _httpContextAccessor; + + public RequestDeserializer(IResourceContextProvider resourceContextProvider, IResourceFactory resourceFactory, ITargetedFields targetedFields, IHttpContextAccessor httpContextAccessor) + : base(resourceContextProvider, resourceFactory) + { + _targetedFields = targetedFields ?? throw new ArgumentNullException(nameof(targetedFields)); + _httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor)); + } + + /// + public object Deserialize(string body) + { + if (body == null) throw new ArgumentNullException(nameof(body)); + + return DeserializeBody(body); + } + + /// + /// Additional processing required for server deserialization. Flags a + /// processed attribute or relationship as updated using . + /// + /// 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 resource, ResourceFieldAttribute field, RelationshipEntry data = null) + { + if (field is AttrAttribute attr) + { + if (attr.Capabilities.HasFlag(AttrCapabilities.AllowChange)) + { + _targetedFields.Attributes.Add(attr); + } + else + { + throw new InvalidRequestBodyException( + "Changing the value of the requested attribute is not allowed.", + $"Changing the value of '{attr.PublicName}' is not allowed.", null); + } + } + else if (field is RelationshipAttribute relationship) + _targetedFields.Relationships.Add(relationship); + } + + protected override IIdentifiable SetAttributes(IIdentifiable resource, IDictionary attributeValues, IReadOnlyCollection attributes) + { + if (resource == null) throw new ArgumentNullException(nameof(resource)); + if (attributes == null) throw new ArgumentNullException(nameof(attributes)); + + if (_httpContextAccessor.HttpContext.Request.Method == HttpMethod.Patch.Method) + { + foreach (AttrAttribute attr in attributes) + { + if (attr.Property.GetCustomAttribute() != null) + { + bool disableValidator = attributeValues == null || attributeValues.Count == 0 || + !attributeValues.TryGetValue(attr.PublicName, out _); + + if (disableValidator) + { + _httpContextAccessor.HttpContext.DisableValidator(attr.Property.Name, resource.GetType().Name); + } + } + } + } + + return base.SetAttributes(resource, attributeValues, attributes); + } + + protected override IIdentifiable SetRelationships(IIdentifiable resource, IDictionary relationshipsValues, IReadOnlyCollection relationshipAttributes) + { + if (resource == null) throw new ArgumentNullException(nameof(resource)); + if (relationshipAttributes == null) throw new ArgumentNullException(nameof(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.Property.Name); + } + + return base.SetRelationships(resource, relationshipsValues, relationshipAttributes); + } + } +} diff --git a/src/JsonApiDotNetCore/Serialization/ResponseSerializer.cs b/src/JsonApiDotNetCore/Serialization/ResponseSerializer.cs new file mode 100644 index 0000000000..5639ed5a77 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/ResponseSerializer.cs @@ -0,0 +1,144 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Building; +using JsonApiDotNetCore.Serialization.Objects; +using Newtonsoft.Json; + +namespace JsonApiDotNetCore.Serialization +{ + /// + /// Server serializer implementation of + /// + /// + /// Because in JsonApiDotNetCore every json:api request is associated with exactly one + /// resource (the primary resource, see ), + /// the serializer can leverage this information using generics. + /// See for how this is instantiated. + /// + /// Type of the resource associated with the scope of the request + /// for which this serializer is used. + public class ResponseSerializer : BaseSerializer, IJsonApiSerializer, IResponseSerializer + where TResource : class, IIdentifiable + { + public RelationshipAttribute RequestRelationship { get; set; } + + private readonly IFieldsToSerialize _fieldsToSerialize; + private readonly IJsonApiOptions _options; + private readonly IMetaBuilder _metaBuilder; + private readonly Type _primaryResourceType; + private readonly ILinkBuilder _linkBuilder; + private readonly IIncludedResourceObjectBuilder _includedBuilder; + + public ResponseSerializer(IMetaBuilder metaBuilder, + ILinkBuilder linkBuilder, + IIncludedResourceObjectBuilder includedBuilder, + IFieldsToSerialize fieldsToSerialize, + IResourceObjectBuilder resourceObjectBuilder, + IJsonApiOptions options) + : base(resourceObjectBuilder) + { + _fieldsToSerialize = fieldsToSerialize ?? throw new ArgumentNullException(nameof(fieldsToSerialize)); + _options = options ?? throw new ArgumentNullException(nameof(options)); + _linkBuilder = linkBuilder ?? throw new ArgumentNullException(nameof(linkBuilder)); + _metaBuilder = metaBuilder ?? throw new ArgumentNullException(nameof(metaBuilder)); + _includedBuilder = includedBuilder ?? throw new ArgumentNullException(nameof(includedBuilder)); + _primaryResourceType = typeof(TResource); + } + + /// + public string Serialize(object data) + { + if (data == null || data is IIdentifiable) + { + return SerializeSingle((IIdentifiable)data); + } + + if (data is IEnumerable collectionOfIdentifiable) + { + return SerializeMany(collectionOfIdentifiable.ToArray()); + } + + if (data is ErrorDocument errorDocument) + { + return SerializeErrorDocument(errorDocument); + } + + throw new InvalidOperationException("Data being returned must be errors or resources."); + } + + private string SerializeErrorDocument(ErrorDocument errorDocument) + { + return SerializeObject(errorDocument, _options.SerializerSettings, serializer => { serializer.ApplyErrorSettings(); }); + } + + /// + /// Converts a single resource into a serialized . + /// + /// + /// This method is internal instead of private for easier testability. + /// + internal string SerializeSingle(IIdentifiable resource) + { + if (RequestRelationship != null && resource != null) + { + var relationship = ((ResponseResourceObjectBuilder)ResourceObjectBuilder).Build(resource, RequestRelationship); + return SerializeObject(relationship, _options.SerializerSettings, serializer => { serializer.NullValueHandling = NullValueHandling.Include; }); + } + + var (attributes, relationships) = GetFieldsToSerialize(); + var document = Build(resource, attributes, relationships); + var resourceObject = document.SingleData; + if (resourceObject != null) + resourceObject.Links = _linkBuilder.GetResourceLinks(resourceObject.Type, resourceObject.Id); + + AddTopLevelObjects(document); + + return SerializeObject(document, _options.SerializerSettings, serializer => { serializer.NullValueHandling = NullValueHandling.Include; }); + } + + private (IReadOnlyCollection, IReadOnlyCollection) GetFieldsToSerialize() + { + return (_fieldsToSerialize.GetAttributes(_primaryResourceType), _fieldsToSerialize.GetRelationships(_primaryResourceType)); + } + + /// + /// Converts a collection of resources into a serialized . + /// + /// + /// This method is internal instead of private for easier testability. + /// + internal string SerializeMany(IReadOnlyCollection resources) + { + var (attributes, relationships) = GetFieldsToSerialize(); + var document = Build(resources, attributes, relationships); + foreach (ResourceObject resourceObject in document.ManyData) + { + var links = _linkBuilder.GetResourceLinks(resourceObject.Type, resourceObject.Id); + if (links == null) + break; + + resourceObject.Links = links; + } + + AddTopLevelObjects(document); + + return SerializeObject(document, _options.SerializerSettings, serializer => { serializer.NullValueHandling = NullValueHandling.Include; }); + } + + /// + /// Adds top-level objects that are only added to a document in the case + /// of server-side serialization. + /// + private void AddTopLevelObjects(Document document) + { + document.Links = _linkBuilder.GetTopLevelLinks(); + document.Meta = _metaBuilder.GetMeta(); + document.Included = _includedBuilder.Build(); + } + } +} diff --git a/src/JsonApiDotNetCore/Serialization/ResponseSerializerFactory.cs b/src/JsonApiDotNetCore/Serialization/ResponseSerializerFactory.cs new file mode 100644 index 0000000000..356c606833 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/ResponseSerializerFactory.cs @@ -0,0 +1,44 @@ +using System; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; + +namespace JsonApiDotNetCore.Serialization +{ + /// + /// A factory class to abstract away the initialization of the serializer from the + /// ASP.NET Core formatter pipeline. + /// + public class ResponseSerializerFactory : IJsonApiSerializerFactory + { + private readonly IServiceProvider _provider; + private readonly IJsonApiRequest _request; + + public ResponseSerializerFactory(IJsonApiRequest request, IRequestScopedServiceProvider provider) + { + _request = request ?? throw new ArgumentNullException(nameof(request)); + _provider = provider ?? throw new ArgumentNullException(nameof(provider)); + } + + /// + /// Initializes the server serializer using the + /// associated with the current request. + /// + public IJsonApiSerializer GetSerializer() + { + var targetType = GetDocumentType(); + + var serializerType = typeof(ResponseSerializer<>).MakeGenericType(targetType); + var serializer = (IResponseSerializer)_provider.GetService(serializerType); + if (_request.Kind == EndpointKind.Relationship && _request.Relationship != null) + serializer.RequestRelationship = _request.Relationship; + + return (IJsonApiSerializer)serializer; + } + + private Type GetDocumentType() + { + var resourceContext = _request.SecondaryResource ?? _request.PrimaryResource; + return resourceContext.ResourceType; + } + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Server/Builders/MetaBuilder.cs b/src/JsonApiDotNetCore/Serialization/Server/Builders/MetaBuilder.cs deleted file mode 100644 index 1a495909a3..0000000000 --- a/src/JsonApiDotNetCore/Serialization/Server/Builders/MetaBuilder.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Query; -using JsonApiDotNetCore.Services; - -namespace JsonApiDotNetCore.Serialization.Server.Builders -{ - /// - public class MetaBuilder : IMetaBuilder where T : class, IIdentifiable - { - private Dictionary _meta = new Dictionary(); - private readonly IPageService _pageService; - private readonly IJsonApiOptions _options; - private readonly IRequestMeta _requestMeta; - private readonly IHasMeta _resourceMeta; - - public MetaBuilder(IPageService pageService, - IJsonApiOptions options, - IRequestMeta requestMeta = null, - ResourceDefinition resourceDefinition = null) - { - _pageService = pageService; - _options = options; - _requestMeta = requestMeta; - _resourceMeta = resourceDefinition as IHasMeta; - } - /// - public void Add(string key, object value) - { - _meta[key] = value; - } - - /// - public void Add(Dictionary values) - { - _meta = values.Keys.Union(_meta.Keys) - .ToDictionary(key => key, - key => values.ContainsKey(key) ? values[key] : _meta[key]); - } - - /// - public Dictionary GetMeta() - { - if (_options.IncludeTotalRecordCount && _pageService.TotalRecords != null) - _meta.Add("total-records", _pageService.TotalRecords); - - if (_requestMeta != null) - Add(_requestMeta.GetMeta()); - - if (_resourceMeta != null) - Add(_resourceMeta.GetMeta()); - - if (_meta.Any()) return _meta; - return 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 deleted file mode 100644 index 2346632e60..0000000000 --- a/src/JsonApiDotNetCore/Serialization/Server/Builders/ResponseResourceObjectBuilder.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Query; -using JsonApiDotNetCore.Serialization.Server.Builders; - -namespace JsonApiDotNetCore.Serialization.Server -{ - public class ResponseResourceObjectBuilder : ResourceObjectBuilder - { - private readonly IIncludedResourceObjectBuilder _includedBuilder; - private readonly IIncludeService _includeService; - private readonly ILinkBuilder _linkBuilder; - private RelationshipAttribute _requestRelationship; - - public ResponseResourceObjectBuilder(ILinkBuilder linkBuilder, - IIncludedResourceObjectBuilder includedBuilder, - IIncludeService includeService, - IResourceContextProvider provider, - IResourceObjectBuilderSettingsProvider settingsProvider) - : base(provider, settingsProvider.Get()) - { - _linkBuilder = linkBuilder; - _includedBuilder = includedBuilder; - _includeService = includeService; - } - - public RelationshipEntry Build(IIdentifiable entity, RelationshipAttribute requestRelationship) - { - _requestRelationship = requestRelationship; - return GetRelationshipData(requestRelationship, entity); - } - - /// - /// Builds the values of the relationships object on a resource object. - /// The server serializer only populates the "data" member when the relationship is included, - /// and adds links unless these are turned off. This means that if a relationship is not included - /// 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) - { - RelationshipEntry relationshipEntry = null; - List> relationshipChains = null; - if (Equals(relationship, _requestRelationship) || ShouldInclude(relationship, out relationshipChains )) - { - relationshipEntry = base.GetRelationshipData(relationship, entity); - 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); - } - - var links = _linkBuilder.GetRelationshipLinks(relationship, entity); - if (links != null) - // if links relationshipLinks should be built for this entry, populate the "links" field. - (relationshipEntry ??= new RelationshipEntry()).Links = links; - - // if neither "links" nor "data" was populated, return null, which will omit this entry from the output. - // (see the NullValueHandling settings on ) - return relationshipEntry; - } - - /// - /// 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(); - } - } -} diff --git a/src/JsonApiDotNetCore/Serialization/Server/Contracts/IFieldsToSerialize.cs b/src/JsonApiDotNetCore/Serialization/Server/Contracts/IFieldsToSerialize.cs deleted file mode 100644 index 2dfd261ebd..0000000000 --- a/src/JsonApiDotNetCore/Serialization/Server/Contracts/IFieldsToSerialize.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System; -using System.Collections.Generic; -using JsonApiDotNetCore.Models; - -namespace JsonApiDotNetCore.Serialization.Server -{ - /// - /// Responsible for getting the set of fields that are to be included for a - /// given type in the serialization result. Typically combines various sources - /// of information, like application-wide hidden fields as set in - /// , or request-wide hidden fields - /// through sparse field selection. - /// - 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 - /// - List GetAllowedAttributes(Type type, RelationshipAttribute relationship = null); - /// - /// Gets the list of relationships that are allowed to be serialized for - /// resource of type - /// - List GetAllowedRelationships(Type type); - } -} diff --git a/src/JsonApiDotNetCore/Serialization/Server/Contracts/IIncludedResourceObjectBuilder.cs b/src/JsonApiDotNetCore/Serialization/Server/Contracts/IIncludedResourceObjectBuilder.cs deleted file mode 100644 index fdaace9d28..0000000000 --- a/src/JsonApiDotNetCore/Serialization/Server/Contracts/IIncludedResourceObjectBuilder.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.Collections.Generic; -using JsonApiDotNetCore.Models; - -namespace JsonApiDotNetCore.Serialization.Server.Builders -{ - public interface IIncludedResourceObjectBuilder - { - /// - /// Gets the list of resource objects representing the included entities - /// - List Build(); - /// - /// Extracts the included entities from using the - /// (arbitrarily deeply nested) included relationships in . - /// - void IncludeRelationshipChain(List inclusionChain, IIdentifiable rootEntity); - } -} diff --git a/src/JsonApiDotNetCore/Serialization/Server/FieldsToSerialize.cs b/src/JsonApiDotNetCore/Serialization/Server/FieldsToSerialize.cs deleted file mode 100644 index 8bf89c4727..0000000000 --- a/src/JsonApiDotNetCore/Serialization/Server/FieldsToSerialize.cs +++ /dev/null @@ -1,62 +0,0 @@ -using JsonApiDotNetCore.Internal.Contracts; -using System; -using System.Collections.Generic; -using JsonApiDotNetCore.Query; -using System.Linq; -using JsonApiDotNetCore.Models; - -namespace JsonApiDotNetCore.Serialization.Server -{ - /// - public class FieldsToSerialize : IFieldsToSerialize - { - private readonly IResourceGraph _resourceGraph; - private readonly ISparseFieldsService _sparseFieldsService ; - private readonly IResourceDefinitionProvider _provider; - - public FieldsToSerialize(IResourceGraph resourceGraph, - ISparseFieldsService sparseFieldsService, - IResourceDefinitionProvider provider) - { - _resourceGraph = resourceGraph; - _sparseFieldsService = sparseFieldsService; - _provider = provider; - } - - /// - public List GetAllowedAttributes(Type type, RelationshipAttribute relationship = null) - { // get the list of all exposed attributes for the given type. - var allowed = _resourceGraph.GetAttributes(type); - - var resourceDefinition = _provider.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 sparseFieldsSelection = _sparseFieldsService.Get(relationship); - if (sparseFieldsSelection.Any()) - // from the allowed attributes, select the ones flagged by sparse field selection. - allowed = allowed.Intersect(sparseFieldsSelection).ToList(); - - return allowed; - } - - /// - /// - /// Note: this method does NOT check if a relationship is included to determine - /// if it should be serialized. This is because completely hiding a relationship - /// 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) - { - 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 deleted file mode 100644 index c17ac03473..0000000000 --- a/src/JsonApiDotNetCore/Serialization/Server/RequestDeserializer.cs +++ /dev/null @@ -1,54 +0,0 @@ -using System; -using JsonApiDotNetCore.Exceptions; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Models; - -namespace JsonApiDotNetCore.Serialization.Server -{ - /// - /// Server deserializer implementation of the - /// - public class RequestDeserializer : BaseDocumentParser, IJsonApiDeserializer - { - private readonly ITargetedFields _targetedFields; - - public RequestDeserializer(IResourceContextProvider contextProvider, IResourceFactory resourceFactory, ITargetedFields targetedFields) - : base(contextProvider, resourceFactory) - { - _targetedFields = targetedFields; - } - - /// - public new object Deserialize(string body) - { - return base.Deserialize(body); - } - - /// - /// 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 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) - { - if (field is AttrAttribute attr) - { - if (attr.Capabilities.HasFlag(AttrCapabilities.AllowMutate)) - { - _targetedFields.Attributes.Add(attr); - } - else - { - throw new InvalidRequestBodyException( - "Changing the value of the requested attribute is not allowed.", - $"Changing the value of '{attr.PublicAttributeName}' is not allowed.", null); - } - } - else if (field is RelationshipAttribute relationship) - _targetedFields.Relationships.Add(relationship); - } - } -} diff --git a/src/JsonApiDotNetCore/Serialization/Server/ResourceObjectBuilderSettingsProvider.cs b/src/JsonApiDotNetCore/Serialization/Server/ResourceObjectBuilderSettingsProvider.cs deleted file mode 100644 index 60f88112d3..0000000000 --- a/src/JsonApiDotNetCore/Serialization/Server/ResourceObjectBuilderSettingsProvider.cs +++ /dev/null @@ -1,27 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Query; - -namespace JsonApiDotNetCore.Serialization.Server -{ - /// - /// This implementation of the behaviour provider reads the query params that - /// can, if provided, override the settings in . - /// - public sealed class ResourceObjectBuilderSettingsProvider : IResourceObjectBuilderSettingsProvider - { - private readonly IDefaultsService _defaultsService; - private readonly INullsService _nullsService; - - public ResourceObjectBuilderSettingsProvider(IDefaultsService defaultsService, INullsService nullsService) - { - _defaultsService = defaultsService; - _nullsService = nullsService; - } - - /// - public ResourceObjectBuilderSettings Get() - { - return new ResourceObjectBuilderSettings(_nullsService.SerializerNullValueHandling, _defaultsService.SerializerDefaultValueHandling); - } - } -} diff --git a/src/JsonApiDotNetCore/Serialization/Server/ResponseSerializer.cs b/src/JsonApiDotNetCore/Serialization/Server/ResponseSerializer.cs deleted file mode 100644 index 0b74f0863d..0000000000 --- a/src/JsonApiDotNetCore/Serialization/Server/ResponseSerializer.cs +++ /dev/null @@ -1,187 +0,0 @@ -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.Serialization.Server.Builders; -using JsonApiDotNetCore.Models.JsonApiDocuments; - -namespace JsonApiDotNetCore.Serialization.Server -{ - /// - /// Server serializer implementation of - /// - /// - /// Because in JsonApiDotNetCore every json:api request is associated with exactly one - /// resource (the request resource, see ), - /// the serializer can leverage this information using generics. - /// See for how this is instantiated. - /// - /// Type of the resource associated with the scope of the request - /// for which this serializer is used. - public class ResponseSerializer : BaseDocumentBuilder, IJsonApiSerializer, IResponseSerializer - 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; - private readonly Type _primaryResourceType; - private readonly ILinkBuilder _linkBuilder; - private readonly IIncludedResourceObjectBuilder _includedBuilder; - - public ResponseSerializer(IMetaBuilder metaBuilder, - ILinkBuilder linkBuilder, - IIncludedResourceObjectBuilder includedBuilder, - IFieldsToSerialize fieldsToSerialize, - IResourceObjectBuilder resourceObjectBuilder, - IJsonApiOptions options) - : base(resourceObjectBuilder) - { - _fieldsToSerialize = fieldsToSerialize; - _options = options; - _linkBuilder = linkBuilder; - _metaBuilder = metaBuilder; - _includedBuilder = includedBuilder; - _primaryResourceType = typeof(TResource); - } - - /// - public string Serialize(object data) - { - if (data == null || data is IIdentifiable) - { - return SerializeSingle((IIdentifiable)data); - } - - if (data is IEnumerable collectionOfIdentifiable) - { - return SerializeMany(collectionOfIdentifiable); - } - - if (data is ErrorDocument errorDocument) - { - return SerializeErrorDocument(errorDocument); - } - - throw new InvalidOperationException("Data being returned must be errors or resources."); - } - - private string SerializeErrorDocument(ErrorDocument errorDocument) - { - return SerializeObject(errorDocument, _options.SerializerSettings, serializer => { serializer.ApplyErrorSettings(); }); - } - - /// - /// Convert a single entity into a serialized - /// - /// - /// This method is set internal instead of private for easier testability. - /// - internal string SerializeSingle(IIdentifiable entity) - { - if (RequestRelationship != null && entity != null) - { - var relationship = ((ResponseResourceObjectBuilder)_resourceObjectBuilder).Build(entity, RequestRelationship); - return SerializeObject(relationship, _options.SerializerSettings, serializer => { serializer.NullValueHandling = NullValueHandling.Include; }); - } - - var (attributes, relationships) = GetFieldsToSerialize(); - var document = Build(entity, attributes, relationships); - var resourceObject = document.SingleData; - if (resourceObject != null) - resourceObject.Links = _linkBuilder.GetResourceLinks(resourceObject.Type, resourceObject.Id); - - AddTopLevelObjects(document); - - return SerializeObject(document, _options.SerializerSettings, serializer => { serializer.NullValueHandling = NullValueHandling.Include; }); - } - - private (List, List) GetFieldsToSerialize() - { - return (GetAttributesToSerialize(_primaryResourceType), GetRelationshipsToSerialize(_primaryResourceType)); - } - - /// - /// Convert a list of entities into a serialized - /// - /// - /// This method is set internal instead of private for easier testability. - /// - internal string SerializeMany(IEnumerable entities) - { - var (attributes, relationships) = GetFieldsToSerialize(); - var document = Build(entities, attributes, relationships); - foreach (ResourceObject resourceObject in document.ManyData) - { - var links = _linkBuilder.GetResourceLinks(resourceObject.Type, resourceObject.Id); - if (links == null) - break; - - resourceObject.Links = links; - } - - AddTopLevelObjects(document); - - 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. - /// - private void AddTopLevelObjects(Document document) - { - document.Links = _linkBuilder.GetTopLevelLinks(); - document.Meta = _metaBuilder.GetMeta(); - document.Included = _includedBuilder.Build(); - } - } -} diff --git a/src/JsonApiDotNetCore/Serialization/Server/ResponseSerializerFactory.cs b/src/JsonApiDotNetCore/Serialization/Server/ResponseSerializerFactory.cs deleted file mode 100644 index 9b39778d45..0000000000 --- a/src/JsonApiDotNetCore/Serialization/Server/ResponseSerializerFactory.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Managers.Contracts; -using JsonApiDotNetCore.Services; - -namespace JsonApiDotNetCore.Serialization.Server -{ - /// - /// A factory class to abstract away the initialization of the serializer from the - /// .net core formatter pipeline. - /// - public class ResponseSerializerFactory : IJsonApiSerializerFactory - { - private readonly IServiceProvider _provider; - private readonly ICurrentRequest _currentRequest; - - public ResponseSerializerFactory(ICurrentRequest currentRequest, IScopedServiceProvider provider) - { - _currentRequest = currentRequest; - _provider = provider; - } - - /// - /// Initializes the server serializer using the - /// associated with the current request. - /// - public IJsonApiSerializer GetSerializer() - { - var targetType = GetDocumentPrimaryType(); - if (targetType == null) - return null; - - var serializerType = typeof(ResponseSerializer<>).MakeGenericType(targetType); - var serializer = (IResponseSerializer)_provider.GetService(serializerType); - if (_currentRequest.RequestRelationship != null && _currentRequest.IsRelationshipPath) - serializer.RequestRelationship = _currentRequest.RequestRelationship; - - return (IJsonApiSerializer)serializer; - } - - private Type GetDocumentPrimaryType() - { - if (_currentRequest.RequestRelationship != null && !_currentRequest.IsRelationshipPath) - return _currentRequest.RequestRelationship.RightType; - - return _currentRequest.GetRequestResource()?.ResourceType; - } - } -} diff --git a/src/JsonApiDotNetCore/Services/Contract/ICreateService.cs b/src/JsonApiDotNetCore/Services/Contract/ICreateService.cs deleted file mode 100644 index df4916856d..0000000000 --- a/src/JsonApiDotNetCore/Services/Contract/ICreateService.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.Threading.Tasks; -using JsonApiDotNetCore.Models; - -namespace JsonApiDotNetCore.Services -{ - public interface ICreateService : ICreateService - where T : class, IIdentifiable - { } - - public interface ICreateService - where T : class, IIdentifiable - { - Task CreateAsync(T entity); - } -} diff --git a/src/JsonApiDotNetCore/Services/Contract/IDeleteService.cs b/src/JsonApiDotNetCore/Services/Contract/IDeleteService.cs deleted file mode 100644 index 8ee8c11b12..0000000000 --- a/src/JsonApiDotNetCore/Services/Contract/IDeleteService.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.Threading.Tasks; -using JsonApiDotNetCore.Models; - -namespace JsonApiDotNetCore.Services -{ - public interface IDeleteService : IDeleteService - where T : class, IIdentifiable - { } - - public interface IDeleteService - where T : class, IIdentifiable - { - Task DeleteAsync(TId id); - } -} diff --git a/src/JsonApiDotNetCore/Services/Contract/IGetAllService.cs b/src/JsonApiDotNetCore/Services/Contract/IGetAllService.cs deleted file mode 100644 index 16b367911c..0000000000 --- a/src/JsonApiDotNetCore/Services/Contract/IGetAllService.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; -using JsonApiDotNetCore.Models; - -namespace JsonApiDotNetCore.Services -{ - public interface IGetAllService : IGetAllService - where T : class, IIdentifiable - { } - - public interface IGetAllService - where T : class, IIdentifiable - { - Task> GetAsync(); - } -} diff --git a/src/JsonApiDotNetCore/Services/Contract/IGetByIdService.cs b/src/JsonApiDotNetCore/Services/Contract/IGetByIdService.cs deleted file mode 100644 index c01c6a1391..0000000000 --- a/src/JsonApiDotNetCore/Services/Contract/IGetByIdService.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.Threading.Tasks; -using JsonApiDotNetCore.Models; - -namespace JsonApiDotNetCore.Services -{ - public interface IGetByIdService : IGetByIdService - where T : class, IIdentifiable - { } - - public interface IGetByIdService - where T : class, IIdentifiable - { - Task GetAsync(TId id); - } -} diff --git a/src/JsonApiDotNetCore/Services/Contract/IGetRelationshipService.cs b/src/JsonApiDotNetCore/Services/Contract/IGetRelationshipService.cs deleted file mode 100644 index de32b77547..0000000000 --- a/src/JsonApiDotNetCore/Services/Contract/IGetRelationshipService.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.Threading.Tasks; -using JsonApiDotNetCore.Models; - -namespace JsonApiDotNetCore.Services -{ - public interface IGetRelationshipService : IGetRelationshipService - where T : class, IIdentifiable - { } - - public interface IGetRelationshipService - where T : class, IIdentifiable - { - Task GetRelationshipAsync(TId id, string relationshipName); - } -} diff --git a/src/JsonApiDotNetCore/Services/Contract/IGetRelationshipsService.cs b/src/JsonApiDotNetCore/Services/Contract/IGetRelationshipsService.cs deleted file mode 100644 index 9597a88830..0000000000 --- a/src/JsonApiDotNetCore/Services/Contract/IGetRelationshipsService.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.Threading.Tasks; -using JsonApiDotNetCore.Models; - -namespace JsonApiDotNetCore.Services -{ - public interface IGetRelationshipsService : IGetRelationshipsService - where T : class, IIdentifiable - { } - - public interface IGetRelationshipsService - where T : class, IIdentifiable - { - Task GetRelationshipsAsync(TId id, string relationshipName); - } -} diff --git a/src/JsonApiDotNetCore/Services/Contract/IResourceCommandService.cs b/src/JsonApiDotNetCore/Services/Contract/IResourceCommandService.cs deleted file mode 100644 index 252090b2d1..0000000000 --- a/src/JsonApiDotNetCore/Services/Contract/IResourceCommandService.cs +++ /dev/null @@ -1,21 +0,0 @@ -using JsonApiDotNetCore.Models; - -namespace JsonApiDotNetCore.Services -{ - public interface IResourceCommandService : - ICreateService, - IUpdateService, - IUpdateRelationshipService, - IDeleteService, - IResourceCommandService - where T : class, IIdentifiable - { } - - public interface IResourceCommandService : - ICreateService, - IUpdateService, - IUpdateRelationshipService, - IDeleteService - where T : class, IIdentifiable - { } -} diff --git a/src/JsonApiDotNetCore/Services/Contract/IResourceQueryService.cs b/src/JsonApiDotNetCore/Services/Contract/IResourceQueryService.cs deleted file mode 100644 index 1cd4a94cf3..0000000000 --- a/src/JsonApiDotNetCore/Services/Contract/IResourceQueryService.cs +++ /dev/null @@ -1,21 +0,0 @@ -using JsonApiDotNetCore.Models; - -namespace JsonApiDotNetCore.Services -{ - public interface IResourceQueryService : - IGetAllService, - IGetByIdService, - IGetRelationshipsService, - IGetRelationshipService, - IResourceQueryService - where T : class, IIdentifiable - { } - - public interface IResourceQueryService : - IGetAllService, - IGetByIdService, - IGetRelationshipsService, - IGetRelationshipService - where T : class, IIdentifiable - { } -} diff --git a/src/JsonApiDotNetCore/Services/Contract/IResourceService.cs b/src/JsonApiDotNetCore/Services/Contract/IResourceService.cs deleted file mode 100644 index 0f221c4509..0000000000 --- a/src/JsonApiDotNetCore/Services/Contract/IResourceService.cs +++ /dev/null @@ -1,14 +0,0 @@ -using JsonApiDotNetCore.Models; - -namespace JsonApiDotNetCore.Services -{ - public interface IResourceService - : IResourceCommandService, IResourceQueryService, IResourceService - where T : class, IIdentifiable - { } - - public interface IResourceService - : IResourceCommandService, IResourceQueryService - where T : class, IIdentifiable - { } -} diff --git a/src/JsonApiDotNetCore/Services/Contract/IUpdateRelationshipService.cs b/src/JsonApiDotNetCore/Services/Contract/IUpdateRelationshipService.cs deleted file mode 100644 index 44b45222f4..0000000000 --- a/src/JsonApiDotNetCore/Services/Contract/IUpdateRelationshipService.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.Threading.Tasks; -using JsonApiDotNetCore.Models; - -namespace JsonApiDotNetCore.Services -{ - public interface IUpdateRelationshipService : IUpdateRelationshipService - where T : class, IIdentifiable - { } - - public interface IUpdateRelationshipService - where T : class, IIdentifiable - { - Task UpdateRelationshipsAsync(TId id, string relationshipName, object relationships); - } -} diff --git a/src/JsonApiDotNetCore/Services/Contract/IUpdateService.cs b/src/JsonApiDotNetCore/Services/Contract/IUpdateService.cs deleted file mode 100644 index bd5f13dd60..0000000000 --- a/src/JsonApiDotNetCore/Services/Contract/IUpdateService.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.Threading.Tasks; -using JsonApiDotNetCore.Models; - -namespace JsonApiDotNetCore.Services -{ - public interface IUpdateService : IUpdateService - where T : class, IIdentifiable - { } - - public interface IUpdateService - where T : class, IIdentifiable - { - Task UpdateAsync(TId id, T entity); - } -} diff --git a/src/JsonApiDotNetCore/Services/DefaultResourceService.cs b/src/JsonApiDotNetCore/Services/DefaultResourceService.cs deleted file mode 100644 index dc060a0e64..0000000000 --- a/src/JsonApiDotNetCore/Services/DefaultResourceService.cs +++ /dev/null @@ -1,447 +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.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.PropertyInfo.SetMethod != null) - .Select(x => x.PropertyInfo.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 => r.Is(relationshipName)); - 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/ICreateService.cs b/src/JsonApiDotNetCore/Services/ICreateService.cs new file mode 100644 index 0000000000..d49a9324e4 --- /dev/null +++ b/src/JsonApiDotNetCore/Services/ICreateService.cs @@ -0,0 +1,20 @@ +using System.Threading.Tasks; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.Services +{ + /// + public interface ICreateService : ICreateService + where TResource : class, IIdentifiable + { } + + /// + public interface ICreateService + where TResource : class, IIdentifiable + { + /// + /// Handles a json:api request to create a new resource. + /// + Task CreateAsync(TResource resource); + } +} diff --git a/src/JsonApiDotNetCore/Services/IDeleteService.cs b/src/JsonApiDotNetCore/Services/IDeleteService.cs new file mode 100644 index 0000000000..92b5979b14 --- /dev/null +++ b/src/JsonApiDotNetCore/Services/IDeleteService.cs @@ -0,0 +1,20 @@ +using System.Threading.Tasks; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.Services +{ + /// + public interface IDeleteService : IDeleteService + where TResource : class, IIdentifiable + { } + + /// + public interface IDeleteService + where TResource : class, IIdentifiable + { + /// + /// Handles a json:api request to delete an existing resource. + /// + Task DeleteAsync(TId id); + } +} diff --git a/src/JsonApiDotNetCore/Services/IGetAllService.cs b/src/JsonApiDotNetCore/Services/IGetAllService.cs new file mode 100644 index 0000000000..e13ad53dce --- /dev/null +++ b/src/JsonApiDotNetCore/Services/IGetAllService.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.Services +{ + /// + public interface IGetAllService : IGetAllService + where TResource : class, IIdentifiable + { } + + /// + public interface IGetAllService + where TResource : class, IIdentifiable + { + /// + /// Handles a json:api request to retrieve a collection of resources for a primary endpoint. + /// + Task> GetAsync(); + } +} diff --git a/src/JsonApiDotNetCore/Services/IGetByIdService.cs b/src/JsonApiDotNetCore/Services/IGetByIdService.cs new file mode 100644 index 0000000000..768b5a6d79 --- /dev/null +++ b/src/JsonApiDotNetCore/Services/IGetByIdService.cs @@ -0,0 +1,20 @@ +using System.Threading.Tasks; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.Services +{ + /// + public interface IGetByIdService : IGetByIdService + where TResource : class, IIdentifiable + { } + + /// + public interface IGetByIdService + where TResource : class, IIdentifiable + { + /// + /// Handles a json:api request to retrieve a single resource for a primary endpoint. + /// + Task GetAsync(TId id); + } +} diff --git a/src/JsonApiDotNetCore/Services/IGetRelationshipService.cs b/src/JsonApiDotNetCore/Services/IGetRelationshipService.cs new file mode 100644 index 0000000000..7cd926b3a0 --- /dev/null +++ b/src/JsonApiDotNetCore/Services/IGetRelationshipService.cs @@ -0,0 +1,20 @@ +using System.Threading.Tasks; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.Services +{ + /// + public interface IGetRelationshipService : IGetRelationshipService + where TResource : class, IIdentifiable + { } + + /// + public interface IGetRelationshipService + where TResource : class, IIdentifiable + { + /// + /// Handles a json:api request to retrieve a single relationship. + /// + Task GetRelationshipAsync(TId id, string relationshipName); + } +} diff --git a/src/JsonApiDotNetCore/Services/IGetSecondaryService.cs b/src/JsonApiDotNetCore/Services/IGetSecondaryService.cs new file mode 100644 index 0000000000..dbe52cf6f9 --- /dev/null +++ b/src/JsonApiDotNetCore/Services/IGetSecondaryService.cs @@ -0,0 +1,20 @@ +using System.Threading.Tasks; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.Services +{ + /// + public interface IGetSecondaryService : IGetSecondaryService + where TResource : class, IIdentifiable + { } + + /// + public interface IGetSecondaryService + where TResource : class, IIdentifiable + { + /// + /// Handles a json:api request to retrieve a single resource or a collection of resources for a secondary endpoint, such as /articles/1/author or /articles/1/revisions. + /// + Task GetSecondaryAsync(TId id, string relationshipName); + } +} diff --git a/src/JsonApiDotNetCore/Services/IResourceCommandService.cs b/src/JsonApiDotNetCore/Services/IResourceCommandService.cs new file mode 100644 index 0000000000..c756d3a87b --- /dev/null +++ b/src/JsonApiDotNetCore/Services/IResourceCommandService.cs @@ -0,0 +1,30 @@ +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.Services +{ + /// + /// Groups write operations. + /// + /// The resource type. + public interface IResourceCommandService : + ICreateService, + IUpdateService, + IUpdateRelationshipService, + IDeleteService, + IResourceCommandService + where TResource : class, IIdentifiable + { } + + /// + /// Groups write operations. + /// + /// The resource type. + /// The resource identifier type. + public interface IResourceCommandService : + ICreateService, + IUpdateService, + IUpdateRelationshipService, + IDeleteService + where TResource : class, IIdentifiable + { } +} diff --git a/src/JsonApiDotNetCore/Services/IResourceQueryService.cs b/src/JsonApiDotNetCore/Services/IResourceQueryService.cs new file mode 100644 index 0000000000..dd337a6bfc --- /dev/null +++ b/src/JsonApiDotNetCore/Services/IResourceQueryService.cs @@ -0,0 +1,30 @@ +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.Services +{ + /// + /// Groups read operations. + /// + /// The resource type. + public interface IResourceQueryService : + IGetAllService, + IGetByIdService, + IGetRelationshipService, + IGetSecondaryService, + IResourceQueryService + where TResource : class, IIdentifiable + { } + + /// + /// Groups read operations. + /// + /// The resource type. + /// The resource identifier type. + public interface IResourceQueryService : + IGetAllService, + IGetByIdService, + IGetRelationshipService, + IGetSecondaryService + where TResource : class, IIdentifiable + { } +} diff --git a/src/JsonApiDotNetCore/Services/IResourceService.cs b/src/JsonApiDotNetCore/Services/IResourceService.cs new file mode 100644 index 0000000000..126f6b43b1 --- /dev/null +++ b/src/JsonApiDotNetCore/Services/IResourceService.cs @@ -0,0 +1,23 @@ +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.Services +{ + /// + /// Represents the foundational Resource Service layer in the JsonApiDotNetCore architecture that uses a Resource Repository for data access. + /// + /// The resource type. + public interface IResourceService + : IResourceCommandService, IResourceQueryService, IResourceService + where TResource : class, IIdentifiable + { } + + /// + /// Represents the foundational Resource Service layer in the JsonApiDotNetCore architecture that uses a Resource Repository for data access. + /// + /// The resource type. + /// The resource identifier type. + public interface IResourceService + : IResourceCommandService, IResourceQueryService + where TResource : class, IIdentifiable + { } +} diff --git a/src/JsonApiDotNetCore/Services/IUpdateRelationshipService.cs b/src/JsonApiDotNetCore/Services/IUpdateRelationshipService.cs new file mode 100644 index 0000000000..0b3b27fa9f --- /dev/null +++ b/src/JsonApiDotNetCore/Services/IUpdateRelationshipService.cs @@ -0,0 +1,20 @@ +using System.Threading.Tasks; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.Services +{ + /// + public interface IUpdateRelationshipService : IUpdateRelationshipService + where TResource : class, IIdentifiable + { } + + /// + public interface IUpdateRelationshipService + where TResource : class, IIdentifiable + { + /// + /// Handles a json:api request to update an existing relationship. + /// + Task UpdateRelationshipAsync(TId id, string relationshipName, object relationships); + } +} diff --git a/src/JsonApiDotNetCore/Services/IUpdateService.cs b/src/JsonApiDotNetCore/Services/IUpdateService.cs new file mode 100644 index 0000000000..c34b8ed511 --- /dev/null +++ b/src/JsonApiDotNetCore/Services/IUpdateService.cs @@ -0,0 +1,20 @@ +using System.Threading.Tasks; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.Services +{ + /// + public interface IUpdateService : IUpdateService + where TResource : class, IIdentifiable + { } + + /// + public interface IUpdateService + where TResource : class, IIdentifiable + { + /// + /// Handles a json:api request to update an existing resource. + /// + Task UpdateAsync(TId id, TResource resource); + } +} diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs new file mode 100644 index 0000000000..a7293799b3 --- /dev/null +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -0,0 +1,362 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Hooks.Internal; +using JsonApiDotNetCore.Hooks.Internal.Execution; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Repositories; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using Microsoft.Extensions.Logging; + +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 TraceLogWriter> _traceWriter; + private readonly IJsonApiRequest _request; + private readonly IResourceChangeTracker _resourceChangeTracker; + private readonly IResourceFactory _resourceFactory; + private readonly IResourceHookExecutor _hookExecutor; + + public JsonApiResourceService( + IResourceRepository repository, + IQueryLayerComposer queryLayerComposer, + IPaginationContext paginationContext, + IJsonApiOptions options, + ILoggerFactory loggerFactory, + IJsonApiRequest request, + IResourceChangeTracker resourceChangeTracker, + IResourceFactory resourceFactory, + IResourceHookExecutor hookExecutor = null) + { + if (loggerFactory == null) throw new ArgumentNullException(nameof(loggerFactory)); + + _repository = repository ?? throw new ArgumentNullException(nameof(repository)); + _queryLayerComposer = queryLayerComposer ?? throw new ArgumentNullException(nameof(queryLayerComposer)); + _paginationContext = paginationContext ?? throw new ArgumentNullException(nameof(paginationContext)); + _options = options ?? throw new ArgumentNullException(nameof(options)); + _traceWriter = new TraceLogWriter>(loggerFactory); + _request = request ?? throw new ArgumentNullException(nameof(request)); + _resourceChangeTracker = resourceChangeTracker ?? throw new ArgumentNullException(nameof(resourceChangeTracker)); + _resourceFactory = resourceFactory ?? throw new ArgumentNullException(nameof(resourceFactory)); + _hookExecutor = hookExecutor; + } + + /// + public virtual async Task CreateAsync(TResource resource) + { + _traceWriter.LogMethodStart(new {resource}); + if (resource == null) throw new ArgumentNullException(nameof(resource)); + + if (_hookExecutor != null) + { + resource = _hookExecutor.BeforeCreate(AsList(resource), ResourcePipeline.Post).Single(); + } + + await _repository.CreateAsync(resource); + + resource = await GetPrimaryResourceById(resource.Id, true); + + if (_hookExecutor != null) + { + _hookExecutor.AfterCreate(AsList(resource), ResourcePipeline.Post); + resource = _hookExecutor.OnReturn(AsList(resource), ResourcePipeline.Post).Single(); + } + + return resource; + } + + /// + public virtual async Task DeleteAsync(TId id) + { + _traceWriter.LogMethodStart(new {id}); + + if (_hookExecutor != null) + { + var resource = _resourceFactory.CreateInstance(); + resource.Id = id; + + _hookExecutor.BeforeDelete(AsList(resource), ResourcePipeline.Delete); + } + + var succeeded = await _repository.DeleteAsync(id); + + if (_hookExecutor != null) + { + var resource = _resourceFactory.CreateInstance(); + resource.Id = id; + + _hookExecutor.AfterDelete(AsList(resource), ResourcePipeline.Delete, succeeded); + } + + if (!succeeded) + { + AssertPrimaryResourceExists(null); + } + } + + /// + public virtual async Task> GetAsync() + { + _traceWriter.LogMethodStart(); + + _hookExecutor?.BeforeRead(ResourcePipeline.Get); + + if (_options.IncludeTotalResourceCount) + { + var topFilter = _queryLayerComposer.GetTopFilter(); + _paginationContext.TotalResourceCount = await _repository.CountAsync(topFilter); + } + + var queryLayer = _queryLayerComposer.Compose(_request.PrimaryResource); + var resources = await _repository.GetAsync(queryLayer); + + if (_hookExecutor != null) + { + _hookExecutor.AfterRead(resources, ResourcePipeline.Get); + return _hookExecutor.OnReturn(resources, ResourcePipeline.Get).ToArray(); + } + + return resources; + } + + /// + public virtual async Task GetAsync(TId id) + { + _traceWriter.LogMethodStart(new {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(_request.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 = _request.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) + { + _traceWriter.LogMethodStart(new {id, relationshipName}); + if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); + + AssertRelationshipExists(relationshipName); + + _hookExecutor?.BeforeRead(ResourcePipeline.GetRelationship, id.ToString()); + + var secondaryLayer = _queryLayerComposer.Compose(_request.SecondaryResource); + secondaryLayer.Projection = _queryLayerComposer.GetSecondaryProjectionForRelationshipEndpoint(_request.SecondaryResource); + secondaryLayer.Include = null; + + var primaryLayer = _queryLayerComposer.WrapLayerForSecondaryEndpoint(secondaryLayer, _request.PrimaryResource, id, _request.Relationship); + + 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) + { + _traceWriter.LogMethodStart(new {id, relationshipName}); + if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); + + AssertRelationshipExists(relationshipName); + + _hookExecutor?.BeforeRead(ResourcePipeline.GetRelationship, id.ToString()); + + var secondaryLayer = _queryLayerComposer.Compose(_request.SecondaryResource); + var primaryLayer = _queryLayerComposer.WrapLayerForSecondaryEndpoint(secondaryLayer, _request.PrimaryResource, id, _request.Relationship); + + 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 _request.Relationship.GetValue(primaryResource); + } + + /// + public virtual async Task UpdateAsync(TId id, TResource requestResource) + { + _traceWriter.LogMethodStart(new {id, requestResource}); + if (requestResource == null) throw new ArgumentNullException(nameof(requestResource)); + + 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 relationships) + { + _traceWriter.LogMethodStart(new {id, relationshipName, related = relationships}); + if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); + + AssertRelationshipExists(relationshipName); + + var secondaryLayer = _queryLayerComposer.Compose(_request.SecondaryResource); + var primaryLayer = _queryLayerComposer.WrapLayerForSecondaryEndpoint(secondaryLayer, _request.PrimaryResource, id, _request.Relationship); + 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 (relationships != null) + { + relationshipIds = _request.Relationship is HasOneAttribute + ? new[] {((IIdentifiable) relationships).StringId} + : ((IEnumerable) relationships).Select(e => e.StringId).ToArray(); + } + + await _repository.UpdateRelationshipsAsync(primaryResource, _request.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(_request.PrimaryId, _request.PrimaryResource.ResourceName); + } + } + + private void AssertRelationshipExists(string relationshipName) + { + var relationship = _request.Relationship; + if (relationship == null) + { + throw new RelationshipNotFoundException(relationshipName, _request.PrimaryResource.ResourceName); + } + } + + private static List AsList(TResource resource) + { + return new List { resource }; + } + } + + /// + /// Represents the foundational Resource Service layer in the JsonApiDotNetCore architecture that uses a Resource Repository for data access. + /// + /// The resource type. + public class JsonApiResourceService : JsonApiResourceService, + IResourceService + where TResource : class, IIdentifiable + { + public JsonApiResourceService( + IResourceRepository repository, + IQueryLayerComposer queryLayerComposer, + IPaginationContext paginationContext, + IJsonApiOptions options, + ILoggerFactory loggerFactory, + IJsonApiRequest request, + IResourceChangeTracker resourceChangeTracker, + IResourceFactory resourceFactory, + IResourceHookExecutor hookExecutor = null) + : base(repository, queryLayerComposer, paginationContext, options, loggerFactory, request, + resourceChangeTracker, resourceFactory, hookExecutor) + { } + } +} diff --git a/src/JsonApiDotNetCore/Services/ResourceDefinitionProvider.cs b/src/JsonApiDotNetCore/Services/ResourceDefinitionProvider.cs deleted file mode 100644 index 32005a984d..0000000000 --- a/src/JsonApiDotNetCore/Services/ResourceDefinitionProvider.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System; -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Services; - -namespace JsonApiDotNetCore.Query -{ - /// - internal sealed class ResourceDefinitionProvider : IResourceDefinitionProvider - { - private readonly IResourceGraph _resourceContextProvider; - private readonly IScopedServiceProvider _serviceProvider; - - public ResourceDefinitionProvider(IResourceGraph resourceContextProvider, IScopedServiceProvider serviceProvider) - { - _resourceContextProvider = resourceContextProvider; - _serviceProvider = serviceProvider; - } - - /// - public IResourceDefinition Get(Type resourceType) - { - return (IResourceDefinition)_serviceProvider.GetService(_resourceContextProvider.GetResourceContext(resourceType).ResourceDefinitionType); - } - } -} diff --git a/src/JsonApiDotNetCore/Internal/TypeHelper.cs b/src/JsonApiDotNetCore/TypeHelper.cs similarity index 57% rename from src/JsonApiDotNetCore/Internal/TypeHelper.cs rename to src/JsonApiDotNetCore/TypeHelper.cs index 2288c35f71..348612bd5e 100644 --- a/src/JsonApiDotNetCore/Internal/TypeHelper.cs +++ b/src/JsonApiDotNetCore/TypeHelper.cs @@ -2,63 +2,92 @@ using System.Collections; using System.Collections.Generic; using System.Linq; -using System.Reflection; using System.Linq.Expressions; -using JsonApiDotNetCore.Extensions; -using JsonApiDotNetCore.Models; +using System.Reflection; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Internal +namespace JsonApiDotNetCore { 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; } - internal static object GetDefaultValue(this Type type) + internal static object GetDefaultValue(Type type) { return type.IsValueType ? CreateInstance(type) : null; } @@ -69,7 +98,7 @@ public static Type TryGetCollectionElementType(Type type) { if (type.IsGenericType && type.GenericTypeArguments.Length == 1) { - if (type.IsOrImplementsInterface(typeof(IEnumerable))) + if (IsOrImplementsInterface(type, typeof(IEnumerable))) { return type.GenericTypeArguments[0]; } @@ -135,11 +164,9 @@ 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(IEnumerable attributes, HashSet resources) { - return attributes?.ToDictionary(attr => attr.PropertyInfo, attr => entities); + return attributes?.ToDictionary(attr => attr.Property, attr => resources); } /// @@ -151,7 +178,7 @@ public static Dictionary> ConvertAttributeDicti /// Open generic type public static object CreateInstanceOfOpenType(Type openType, Type parameter, params object[] constructorArguments) { - return CreateInstanceOfOpenType(openType, new[] { parameter }, constructorArguments); + return CreateInstanceOfOpenType(openType, new[] {parameter}, constructorArguments); } /// @@ -159,7 +186,7 @@ public static object CreateInstanceOfOpenType(Type openType, Type parameter, par /// public static object CreateInstanceOfOpenType(Type openType, Type parameter, bool hasInternalConstructor, params object[] constructorArguments) { - Type[] parameters = { parameter }; + Type[] parameters = {parameter}; if (!hasInternalConstructor) return CreateInstanceOfOpenType(openType, parameters, constructorArguments); var parameterizedType = openType.MakeGenericType(parameters); // note that if for whatever reason the constructor of AffectedResource is set from @@ -188,11 +215,11 @@ public static IEnumerable CreateHashSetFor(Type type, object elements = null) /// /// Returns a compatible collection type that can be instantiated, for example IList{Article} -> List{Article} or ISet{Article} -> HashSet{Article} /// - public static Type ToConcreteCollectionType(this Type collectionType) + public static Type ToConcreteCollectionType(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<>)) @@ -221,15 +248,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); } } @@ -246,11 +277,63 @@ public static object GetResourceTypedId(IIdentifiable resource) return property.GetValue(resource); } - public static string GetResourceStringId(TId id, IResourceFactory resourceFactory) where TResource : class, IIdentifiable + /// + /// Extension to use the LINQ cast method in a non-generic way: + /// + /// Type targetType = typeof(TResource) + /// ((IList)myList).CopyToList(targetType). + /// + /// + public static IList CopyToList(IEnumerable copyFrom, Type elementType, Converter elementConverter = null) + { + Type collectionType = typeof(List<>).MakeGenericType(elementType); + + if (elementConverter != null) + { + var converted = copyFrom.Cast().Select(element => elementConverter(element)); + return (IList) CopyToTypedCollection(converted, collectionType); + } + + return (IList)CopyToTypedCollection(copyFrom, collectionType); + } + + /// + /// Creates a collection instance based on the specified collection type and copies the specified elements into it. + /// + /// Source to copy from. + /// Target collection type, for example: typeof(List{Article}) or typeof(ISet{Person}). + public static IEnumerable CopyToTypedCollection(IEnumerable source, Type collectionType) { - TResource tempResource = resourceFactory.CreateInstance(); - tempResource.Id = id; - return tempResource.StringId; + if (source == null) throw new ArgumentNullException(nameof(source)); + if (collectionType == null) throw new ArgumentNullException(nameof(collectionType)); + + var concreteCollectionType = ToConcreteCollectionType(collectionType); + dynamic concreteCollectionInstance = CreateInstance(concreteCollectionType); + + foreach (var item in source) + { + concreteCollectionInstance.Add((dynamic) item); + } + + return concreteCollectionInstance; + } + + /// + /// Whether the specified source type implements or equals the specified interface. + /// + public static bool IsOrImplementsInterface(Type source, Type interfaceType) + { + if (interfaceType == null) + { + throw new ArgumentNullException(nameof(interfaceType)); + } + + if (source == null) + { + return false; + } + + return source == interfaceType || source.GetInterfaces().Any(type => type == interfaceType); } } } diff --git a/src/JsonApiDotNetCore/index.md b/src/JsonApiDotNetCore/index.md deleted file mode 100644 index 3ae2506361..0000000000 --- a/src/JsonApiDotNetCore/index.md +++ /dev/null @@ -1,4 +0,0 @@ -# This is the **HOMEPAGE**. -Refer to [Markdown](http://daringfireball.net/projects/markdown/) for how to write markdown files. -## Quick Start Notes: -1. Add images to the *images* folder if the file is referencing an image. diff --git a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs index 3f1266b3c6..2d38877853 100644 --- a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs +++ b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs @@ -1,18 +1,11 @@ using System.Collections.Generic; -using JsonApiDotNetCore.Builders; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Data; -using JsonApiDotNetCore.Graph; -using JsonApiDotNetCore.Hooks; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Internal.Generics; -using JsonApiDotNetCore.Managers.Contracts; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Query; -using JsonApiDotNetCore.RequestServices; -using JsonApiDotNetCore.Serialization; -using JsonApiDotNetCore.Serialization.Server.Builders; +using JsonApiDotNetCore.Hooks.Internal; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Repositories; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Building; using JsonApiDotNetCore.Services; using JsonApiDotNetCoreExample.Models; using Microsoft.EntityFrameworkCore; @@ -40,13 +33,16 @@ public ServiceDiscoveryFacadeTests() _services.AddSingleton(options); _services.AddSingleton(new LoggerFactory()); _services.AddScoped(_ => new Mock().Object); - _services.AddScoped(_ => new Mock().Object); + _services.AddScoped(_ => new Mock().Object); _services.AddScoped(_ => new Mock().Object); _services.AddScoped(_ => new Mock().Object); _services.AddScoped(_ => new Mock().Object); _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 +59,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 +101,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, + IJsonApiRequest request, IResourceChangeTracker resourceChangeTracker, IResourceFactory resourceFactory, IResourceHookExecutor hookExecutor = null) - : base(queryParameters, options, loggerFactory, repository, provider, resourceChangeTracker, resourceFactory, hookExecutor) - { } + : base(repository, queryLayerComposer, paginationContext, options, loggerFactory, request, + resourceChangeTracker, resourceFactory, hookExecutor) + { + } } - public class TestModelRepository : DefaultResourceRepository + public class TestModelRepository : EntityFrameworkCoreRepository { internal static IDbContextResolver _dbContextResolver; @@ -131,8 +128,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..09174695cd --- /dev/null +++ b/test/IntegrationTests/Data/EntityFrameworkCoreRepositoryTests.cs @@ -0,0 +1,108 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using IntegrationTests; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Repositories; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCoreExample.Data; +using JsonApiDotNetCoreExample.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +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 + { + PublicName = "description", + Property = typeof(TodoItem).GetProperty(nameof(TodoItem.Description)) + }; + targetedFields.Setup(m => m.Attributes).Returns(new List { descAttr }); + targetedFields.Setup(m => m.Relationships).Returns(new List()); + + // 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).Add().Build(); + var targetedFields = new Mock(); + var serviceFactory = new Mock().Object; + var repository = new EntityFrameworkCoreRepository(targetedFields.Object, contextResolverMock.Object, resourceGraph, serviceFactory, resourceFactory, new List(), NullLoggerFactory.Instance); + 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 2182ac475c..0000000000 --- a/test/IntegrationTests/Data/EntityRepositoryTests.cs +++ /dev/null @@ -1,201 +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 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") - { - PropertyInfo = 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/ActionResultTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/ActionResultTests.cs index b71ee6a6db..5fd33a87f5 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/ActionResultTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/ActionResultTests.cs @@ -3,8 +3,8 @@ using System.Net.Http; using System.Net.Http.Headers; using System.Threading.Tasks; -using JsonApiDotNetCore; -using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExample; using Newtonsoft.Json; using Xunit; diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomControllerTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomControllerTests.cs index e3d86523a7..cdd5d448e4 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomControllerTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomControllerTests.cs @@ -4,11 +4,12 @@ using System.Net.Http.Headers; using System.Threading.Tasks; using Bogus; -using JsonApiDotNetCore; -using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; +using Microsoft.AspNetCore; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; using 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..51f3568667 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomErrorHandlingTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomErrorHandlingTests.cs @@ -1,9 +1,9 @@ using System; using System.Net; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Exceptions; +using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCore.Serialization.Objects; using Microsoft.Extensions.Logging; using Xunit; @@ -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..bc4400b7c1 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/IgnoreDefaultValuesTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/IgnoreDefaultValuesTests.cs @@ -3,11 +3,11 @@ using System.Net.Http; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; +using Microsoft.AspNetCore; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; using Newtonsoft.Json; @@ -24,12 +24,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 +90,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) { @@ -117,7 +117,7 @@ public async Task CheckBehaviorCombination(DefaultValueHandling? defaultValue, b var body = await response.Content.ReadAsStringAsync(); var isQueryStringValueEmpty = queryStringValue == string.Empty; - var isDisallowedOverride = options.AllowQueryStringOverrideForSerializerDefaultValueHandling == false && queryStringValue != null; + var isDisallowedOverride = !options.AllowQueryStringOverrideForSerializerDefaultValueHandling && queryStringValue != null; var isQueryStringInvalid = queryStringValue != null && !bool.TryParse(queryStringValue, out _); if (isQueryStringValueEmpty) @@ -149,8 +149,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..f6a03bf098 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/IgnoreNullValuesTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/IgnoreNullValuesTests.cs @@ -3,11 +3,11 @@ using System.Net.Http; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; +using Microsoft.AspNetCore; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; using Newtonsoft.Json; @@ -24,7 +24,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 +32,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 +93,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) { @@ -120,7 +120,7 @@ public async Task CheckBehaviorCombination(NullValueHandling? defaultValue, bool var body = await response.Content.ReadAsStringAsync(); var isQueryStringValueEmpty = queryStringValue == string.Empty; - var isDisallowedOverride = options.AllowQueryStringOverrideForSerializerNullValueHandling == false && queryStringValue != null; + var isDisallowedOverride = !options.AllowQueryStringOverrideForSerializerNullValueHandling && queryStringValue != null; var isQueryStringInvalid = queryStringValue != null && !bool.TryParse(queryStringValue, out _); if (isQueryStringValueEmpty) @@ -152,8 +152,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..46d3074325 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/RequestMetaTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/RequestMetaTests.cs @@ -1,65 +1,56 @@ +using System.Collections.Generic; 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 FluentAssertions; +using JsonApiDotNetCore.Serialization; +using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExample; +using JsonApiDotNetCoreExample.Data; +using Microsoft.Extensions.DependencyInjection; +using Xunit; 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 IReadOnlyDictionary 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..16f660e3f6 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/HttpReadOnlyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/HttpReadOnlyTests.cs @@ -1,8 +1,9 @@ using System.Net; using System.Net.Http; using System.Threading.Tasks; -using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCore.Serialization.Objects; 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..7103dc3a2c 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpDeleteTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpDeleteTests.cs @@ -1,8 +1,9 @@ using System.Net; using System.Net.Http; using System.Threading.Tasks; -using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCore.Serialization.Objects; 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..7d26157c46 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpPatchTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpPatchTests.cs @@ -1,8 +1,9 @@ using System.Net; using System.Net.Http; using System.Threading.Tasks; -using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCore.Serialization.Objects; 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..23d63eca51 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpPostTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpPostTests.cs @@ -1,8 +1,9 @@ using System.Net; using System.Net.Http; using System.Threading.Tasks; -using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCore.Serialization.Objects; 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..1af223de4a 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/InjectableResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/InjectableResourceTests.cs @@ -3,8 +3,7 @@ using System.Net.Http; using System.Threading.Tasks; using Bogus; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; @@ -54,7 +53,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 +74,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 +84,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 +107,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 +127,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 +151,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 +162,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"); @@ -216,7 +215,7 @@ public async Task Fail_When_Deleting_Missing_Passport() Assert.Single(errorDocument.Errors); Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); Assert.Equal("The requested resource does not exist.", errorDocument.Errors[0].Title); - Assert.Equal("Resource of type 'passports' with id '" + passportId + "' does not exist.", errorDocument.Errors[0].Detail); + Assert.Equal("Resource of type 'passports' with ID '" + passportId + "' does not exist.", errorDocument.Errors[0].Detail); } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/KebabCaseFormatterTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/KebabCaseFormatterTests.cs index e2e18fef53..49748e4f54 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.Resources; +using JsonApiDotNetCore.Serialization.Client.Internal; +using JsonApiDotNetCore.Serialization.Objects; +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 94cffa66bb..76d18461b7 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/ManyToManyTests.cs @@ -5,11 +5,12 @@ using System.Net.Http.Headers; using System.Threading.Tasks; using Bogus; -using JsonApiDotNetCore; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; +using Microsoft.AspNetCore; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; using Microsoft.EntityFrameworkCore; @@ -21,111 +22,27 @@ namespace JsonApiDotNetCoreExampleTests.Acceptance [Collection("WebHostCollection")] public sealed class ManyToManyTests { - private readonly Faker
_articleFaker = new Faker
() - .RuleFor(a => a.Name, f => f.Random.AlphaNumeric(10)) - .RuleFor(a => a.Author, f => new Author()); + private readonly TestFixture _fixture; + private readonly Faker _authorFaker; + private readonly Faker
_articleFaker; private readonly Faker _tagFaker; - private readonly TestFixture _fixture; - public ManyToManyTests(TestFixture fixture) { _fixture = fixture; - - _tagFaker = new Faker() - .CustomInstantiator(f => new Tag(_fixture.GetService())) - .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(); + _authorFaker = new Faker() + .RuleFor(a => a.LastName, f => f.Random.Words(2)); - var articleTag = new ArticleTag(context) - { - Article = article, - Tag = tag - }; - context.ArticleTags.Add(articleTag); - await context.SaveChangesAsync(); - var route = "/api/v1/articles?include=tags"; + _articleFaker = new Faker
() + .RuleFor(a => a.Caption, f => f.Random.AlphaNumeric(10)) + .RuleFor(a => a.Author, f => _authorFaker.Generate()); - // @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); + _tagFaker = new Faker() + .CustomInstantiator(f => new Tag()) + .RuleFor(a => a.Name, f => f.Random.AlphaNumeric(10)); } [Fact] @@ -135,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 @@ -146,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(); @@ -161,7 +78,7 @@ public async Task Can_Fetch_Many_To_Many_Through_Id() var document = JsonConvert.DeserializeObject(body); Assert.Single(document.ManyData); - var tagResponse = _fixture.GetDeserializer().DeserializeList(body).Data.First(); + var tagResponse = _fixture.GetDeserializer().DeserializeMany(body).Data.First(); Assert.NotNull(tagResponse); Assert.Equal(tag.Id, tagResponse.Id); Assert.Equal(tag.Name, tagResponse.Name); @@ -174,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 @@ -185,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(); @@ -200,7 +117,7 @@ public async Task Can_Fetch_Many_To_Many_Through_GetById_Relationship_Link() var document = JsonConvert.DeserializeObject(body); Assert.Null(document.Included); - var tagResponse = _fixture.GetDeserializer().DeserializeList(body).Data.First(); + var tagResponse = _fixture.GetDeserializer().DeserializeMany(body).Data.First(); Assert.NotNull(tagResponse); Assert.Equal(tag.Id, tagResponse.Id); } @@ -212,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 @@ -223,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(); @@ -238,7 +155,7 @@ public async Task Can_Fetch_Many_To_Many_Through_Relationship_Link() var document = JsonConvert.DeserializeObject(body); Assert.Null(document.Included); - var tagResponse = _fixture.GetDeserializer().DeserializeList(body).Data.First(); + var tagResponse = _fixture.GetDeserializer().DeserializeMany(body).Data.First(); Assert.NotNull(tagResponse); Assert.Equal(tag.Id, tagResponse.Id); } @@ -250,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 @@ -260,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(); @@ -282,7 +199,7 @@ public async Task Can_Create_Many_To_Many() // Arrange var context = _fixture.GetService(); var tag = _tagFaker.Generate(); - var author = new Author(); + var author = _authorFaker.Generate(); context.Tags.Add(tag); context.AuthorDifferentDbContextName.Add(author); await context.SaveChangesAsync(); @@ -294,6 +211,10 @@ public async Task Can_Create_Many_To_Many() data = new { type = "articles", + attributes = new Dictionary + { + {"caption", "An article with relationships"} + }, relationships = new Dictionary { { "author", new { @@ -319,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(); @@ -378,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(); @@ -409,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 @@ -444,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(); @@ -473,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 @@ -512,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(); @@ -561,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 7907b28305..7a9d6f077b 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/ModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/ModelStateValidationTests.cs @@ -1,13 +1,16 @@ +using System.Collections.Generic; using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Threading.Tasks; -using JsonApiDotNetCore; +using Bogus; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; using JsonApiDotNetCoreExampleTests.Acceptance.Spec; +using Microsoft.EntityFrameworkCore; using Newtonsoft.Json; using Xunit; @@ -15,16 +18,34 @@ namespace JsonApiDotNetCoreExampleTests.Acceptance { public sealed class ModelStateValidationTests : FunctionalTestCollection { + private readonly Faker
_articleFaker; + private readonly Faker _authorFaker; + private readonly Faker _tagFaker; + public ModelStateValidationTests(StandardApplicationFactory factory) : base(factory) { + var options = (JsonApiOptions) _factory.GetService(); + options.ValidateModelState = true; + + var context = _factory.GetService(); + + _authorFaker = new Faker() + .RuleFor(a => a.LastName, f => f.Random.Words(2)); + + _articleFaker = new Faker
() + .RuleFor(a => a.Caption, f => f.Random.AlphaNumeric(10)) + .RuleFor(a => a.Author, f => _authorFaker.Generate()); + + _tagFaker = new Faker() + .RuleFor(a => a.Name, f => f.Random.AlphaNumeric(10)); } [Fact] public async Task When_posting_tag_with_invalid_name_it_must_fail() { // Arrange - var tag = new Tag(_dbContext) + var tag = new Tag { Name = "!@#$%^&*().-" }; @@ -38,9 +59,6 @@ public async Task When_posting_tag_with_invalid_name_it_must_fail() }; request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - var options = (JsonApiOptions)_factory.GetService(); - options.ValidateModelState = true; - // Act var response = await _factory.Client.SendAsync(request); @@ -60,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 = "!@#$%^&*().-" }; @@ -88,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 = "!@#$%^&*().-" @@ -112,9 +130,6 @@ public async Task When_patching_tag_with_invalid_name_it_must_fail() }; request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - var options = (JsonApiOptions)_factory.GetService(); - options.ValidateModelState = true; - // Act var response = await _factory.Client.SendAsync(request); @@ -134,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 = "!@#$%^&*().-" @@ -167,5 +182,378 @@ public async Task When_patching_tag_with_invalid_name_without_model_state_valida // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); } + + [Fact] + public async Task Create_Article_With_IsRequired_Name_Attribute_Succeeds() + { + // Arrange + string name = "Article Title"; + var context = _factory.GetService(); + var author = _authorFaker.Generate(); + context.AuthorDifferentDbContextName.Add(author); + await context.SaveChangesAsync(); + + var route = "/api/v1/articles"; + var request = new HttpRequestMessage(HttpMethod.Post, route); + var content = new + { + data = new + { + type = "articles", + attributes = new Dictionary + { + {"caption", name} + }, + relationships = new Dictionary + { + { "author", new + { + data = new + { + type = "authors", + id = author.StringId + } + } + } + } + } + }; + request.Content = new StringContent(JsonConvert.SerializeObject(content)); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); + + // Act + var response = await _factory.Client.SendAsync(request); + + // Assert + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + + var articleResponse = GetDeserializer().DeserializeSingle
(body).Data; + Assert.NotNull(articleResponse); + + var persistedArticle = await _dbContext.Articles + .SingleAsync(a => a.Id == articleResponse.Id); + + Assert.Equal(name, persistedArticle.Caption); + } + + [Fact] + public async Task Create_Article_With_IsRequired_Name_Attribute_Empty_Succeeds() + { + // Arrange + string name = string.Empty; + var context = _factory.GetService(); + var author = _authorFaker.Generate(); + context.AuthorDifferentDbContextName.Add(author); + await context.SaveChangesAsync(); + + var route = "/api/v1/articles"; + var request = new HttpRequestMessage(HttpMethod.Post, route); + var content = new + { + data = new + { + type = "articles", + attributes = new Dictionary + { + {"caption", name} + }, + relationships = new Dictionary + { + { "author", new + { + data = new + { + type = "authors", + id = author.StringId + } + } + } + } + } + }; + request.Content = new StringContent(JsonConvert.SerializeObject(content)); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); + + // Act + var response = await _factory.Client.SendAsync(request); + + // Assert + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + + var articleResponse = GetDeserializer().DeserializeSingle
(body).Data; + Assert.NotNull(articleResponse); + + var persistedArticle = await _dbContext.Articles + .SingleAsync(a => a.Id == articleResponse.Id); + + Assert.Equal(name, persistedArticle.Caption); + } + + [Fact] + public async Task Create_Article_With_IsRequired_Name_Attribute_Explicitly_Null_Fails() + { + // Arrange + var context = _factory.GetService(); + var author = _authorFaker.Generate(); + context.AuthorDifferentDbContextName.Add(author); + await context.SaveChangesAsync(); + + var route = "/api/v1/articles"; + var request = new HttpRequestMessage(HttpMethod.Post, route); + var content = new + { + data = new + { + type = "articles", + attributes = new Dictionary + { + {"caption", null} + }, + relationships = new Dictionary + { + { "author", new + { + data = new + { + type = "authors", + id = author.StringId + } + } + } + } + } + }; + request.Content = new StringContent(JsonConvert.SerializeObject(content)); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); + + // Act + var response = await _factory.Client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal("Input validation failed.", errorDocument.Errors[0].Title); + Assert.Equal("422", errorDocument.Errors[0].Status); + Assert.Equal("The Caption field is required.", errorDocument.Errors[0].Detail); + } + + [Fact] + public async Task Create_Article_With_IsRequired_Name_Attribute_Missing_Fails() + { + // Arrange + var context = _factory.GetService(); + var author = _authorFaker.Generate(); + context.AuthorDifferentDbContextName.Add(author); + await context.SaveChangesAsync(); + + var route = "/api/v1/articles"; + var request = new HttpRequestMessage(HttpMethod.Post, route); + var content = new + { + data = new + { + type = "articles", + relationships = new Dictionary + { + { "author", new + { + data = new + { + type = "authors", + id = author.StringId + } + } + } + } + } + }; + request.Content = new StringContent(JsonConvert.SerializeObject(content)); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); + + // Act + var response = await _factory.Client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal("Input validation failed.", errorDocument.Errors[0].Title); + Assert.Equal("422", errorDocument.Errors[0].Status); + Assert.Equal("The Caption field is required.", errorDocument.Errors[0].Detail); + } + + [Fact] + public async Task Update_Article_With_IsRequired_Name_Attribute_Succeeds() + { + // Arrange + var name = "Article Name"; + var context = _factory.GetService(); + var article = _articleFaker.Generate(); + context.Articles.Add(article); + await context.SaveChangesAsync(); + + var route = $"/api/v1/articles/{article.Id}"; + var request = new HttpRequestMessage(HttpMethod.Patch, route); + var content = new + { + data = new + { + type = "articles", + id = article.StringId, + attributes = new Dictionary + { + {"caption", name} + } + } + }; + + request.Content = new StringContent(JsonConvert.SerializeObject(content)); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); + + // Act + var response = await _factory.Client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var persistedArticle = await _dbContext.Articles + .SingleOrDefaultAsync(a => a.Id == article.Id); + + var updatedName = persistedArticle.Caption; + Assert.Equal(name, updatedName); + } + + [Fact] + public async Task Update_Article_With_IsRequired_Name_Attribute_Missing_Succeeds() + { + // Arrange + var context = _factory.GetService(); + var tag = _tagFaker.Generate(); + var article = _articleFaker.Generate(); + context.Tags.Add(tag); + context.Articles.Add(article); + await context.SaveChangesAsync(); + + var route = $"/api/v1/articles/{article.Id}"; + var request = new HttpRequestMessage(HttpMethod.Patch, route); + var content = new + { + data = new + { + type = "articles", + id = article.StringId, + relationships = new Dictionary + { + { "tags", new + { + data = new [] + { + new + { + type = "tags", + id = tag.StringId + } + } + } + } + } + } + }; + + request.Content = new StringContent(JsonConvert.SerializeObject(content)); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); + + // Act + var response = await _factory.Client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Update_Article_With_IsRequired_Name_Attribute_Explicitly_Null_Fails() + { + // Arrange + var context = _factory.GetService(); + var article = _articleFaker.Generate(); + context.Articles.Add(article); + await context.SaveChangesAsync(); + + var route = $"/api/v1/articles/{article.Id}"; + var request = new HttpRequestMessage(HttpMethod.Patch, route); + var content = new + { + data = new + { + type = "articles", + id = article.StringId, + attributes = new Dictionary + { + {"caption", null} + } + } + }; + + request.Content = new StringContent(JsonConvert.SerializeObject(content)); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); + + // Act + var response = await _factory.Client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal("Input validation failed.", errorDocument.Errors[0].Title); + Assert.Equal("422", errorDocument.Errors[0].Status); + Assert.Equal("The Caption field is required.", errorDocument.Errors[0].Detail); + } + + [Fact] + public async Task Update_Article_With_IsRequired_Name_Attribute_Empty_Succeeds() + { + // Arrange + var context = _factory.GetService(); + var article = _articleFaker.Generate(); + context.Articles.Add(article); + await context.SaveChangesAsync(); + + var route = $"/api/v1/articles/{article.Id}"; + var request = new HttpRequestMessage(HttpMethod.Patch, route); + var content = new + { + data = new + { + type = "articles", + id = article.StringId, + attributes = new Dictionary + { + {"caption", ""} + } + } + }; + + request.Content = new StringContent(JsonConvert.SerializeObject(content)); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); + + // Act + var response = await _factory.Client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var persistedArticle = await _dbContext.Articles + .SingleOrDefaultAsync(a => a.Id == article.Id); + + 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 41ea2d9d45..6742ab6d80 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/ResourceDefinitions/ResourceDefinitionTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/ResourceDefinitions/ResourceDefinitionTests.cs @@ -5,73 +5,58 @@ using System.Net.Http.Headers; using System.Threading.Tasks; using Bogus; -using JsonApiDotNetCore; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Models.JsonApiDocuments; -using JsonApiDotNetCoreExample; -using JsonApiDotNetCoreExample.Data; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Serialization.Objects; 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; - private readonly Faker
_articleFaker = new Faker
() - .RuleFor(a => a.Name, f => f.Random.AlphaNumeric(10)) - .RuleFor(a => a.Author, f => new Author()); - + private readonly Faker
_articleFaker; + 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.LastName, f => f.Random.Words(2)); + + _articleFaker = new Faker
() + .RuleFor(a => a.Caption, f => f.Random.AlphaNumeric(10)) + .RuleFor(a => a.Author, f => _authorFaker.Generate()); + _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] @@ -80,9 +65,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"; @@ -93,21 +77,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); } @@ -116,10 +100,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) @@ -129,7 +114,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); @@ -138,10 +123,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); } @@ -152,7 +137,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(); @@ -172,7 +157,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(); @@ -189,18 +174,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(); @@ -217,20 +199,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(); @@ -242,9 +221,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"; @@ -252,24 +228,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(); @@ -278,7 +259,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. @@ -287,13 +268,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 { @@ -320,7 +300,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(); @@ -337,14 +317,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 { @@ -372,7 +351,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(); @@ -389,14 +368,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 { @@ -424,7 +402,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(); @@ -441,20 +419,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(); @@ -471,13 +448,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 { @@ -509,7 +485,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(); @@ -526,15 +502,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 { @@ -567,7 +542,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(); @@ -584,20 +559,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..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; @@ -30,9 +31,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); @@ -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/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..b5bdac6497 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ContentNegotiationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ContentNegotiationTests.cs @@ -2,10 +2,11 @@ using System.Net.Http; using System.Net.Http.Headers; using System.Threading.Tasks; -using JsonApiDotNetCore; -using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Serialization.Objects; 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..46b85a9f97 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/CreatingDataTests.cs @@ -1,10 +1,9 @@ -using System; using System.Collections.Generic; using System.Linq; using System.Net; using System.Threading.Tasks; using Bogus; -using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExample.Models; using JsonApiDotNetCoreExampleTests.Helpers.Models; using Microsoft.EntityFrameworkCore; @@ -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 @@ -82,7 +81,7 @@ public async Task ClientGeneratedId_IntegerIdAndNotEnabled_IsForbidden() var errorDocument = JsonConvert.DeserializeObject(body); Assert.Single(errorDocument.Errors); Assert.Equal(HttpStatusCode.Forbidden, errorDocument.Errors[0].StatusCode); - Assert.Equal("Specifying the resource id in POST requests is not allowed.", errorDocument.Errors[0].Title); + Assert.Equal("Specifying the resource ID in POST requests is not allowed.", errorDocument.Errors[0].Title); Assert.Null(errorDocument.Errors[0].Detail); } @@ -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,32 +275,7 @@ public async Task CreateResource_SimpleResource_HeaderLocationsAreCorrect() } [Fact] - public async Task CreateResource_EntityTypeMismatch_IsConflict() - { - // Arrange - string content = JsonConvert.SerializeObject(new - { - data = new - { - type = "people" - } - }); - - // Act - var (body, response) = await Post("/api/v1/todoItems", content); - - // Assert - AssertEqualStatusCode(HttpStatusCode.Conflict, response); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.Conflict, errorDocument.Errors[0].StatusCode); - Assert.Equal("Resource type mismatch between request body and endpoint URL.", errorDocument.Errors[0].Title); - Assert.Equal("Expected resource of type 'todoItems' in POST request body at endpoint '/api/v1/todoItems', instead of 'people'.", errorDocument.Errors[0].Detail); - } - - [Fact] - public async Task CreateResource_UnknownEntityType_Fails() + public async Task CreateResource_UnknownResourceType_Fails() { // Arrange string content = JsonConvert.SerializeObject(new @@ -335,7 +309,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 +333,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 +356,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..2a434f8508 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeletingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DeletingDataTests.cs @@ -1,9 +1,11 @@ using System.Net; using System.Net.Http; using System.Threading.Tasks; -using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Data; +using JsonApiDotNetCoreExample.Models; +using Microsoft.AspNetCore; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; using Newtonsoft.Json; @@ -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); @@ -49,7 +51,7 @@ public async Task Respond_404_If_EntityDoesNotExist() Assert.Single(errorDocument.Errors); Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); Assert.Equal("The requested resource does not exist.", errorDocument.Errors[0].Title); - Assert.Equal("Resource of type 'todoItems' with id '123' does not exist.",errorDocument.Errors[0].Detail); + Assert.Equal("Resource of type 'todoItems' with ID '123' does not exist.",errorDocument.Errors[0].Detail); } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DisableQueryAttributeTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DisableQueryAttributeTests.cs index c76e118179..82fdfe32d7 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DisableQueryAttributeTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DisableQueryAttributeTests.cs @@ -1,7 +1,7 @@ using System.Net; using System.Net.Http; using System.Threading.Tasks; -using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExample; using Newtonsoft.Json; using Xunit; @@ -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..e347d8097f 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/LinksWithNamespaceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/LinksWithNamespaceTests.cs @@ -2,7 +2,7 @@ using System.Net.Http; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExample.Models; using Newtonsoft.Json; using Xunit; @@ -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..9670d5003a 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 JsonApiDotNetCore.Models; -using Newtonsoft.Json; -using Xunit; +using FluentAssertions; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExample; +using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; +using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Xunit; 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..65ce8d8f67 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Meta.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Meta.cs @@ -3,11 +3,13 @@ using System.Net.Http; using System.Net.Http.Headers; using System.Threading.Tasks; -using JsonApiDotNetCore; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; +using Microsoft.AspNetCore; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; using Newtonsoft.Json; @@ -27,15 +29,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 +54,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 +81,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 +106,7 @@ public async Task Total_Record_Count_Not_Included_In_POST_Response() type = "todoItems", attributes = new { - description = "New Description", + description = "New Description" } } }; @@ -119,19 +121,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 == null || !documents.Meta.ContainsKey("totalResources")); } [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 +149,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 +164,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 == null || !documents.Meta.ContainsKey("totalResources")); } [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..cf183eb641 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Relationships.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Relationships.cs @@ -1,15 +1,16 @@ using System.Net; using System.Net.Http; using System.Threading.Tasks; +using Bogus; +using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExample; +using JsonApiDotNetCoreExample.Data; +using JsonApiDotNetCoreExample.Models; +using Microsoft.AspNetCore; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; using Newtonsoft.Json; using Xunit; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCoreExample.Data; -using Bogus; -using JsonApiDotNetCoreExample.Models; 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..cc6a9532b6 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/EagerLoadTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/EagerLoadTests.cs @@ -3,7 +3,7 @@ using System.Net; using System.Threading.Tasks; using Bogus; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; using Microsoft.Extensions.DependencyInjection; @@ -57,7 +57,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 +73,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 +106,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 +141,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 +171,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 +195,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..bdaf4ee109 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/EndToEndTest.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/EndToEndTest.cs @@ -4,123 +4,19 @@ 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 JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Client.Internal; 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..8a1ba5b09b 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingDataTests.cs @@ -2,14 +2,16 @@ using System.Net.Http; using System.Threading.Tasks; using Bogus; -using JsonApiDotNetCore; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; +using Microsoft.AspNetCore; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; using Newtonsoft.Json; using Xunit; using Person = JsonApiDotNetCoreExample.Models.Person; @@ -40,10 +42,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"; @@ -54,7 +56,7 @@ public async Task Request_ForEmptyCollection_Returns_EmptyDataCollection() // Act var response = await client.SendAsync(request); var body = await response.Content.ReadAsStringAsync(); - var result = _fixture.GetDeserializer().DeserializeList(body); + var result = _fixture.GetDeserializer().DeserializeMany(body); var items = result.Data; var meta = result.Meta; @@ -62,12 +64,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 +79,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,25 +106,29 @@ 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); // Act var response = await client.SendAsync(request); var body = await response.Content.ReadAsStringAsync(); - var result = _fixture.GetDeserializer().DeserializeList(body); + var result = _fixture.GetDeserializer().DeserializeMany(body); // Assert Assert.True(result.Data.Count == 20); @@ -133,11 +139,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); @@ -155,7 +161,7 @@ public async Task GetSingleResource_ResourceDoesNotExist_ReturnsNotFound() Assert.Single(errorDocument.Errors); Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); Assert.Equal("The requested resource does not exist.", errorDocument.Errors[0].Title); - Assert.Equal("Resource of type 'todoItems' with id '123' does not exist.", errorDocument.Errors[0].Detail); + Assert.Equal("Resource of type 'todoItems' with ID '123' does not exist.", errorDocument.Errors[0].Detail); } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs index f3b4ccc0f4..d351fc510e 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FetchingRelationshipsTests.cs @@ -3,15 +3,18 @@ using System.Net.Http; using System.Threading.Tasks; using Bogus; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; +using JsonApiDotNetCoreExampleTests.Helpers.Extensions; +using Microsoft.AspNetCore; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using Xunit; +using Person = JsonApiDotNetCoreExample.Models.Person; namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec { @@ -30,6 +33,106 @@ 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(); + + string expected = @"{ + ""links"": { + ""self"": ""http://localhost/api/v1/todoItems/" + todoItem.StringId + @"/relationships/owner"", + ""related"": ""http://localhost/api/v1/todoItems/" + todoItem.StringId + @"/owner"" + }, + ""data"": { + ""type"": ""people"", + ""id"": """ + todoItem.Owner.StringId + @""" + } +}"; + Assert.Equal(expected.NormalizeLineEndings(), json.NormalizeLineEndings()); + } + + [Fact] + public async Task When_getting_existing_ToMany_relationship_it_should_succeed() + { + // Arrange + var author = new Author + { + LastName = "X", + Articles = new List
+ { + new Article + { + Caption = "Y" + }, + new Article + { + Caption = "Z" + } + } + }; + + var context = _fixture.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(); + + var expected = @"{ + ""links"": { + ""self"": ""http://localhost/api/v1/authors/" + author.StringId + @"/relationships/articles"", + ""related"": ""http://localhost/api/v1/authors/" + author.StringId + @"/articles"" + }, + ""data"": [ + { + ""type"": ""articles"", + ""id"": """ + author.Articles[0].StringId + @""" + }, + { + ""type"": ""articles"", + ""id"": """ + author.Articles[1].StringId + @""" + } + ] +}"; + + Assert.Equal(expected.NormalizeLineEndings(), json.NormalizeLineEndings()); + } + [Fact] public async Task When_getting_related_missing_to_one_resource_it_should_succeed_with_null_data() { @@ -41,9 +144,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 +158,24 @@ 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(); + + var expected = @"{ + ""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 +}"; + + Assert.Equal(expected.NormalizeLineEndings(), json.NormalizeLineEndings()); } [Fact] @@ -75,7 +191,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 +221,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 +251,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 +274,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); @@ -173,7 +289,7 @@ public async Task When_getting_related_for_missing_parent_resource_it_should_fai Assert.Single(errorDocument.Errors); Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); Assert.Equal("The requested resource does not exist.", errorDocument.Errors[0].Title); - Assert.Equal("Resource of type 'todoItems' with id '99999999' does not exist.",errorDocument.Errors[0].Detail); + Assert.Equal("Resource of type 'todoItems' with ID '99999999' does not exist.",errorDocument.Errors[0].Detail); } [Fact] @@ -182,7 +298,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); @@ -197,7 +313,7 @@ public async Task When_getting_relationship_for_missing_parent_resource_it_shoul Assert.Single(errorDocument.Errors); Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); Assert.Equal("The requested resource does not exist.", errorDocument.Errors[0].Title); - Assert.Equal("Resource of type 'todoItems' with id '99999999' does not exist.",errorDocument.Errors[0].Detail); + Assert.Equal("Resource of type 'todoItems' with ID '99999999' does not exist.",errorDocument.Errors[0].Detail); } [Fact] @@ -212,7 +328,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 +358,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..870a2af9fa --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FunctionalTestCollection.cs @@ -0,0 +1,117 @@ +using System; +using System.Linq.Expressions; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Client.Internal; +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.Add(rc.ResourceType, rc.IdentityType, rc.ResourceName); + } + builder.Add(formatter.FormatResourceName(typeof(TodoItem))); + builder.Add(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..a1e38ad007 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/PaginationLinkTests.cs @@ -0,0 +1,110 @@ +using System.Net; +using System.Threading.Tasks; +using Bogus; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +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/ResourceTypeMismatchTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ResourceTypeMismatchTests.cs new file mode 100644 index 0000000000..c6d235d128 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ResourceTypeMismatchTests.cs @@ -0,0 +1,84 @@ +using System.Net; +using System.Threading.Tasks; +using JsonApiDotNetCore.Serialization.Objects; +using Newtonsoft.Json; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec +{ + public sealed class ResourceTypeMismatchTests : FunctionalTestCollection + { + public ResourceTypeMismatchTests(StandardApplicationFactory factory) : base(factory) { } + + [Fact] + public async Task Posting_Resource_With_Mismatching_Resource_Type_Returns_Conflict() + { + // Arrange + string content = JsonConvert.SerializeObject(new + { + data = new + { + type = "people" + } + }); + + // Act + var (body, _) = await Post("/api/v1/todoItems", content); + + // Assert + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.Conflict, errorDocument.Errors[0].StatusCode); + Assert.Equal("Resource type mismatch between request body and endpoint URL.", errorDocument.Errors[0].Title); + Assert.Equal("Expected resource of type 'todoItems' in POST request body at endpoint '/api/v1/todoItems', instead of 'people'.", errorDocument.Errors[0].Detail); + } + + [Fact] + public async Task Patching_Resource_With_Mismatching_Resource_Type_Returns_Conflict() + { + // Arrange + string content = JsonConvert.SerializeObject(new + { + data = new + { + type = "people", + id = 1 + } + }); + + // Act + var (body, _) = await Patch("/api/v1/todoItems/1", content); + + // Assert + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.Conflict, errorDocument.Errors[0].StatusCode); + Assert.Equal("Resource type mismatch between request body and endpoint URL.", errorDocument.Errors[0].Title); + Assert.Equal("Expected resource of type 'todoItems' in PATCH request body at endpoint '/api/v1/todoItems/1', instead of 'people'.", errorDocument.Errors[0].Detail); + } + + [Fact] + public async Task Patching_Through_Relationship_Link_With_Mismatching_Resource_Type_Returns_Conflict() + { + // Arrange + string content = JsonConvert.SerializeObject(new + { + data = new + { + type = "todoItems", + id = 1 + } + }); + + // Act + var (body, _) = await Patch("/api/v1/todoItems/1/relationships/owner", content); + + // Assert + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.Conflict, errorDocument.Errors[0].StatusCode); + Assert.Equal("Resource type mismatch between request body and endpoint URL.", errorDocument.Errors[0].Title); + Assert.Equal("Expected resource of type 'people' in PATCH request body at endpoint '/api/v1/todoItems/1/relationships/owner', instead of 'todoItems'.", errorDocument.Errors[0].Detail); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/SparseFieldSetTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/SparseFieldSetTests.cs deleted file mode 100644 index 1b575be29a..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.PropertyInfo.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..d597adc62c 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ThrowingResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ThrowingResourceTests.cs @@ -1,7 +1,7 @@ using System.Linq; using System.Net; using System.Threading.Tasks; -using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExample.Models; using Newtonsoft.Json; using Newtonsoft.Json.Linq; @@ -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..117c4cb464 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingDataTests.cs @@ -5,13 +5,13 @@ using System.Net.Http.Headers; using System.Threading.Tasks; using Bogus; -using JsonApiDotNetCore; -using JsonApiDotNetCore.Formatters; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Serialization; +using JsonApiDotNetCore.Serialization.Objects; 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 +50,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 +70,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 +94,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 +123,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); @@ -152,7 +153,7 @@ public async Task Respond_404_If_EntityDoesNotExist() Assert.Single(errorDocument.Errors); Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); Assert.Equal("The requested resource does not exist.", errorDocument.Errors[0].Title); - Assert.Equal("Resource of type 'todoItems' with id '100' does not exist.", errorDocument.Errors[0].Detail); + Assert.Equal("Resource of type 'todoItems' with ID '100' does not exist.", errorDocument.Errors[0].Detail); } [Fact] @@ -162,7 +163,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); @@ -183,7 +184,7 @@ public async Task Respond_422_If_IdNotInAttributeList() var error = document.Errors.Single(); Assert.Equal(HttpStatusCode.UnprocessableEntity, error.StatusCode); - Assert.Equal("Failed to deserialize request body: Payload must include id attribute.", error.Title); + Assert.Equal("Failed to deserialize request body: Payload must include 'id' element.", error.Title); Assert.StartsWith("Request body: <<", error.Detail); } @@ -195,11 +196,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}); @@ -218,22 +219,22 @@ public async Task Respond_409_If_IdInUrlIsDifferentFromIdInRequestBody() var error = document.Errors.Single(); Assert.Equal(HttpStatusCode.Conflict, error.StatusCode); - Assert.Equal("Resource id mismatch between request body and endpoint URL.", error.Title); - Assert.Equal($"Expected resource id '{wrongTodoItemId}' in PATCH request body at endpoint 'http://localhost/api/v1/todoItems/{wrongTodoItemId}', instead of '{todoItem.Id}'.", error.Detail); + Assert.Equal("Resource ID mismatch between request body and endpoint URL.", error.Title); + Assert.Equal($"Expected resource ID '{wrongTodoItemId}' in PATCH request body at endpoint 'http://localhost/api/v1/todoItems/{wrongTodoItemId}', instead of '{todoItem.Id}'.", error.Detail); } [Fact] 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 +253,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 +301,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 +335,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 +343,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..52cda9df1b 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/UpdatingRelationshipsTests.cs @@ -5,11 +5,12 @@ using System.Net.Http.Headers; using System.Threading.Tasks; using Bogus; -using JsonApiDotNetCore; -using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; +using Microsoft.AspNetCore; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; using Microsoft.EntityFrameworkCore; @@ -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); @@ -847,7 +844,7 @@ public async Task Fails_On_Missing_Resource() Assert.Single(errorDocument.Errors); Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); Assert.Equal("The requested resource does not exist.", errorDocument.Errors[0].Title); - Assert.Equal("Resource of type 'todoItems' with id '99999999' does not exist.",errorDocument.Errors[0].Detail); + Assert.Equal("Resource of type 'todoItems' with ID '99999999' does not exist.",errorDocument.Errors[0].Detail); } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/TestFixture.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/TestFixture.cs index d6dd9fd675..b9e35a4ca2 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/TestFixture.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/TestFixture.cs @@ -2,16 +2,14 @@ using System.Linq.Expressions; using System.Net; using System.Net.Http; -using JsonApiDotNetCore.Builders; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Data; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Serialization.Client; +using JsonApiDotNetCore.Repositories; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Client.Internal; 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 +26,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; @@ -62,18 +60,18 @@ public IResponseDeserializer GetDeserializer() var options = GetService(); var resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance) - .AddResource() - .AddResource
() - .AddResource() - .AddResource() - .AddResource() - .AddResource() - .AddResource() - .AddResource() - .AddResource() - .AddResource("todoItems") - .AddResource().Build(); - return new ResponseDeserializer(resourceGraph, new DefaultResourceFactory(ServiceProvider)); + .Add() + .Add
() + .Add() + .Add() + .Add() + .Add() + .Add() + .Add() + .Add() + .Add("todoItems") + .Add().Build(); + 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..b4b03d5a65 --- /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.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExample; +using JsonApiDotNetCoreExample.Data; +using JsonApiDotNetCoreExample.Models; +using JsonApiDotNetCoreExampleTests.Helpers.Models; +using Microsoft.EntityFrameworkCore; +using Newtonsoft.Json; +using Xunit; +using Person = JsonApiDotNetCoreExample.Models.Person; + +namespace JsonApiDotNetCoreExampleTests.Acceptance +{ + [Collection("WebHostCollection")] + public sealed class TodoItemControllerTests + { + private readonly TestFixture _fixture; + private readonly AppDbContext _context; + private readonly Faker _todoItemFaker; + private readonly Faker _personFaker; + + public TodoItemControllerTests(TestFixture fixture) + { + _fixture = fixture; + _context = fixture.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().DeserializeMany(body).Data; + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotEmpty(deserializedBody); + Assert.True(deserializedBody.Count <= expectedResourcesPerPage, $"There are more items on the page than the default page size. {deserializedBody.Count} > {expectedResourcesPerPage}"); + } + + [Fact] + public async Task Can_Get_TodoItem_ById() + { + // Arrange + var person = new Person(); + var todoItem = _todoItemFaker.Generate(); + todoItem.Owner = person; + _context.TodoItems.Add(todoItem); + await _context.SaveChangesAsync(); + + var httpMethod = new HttpMethod("GET"); + var route = $"/api/v1/todoItems/{todoItem.Id}"; + var request = new HttpRequestMessage(httpMethod, route); + + // Act + var response = await _fixture.Client.SendAsync(request); + var body = await response.Content.ReadAsStringAsync(); + var deserializedBody = _fixture.GetDeserializer().DeserializeSingle(body).Data; + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(todoItem.Id, deserializedBody.Id); + Assert.Equal(todoItem.Description, deserializedBody.Description); + Assert.Equal(todoItem.Ordinal, deserializedBody.Ordinal); + Assert.Equal(todoItem.CreatedDate.ToString("G"), deserializedBody.CreatedDate.ToString("G")); + Assert.Null(deserializedBody.AchievedDate); + } + + [Fact] + public async Task Can_Post_TodoItem() + { + // Arrange + var person = new Person(); + _context.People.Add(person); + await _context.SaveChangesAsync(); + + var serializer = _fixture.GetSerializer(e => new { e.Description, e.OffsetDate, e.Ordinal, e.CreatedDate }, e => new { e.Owner }); + + var todoItem = _todoItemFaker.Generate(); + var nowOffset = new DateTimeOffset(); + todoItem.OffsetDate = nowOffset; + + var httpMethod = new HttpMethod("POST"); + var route = "/api/v1/todoItems"; + + var request = new HttpRequestMessage(httpMethod, route) + { + Content = new StringContent(serializer.Serialize(todoItem)) + }; + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); + + // Act + var response = await _fixture.Client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var deserializedBody = _fixture.GetDeserializer().DeserializeSingle(body).Data; + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + Assert.Equal(todoItem.Description, deserializedBody.Description); + Assert.Equal(todoItem.CreatedDate.ToString("G"), deserializedBody.CreatedDate.ToString("G")); + Assert.Equal(nowOffset, deserializedBody.OffsetDate); + Assert.Null(deserializedBody.AchievedDate); + } + + [Fact] + public async Task Can_Post_TodoItem_With_Different_Owner_And_Assignee() + { + // Arrange + var person1 = new Person(); + var person2 = new Person(); + _context.People.Add(person1); + _context.People.Add(person2); + await _context.SaveChangesAsync(); + + var todoItem = _todoItemFaker.Generate(); + var content = new + { + data = new + { + type = "todoItems", + attributes = new Dictionary + { + { "description", todoItem.Description }, + { "ordinal", todoItem.Ordinal }, + { "createdDate", todoItem.CreatedDate } + }, + relationships = new + { + owner = new + { + data = new + { + type = "people", + id = person1.Id.ToString() + } + }, + assignee = new + { + data = new + { + type = "people", + id = person2.Id.ToString() + } + } + } + } + }; + + var httpMethod = new HttpMethod("POST"); + var route = "/api/v1/todoItems"; + + var request = new HttpRequestMessage(httpMethod, route) + { + Content = new StringContent(JsonConvert.SerializeObject(content)) + }; + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); + + // Act + var response = await _fixture.Client.SendAsync(request); + + // Assert -- response + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + var document = JsonConvert.DeserializeObject(body); + var resultId = int.Parse(document.SingleData.Id); + + // Assert -- database + var todoItemResult = await _context.TodoItems.SingleAsync(t => t.Id == resultId); + + Assert.Equal(person1.Id, todoItemResult.OwnerId); + Assert.Equal(person2.Id, todoItemResult.AssigneeId); + } + + [Fact] + public async Task Can_Patch_TodoItem() + { + // Arrange + var person = new Person(); + _context.People.Add(person); + await _context.SaveChangesAsync(); + + var todoItem = _todoItemFaker.Generate(); + todoItem.Owner = person; + _context.TodoItems.Add(todoItem); + await _context.SaveChangesAsync(); + + var newTodoItem = _todoItemFaker.Generate(); + + var content = new + { + data = new + { + id = todoItem.Id, + type = "todoItems", + attributes = new Dictionary + { + { "description", newTodoItem.Description }, + { "ordinal", newTodoItem.Ordinal }, + { "alwaysChangingValue", "ignored" }, + { "createdDate", newTodoItem.CreatedDate } + } + } + }; + + var httpMethod = new HttpMethod("PATCH"); + var route = $"/api/v1/todoItems/{todoItem.Id}"; + + var request = new HttpRequestMessage(httpMethod, route) + { + Content = new StringContent(JsonConvert.SerializeObject(content)) + }; + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); + + // Act + var response = await _fixture.Client.SendAsync(request); + var body = await response.Content.ReadAsStringAsync(); + var deserializedBody = _fixture.GetDeserializer().DeserializeSingle(body).Data; + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(newTodoItem.Description, deserializedBody.Description); + Assert.Equal(newTodoItem.Ordinal, deserializedBody.Ordinal); + Assert.Equal(newTodoItem.CreatedDate.ToString("G"), deserializedBody.CreatedDate.ToString("G")); + Assert.Null(deserializedBody.AchievedDate); + } + + [Fact] + public async Task Can_Patch_TodoItemWithNullable() + { + // Arrange + var person = new Person(); + _context.People.Add(person); + await _context.SaveChangesAsync(); + + var todoItem = _todoItemFaker.Generate(); + todoItem.AchievedDate = new DateTime(2002, 2,2); + todoItem.Owner = person; + _context.TodoItems.Add(todoItem); + await _context.SaveChangesAsync(); + + var newTodoItem = _todoItemFaker.Generate(); + newTodoItem.AchievedDate = new DateTime(2002, 2,4); + + var content = new + { + data = new + { + id = todoItem.Id, + type = "todoItems", + attributes = new Dictionary + { + { "description", newTodoItem.Description }, + { "ordinal", newTodoItem.Ordinal }, + { "createdDate", newTodoItem.CreatedDate }, + { "achievedDate", newTodoItem.AchievedDate } + } + } + }; + + var httpMethod = new HttpMethod("PATCH"); + var route = $"/api/v1/todoItems/{todoItem.Id}"; + + var request = new HttpRequestMessage(httpMethod, route) + { + Content = new StringContent(JsonConvert.SerializeObject(content)) + }; + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); + + // Act + var response = await _fixture.Client.SendAsync(request); + var body = await response.Content.ReadAsStringAsync(); + var deserializedBody = _fixture.GetDeserializer().DeserializeSingle(body).Data; + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(newTodoItem.Description, deserializedBody.Description); + Assert.Equal(newTodoItem.Ordinal, deserializedBody.Ordinal); + Assert.Equal(newTodoItem.CreatedDate.ToString("G"), deserializedBody.CreatedDate.ToString("G")); + Assert.Equal(newTodoItem.AchievedDate.GetValueOrDefault().ToString("G"), deserializedBody.AchievedDate.GetValueOrDefault().ToString("G")); + } + + [Fact] + public async Task Can_Patch_TodoItemWithNullValue() + { + // Arrange + var person = new Person(); + _context.People.Add(person); + await _context.SaveChangesAsync(); + + var todoItem = _todoItemFaker.Generate(); + todoItem.AchievedDate = new DateTime(2002, 2,2); + todoItem.Owner = person; + _context.TodoItems.Add(todoItem); + await _context.SaveChangesAsync(); + + var newTodoItem = _todoItemFaker.Generate(); + + var content = new + { + data = new + { + id = todoItem.Id, + type = "todoItems", + attributes = new Dictionary + { + { "description", newTodoItem.Description }, + { "ordinal", newTodoItem.Ordinal }, + { "createdDate", newTodoItem.CreatedDate }, + { "achievedDate", null } + } + } + }; + + var httpMethod = new HttpMethod("PATCH"); + var route = $"/api/v1/todoItems/{todoItem.Id}"; + + var request = new HttpRequestMessage(httpMethod, route) + { + Content = new StringContent(JsonConvert.SerializeObject(content)) + }; + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); + + // Act + var response = await _fixture.Client.SendAsync(request); + var body = await response.Content.ReadAsStringAsync(); + var deserializedBody = _fixture.GetDeserializer().DeserializeSingle(body).Data; + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(newTodoItem.Description, deserializedBody.Description); + Assert.Equal(newTodoItem.Ordinal, deserializedBody.Ordinal); + Assert.Equal(newTodoItem.CreatedDate.ToString("G"), deserializedBody.CreatedDate.ToString("G")); + Assert.Null(deserializedBody.AchievedDate); + } + + [Fact] + public async Task Can_Delete_TodoItem() + { + // Arrange + var person = new Person(); + _context.People.Add(person); + await _context.SaveChangesAsync(); + + var todoItem = _todoItemFaker.Generate(); + todoItem.Owner = person; + _context.TodoItems.Add(todoItem); + await _context.SaveChangesAsync(); + + var httpMethod = new HttpMethod("DELETE"); + var route = $"/api/v1/todoItems/{todoItem.Id}"; + + var request = new HttpRequestMessage(httpMethod, route) {Content = new StringContent(string.Empty)}; + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); + + // Act + var response = await _fixture.Client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + Assert.Null(_context.TodoItems.FirstOrDefault(t => t.Id == todoItem.Id)); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/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 74% rename from test/JsonApiDotNetCoreExampleTests/Factories/ClientEnabledIdsApplicationFactory.cs rename to test/JsonApiDotNetCoreExampleTests/Factories/ClientGeneratedIdsApplicationFactory.cs index f1b0bfed37..9496c0394b 100644 --- a/test/JsonApiDotNetCoreExampleTests/Factories/ClientEnabledIdsApplicationFactory.cs +++ b/test/JsonApiDotNetCoreExampleTests/Factories/ClientGeneratedIdsApplicationFactory.cs @@ -1,11 +1,11 @@ -using JsonApiDotNetCore; +using System.Reflection; +using JsonApiDotNetCore.Configuration; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; -using System.Reflection; 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/CustomApplicationFactoryBase.cs b/test/JsonApiDotNetCoreExampleTests/Factories/CustomApplicationFactoryBase.cs index 9497a3e123..d9d1fb8fb5 100644 --- a/test/JsonApiDotNetCoreExampleTests/Factories/CustomApplicationFactoryBase.cs +++ b/test/JsonApiDotNetCoreExampleTests/Factories/CustomApplicationFactoryBase.cs @@ -1,9 +1,9 @@ using System; +using System.Net.Http; using JsonApiDotNetCoreExample; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; -using System.Net.Http; -using Microsoft.AspNetCore.Hosting; namespace JsonApiDotNetCoreExampleTests { 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..b38c58be59 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Factories/ResourceHooksApplicationFactory.cs @@ -0,0 +1,33 @@ +using System.Reflection; +using JsonApiDotNetCore.Configuration; +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/Factories/StandardApplicationFactory.cs b/test/JsonApiDotNetCoreExampleTests/Factories/StandardApplicationFactory.cs index 8915bdf014..8c45e2e5e7 100644 --- a/test/JsonApiDotNetCoreExampleTests/Factories/StandardApplicationFactory.cs +++ b/test/JsonApiDotNetCoreExampleTests/Factories/StandardApplicationFactory.cs @@ -1,4 +1,4 @@ -using JsonApiDotNetCore; +using JsonApiDotNetCore.Configuration; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; 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/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"); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/Helpers/Models/TodoItemClient.cs b/test/JsonApiDotNetCoreExampleTests/Helpers/Models/TodoItemClient.cs index e6e7e761a4..8dd88c4633 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 JsonApiDotNetCore.Models; +using System; +using System.Collections.Generic; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCoreExample.Models; namespace JsonApiDotNetCoreExampleTests.Helpers.Models 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..70e7136cd7 --- /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.Middleware; +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..1cb8987091 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterDataTypeTests.cs @@ -0,0 +1,334 @@ +using System; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using FluentAssertions.Extensions; +using Humanizer; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +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..e497ab68dd --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterDepthTests.cs @@ -0,0 +1,660 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using FluentAssertions.Extensions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +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..792de65335 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterOperatorTests.cs @@ -0,0 +1,569 @@ +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.Queries.Expressions; +using JsonApiDotNetCore.Serialization.Objects; +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..43a105b644 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterTests.cs @@ -0,0 +1,196 @@ +using System.Collections.Generic; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExample; +using JsonApiDotNetCoreExample.Data; +using JsonApiDotNetCoreExample.Models; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.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..be66eec1de --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterableResource.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +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..7314d155d0 --- /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 options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Includes/IncludeTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Includes/IncludeTests.cs new file mode 100644 index 0000000000..97f7c15dd9 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Includes/IncludeTests.cs @@ -0,0 +1,840 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using FluentAssertions.Extensions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +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
>(); + }); + + var options = (JsonApiOptions) testContext.Factory.Services.GetRequiredService(); + options.MaximumIncludeDepth = null; + } + + [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); + } + + [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("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"); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationRangeTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationRangeTests.cs new file mode 100644 index 0000000000..82075768d9 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationRangeTests.cs @@ -0,0 +1,153 @@ +using System.Net; +using System.Threading.Tasks; +using Bogus; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExample; +using JsonApiDotNetCoreExample.Data; +using JsonApiDotNetCoreExample.Models; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.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..6e6901a0e6 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationRangeWithMaximumTests.cs @@ -0,0 +1,145 @@ +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +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..aecee57f26 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationTests.cs @@ -0,0 +1,503 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using FluentAssertions.Extensions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +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..6241847a31 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/QueryStringTests.cs @@ -0,0 +1,89 @@ +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +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..e63ae24daa --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableResource.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions +{ + public sealed class CallableResource : Identifiable + { + [Attr] + public string Label { get; set; } + + [Attr] + public int PercentageComplete { get; set; } + + [Attr] + public string Status => $"{PercentageComplete}% completed."; + + [Attr] + public int RiskLevel { get; set; } + + [Attr(Capabilities = AttrCapabilities.AllowView | AttrCapabilities.AllowSort)] + public DateTime CreatedAt { get; set; } + + [Attr(Capabilities = AttrCapabilities.AllowView | AttrCapabilities.AllowSort)] + public DateTime ModifiedAt { get; set; } + + [Attr(Capabilities = AttrCapabilities.None)] + public bool IsDeleted { get; set; } + + [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 new file mode 100644 index 0000000000..87bef32537 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableResourceDefinition.cs @@ -0,0 +1,117 @@ +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Net; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; +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, 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) + { + // 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 retrieve 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..fba77b6b8f --- /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 options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/ResourceDefinitionQueryCallbackTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/ResourceDefinitionQueryCallbackTests.cs new file mode 100644 index 0000000000..e9a27ebda4 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/ResourceDefinitionQueryCallbackTests.cs @@ -0,0 +1,547 @@ +using System.Collections.Generic; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using FluentAssertions.Extensions; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; +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>(); + 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() + { + // 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,status"; + + // 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("percentageComplete"); + responseDocument.SingleData.Attributes["status"].Should().Be("5% completed."); + } + + [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"); + } + + private sealed class FakeUserRolesService : IUserRolesService + { + public bool AllowIncludeOwner { get; set; } = true; + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Sorting/SortTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Sorting/SortTests.cs new file mode 100644 index 0000000000..5ec7869582 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Sorting/SortTests.cs @@ -0,0 +1,752 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using Bogus; +using FluentAssertions; +using FluentAssertions.Extensions; +using JsonApiDotNetCore.Serialization.Objects; +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..a4cef25a9f --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/ResourceCaptureStore.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Resources; + +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..557834c518 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/ResultCapturingRepository.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Repositories; +using JsonApiDotNetCore.Resources; +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..97a7d1b117 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/SparseFieldSetTests.cs @@ -0,0 +1,663 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using Bogus; +using FluentAssertions; +using FluentAssertions.Extensions; +using JsonApiDotNetCore.Repositories; +using JsonApiDotNetCore.Serialization.Objects; +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..ba110a7399 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/TestableStartup.cs @@ -0,0 +1,37 @@ +using JsonApiDotNetCore.Configuration; +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/NoEntityFrameworkTests/WorkItemTests.cs b/test/NoEntityFrameworkTests/WorkItemTests.cs index 2bb2edc35f..decaf0e837 100644 --- a/test/NoEntityFrameworkTests/WorkItemTests.cs +++ b/test/NoEntityFrameworkTests/WorkItemTests.cs @@ -1,16 +1,16 @@ -using JsonApiDotNetCore; -using JsonApiDotNetCore.Models; +using System; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Serialization.Objects; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; using Newtonsoft.Json; using NoEntityFrameworkExample; using NoEntityFrameworkExample.Data; using NoEntityFrameworkExample.Models; -using System; -using System.Net; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Threading.Tasks; using Xunit; namespace NoEntityFrameworkTests diff --git a/test/UnitTests/Builders/ContextGraphBuilder_Tests.cs b/test/UnitTests/Builders/ContextGraphBuilder_Tests.cs index bb0ff76114..22c9b9e927 100644 --- a/test/UnitTests/Builders/ContextGraphBuilder_Tests.cs +++ b/test/UnitTests/Builders/ContextGraphBuilder_Tests.cs @@ -1,11 +1,9 @@ using System; using System.Collections.Generic; using System.Linq; -using JsonApiDotNetCore; -using JsonApiDotNetCore.Builders; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; @@ -37,7 +35,7 @@ public void Can_Build_ResourceGraph_Using_Builder() services.AddLogging(); services.AddDbContext(); - services.AddJsonApi(resources: builder => builder.AddResource("nonDbResources")); + services.AddJsonApi(resources: builder => builder.Add("nonDbResources")); // Act var container = services.BuildServiceProvider(); @@ -56,7 +54,7 @@ public void Resources_Without_Names_Specified_Will_Use_Configured_Formatter() { // Arrange var builder = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance); - builder.AddResource(); + builder.Add(); // Act var resourceGraph = builder.Build(); @@ -71,14 +69,14 @@ public void Attrs_Without_Names_Specified_Will_Use_Configured_Formatter() { // Arrange var builder = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance); - builder.AddResource(); + builder.Add(); // Act var resourceGraph = builder.Build(); // Assert var resource = resourceGraph.GetResourceContext(typeof(TestResource)); - Assert.Contains(resource.Attributes, (i) => i.PublicAttributeName == "compoundAttribute"); + Assert.Contains(resource.Attributes, (i) => i.PublicName == "compoundAttribute"); } [Fact] @@ -86,15 +84,15 @@ public void Relationships_Without_Names_Specified_Will_Use_Configured_Formatter( { // Arrange var builder = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance); - builder.AddResource(); + builder.Add(); // Act var resourceGraph = builder.Build(); // 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..21f90d062b 100644 --- a/test/UnitTests/Builders/LinkBuilderTests.cs +++ b/test/UnitTests/Builders/LinkBuilderTests.cs @@ -1,27 +1,24 @@ using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Managers.Contracts; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Models.Links; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.QueryStrings; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Building; 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; +using Moq; +using Xunit; 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,29 +29,29 @@ 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(LinkTypes.All, LinkTypes.NotConfigured, _resourceSelf)] + [InlineData(LinkTypes.Self, LinkTypes.NotConfigured, _resourceSelf)] + [InlineData(LinkTypes.None, LinkTypes.NotConfigured, null)] + [InlineData(LinkTypes.All, LinkTypes.Self, _resourceSelf)] + [InlineData(LinkTypes.Self, LinkTypes.Self, _resourceSelf)] + [InlineData(LinkTypes.None, LinkTypes.Self, _resourceSelf)] + [InlineData(LinkTypes.All, LinkTypes.None, null)] + [InlineData(LinkTypes.Self, LinkTypes.None, null)] + [InlineData(LinkTypes.None, LinkTypes.None, null)] + public void BuildResourceLinks_GlobalAndResourceConfiguration_ExpectedResult(LinkTypes global, LinkTypes resource, object expectedResult) { // Arrange var config = GetConfiguration(resourceLinks: global); var primaryResource = GetArticleResourceContext(resourceLinks: resource); _provider.Setup(m => m.GetResourceContext("articles")).Returns(primaryResource); - var builder = new LinkBuilder(config, GetRequestManager(), null, _provider.Object, _queryStringAccessor); + var builder = new LinkBuilder(config, GetRequestManager(), new PaginationContext(), _provider.Object, _queryStringAccessor); // Act - var links = builder.GetResourceLinks("articles", _baseId.ToString()); + var links = builder.GetResourceLinks("articles", _primaryId.ToString()); // Assert if (expectedResult == null) @@ -64,46 +61,43 @@ 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(LinkTypes.All, LinkTypes.NotConfigured, LinkTypes.NotConfigured, _relSelf, _relRelated)] + [InlineData(LinkTypes.All, LinkTypes.NotConfigured, LinkTypes.All, _relSelf, _relRelated)] + [InlineData(LinkTypes.All, LinkTypes.NotConfigured, LinkTypes.Self, _relSelf, null)] + [InlineData(LinkTypes.All, LinkTypes.NotConfigured, LinkTypes.Related, null, _relRelated)] + [InlineData(LinkTypes.All, LinkTypes.NotConfigured, LinkTypes.None, null, null)] + [InlineData(LinkTypes.All, LinkTypes.All, LinkTypes.NotConfigured, _relSelf, _relRelated)] + [InlineData(LinkTypes.All, LinkTypes.All, LinkTypes.All, _relSelf, _relRelated)] + [InlineData(LinkTypes.All, LinkTypes.All, LinkTypes.Self, _relSelf, null)] + [InlineData(LinkTypes.All, LinkTypes.All, LinkTypes.Related, null, _relRelated)] + [InlineData(LinkTypes.All, LinkTypes.All, LinkTypes.None, null, null)] + [InlineData(LinkTypes.All, LinkTypes.Self, LinkTypes.NotConfigured, _relSelf, null)] + [InlineData(LinkTypes.All, LinkTypes.Self, LinkTypes.All, _relSelf, _relRelated)] + [InlineData(LinkTypes.All, LinkTypes.Self, LinkTypes.Self, _relSelf, null)] + [InlineData(LinkTypes.All, LinkTypes.Self, LinkTypes.Related, null, _relRelated)] + [InlineData(LinkTypes.All, LinkTypes.Self, LinkTypes.None, null, null)] + [InlineData(LinkTypes.All, LinkTypes.Related, LinkTypes.NotConfigured, null, _relRelated)] + [InlineData(LinkTypes.All, LinkTypes.Related, LinkTypes.All, _relSelf, _relRelated)] + [InlineData(LinkTypes.All, LinkTypes.Related, LinkTypes.Self, _relSelf, null)] + [InlineData(LinkTypes.All, LinkTypes.Related, LinkTypes.Related, null, _relRelated)] + [InlineData(LinkTypes.All, LinkTypes.Related, LinkTypes.None, null, null)] + [InlineData(LinkTypes.All, LinkTypes.None, LinkTypes.NotConfigured, null, null)] + [InlineData(LinkTypes.All, LinkTypes.None, LinkTypes.All, _relSelf, _relRelated)] + [InlineData(LinkTypes.All, LinkTypes.None, LinkTypes.Self, _relSelf, null)] + [InlineData(LinkTypes.All, LinkTypes.None, LinkTypes.Related, null, _relRelated)] + [InlineData(LinkTypes.All, LinkTypes.None, LinkTypes.None, null, null)] + public void BuildRelationshipLinks_GlobalResourceAndAttrConfiguration_ExpectedLinks( + LinkTypes global, LinkTypes resource, LinkTypes relationship, object expectedSelfLink, object expectedRelatedLink) { // Arrange var config = GetConfiguration(relationshipLinks: global); 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 builder = new LinkBuilder(config, GetRequestManager(), new PaginationContext(), _provider.Object, _queryStringAccessor); + 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 +112,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(LinkTypes.All, LinkTypes.NotConfigured, _topSelf, true)] + [InlineData(LinkTypes.All, LinkTypes.All, _topSelf, true)] + [InlineData(LinkTypes.All, LinkTypes.Self, _topSelf, false)] + [InlineData(LinkTypes.All, LinkTypes.Paging, null, true)] + [InlineData(LinkTypes.All, LinkTypes.None, null, false)] + [InlineData(LinkTypes.Self, LinkTypes.NotConfigured, _topSelf, false)] + [InlineData(LinkTypes.Self, LinkTypes.All, _topSelf, true)] + [InlineData(LinkTypes.Self, LinkTypes.Self, _topSelf, false)] + [InlineData(LinkTypes.Self, LinkTypes.Paging, null, true)] + [InlineData(LinkTypes.Self, LinkTypes.None, null, false)] + [InlineData(LinkTypes.Paging, LinkTypes.NotConfigured, null, true)] + [InlineData(LinkTypes.Paging, LinkTypes.All, _topSelf, true)] + [InlineData(LinkTypes.Paging, LinkTypes.Self, _topSelf, false)] + [InlineData(LinkTypes.Paging, LinkTypes.Paging, null, true)] + [InlineData(LinkTypes.Paging, LinkTypes.None, null, false)] + [InlineData(LinkTypes.None, LinkTypes.NotConfigured, null, false)] + [InlineData(LinkTypes.None, LinkTypes.All, _topSelf, true)] + [InlineData(LinkTypes.None, LinkTypes.Self, _topSelf, false)] + [InlineData(LinkTypes.None, LinkTypes.Paging, null, true)] + [InlineData(LinkTypes.None, LinkTypes.None, null, false)] + [InlineData(LinkTypes.All, LinkTypes.Self, _topResourceSelf, false)] + [InlineData(LinkTypes.Self, LinkTypes.Self, _topResourceSelf, false)] + [InlineData(LinkTypes.Paging, LinkTypes.Self, _topResourceSelf, false)] + [InlineData(LinkTypes.None, LinkTypes.Self, _topResourceSelf, false)] + [InlineData(LinkTypes.All, LinkTypes.Self, _topRelatedSelf, false)] + [InlineData(LinkTypes.Self, LinkTypes.Self, _topRelatedSelf, false)] + [InlineData(LinkTypes.Paging, LinkTypes.Self, _topRelatedSelf, false)] + [InlineData(LinkTypes.None, LinkTypes.Self, _topRelatedSelf, false)] + public void BuildTopLevelLinks_GlobalAndResourceConfiguration_ExpectedLinks( + LinkTypes global, LinkTypes 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); + IJsonApiRequest request = GetRequestManager(primaryResource, usePrimaryId, relationshipName); - var builder = new LinkBuilder(config, currentRequest, _pageService, _provider.Object, _queryStringAccessor); + var builder = new LinkBuilder(config, request, _paginationContext, _provider.Object, _queryStringAccessor); // Act var links = builder.GetTopLevelLinks(); @@ -172,58 +164,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) + private IJsonApiRequest GetRequestManager(ResourceContext resourceContext = null, bool usePrimaryId = false, string relationshipName = null) { - 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) - { - var mock = new Mock(); + 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 {PublicName = 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(LinkTypes resourceLinks = LinkTypes.All, LinkTypes topLevelLinks = LinkTypes.All, LinkTypes relationshipLinks = LinkTypes.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(LinkTypes resourceLinks = LinkTypes.NotConfigured, + LinkTypes topLevelLinks = LinkTypes.NotConfigured, + LinkTypes relationshipLinks = LinkTypes.NotConfigured) { return new ResourceContext { diff --git a/test/UnitTests/Builders/LinkTests.cs b/test/UnitTests/Builders/LinkTests.cs index df1504930c..1cf5501ec0 100644 --- a/test/UnitTests/Builders/LinkTests.cs +++ b/test/UnitTests/Builders/LinkTests.cs @@ -1,4 +1,4 @@ -using JsonApiDotNetCore.Models.Links; +using JsonApiDotNetCore.Resources.Annotations; 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(LinkTypes.All, LinkTypes.Self, true)] + [InlineData(LinkTypes.All, LinkTypes.Related, true)] + [InlineData(LinkTypes.All, LinkTypes.Paging, true)] + [InlineData(LinkTypes.None, LinkTypes.Self, false)] + [InlineData(LinkTypes.None, LinkTypes.Related, false)] + [InlineData(LinkTypes.None, LinkTypes.Paging, false)] + [InlineData(LinkTypes.NotConfigured, LinkTypes.Self, false)] + [InlineData(LinkTypes.NotConfigured, LinkTypes.Related, false)] + [InlineData(LinkTypes.NotConfigured, LinkTypes.Paging, false)] + [InlineData(LinkTypes.Self, LinkTypes.Self, true)] + [InlineData(LinkTypes.Self, LinkTypes.Related, false)] + [InlineData(LinkTypes.Self, LinkTypes.Paging, false)] + [InlineData(LinkTypes.Self, LinkTypes.None, false)] + [InlineData(LinkTypes.Self, LinkTypes.NotConfigured, false)] + [InlineData(LinkTypes.Related, LinkTypes.Self, false)] + [InlineData(LinkTypes.Related, LinkTypes.Related, true)] + [InlineData(LinkTypes.Related, LinkTypes.Paging, false)] + [InlineData(LinkTypes.Related, LinkTypes.None, false)] + [InlineData(LinkTypes.Related, LinkTypes.NotConfigured, false)] + [InlineData(LinkTypes.Paging, LinkTypes.Self, false)] + [InlineData(LinkTypes.Paging, LinkTypes.Related, false)] + [InlineData(LinkTypes.Paging, LinkTypes.Paging, true)] + [InlineData(LinkTypes.Paging, LinkTypes.None, false)] + [InlineData(LinkTypes.Paging, LinkTypes.NotConfigured, false)] + public void LinkHasFlag_BaseLinkAndCheckLink_ExpectedResult(LinkTypes baseLink, LinkTypes 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 c61dfddb3a..10d71866b9 100644 --- a/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs +++ b/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs @@ -1,17 +1,18 @@ using System.Net; using System.Net.Http; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Services; -using Moq; -using Xunit; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Exceptions; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Services; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; namespace UnitTests { @@ -25,24 +26,24 @@ public sealed class Resource : Identifiable public sealed class ResourceController : BaseJsonApiController { public ResourceController( - IJsonApiOptions jsonApiOptions, + IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(jsonApiOptions, loggerFactory, resourceService) + : base(options, loggerFactory, resourceService) { } public ResourceController( - IJsonApiOptions jsonApiOptions, + IJsonApiOptions options, 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(options, loggerFactory, getAll, getById, getSecondary, getRelationship, create, update, updateRelationships, delete) { } } @@ -110,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] @@ -128,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); @@ -140,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] @@ -158,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); @@ -224,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] @@ -238,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 93% rename from test/UnitTests/Controllers/JsonApiControllerMixin_Tests.cs rename to test/UnitTests/Controllers/CoreJsonApiControllerTests.cs index 46d5ecf784..215ac7ab8c 100644 --- a/test/UnitTests/Controllers/JsonApiControllerMixin_Tests.cs +++ b/test/UnitTests/Controllers/CoreJsonApiControllerTests.cs @@ -1,15 +1,14 @@ using System.Collections.Generic; using System.Net; using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCore.Serialization.Objects; using Microsoft.AspNetCore.Mvc; using Xunit; 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..c8dd510bf6 100644 --- a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs +++ b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs @@ -1,24 +1,21 @@ -using JsonApiDotNetCore.Data; -using JsonApiDotNetCore.Formatters; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Internal.Generics; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Repositories; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization; +using JsonApiDotNetCore.Serialization.Building; using JsonApiDotNetCore.Services; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Xunit; -using Microsoft.EntityFrameworkCore; -using JsonApiDotNetCore.Models; -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using JsonApiDotNetCore; -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Managers.Contracts; -using JsonApiDotNetCore.Serialization.Server.Builders; -using JsonApiDotNetCore.Serialization.Server; -using Microsoft.AspNetCore.Authentication; namespace UnitTests.Extensions { @@ -31,21 +28,21 @@ 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 // this is required because the DbContextResolver requires access to the current HttpContext // to get the request scoped DbContext instance - services.AddScoped(); + services.AddScoped(); var provider = services.BuildServiceProvider(); // Assert - var currentRequest = provider.GetService(); - Assert.NotNull(currentRequest); + var request = provider.GetService() as JsonApiRequest; + Assert.NotNull(request); var resourceGraph = provider.GetService(); Assert.NotNull(resourceGraph); - currentRequest.SetRequestResource(resourceGraph.GetResourceContext()); + request.PrimaryResource = resourceGraph.GetResourceContext(); Assert.NotNull(provider.GetService()); Assert.NotNull(provider.GetService()); Assert.NotNull(provider.GetService(typeof(IResourceRepository))); @@ -67,13 +64,13 @@ 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 // this is required because the DbContextResolver requires access to the current HttpContext // to get the request scoped DbContext instance - services.AddScoped(); + services.AddScoped(); var provider = services.BuildServiceProvider(); var graph = provider.GetService(); var resourceContext = graph.GetResourceContext(); @@ -98,8 +95,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 +118,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))); @@ -135,7 +132,7 @@ public void AddResourceService_Throws_If_Type_Does_Not_Implement_Any_Interfaces( var services = new ServiceCollection(); // Act, assert - Assert.Throws(() => services.AddResourceService()); + Assert.Throws(() => services.AddResourceService()); } [Fact] @@ -146,7 +143,7 @@ public void AddJsonApi_With_Context_Uses_Resource_Type_Name_If_NoOtherSpecified( services.AddLogging(); services.AddDbContext(options => options.UseInMemoryDatabase(Guid.NewGuid().ToString())); - services.AddScoped(); + services.AddScoped(); // Act services.AddJsonApi(); @@ -163,26 +160,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/Extensions/TypeExtensions_Tests.cs b/test/UnitTests/Extensions/TypeExtensions_Tests.cs deleted file mode 100644 index 7bdba9ee32..0000000000 --- a/test/UnitTests/Extensions/TypeExtensions_Tests.cs +++ /dev/null @@ -1,55 +0,0 @@ -using JsonApiDotNetCore.Extensions; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Models; -using Xunit; - -namespace UnitTests.Extensions -{ - public sealed class TypeExtensions_Tests - { - [Fact] - public void New_Creates_An_Instance_If_T_Implements_Interface() - { - // Arrange - var type = typeof(Model); - - // Act - var instance = (IIdentifiable)TypeHelper.CreateInstance(type); - - // Assert - Assert.NotNull(instance); - Assert.IsType(instance); - } - - [Fact] - public void Implements_Returns_True_If_Type_Implements_Interface() - { - // Arrange - var type = typeof(Model); - - // Act - var result = type.IsOrImplementsInterface(typeof(IIdentifiable)); - - // Assert - Assert.True(result); - } - - [Fact] - public void Implements_Returns_False_If_Type_DoesNot_Implement_Interface() - { - // Arrange - var type = typeof(string); - - // Act - var result = type.IsOrImplementsInterface(typeof(IIdentifiable)); - - // Assert - Assert.False(result); - } - - private sealed class Model : IIdentifiable - { - public string StringId { get; set; } - } - } -} diff --git a/test/UnitTests/Graph/IdentifiableTypeCacheTests.cs b/test/UnitTests/Graph/IdentifiableTypeCacheTests.cs index a0b4220f75..352b3e4f4e 100644 --- a/test/UnitTests/Graph/IdentifiableTypeCacheTests.cs +++ b/test/UnitTests/Graph/IdentifiableTypeCacheTests.cs @@ -1,5 +1,5 @@ -using JsonApiDotNetCore.Graph; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; using UnitTests.Internal; using Xunit; diff --git a/test/UnitTests/Graph/TypeLocator_Tests.cs b/test/UnitTests/Graph/TypeLocator_Tests.cs index e15b037c2b..03d17f8dcb 100644 --- a/test/UnitTests/Graph/TypeLocator_Tests.cs +++ b/test/UnitTests/Graph/TypeLocator_Tests.cs @@ -1,6 +1,6 @@ using System; -using JsonApiDotNetCore.Graph; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; using Xunit; namespace UnitTests.Internal diff --git a/test/UnitTests/Internal/ErrorDocumentTests.cs b/test/UnitTests/Internal/ErrorDocumentTests.cs index a8b2946ca2..4a7dd40a24 100644 --- a/test/UnitTests/Internal/ErrorDocumentTests.cs +++ b/test/UnitTests/Internal/ErrorDocumentTests.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; using System.Net; -using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCore.Serialization.Objects; using Xunit; namespace UnitTests.Internal diff --git a/test/UnitTests/Internal/RequestScopedServiceProviderTests.cs b/test/UnitTests/Internal/RequestScopedServiceProviderTests.cs index 2b5fbe9119..6fe5da75ab 100644 --- a/test/UnitTests/Internal/RequestScopedServiceProviderTests.cs +++ b/test/UnitTests/Internal/RequestScopedServiceProviderTests.cs @@ -1,6 +1,6 @@ using System; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Services; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; using JsonApiDotNetCoreExample.Models; using Microsoft.AspNetCore.Http; using Xunit; @@ -13,17 +13,17 @@ public sealed class RequestScopedServiceProviderTests public void When_http_context_is_unavailable_it_must_fail() { // Arrange + var serviceType = typeof(IIdentifiable); + var provider = new RequestScopedServiceProvider(new HttpContextAccessor()); // Act - Action action = () => provider.GetService(typeof(IIdentifiable)); + Action action = () => provider.GetService(serviceType); // Assert var exception = Assert.Throws(action); - Assert.StartsWith("Cannot resolve scoped service " + - "'JsonApiDotNetCore.Models.IIdentifiable`1[[JsonApiDotNetCoreExample.Models.Tag, JsonApiDotNetCoreExample, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null]]' " + - "outside the context of an HTTP request.", exception.Message); + Assert.StartsWith("Cannot resolve scoped service " + $"'{serviceType.FullName}' outside the context of an HTTP request.", exception.Message); } } } diff --git a/test/UnitTests/Internal/ResourceGraphBuilderTests.cs b/test/UnitTests/Internal/ResourceGraphBuilderTests.cs new file mode 100644 index 0000000000..c5350640db --- /dev/null +++ b/test/UnitTests/Internal/ResourceGraphBuilderTests.cs @@ -0,0 +1,87 @@ +using Castle.DynamicProxy; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace UnitTests.Internal +{ + public sealed class ResourceGraphBuilderTests + { + [Fact] + public void AddDbContext_Does_Not_Throw_If_Context_Contains_Members_That_Do_Not_Implement_IIdentifiable() + { + // Arrange + var resourceGraphBuilder = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance); + + // Act + resourceGraphBuilder.Add(typeof(TestContext)); + var resourceGraph = (ResourceGraph)resourceGraphBuilder.Build(); + + // Assert + Assert.Empty(resourceGraph.GetResourceContexts()); + } + + [Fact] + public void Adding_DbContext_Members_That_Do_Not_Implement_IIdentifiable_Logs_Warning() + { + // Arrange + var loggerFactory = new FakeLoggerFactory(); + var resourceGraphBuilder = new ResourceGraphBuilder(new JsonApiOptions(), loggerFactory); + resourceGraphBuilder.Add(typeof(TestContext)); + + // Act + resourceGraphBuilder.Build(); + + // Assert + Assert.Single(loggerFactory.Logger.Messages); + Assert.Equal(LogLevel.Warning, loggerFactory.Logger.Messages[0].LogLevel); + Assert.Equal("Entity 'UnitTests.Internal.ResourceGraphBuilderTests+TestContext' does not implement 'IIdentifiable'.", loggerFactory.Logger.Messages[0].Text); + } + + [Fact] + public void GetResourceContext_Yields_Right_Type_For_LazyLoadingProxy() + { + // Arrange + var resourceGraphBuilder = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance); + resourceGraphBuilder.Add(); + var resourceGraph = (ResourceGraph)resourceGraphBuilder.Build(); + var proxyGenerator = new ProxyGenerator(); + + // Act + var proxy = proxyGenerator.CreateClassProxy(); + var result = resourceGraph.GetResourceContext(proxy.GetType()); + + // Assert + Assert.Equal(typeof(Bar), result.ResourceType); + } + + [Fact] + public void GetResourceContext_Yields_Right_Type_For_Identifiable() + { + // Arrange + var resourceGraphBuilder = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance); + resourceGraphBuilder.Add(); + var resourceGraph = (ResourceGraph)resourceGraphBuilder.Build(); + + // Act + var result = resourceGraph.GetResourceContext(typeof(Bar)); + + // Assert + Assert.Equal(typeof(Bar), result.ResourceType); + } + + private class Foo { } + + private class TestContext : DbContext + { + public DbSet Foos { get; set; } + } + + public class Bar : Identifiable { } + + } + +} diff --git a/test/UnitTests/Internal/ResourceGraphBuilder_Tests.cs b/test/UnitTests/Internal/ResourceGraphBuilder_Tests.cs deleted file mode 100644 index 1ceb41f2aa..0000000000 --- a/test/UnitTests/Internal/ResourceGraphBuilder_Tests.cs +++ /dev/null @@ -1,52 +0,0 @@ -using JsonApiDotNetCore.Builders; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Internal; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; -using Xunit; - -namespace UnitTests.Internal -{ - public sealed class ResourceGraphBuilder_Tests - { - [Fact] - public void AddDbContext_Does_Not_Throw_If_Context_Contains_Members_That_Do_Not_Implement_IIdentifiable() - { - // Arrange - var resourceGraphBuilder = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance); - - // Act - resourceGraphBuilder.AddResource(typeof(TestContext)); - var resourceGraph = (ResourceGraph)resourceGraphBuilder.Build(); - - // Assert - Assert.Empty(resourceGraph.GetResourceContexts()); - } - - [Fact] - public void Adding_DbContext_Members_That_Do_Not_Implement_IIdentifiable_Logs_Warning() - { - // Arrange - var loggerFactory = new FakeLoggerFactory(); - var resourceGraphBuilder = new ResourceGraphBuilder(new JsonApiOptions(), loggerFactory); - resourceGraphBuilder.AddResource(typeof(TestContext)); - - // Act - resourceGraphBuilder.Build(); - - // 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); - } - - private class Foo { } - - private class TestContext : DbContext - { - public DbSet Foos { get; set; } - } - } - -} diff --git a/test/UnitTests/Internal/TypeHelper_Tests.cs b/test/UnitTests/Internal/TypeHelper_Tests.cs index 89c926aaab..7185641091 100644 --- a/test/UnitTests/Internal/TypeHelper_Tests.cs +++ b/test/UnitTests/Internal/TypeHelper_Tests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; -using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Resources; using Xunit; namespace UnitTests.Internal @@ -131,6 +132,46 @@ public void Bad_TimeSpanString_Throws() Assert.Throws(() => TypeHelper.ConvertType(formattedString, typeof(TimeSpan))); } + [Fact] + public void New_Creates_An_Instance_If_T_Implements_Interface() + { + // Arrange + var type = typeof(Model); + + // Act + var instance = (IIdentifiable)TypeHelper.CreateInstance(type); + + // Assert + Assert.NotNull(instance); + Assert.IsType(instance); + } + + [Fact] + public void Implements_Returns_True_If_Type_Implements_Interface() + { + // Arrange + var type = typeof(Model); + + // Act + var result = TypeHelper.IsOrImplementsInterface(type, typeof(IIdentifiable)); + + // Assert + Assert.True(result); + } + + [Fact] + public void Implements_Returns_False_If_Type_DoesNot_Implement_Interface() + { + // Arrange + var type = typeof(string); + + // Act + var result = TypeHelper.IsOrImplementsInterface(type, typeof(IIdentifiable)); + + // Assert + Assert.False(result); + } + private enum TestEnum { Test = 1 @@ -146,5 +187,10 @@ private class BaseType : IType private interface IType { } + + private sealed class Model : IIdentifiable + { + public string StringId { get; set; } + } } } diff --git a/test/UnitTests/Middleware/JsonApiMiddlewareTests.cs b/test/UnitTests/Middleware/JsonApiMiddlewareTests.cs index d3d446e211..75130f1b3f 100644 --- a/test/UnitTests/Middleware/JsonApiMiddlewareTests.cs +++ b/test/UnitTests/Middleware/JsonApiMiddlewareTests.cs @@ -1,16 +1,13 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Managers; using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Resources.Annotations; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; using Moq; -using System; -using System.IO; -using System.Linq; -using System.Threading.Tasks; using Xunit; namespace UnitTests.Middleware @@ -23,46 +20,46 @@ public async Task ParseUrlBase_ObfuscatedIdClass_ShouldSetIdCorrectly() // Arrange var id = "ABC123ABC"; var configuration = GetConfiguration($"/obfuscatedIdModel/{id}", action: "GetAsync", id: id); - var currentRequest = configuration.CurrentRequest; + var request = configuration.Request; // Act await RunMiddlewareTask(configuration); // Assert - Assert.Equal(id, currentRequest.BaseId); - + Assert.Equal(id, request.PrimaryId); } + [Fact] - public async Task ParseUrlBase_UrlHasBaseIdSet_ShouldSetCurrentRequestWithSaidId() + public async Task ParseUrlBase_UrlHasPrimaryIdSet_ShouldSetupRequestWithSameId() { // Arrange var id = "123"; var configuration = GetConfiguration($"/users/{id}", id: id); - var currentRequest = configuration.CurrentRequest; + var request = configuration.Request; // Act await RunMiddlewareTask(configuration); // Assert - Assert.Equal(id, currentRequest.BaseId); + Assert.Equal(id, request.PrimaryId); } [Fact] - public async Task ParseUrlBase_UrlHasNoBaseIdSet_ShouldHaveBaseIdSetToNull() + public async Task ParseUrlBase_UrlHasNoPrimaryIdSet_ShouldHaveBaseIdSetToNull() { // Arrange var configuration = GetConfiguration("/users"); - var currentRequest = configuration.CurrentRequest; + var request = configuration.Request; // Act await RunMiddlewareTask(configuration); // Assert - Assert.Null(currentRequest.BaseId); + Assert.Null(request.PrimaryId); } [Fact] - public async Task ParseUrlBase_UrlHasNegativeBaseIdAndTypeIsInt_ShouldNotThrowJAException() + public async Task ParseUrlBase_UrlHasNegativePrimaryIdAndTypeIsInt_ShouldNotThrowJAException() { // Arrange var configuration = GetConfiguration("/users/-5/"); @@ -77,7 +74,7 @@ private sealed class InvokeConfiguration public HttpContext HttpContext; public Mock ControllerResourceMapping; public Mock Options; - public CurrentRequest CurrentRequest; + public JsonApiRequest Request; public Mock ResourceGraph; } private Task RunMiddlewareTask(InvokeConfiguration holder) @@ -85,9 +82,9 @@ private Task RunMiddlewareTask(InvokeConfiguration holder) var controllerResourceMapping = holder.ControllerResourceMapping.Object; var context = holder.HttpContext; var options = holder.Options.Object; - var currentRequest = holder.CurrentRequest; + var request = holder.Request; var resourceGraph = holder.ResourceGraph.Object; - return holder.MiddleWare.Invoke(context, controllerResourceMapping, options, currentRequest, resourceGraph); + return holder.MiddleWare.Invoke(context, controllerResourceMapping, options, request, resourceGraph); } private InvokeConfiguration GetConfiguration(string path, string resourceName = "users", string action = "", string id =null, Type relType = null) { @@ -101,12 +98,14 @@ private InvokeConfiguration GetConfiguration(string path, string resourceName = }); var forcedNamespace = "api/v1"; var mockMapping = new Mock(); + mockMapping.Setup(x => x.GetAssociatedResource(It.IsAny())).Returns(typeof(string)); + Mock mockOptions = CreateMockOptions(forcedNamespace); var mockGraph = CreateMockResourceGraph(resourceName, includeRelationship: relType != null); - var currentRequest = new CurrentRequest(); + var request = new JsonApiRequest(); if (relType != null) { - currentRequest.RequestRelationship = new HasManyAttribute + request.Relationship = new HasManyAttribute { RightType = relType }; @@ -117,7 +116,7 @@ private InvokeConfiguration GetConfiguration(string path, string resourceName = MiddleWare = middleware, ControllerResourceMapping = mockMapping, Options = mockOptions, - CurrentRequest = currentRequest, + Request = request, HttpContext = context, ResourceGraph = mockGraph }; diff --git a/test/UnitTests/Models/AttributesEqualsTests.cs b/test/UnitTests/Models/AttributesEqualsTests.cs index 5aa4ba1bd1..74879accbb 100644 --- a/test/UnitTests/Models/AttributesEqualsTests.cs +++ b/test/UnitTests/Models/AttributesEqualsTests.cs @@ -1,4 +1,4 @@ -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Resources.Annotations; using Xunit; namespace UnitTests.Models @@ -8,8 +8,8 @@ public sealed class AttributesEqualsTests [Fact] public void HasManyAttribute_Equals_Returns_True_When_Same_Name() { - var a = new HasManyAttribute("test"); - var b = new HasManyAttribute("test"); + var a = new HasManyAttribute {PublicName = "test"}; + var b = new HasManyAttribute {PublicName = "test"}; Assert.Equal(a, b); } @@ -17,8 +17,8 @@ public void HasManyAttribute_Equals_Returns_True_When_Same_Name() [Fact] public void HasManyAttribute_Equals_Returns_False_When_Different_Name() { - var a = new HasManyAttribute("test"); - var b = new HasManyAttribute("test2"); + var a = new HasManyAttribute {PublicName = "test"}; + var b = new HasManyAttribute {PublicName = "test2"}; Assert.NotEqual(a, b); } @@ -26,8 +26,8 @@ public void HasManyAttribute_Equals_Returns_False_When_Different_Name() [Fact] public void HasOneAttribute_Equals_Returns_True_When_Same_Name() { - var a = new HasOneAttribute("test"); - var b = new HasOneAttribute("test"); + var a = new HasOneAttribute {PublicName = "test"}; + var b = new HasOneAttribute {PublicName = "test"}; Assert.Equal(a, b); } @@ -35,8 +35,8 @@ public void HasOneAttribute_Equals_Returns_True_When_Same_Name() [Fact] public void HasOneAttribute_Equals_Returns_False_When_Different_Name() { - var a = new HasOneAttribute("test"); - var b = new HasOneAttribute("test2"); + var a = new HasOneAttribute {PublicName = "test"}; + var b = new HasOneAttribute {PublicName = "test2"}; Assert.NotEqual(a, b); } @@ -44,8 +44,8 @@ public void HasOneAttribute_Equals_Returns_False_When_Different_Name() [Fact] public void AttrAttribute_Equals_Returns_True_When_Same_Name() { - var a = new AttrAttribute("test"); - var b = new AttrAttribute("test"); + var a = new AttrAttribute {PublicName = "test"}; + var b = new AttrAttribute {PublicName = "test"}; Assert.Equal(a, b); } @@ -53,8 +53,8 @@ public void AttrAttribute_Equals_Returns_True_When_Same_Name() [Fact] public void AttrAttribute_Equals_Returns_False_When_Different_Name() { - var a = new AttrAttribute("test"); - var b = new AttrAttribute("test2"); + var a = new AttrAttribute {PublicName = "test"}; + var b = new AttrAttribute {PublicName = "test2"}; Assert.NotEqual(a, b); } @@ -62,8 +62,8 @@ public void AttrAttribute_Equals_Returns_False_When_Different_Name() [Fact] public void HasManyAttribute_Does_Not_Equal_HasOneAttribute_With_Same_Name() { - RelationshipAttribute a = new HasManyAttribute("test"); - RelationshipAttribute b = new HasOneAttribute("test"); + RelationshipAttribute a = new HasManyAttribute {PublicName = "test"}; + RelationshipAttribute b = new HasOneAttribute {PublicName = "test"}; Assert.NotEqual(a, b); Assert.NotEqual(b, a); diff --git a/test/UnitTests/Models/IdentifiableTests.cs b/test/UnitTests/Models/IdentifiableTests.cs index 971a936e39..cf90129eac 100644 --- a/test/UnitTests/Models/IdentifiableTests.cs +++ b/test/UnitTests/Models/IdentifiableTests.cs @@ -1,4 +1,4 @@ -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Resources; using Xunit; namespace UnitTests.Models @@ -20,15 +20,15 @@ public void Setting_StringId_To_Null_Sets_Id_As_Default() } [Fact] - public void GetStringId_Returns_EmptyString_If_Object_Is_Null() + public void GetStringId_Returns_Null_If_Object_Is_Default() { var resource = new IntId(); - var stringId = resource.ExposedGetStringId(null); - Assert.Equal(string.Empty, stringId); + var stringId = resource.ExposedGetStringId(default); + Assert.Null(stringId); } private sealed class IntId : Identifiable { - public string ExposedGetStringId(object value) => GetStringId(value); + public string ExposedGetStringId(int value) => GetStringId(value); } } } diff --git a/test/UnitTests/Models/LinkTests.cs b/test/UnitTests/Models/LinkTests.cs index 69ef23d577..cda2699d39 100644 --- a/test/UnitTests/Models/LinkTests.cs +++ b/test/UnitTests/Models/LinkTests.cs @@ -1,4 +1,4 @@ -using JsonApiDotNetCore.Models.Links; +using JsonApiDotNetCore.Resources.Annotations; 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 = LinkTypes.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(LinkTypes.Self)); + Assert.True(e.HasFlag(LinkTypes.Paging)); + Assert.True(e.HasFlag(LinkTypes.Related)); + Assert.True(e.HasFlag(LinkTypes.All)); + Assert.False(e.HasFlag(LinkTypes.None)); } [Fact] public void None_Contains_Only_None() { // Arrange - var e = Link.None; + var e = LinkTypes.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(LinkTypes.Self)); + Assert.False(e.HasFlag(LinkTypes.Paging)); + Assert.False(e.HasFlag(LinkTypes.Related)); + Assert.False(e.HasFlag(LinkTypes.All)); + Assert.True(e.HasFlag(LinkTypes.None)); } [Fact] public void Self() { // Arrange - var e = Link.Self; + var e = LinkTypes.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(LinkTypes.Self)); + Assert.False(e.HasFlag(LinkTypes.Paging)); + Assert.False(e.HasFlag(LinkTypes.Related)); + Assert.False(e.HasFlag(LinkTypes.All)); + Assert.False(e.HasFlag(LinkTypes.None)); } [Fact] public void Paging() { // Arrange - var e = Link.Paging; + var e = LinkTypes.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(LinkTypes.Self)); + Assert.True(e.HasFlag(LinkTypes.Paging)); + Assert.False(e.HasFlag(LinkTypes.Related)); + Assert.False(e.HasFlag(LinkTypes.All)); + Assert.False(e.HasFlag(LinkTypes.None)); } [Fact] public void Related() { // Arrange - var e = Link.Related; + var e = LinkTypes.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(LinkTypes.Self)); + Assert.False(e.HasFlag(LinkTypes.Paging)); + Assert.True(e.HasFlag(LinkTypes.Related)); + Assert.False(e.HasFlag(LinkTypes.All)); + Assert.False(e.HasFlag(LinkTypes.None)); } } } diff --git a/test/UnitTests/Models/RelationshipDataTests.cs b/test/UnitTests/Models/RelationshipDataTests.cs index 8eb271ff52..4fc3d2f98c 100644 --- a/test/UnitTests/Models/RelationshipDataTests.cs +++ b/test/UnitTests/Models/RelationshipDataTests.cs @@ -1,5 +1,5 @@ using System.Collections.Generic; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Serialization.Objects; using Newtonsoft.Json.Linq; using Xunit; diff --git a/test/UnitTests/Models/ResourceConstructionExpressionTests.cs b/test/UnitTests/Models/ResourceConstructionExpressionTests.cs index 90ac696958..242ee0feef 100644 --- a/test/UnitTests/Models/ResourceConstructionExpressionTests.cs +++ b/test/UnitTests/Models/ResourceConstructionExpressionTests.cs @@ -1,7 +1,7 @@ using System; using System.ComponentModel.Design; using System.Linq.Expressions; -using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Resources; using JsonApiDotNetCoreExample.Data; using Microsoft.AspNetCore.Authentication; using Microsoft.EntityFrameworkCore; @@ -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 b684491f85..fb0cacc5b5 100644 --- a/test/UnitTests/Models/ResourceConstructionTests.cs +++ b/test/UnitTests/Models/ResourceConstructionTests.cs @@ -1,14 +1,13 @@ using System; using System.ComponentModel.Design; -using JsonApiDotNetCore.Builders; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization; -using JsonApiDotNetCore.Serialization.Server; using JsonApiDotNetCoreExample.Data; +using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; +using Moq; using Newtonsoft.Json; using Xunit; @@ -16,15 +15,23 @@ namespace UnitTests.Models { public sealed class ResourceConstructionTests { + public Mock _mockHttpContextAccessor; + + public ResourceConstructionTests() + { + _mockHttpContextAccessor = new Mock(); + _mockHttpContextAccessor.Setup(mock => mock.HttpContext).Returns(new DefaultHttpContext()); + } + [Fact] public void When_resource_has_default_constructor_it_must_succeed() { // Arrange var graph = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance) - .AddResource() + .Add() .Build(); - var serializer = new RequestDeserializer(graph, new DefaultResourceFactory(new ServiceContainer()), new TargetedFields()); + var serializer = new RequestDeserializer(graph, new ResourceFactory(new ServiceContainer()), new TargetedFields(), _mockHttpContextAccessor.Object); var body = new { @@ -50,10 +57,10 @@ public void When_resource_has_default_constructor_that_throws_it_must_fail() { // Arrange var graph = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance) - .AddResource() + .Add() .Build(); - var serializer = new RequestDeserializer(graph, new DefaultResourceFactory(new ServiceContainer()), new TargetedFields()); + var serializer = new RequestDeserializer(graph, new ResourceFactory(new ServiceContainer()), new TargetedFields(), _mockHttpContextAccessor.Object); var body = new { @@ -81,7 +88,7 @@ public void When_resource_has_constructor_with_injectable_parameter_it_must_succ { // Arrange var graph = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance) - .AddResource() + .Add() .Build(); var appDbContext = new AppDbContext(new DbContextOptionsBuilder().Options, new FrozenSystemClock()); @@ -89,7 +96,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()); + var serializer = new RequestDeserializer(graph, new ResourceFactory(serviceContainer), new TargetedFields(), _mockHttpContextAccessor.Object); var body = new { @@ -116,10 +123,10 @@ public void When_resource_has_constructor_with_string_parameter_it_must_fail() { // Arrange var graph = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance) - .AddResource() + .Add() .Build(); - var serializer = new RequestDeserializer(graph, new DefaultResourceFactory(new ServiceContainer()), new TargetedFields()); + 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 9beb4d6de9..0000000000 --- a/test/UnitTests/Models/ResourceDefinitionTests.cs +++ /dev/null @@ -1,93 +0,0 @@ -using System; -using System.Collections.Generic; -using JsonApiDotNetCore.Builders; -using JsonApiDotNetCore.Internal.Query; -using JsonApiDotNetCore.Models; -using System.Linq; -using JsonApiDotNetCore.Configuration; -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.PropertyInfo.Name); - Assert.Equal(SortDirection.Ascending, sorts[0].SortDirection); - - Assert.Equal(nameof(Model.Password), sorts[1].Attribute.PropertyInfo.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.PropertyInfo.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.PropertyInfo.Name == nameof(Model.AlwaysExcluded)); - Assert.DoesNotContain(attrs, a => a.PropertyInfo.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 ad3aee7d83..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().PublicRelationshipName); - Assert.Equal("favoriteFood", firstChain.Last().PublicRelationshipName); - var secondChain = chains[1]; - Assert.Equal("reviewer", secondChain.First().PublicRelationshipName); - Assert.Equal("favoriteSong", secondChain.Last().PublicRelationshipName); - } - - [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 4a5158de98..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; -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) {PropertyInfo = typeof(Article).GetProperty(nameof(Article.Name))}; - var idAttribute = new AttrAttribute("id") {PropertyInfo = 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") {PropertyInfo = 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") {PropertyInfo = 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") - { - PropertyInfo = 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/BaseParseTests.cs b/test/UnitTests/QueryStringParameters/BaseParseTests.cs new file mode 100644 index 0000000000..3952d9af61 --- /dev/null +++ b/test/UnitTests/QueryStringParameters/BaseParseTests.cs @@ -0,0 +1,35 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCoreExample.Models; +using Microsoft.Extensions.Logging.Abstractions; + +namespace UnitTests.QueryStringParameters +{ + public abstract class BaseParseTests + { + protected JsonApiOptions Options { get; } + protected IResourceGraph ResourceGraph { get; } + protected JsonApiRequest Request { get; } + + protected BaseParseTests() + { + Options = new JsonApiOptions(); + + ResourceGraph = new ResourceGraphBuilder(Options, NullLoggerFactory.Instance) + .Add() + .Add
() + .Add() + .Add
() + .Add() + .Add() + .Add() + .Build(); + + Request = new JsonApiRequest + { + PrimaryResource = ResourceGraph.GetResourceContext(), + IsCollection = true + }; + } + } +} diff --git a/test/UnitTests/QueryStringParameters/DefaultsParseTests.cs b/test/UnitTests/QueryStringParameters/DefaultsParseTests.cs new file mode 100644 index 0000000000..90459eaa36 --- /dev/null +++ b/test/UnitTests/QueryStringParameters/DefaultsParseTests.cs @@ -0,0 +1,130 @@ +using System; +using System.Net; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers.Annotations; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.QueryStrings; +using JsonApiDotNetCore.QueryStrings.Internal; +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 DisableQueryStringAttribute(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(DisableQueryStringAttribute.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..dc8b74411b --- /dev/null +++ b/test/UnitTests/QueryStringParameters/FilterParseTests.cs @@ -0,0 +1,142 @@ +using System; +using System.ComponentModel.Design; +using System.Linq; +using System.Net; +using FluentAssertions; +using JsonApiDotNetCore.Controllers.Annotations; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.QueryStrings; +using JsonApiDotNetCore.QueryStrings.Internal; +using JsonApiDotNetCore.Resources; +using Xunit; + +namespace UnitTests.QueryStringParameters +{ + public sealed class FilterParseTests : BaseParseTests + { + private readonly FilterQueryStringParameterReader _reader; + + public FilterParseTests() + { + Options.EnableLegacyFilterNotation = false; + + var resourceFactory = new ResourceFactory(new ServiceContainer()); + _reader = new FilterQueryStringParameterReader(Request, 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 DisableQueryStringAttribute(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..a52dc5ddc1 --- /dev/null +++ b/test/UnitTests/QueryStringParameters/IncludeParseTests.cs @@ -0,0 +1,96 @@ +using System; +using System.Linq; +using System.Net; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers.Annotations; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.QueryStrings; +using JsonApiDotNetCore.QueryStrings.Internal; +using Xunit; + +namespace UnitTests.QueryStringParameters +{ + public sealed class IncludeParseTests : BaseParseTests + { + private readonly IncludeQueryStringParameterReader _reader; + + public IncludeParseTests() + { + _reader = new IncludeQueryStringParameterReader(Request, ResourceGraph, new JsonApiOptions()); + } + + [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 DisableQueryStringAttribute(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.revisions")] + [InlineData("includes", "articles,articles.revisions,articles.tags", "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..b12a389a95 --- /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.Errors; +using JsonApiDotNetCore.QueryStrings.Internal; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCoreExample.Models; +using Xunit; + +namespace UnitTests.QueryStringParameters +{ + public sealed class LegacyFilterParseTests : BaseParseTests + { + private readonly FilterQueryStringParameterReader _reader; + + public LegacyFilterParseTests() + { + Options.EnableLegacyFilterNotation = true; + + Request.PrimaryResource = ResourceGraph.GetResourceContext
(); + + var resourceFactory = new ResourceFactory(new ServiceContainer()); + _reader = new FilterQueryStringParameterReader(Request, 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..5d754cb17b --- /dev/null +++ b/test/UnitTests/QueryStringParameters/NullsParseTests.cs @@ -0,0 +1,130 @@ +using System; +using System.Net; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers.Annotations; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.QueryStrings; +using JsonApiDotNetCore.QueryStrings.Internal; +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 DisableQueryStringAttribute(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(DisableQueryStringAttribute.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..d62b379587 --- /dev/null +++ b/test/UnitTests/QueryStringParameters/PaginationParseTests.cs @@ -0,0 +1,156 @@ +using System; +using System.Linq; +using System.Net; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers.Annotations; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.QueryStrings; +using JsonApiDotNetCore.QueryStrings.Internal; +using Xunit; + +namespace UnitTests.QueryStringParameters +{ + public sealed class PaginationParseTests : BaseParseTests + { + private readonly IPaginationQueryStringParameterReader _reader; + + public PaginationParseTests() + { + Options.DefaultPageSize = new PageSize(25); + _reader = new PaginationQueryStringParameterReader(Request, 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 DisableQueryStringAttribute(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/SortParseTests.cs b/test/UnitTests/QueryStringParameters/SortParseTests.cs new file mode 100644 index 0000000000..c3b9c7ce09 --- /dev/null +++ b/test/UnitTests/QueryStringParameters/SortParseTests.cs @@ -0,0 +1,114 @@ +using System; +using System.Linq; +using System.Net; +using FluentAssertions; +using JsonApiDotNetCore.Controllers.Annotations; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.QueryStrings; +using JsonApiDotNetCore.QueryStrings.Internal; +using Xunit; + +namespace UnitTests.QueryStringParameters +{ + public sealed class SortParseTests : BaseParseTests + { + private readonly SortQueryStringParameterReader _reader; + + public SortParseTests() + { + _reader = new SortQueryStringParameterReader(Request, 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 DisableQueryStringAttribute(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..82decd2696 --- /dev/null +++ b/test/UnitTests/QueryStringParameters/SparseFieldSetParseTests.cs @@ -0,0 +1,101 @@ +using System; +using System.Linq; +using System.Net; +using FluentAssertions; +using JsonApiDotNetCore.Controllers.Annotations; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.QueryStrings; +using JsonApiDotNetCore.QueryStrings.Internal; +using Xunit; + +namespace UnitTests.QueryStringParameters +{ + public sealed class SparseFieldSetParseTests : BaseParseTests + { + private readonly SparseFieldSetQueryStringParameterReader _reader; + + public SparseFieldSetParseTests() + { + _reader = new SparseFieldSetQueryStringParameterReader(Request, 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 DisableQueryStringAttribute(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..e05da73ba0 100644 --- a/test/UnitTests/ResourceHooks/DiscoveryTests.cs +++ b/test/UnitTests/ResourceHooks/DiscoveryTests.cs @@ -1,14 +1,13 @@ -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Hooks; -using System.Collections.Generic; -using Xunit; -using JsonApiDotNetCore.Builders; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Internal.Contracts; using System; +using System.Collections.Generic; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Hooks.Internal.Discovery; +using JsonApiDotNetCore.Hooks.Internal.Execution; +using JsonApiDotNetCore.Resources; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; +using Xunit; namespace UnitTests.ResourceHooks { @@ -17,10 +16,10 @@ public sealed class DiscoveryTests public class Dummy : Identifiable { } public sealed class DummyResourceDefinition : ResourceDefinition { - public DummyResourceDefinition() : base(new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance).AddResource().Build()) { } + public DummyResourceDefinition() : base(new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance).Add().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,13 +43,13 @@ 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 { - public AnotherDummyResourceDefinition() : base(new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance).AddResource().Build()) { } + public AnotherDummyResourceDefinition() : base(new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance).Add().Build()) { } } [Fact] @@ -66,19 +65,19 @@ public void HookDiscovery_InheritanceSubclass_CanDiscover() public class YetAnotherDummy : Identifiable { } public sealed class YetAnotherDummyResourceDefinition : ResourceDefinition { - public YetAnotherDummyResourceDefinition() : base(new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance).AddResource().Build()) { } + public YetAnotherDummyResourceDefinition() : base(new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance).Add().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] public void HookDiscovery_WronglyUsedLoadDatabaseValueAttribute_ThrowsJsonApiSetupException() { // assert - Assert.Throws(() => + Assert.Throws(() => { // Arrange & act new HooksDiscovery(MockProvider(new YetAnotherDummyResourceDefinition())); @@ -98,10 +97,10 @@ public void HookDiscovery_InheritanceWithGenericSubclass_CanDiscover() public sealed class GenericDummyResourceDefinition : ResourceDefinition where TResource : class, IIdentifiable { - public GenericDummyResourceDefinition() : base(new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance).AddResource().Build()) { } + public GenericDummyResourceDefinition() : base(new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance).Add().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 51% rename from test/UnitTests/ResourceHooks/AffectedEntitiesHelperTests.cs rename to test/UnitTests/ResourceHooks/RelationshipDictionaryTests.cs index a30d928581..9873f7f4fb 100644 --- a/test/UnitTests/ResourceHooks/AffectedEntitiesHelperTests.cs +++ b/test/UnitTests/ResourceHooks/RelationshipDictionaryTests.cs @@ -1,11 +1,12 @@ -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Hooks; using System.Collections.Generic; -using Xunit; using System.Linq; using System.Reflection; +using JsonApiDotNetCore.Hooks.Internal.Execution; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using Xunit; -namespace UnitTests.ResourceHooks.AffectedEntities +namespace UnitTests.ResourceHooks { public sealed class Dummy : Identifiable { @@ -31,35 +32,38 @@ 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") + FirstToOneAttr = new HasOneAttribute { + PublicName = "firstToOne", LeftType = typeof(Dummy), RightType = typeof(ToOne), - PropertyInfo = typeof(Dummy).GetProperty(nameof(Dummy.FirstToOne)) + Property = typeof(Dummy).GetProperty(nameof(Dummy.FirstToOne)) }; - SecondToOneAttr = new HasOneAttribute("secondToOne") + SecondToOneAttr = new HasOneAttribute { + PublicName = "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") + ToManyAttr = new HasManyAttribute { + PublicName = "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); - 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] @@ -89,38 +93,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(); @@ -130,47 +134,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(); @@ -178,21 +182,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); @@ -213,19 +217,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/AfterCreateTests.cs b/test/UnitTests/ResourceHooks/ResourceHookExecutor/Create/AfterCreateTests.cs index 4dafef66a4..a465f7dce6 100644 --- a/test/UnitTests/ResourceHooks/ResourceHookExecutor/Create/AfterCreateTests.cs +++ b/test/UnitTests/ResourceHooks/ResourceHookExecutor/Create/AfterCreateTests.cs @@ -1,7 +1,7 @@ -using JsonApiDotNetCore.Hooks; +using System.Collections.Generic; +using JsonApiDotNetCore.Hooks.Internal.Execution; using JsonApiDotNetCoreExample.Models; using Moq; -using System.Collections.Generic; using Xunit; namespace UnitTests.ResourceHooks diff --git a/test/UnitTests/ResourceHooks/ResourceHookExecutor/Create/BeforeCreateTests.cs b/test/UnitTests/ResourceHooks/ResourceHookExecutor/Create/BeforeCreateTests.cs index 64c2e24e9b..2f580128a9 100644 --- a/test/UnitTests/ResourceHooks/ResourceHookExecutor/Create/BeforeCreateTests.cs +++ b/test/UnitTests/ResourceHooks/ResourceHookExecutor/Create/BeforeCreateTests.cs @@ -1,7 +1,7 @@ -using JsonApiDotNetCore.Hooks; +using System.Collections.Generic; +using JsonApiDotNetCore.Hooks.Internal.Execution; using JsonApiDotNetCoreExample.Models; using Moq; -using System.Collections.Generic; using Xunit; namespace UnitTests.ResourceHooks @@ -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..2efe5e1391 100644 --- a/test/UnitTests/ResourceHooks/ResourceHookExecutor/Create/BeforeCreate_WithDbValues_Tests.cs +++ b/test/UnitTests/ResourceHooks/ResourceHookExecutor/Create/BeforeCreate_WithDbValues_Tests.cs @@ -1,10 +1,10 @@ -using JsonApiDotNetCore.Hooks; +using System.Collections.Generic; +using System.Linq; +using JsonApiDotNetCore.Hooks.Internal.Execution; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; using Microsoft.EntityFrameworkCore; using Moq; -using System.Collections.Generic; -using System.Linq; using Xunit; namespace UnitTests.ResourceHooks @@ -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/AfterDeleteTests.cs b/test/UnitTests/ResourceHooks/ResourceHookExecutor/Delete/AfterDeleteTests.cs index 0da8920138..af85d5d487 100644 --- a/test/UnitTests/ResourceHooks/ResourceHookExecutor/Delete/AfterDeleteTests.cs +++ b/test/UnitTests/ResourceHooks/ResourceHookExecutor/Delete/AfterDeleteTests.cs @@ -1,7 +1,7 @@ -using JsonApiDotNetCore.Hooks; +using System.Collections.Generic; +using JsonApiDotNetCore.Hooks.Internal.Execution; using JsonApiDotNetCoreExample.Models; using Moq; -using System.Collections.Generic; using Xunit; namespace UnitTests.ResourceHooks diff --git a/test/UnitTests/ResourceHooks/ResourceHookExecutor/Delete/BeforeDeleteTests.cs b/test/UnitTests/ResourceHooks/ResourceHookExecutor/Delete/BeforeDeleteTests.cs index d6493c2f61..b904846ec3 100644 --- a/test/UnitTests/ResourceHooks/ResourceHookExecutor/Delete/BeforeDeleteTests.cs +++ b/test/UnitTests/ResourceHooks/ResourceHookExecutor/Delete/BeforeDeleteTests.cs @@ -1,4 +1,4 @@ -using JsonApiDotNetCore.Hooks; +using JsonApiDotNetCore.Hooks.Internal.Execution; using JsonApiDotNetCoreExample.Models; using Moq; using Xunit; @@ -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..5500fb9c2a 100644 --- a/test/UnitTests/ResourceHooks/ResourceHookExecutor/Delete/BeforeDelete_WithDbValue_Tests.cs +++ b/test/UnitTests/ResourceHooks/ResourceHookExecutor/Delete/BeforeDelete_WithDbValue_Tests.cs @@ -1,10 +1,10 @@ -using JsonApiDotNetCore.Hooks; +using System.Collections.Generic; +using System.Linq; +using JsonApiDotNetCore.Hooks.Internal.Execution; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; using Microsoft.EntityFrameworkCore; using Moq; -using System.Collections.Generic; -using System.Linq; using Xunit; namespace UnitTests.ResourceHooks @@ -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..02b0e6a12d 100644 --- a/test/UnitTests/ResourceHooks/ResourceHookExecutor/IdentifiableManyToMany_OnReturnTests.cs +++ b/test/UnitTests/ResourceHooks/ResourceHookExecutor/IdentifiableManyToMany_OnReturnTests.cs @@ -1,8 +1,8 @@ -using JsonApiDotNetCore.Hooks; -using JsonApiDotNetCoreExample.Models; -using Moq; using System.Collections.Generic; using System.Linq; +using JsonApiDotNetCore.Hooks.Internal.Execution; +using JsonApiDotNetCoreExample.Models; +using Moq; using Xunit; namespace UnitTests.ResourceHooks @@ -144,4 +144,3 @@ public void OnReturn_Without_Any_Hook_Implemented() } } } - diff --git a/test/UnitTests/ResourceHooks/ResourceHookExecutor/ManyToMany_OnReturnTests.cs b/test/UnitTests/ResourceHooks/ResourceHookExecutor/ManyToMany_OnReturnTests.cs index c4a2a1a6b6..e4db309781 100644 --- a/test/UnitTests/ResourceHooks/ResourceHookExecutor/ManyToMany_OnReturnTests.cs +++ b/test/UnitTests/ResourceHooks/ResourceHookExecutor/ManyToMany_OnReturnTests.cs @@ -1,8 +1,8 @@ -using JsonApiDotNetCore.Hooks; -using JsonApiDotNetCoreExample.Models; -using Moq; using System.Collections.Generic; using System.Linq; +using JsonApiDotNetCore.Hooks.Internal.Execution; +using JsonApiDotNetCoreExample.Models; +using Moq; using Xunit; namespace UnitTests.ResourceHooks diff --git a/test/UnitTests/ResourceHooks/ResourceHookExecutor/Read/BeforeReadTests.cs b/test/UnitTests/ResourceHooks/ResourceHookExecutor/Read/BeforeReadTests.cs index 19748c2c0d..a967bc2c9d 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.Hooks.Internal.Execution; +using JsonApiDotNetCore.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/Read/IdentifiableManyToMany_AfterReadTests.cs b/test/UnitTests/ResourceHooks/ResourceHookExecutor/Read/IdentifiableManyToMany_AfterReadTests.cs index c2a1fd8e1b..e91e3a6ebd 100644 --- a/test/UnitTests/ResourceHooks/ResourceHookExecutor/Read/IdentifiableManyToMany_AfterReadTests.cs +++ b/test/UnitTests/ResourceHooks/ResourceHookExecutor/Read/IdentifiableManyToMany_AfterReadTests.cs @@ -1,8 +1,8 @@ -using JsonApiDotNetCore.Hooks; -using JsonApiDotNetCoreExample.Models; -using Moq; using System.Collections.Generic; using System.Linq; +using JsonApiDotNetCore.Hooks.Internal.Execution; +using JsonApiDotNetCoreExample.Models; +using Moq; using Xunit; namespace UnitTests.ResourceHooks diff --git a/test/UnitTests/ResourceHooks/ResourceHookExecutor/Read/ManyToMany_AfterReadTests.cs b/test/UnitTests/ResourceHooks/ResourceHookExecutor/Read/ManyToMany_AfterReadTests.cs index 19039e2ff2..7ca674ea65 100644 --- a/test/UnitTests/ResourceHooks/ResourceHookExecutor/Read/ManyToMany_AfterReadTests.cs +++ b/test/UnitTests/ResourceHooks/ResourceHookExecutor/Read/ManyToMany_AfterReadTests.cs @@ -1,8 +1,8 @@ -using JsonApiDotNetCore.Hooks; -using JsonApiDotNetCoreExample.Models; -using Moq; using System.Collections.Generic; using System.Linq; +using JsonApiDotNetCore.Hooks.Internal.Execution; +using JsonApiDotNetCoreExample.Models; +using Moq; using Xunit; namespace UnitTests.ResourceHooks diff --git a/test/UnitTests/ResourceHooks/ResourceHookExecutor/ScenarioTests.cs b/test/UnitTests/ResourceHooks/ResourceHookExecutor/ScenarioTests.cs index c528e86929..eb963e38f2 100644 --- a/test/UnitTests/ResourceHooks/ResourceHookExecutor/ScenarioTests.cs +++ b/test/UnitTests/ResourceHooks/ResourceHookExecutor/ScenarioTests.cs @@ -1,22 +1,22 @@ -using JsonApiDotNetCore.Hooks; +using System.Collections.Generic; +using JsonApiDotNetCore.Hooks.Internal.Execution; using JsonApiDotNetCoreExample.Models; using Moq; -using System.Collections.Generic; using Xunit; 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/AfterUpdateTests.cs b/test/UnitTests/ResourceHooks/ResourceHookExecutor/Update/AfterUpdateTests.cs index 3979a124e6..c4402b809c 100644 --- a/test/UnitTests/ResourceHooks/ResourceHookExecutor/Update/AfterUpdateTests.cs +++ b/test/UnitTests/ResourceHooks/ResourceHookExecutor/Update/AfterUpdateTests.cs @@ -1,7 +1,7 @@ -using JsonApiDotNetCore.Hooks; +using System.Collections.Generic; +using JsonApiDotNetCore.Hooks.Internal.Execution; using JsonApiDotNetCoreExample.Models; using Moq; -using System.Collections.Generic; using Xunit; namespace UnitTests.ResourceHooks diff --git a/test/UnitTests/ResourceHooks/ResourceHookExecutor/Update/BeforeUpdateTests.cs b/test/UnitTests/ResourceHooks/ResourceHookExecutor/Update/BeforeUpdateTests.cs index a4fcdd22e2..7a299416c3 100644 --- a/test/UnitTests/ResourceHooks/ResourceHookExecutor/Update/BeforeUpdateTests.cs +++ b/test/UnitTests/ResourceHooks/ResourceHookExecutor/Update/BeforeUpdateTests.cs @@ -1,7 +1,7 @@ -using JsonApiDotNetCore.Hooks; +using System.Collections.Generic; +using JsonApiDotNetCore.Hooks.Internal.Execution; using JsonApiDotNetCoreExample.Models; using Moq; -using System.Collections.Generic; using Xunit; namespace UnitTests.ResourceHooks @@ -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 05f6108041..46000c9318 100644 --- a/test/UnitTests/ResourceHooks/ResourceHookExecutor/Update/BeforeUpdate_WithDbValues_Tests.cs +++ b/test/UnitTests/ResourceHooks/ResourceHookExecutor/Update/BeforeUpdate_WithDbValues_Tests.cs @@ -1,10 +1,10 @@ -using JsonApiDotNetCore.Hooks; +using System.Collections.Generic; +using System.Linq; +using JsonApiDotNetCore.Hooks.Internal.Execution; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; using Microsoft.EntityFrameworkCore; using Moq; -using System.Collections.Generic; -using System.Linq; using Xunit; namespace UnitTests.ResourceHooks @@ -27,7 +27,7 @@ public BeforeUpdate_WithDbValues_Tests() var todoId = todoList[0].Id; var _personId = todoList[0].OneToOnePerson.Id; personId = _personId.ToString(); - var _implicitPersonId = (_personId + 10000); + var _implicitPersonId = _personId + 10000; var implicitTodo = _todoFaker.Generate(); implicitTodo.Id += 1000; @@ -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)), @@ -82,14 +82,14 @@ public void BeforeUpdate_Deleting_Relationship() var personDiscovery = SetDiscoverableHooks(targetHooks, EnableDbValues); var (_, ufMock, hookExecutor, todoResourceMock, ownerResourceMock) = CreateTestObjects(todoDiscovery, personDiscovery, repoDbContextOptions: options); - ufMock.Setup(c => c.Relationships).Returns(_resourceGraph.GetRelationships((TodoItem t) => t.OneToOnePerson)); + ufMock.Setup(c => c.Relationships).Returns(_resourceGraph.GetRelationships((TodoItem t) => t.OneToOnePerson).ToList()); // 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,22 +195,22 @@ 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 diffCheck = updatedRelationship.Key.PublicRelationshipName == "oneToOnePerson"; + 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); + return dbCheck && reqCheck && diffCheck && getAffectedCheck; } private bool TodoCheck(IRelationshipsDictionary rh, string checksum) diff --git a/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs b/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs index 44547a7a9e..8a1cfa0c24 100644 --- a/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs +++ b/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs @@ -1,25 +1,25 @@ +using System; +using System.Collections.Generic; +using System.Linq; using Bogus; -using JsonApiDotNetCore.Builders; +using JsonApiDotNetCore; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Data; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Internal.Generics; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Hooks; +using JsonApiDotNetCore.Hooks.Internal; +using JsonApiDotNetCore.Hooks.Internal.Discovery; +using JsonApiDotNetCore.Hooks.Internal.Execution; +using JsonApiDotNetCore.Hooks.Internal.Traversal; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Repositories; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; using Microsoft.EntityFrameworkCore; -using Moq; -using System; -using System.Collections.Generic; -using System.Linq; -using Person = JsonApiDotNetCoreExample.Models.Person; -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Serialization; -using JsonApiDotNetCore.Internal.Query; -using JsonApiDotNetCore.Query; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Person = JsonApiDotNetCoreExample.Models.Person; namespace UnitTests.ResourceHooks { @@ -42,23 +42,23 @@ public HooksDummyData() var appDbContext = new AppDbContext(new DbContextOptionsBuilder().Options, new FrozenSystemClock()); _resourceGraph = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance) - .AddResource() - .AddResource() - .AddResource() - .AddResource
() - .AddResource() - .AddResource() - .AddResource() + .Add() + .Add() + .Add() + .Add
() + .Add() + .Add() + .Add() .Build(); _todoFaker = new Faker().Rules((f, i) => i.Id = f.UniqueIndex + 1); _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() @@ -147,102 +147,108 @@ 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 resourceFactory = new Mock().Object; + var hookExecutor = new ResourceHookExecutor(execHelper, traversalHelper, ufMock.Object, constraintsMock.Object, _resourceGraph, resourceFactory); - 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() + .Add() + .Add() .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 resourceFactory = new Mock().Object; + var hookExecutor = new ResourceHookExecutor(execHelper, traversalHelper, ufMock.Object, constraintsMock.Object, _resourceGraph, resourceFactory); - 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() + .Add() + .Add() + .Add() .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 resourceFactory = new Mock().Object; + var hookExecutor = new ResourceHookExecutor(execHelper, traversalHelper, ufMock.Object, constraintsMock.Object, _resourceGraph, resourceFactory); - return (iqMock, hookExecutor, mainResource, firstNestedResource, secondNestedResource); + return (constraintsMock, hookExecutor, primaryResource, firstSecondaryResource, secondSecondaryResource); } protected IHooksDiscovery SetDiscoverableHooks(ResourceHook[] implementedHooks, params ResourceHook[] enableDbValuesHooks) @@ -288,19 +294,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())) @@ -311,7 +317,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())) @@ -362,9 +368,11 @@ 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); + var serviceFactory = new Mock().Object; + var targetedFields = new TargetedFields(); + return new EntityFrameworkCoreRepository(targetedFields, resolver, resourceGraph, serviceFactory, resourceFactory, new List(), NullLoggerFactory.Instance); } private IDbContextResolver CreateTestDbResolver(AppDbContext dbContext) where TModel : class, IIdentifiable @@ -403,14 +411,33 @@ 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.PublicRelationshipName == requestedRelationship); + var relationship = resourceContext.Relationships.Single(r => r.PublicName == requestedRelationship); parsedChain.Add(relationship); resourceContext = _resourceGraph.GetResourceContext(relationship.RightType); } 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 = IncludeChainConverter.FromRelationshipChains(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..a001b2c1f9 100644 --- a/test/UnitTests/Serialization/Client/RequestSerializerTests.cs +++ b/test/UnitTests/Serialization/Client/RequestSerializerTests.cs @@ -1,10 +1,10 @@ using System.Collections.Generic; using System.Text.RegularExpressions; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Serialization; -using JsonApiDotNetCore.Serialization.Client; -using Xunit; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Building; +using JsonApiDotNetCore.Serialization.Client.Internal; using UnitTests.TestModels; +using Xunit; namespace UnitTests.Serialization.Client { @@ -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..c5d2d1d6ce 100644 --- a/test/UnitTests/Serialization/Client/ResponseDeserializerTests.cs +++ b/test/UnitTests/Serialization/Client/ResponseDeserializerTests.cs @@ -1,13 +1,12 @@ using System.Collections.Generic; using System.ComponentModel.Design; using System.Linq; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Models.Links; -using JsonApiDotNetCore.Serialization.Client; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Client.Internal; +using JsonApiDotNetCore.Serialization.Objects; using Newtonsoft.Json; -using Xunit; using UnitTests.TestModels; +using Xunit; namespace UnitTests.Serialization.Client { @@ -18,10 +17,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] @@ -77,7 +76,7 @@ public void DeserializeList_EmptyResponseWithTopLevelLinks_CanDeserialize() var body = JsonConvert.SerializeObject(content); // Act - var result = _deserializer.DeserializeList(body); + var result = _deserializer.DeserializeMany(body); // Assert Assert.Empty(result.Data); @@ -97,13 +96,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 +135,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 +178,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 +218,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 +269,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(); @@ -321,12 +320,12 @@ public void DeserializeList_DeeplyNestedIncluded_CanDeserialize() var body = JsonConvert.SerializeObject(content); // Act - var result = _deserializer.DeserializeList(body); - var entity = result.Data.First(); + var result = _deserializer.DeserializeMany(body); + 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 6c498a7964..1023998c15 100644 --- a/test/UnitTests/Serialization/Common/DocumentBuilderTests.cs +++ b/test/UnitTests/Serialization/Common/DocumentBuilderTests.cs @@ -1,26 +1,28 @@ using System.Collections.Generic; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Serialization; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Building; +using JsonApiDotNetCore.Serialization.Objects; using Moq; -using Xunit; using UnitTests.TestModels; +using Xunit; namespace UnitTests.Serialization.Serializer { public sealed class BaseDocumentBuilderTests : SerializerTestsSetup { - private readonly TestDocumentBuilder _builder; + private readonly TestSerializer _builder; public BaseDocumentBuilderTests() { var mock = new Mock(); - mock.Setup(m => m.Build(It.IsAny(), It.IsAny>(), It.IsAny>())).Returns(new ResourceObject()); - _builder = new TestDocumentBuilder(mock.Object); + mock.Setup(m => m.Build(It.IsAny(), It.IsAny>(), It.IsAny>())).Returns(new ResourceObject()); + _builder = new TestSerializer(mock.Object); } [Fact] - public void EntityToDocument_NullEntity_CanBuild() + public void ResourceToDocument_NullResource_CanBuild() { // Act var document = _builder.Build((TestResource) null); @@ -32,13 +34,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); @@ -47,7 +49,7 @@ public void EntityToDocument_EmptyList_CanBuild() [Fact] - public void EntityToDocument_SingleEntity_CanBuild() + public void ResourceToDocument_SingleResource_CanBuild() { // Arrange IIdentifiable dummy = new DummyResource(); @@ -61,13 +63,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 c6911cb1f5..09e10ab8c3 100644 --- a/test/UnitTests/Serialization/Common/DocumentParserTests.cs +++ b/test/UnitTests/Serialization/Common/DocumentParserTests.cs @@ -3,21 +3,21 @@ using System.Collections.Generic; using System.ComponentModel.Design; using System.Linq; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; using Newtonsoft.Json; -using Xunit; using UnitTests.TestModels; +using Xunit; namespace UnitTests.Serialization.Deserializer { public sealed class BaseDocumentParserTests : DeserializerTestsSetup { - private readonly TestDocumentParser _deserializer; + private readonly TestDeserializer _deserializer; public BaseDocumentParserTests() { - _deserializer = new TestDocumentParser(_resourceGraph, new DefaultResourceFactory(new ServiceContainer())); + _deserializer = new TestDeserializer(_resourceGraph, new ResourceFactory(new ServiceContainer())); } [Fact] @@ -73,7 +73,7 @@ public void DeserializeResourceIdentifiers_ArrayData_CanDeserialize() var body = JsonConvert.SerializeObject(content); // Act - var result = (List)_deserializer.Deserialize(body); + var result = (IIdentifiable[])_deserializer.Deserialize(body); // Assert Assert.Equal("1", result.First().StringId); @@ -130,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.PublicAttributeName == member).PropertyInfo; - var deserializedValue = pi.GetValue(entity); + var pi = _resourceGraph.GetResourceContext("testResource").Attributes.Single(attr => attr.PublicName == member).Property; + 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..58d380cee9 100644 --- a/test/UnitTests/Serialization/Common/ResourceObjectBuilderTests.cs +++ b/test/UnitTests/Serialization/Common/ResourceObjectBuilderTests.cs @@ -2,10 +2,10 @@ using System.Collections; using System.Collections.Generic; using System.Linq; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Serialization; -using Xunit; +using JsonApiDotNetCore.Serialization.Building; +using JsonApiDotNetCore.Serialization.Objects; using UnitTests.TestModels; +using Xunit; namespace UnitTests.Serialization.Serializer { @@ -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 16c0426b7e..95579b3f9f 100644 --- a/test/UnitTests/Serialization/DeserializerTestsSetup.cs +++ b/test/UnitTests/Serialization/DeserializerTestsSetup.cs @@ -1,41 +1,50 @@ -using System; -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Serialization; using System.Collections.Generic; -using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.AspNetCore.Http; +using Moq; namespace UnitTests.Serialization { public class DeserializerTestsSetup : SerializationTestsSetupBase { - protected sealed class TestDocumentParser : BaseDocumentParser + public Mock _mockHttpContextAccessor; + + public DeserializerTestsSetup() { - public TestDocumentParser(IResourceGraph resourceGraph, IResourceFactory resourceFactory) : base(resourceGraph, resourceFactory) { } + _mockHttpContextAccessor = new Mock(); + _mockHttpContextAccessor.Setup(mock => mock.HttpContext).Returns(new DefaultHttpContext()); + } + protected sealed class TestDeserializer : BaseDeserializer + { + public TestDeserializer(IResourceGraph resourceGraph, IResourceFactory resourceFactory) : base(resourceGraph, resourceFactory) { } - public new object Deserialize(string body) + public object Deserialize(string body) { - return base.Deserialize(body); + return DeserializeBody(body); } - protected override void AfterProcessField(IIdentifiable entity, IResourceField 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() } }; @@ -59,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/SerializationTestsSetupBase.cs b/test/UnitTests/Serialization/SerializationTestsSetupBase.cs index 2c3c85368f..ad51859960 100644 --- a/test/UnitTests/Serialization/SerializationTestsSetupBase.cs +++ b/test/UnitTests/Serialization/SerializationTestsSetupBase.cs @@ -1,7 +1,5 @@ using Bogus; -using JsonApiDotNetCore.Builders; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Internal.Contracts; using Microsoft.Extensions.Logging.Abstractions; using UnitTests.TestModels; using Person = UnitTests.TestModels.Person; @@ -40,25 +38,25 @@ public SerializationTestsSetupBase() protected IResourceGraph BuildGraph() { var resourceGraphBuilder = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance); - resourceGraphBuilder.AddResource("testResource"); - resourceGraphBuilder.AddResource("testResource-with-list"); + resourceGraphBuilder.Add("testResource"); + resourceGraphBuilder.Add("testResource-with-list"); // one to one relationships - resourceGraphBuilder.AddResource("oneToOnePrincipals"); - resourceGraphBuilder.AddResource("oneToOneDependents"); - resourceGraphBuilder.AddResource("oneToOneRequiredDependents"); + resourceGraphBuilder.Add("oneToOnePrincipals"); + resourceGraphBuilder.Add("oneToOneDependents"); + resourceGraphBuilder.Add("oneToOneRequiredDependents"); // one to many relationships - resourceGraphBuilder.AddResource("oneToManyPrincipals"); - resourceGraphBuilder.AddResource("oneToManyDependents"); - resourceGraphBuilder.AddResource("oneToMany-requiredDependents"); + resourceGraphBuilder.Add("oneToManyPrincipals"); + resourceGraphBuilder.Add("oneToManyDependents"); + resourceGraphBuilder.Add("oneToMany-requiredDependents"); // collective relationships - resourceGraphBuilder.AddResource("multiPrincipals"); - resourceGraphBuilder.AddResource("multiDependents"); + resourceGraphBuilder.Add("multiPrincipals"); + resourceGraphBuilder.Add("multiDependents"); - resourceGraphBuilder.AddResource
(); - resourceGraphBuilder.AddResource(); - resourceGraphBuilder.AddResource(); - resourceGraphBuilder.AddResource(); - resourceGraphBuilder.AddResource(); + resourceGraphBuilder.Add
(); + resourceGraphBuilder.Add(); + resourceGraphBuilder.Add(); + resourceGraphBuilder.Add(); + resourceGraphBuilder.Add(); return resourceGraphBuilder.Build(); } diff --git a/test/UnitTests/Serialization/SerializerTestsSetup.cs b/test/UnitTests/Serialization/SerializerTestsSetup.cs index e7b5c0fe9f..840250f799 100644 --- a/test/UnitTests/Serialization/SerializerTestsSetup.cs +++ b/test/UnitTests/Serialization/SerializerTestsSetup.cs @@ -1,13 +1,14 @@ using System; -using System.Collections; using System.Collections.Generic; +using System.Linq; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Models.Links; -using JsonApiDotNetCore.Query; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization; -using JsonApiDotNetCore.Serialization.Server; -using JsonApiDotNetCore.Serialization.Server.Builders; +using JsonApiDotNetCore.Serialization.Building; +using JsonApiDotNetCore.Serialization.Objects; using Moq; namespace UnitTests.Serialization @@ -42,19 +43,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,36 +89,45 @@ 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 = IncludeChainConverter.FromRelationshipChains(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}; } /// /// Minimal implementation of abstract JsonApiSerializer base class, with /// the purpose of testing the business logic for building the document structure. /// - protected sealed class TestDocumentBuilder : BaseDocumentBuilder + protected sealed class TestSerializer : BaseSerializer { - public TestDocumentBuilder(IResourceObjectBuilder resourceObjectBuilder) : base(resourceObjectBuilder) { } + public TestSerializer(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(IReadOnlyCollection 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 ed5bca93b9..798f3b4f79 100644 --- a/test/UnitTests/Serialization/Server/IncludedResourceObjectBuilderTests.cs +++ b/test/UnitTests/Serialization/Server/IncludedResourceObjectBuilderTests.cs @@ -1,11 +1,9 @@ -using JsonApiDotNetCore.Models; -using Xunit; using System.Collections.Generic; using System.Linq; -using JsonApiDotNetCore.Internal.Query; -using JsonApiDotNetCore.Serialization.Server.Builders; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Building; using UnitTests.TestModels; -using Person = UnitTests.TestModels.Person; +using Xunit; namespace UnitTests.Serialization.Server { @@ -155,10 +153,10 @@ 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.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 1094f60b37..e2ca796928 100644 --- a/test/UnitTests/Serialization/Server/RequestDeserializerTests.cs +++ b/test/UnitTests/Serialization/Server/RequestDeserializerTests.cs @@ -1,16 +1,15 @@ using System.Collections.Generic; -using System.Net; using System.ComponentModel.Design; -using JsonApiDotNetCore.Exceptions; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Models; +using System.Net; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization; -using JsonApiDotNetCore.Serialization.Server; +using JsonApiDotNetCore.Serialization.Objects; using Moq; using Newtonsoft.Json; using Xunit; - namespace UnitTests.Serialization.Server { public sealed class RequestDeserializerTests : DeserializerTestsSetup @@ -19,7 +18,7 @@ public sealed class RequestDeserializerTests : DeserializerTestsSetup private readonly Mock _fieldsManagerMock = new Mock(); public RequestDeserializerTests() { - _deserializer = new RequestDeserializer(_resourceGraph, new DefaultResourceFactory(new ServiceContainer()), _fieldsManagerMock.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 80ec869c01..bc50c2ec93 100644 --- a/test/UnitTests/Serialization/Server/ResponseResourceObjectBuilderTests.cs +++ b/test/UnitTests/Serialization/Server/ResponseResourceObjectBuilderTests.cs @@ -1,8 +1,8 @@ using System.Collections.Generic; using System.Linq; -using JsonApiDotNetCore.Models; -using Xunit; +using JsonApiDotNetCore.Resources.Annotations; using UnitTests.TestModels; +using Xunit; namespace UnitTests.Serialization.Server { @@ -13,18 +13,18 @@ public sealed class ResponseResourceObjectBuilderTests : SerializerTestsSetup public ResponseResourceObjectBuilderTests() { - _relationshipsForBuild = _resourceGraph.GetRelationships(e => new { e.Dependents }); + _relationshipsForBuild = _resourceGraph.GetRelationships(e => new { e.Dependents }).ToList(); } [Fact] 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 d210ab4ca2..665b4b9647 100644 --- a/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs +++ b/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs @@ -2,11 +2,11 @@ using System.Linq; using System.Net; using System.Text.RegularExpressions; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Models.JsonApiDocuments; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Objects; using Newtonsoft.Json; -using Xunit; using UnitTests.TestModels; +using Xunit; namespace UnitTests.Serialization.Server { @@ -16,15 +16,14 @@ 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 = - @"{ + var expectedFormatted = @"{ ""data"":{ ""type"":""testResource"", ""id"":""1"", @@ -50,15 +49,14 @@ 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 = - @"{ + var expectedFormatted = @"{ ""data"":[{ ""type"":""testResource"", ""id"":""1"", @@ -83,7 +81,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,11 +91,10 @@ public void SerializeSingle_ResourceWithIncludedRelationships_CanSerialize() var serializer = GetResponseSerializer(inclusionChains: chain); // Act - string serialized = serializer.SerializeSingle(entity); + string serialized = serializer.SerializeSingle(resource); // Assert - var expectedFormatted = - @"{ + var expectedFormatted = @"{ ""data"":{ ""type"":""multiPrincipals"", ""id"":""1"", @@ -144,32 +141,31 @@ 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() - .Select(r => - { - var chain = new List { r }; - if (r.PublicRelationshipName != "populatedToManies") - return new List { r }; - chain.AddRange(_resourceGraph.GetRelationships()); - return chain; - }).ToList(); + .Select(r => + { + var chain = new List {r}; + if (r.PublicName != "populatedToManies") + return new List {r}; + chain.AddRange(_resourceGraph.GetRelationships()); + return chain; + }).ToList(); var serializer = GetResponseSerializer(inclusionChains: chains); // Act - string serialized = serializer.SerializeSingle(entity); + string serialized = serializer.SerializeSingle(resource); // Assert - var expectedFormatted = - @"{ + var expectedFormatted = @"{ ""data"":{ ""type"":""multiPrincipals"", ""id"":""10"", @@ -262,21 +258,20 @@ 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 = - @"{ + var expectedFormatted = @"{ ""links"":{ ""self"":""http://www.dummy.com/dummy-self-link"", - ""next"":""http://www.dummy.com/dummy-next-link"", - ""prev"":""http://www.dummy.com/dummy-prev-link"", ""first"":""http://www.dummy.com/dummy-first-link"", - ""last"":""http://www.dummy.com/dummy-last-link"" + ""last"":""http://www.dummy.com/dummy-last-link"", + ""prev"":""http://www.dummy.com/dummy-prev-link"", + ""next"":""http://www.dummy.com/dummy-next-link"" }, ""data"":{ ""type"":""oneToManyPrincipals"", @@ -307,15 +302,14 @@ 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 = - @"{ + var expectedFormatted = @"{ ""meta"":{ ""test"": ""meta"" }, ""data"":{ ""type"":""oneToManyPrincipals"", @@ -341,15 +335,14 @@ public void SerializeSingle_NullWithLinksAndMeta_StillShowsLinksAndMeta() string serialized = serializer.SerializeSingle(null); // Assert - var expectedFormatted = - @"{ + var expectedFormatted = @"{ ""meta"":{ ""test"": ""meta"" }, ""links"":{ ""self"":""http://www.dummy.com/dummy-self-link"", - ""next"":""http://www.dummy.com/dummy-next-link"", - ""prev"":""http://www.dummy.com/dummy-prev-link"", ""first"":""http://www.dummy.com/dummy-first-link"", - ""last"":""http://www.dummy.com/dummy-last-link"" + ""last"":""http://www.dummy.com/dummy-last-link"", + ""prev"":""http://www.dummy.com/dummy-prev-link"", + ""next"":""http://www.dummy.com/dummy-next-link"" }, ""data"": null }"; @@ -362,13 +355,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,18 +373,17 @@ 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 = - @"{ + var expectedFormatted = @"{ ""data"":{ ""type"":""oneToOneDependents"", ""id"":""1"" @@ -407,14 +399,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,18 +418,17 @@ 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 = - @"{ + var expectedFormatted = @"{ ""data"":[{ ""type"":""oneToManyDependents"", ""id"":""1"" diff --git a/test/UnitTests/Services/DefaultResourceService_Tests.cs b/test/UnitTests/Services/DefaultResourceService_Tests.cs new file mode 100644 index 0000000000..986db2427e --- /dev/null +++ b/test/UnitTests/Services/DefaultResourceService_Tests.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.Design; +using System.Linq; +using System.Threading.Tasks; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Internal; +using JsonApiDotNetCore.Repositories; +using JsonApiDotNetCore.Resources; +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) + .Add() + .Add() + .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 request = new JsonApiRequest + { + 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, request, changeTracker, resourceFactory, null); + } + } +} diff --git a/test/UnitTests/Services/EntityResourceService_Tests.cs b/test/UnitTests/Services/EntityResourceService_Tests.cs deleted file mode 100644 index 4482a08db5..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; -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 c05d99df92..28d36773b9 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.Resources; +using JsonApiDotNetCore.Resources.Annotations; namespace UnitTests.TestModels { @@ -13,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(Capabilities = AttrCapabilities.All & ~AttrCapabilities.AllowChange)] public string Immutable { get; set; } } public class TestResourceWithList : Identifiable @@ -92,7 +93,7 @@ public class Article : Identifiable [HasOne] public Person Reviewer { get; set; } [HasOne] public Person Author { get; set; } - [HasOne(canInclude: false)] public Person CannotInclude { get; set; } + [HasOne(CanInclude = false)] public Person CannotInclude { get; set; } } public class Person : Identifiable diff --git a/test/UnitTests/TestScopedServiceProvider.cs b/test/UnitTests/TestScopedServiceProvider.cs index c770ce681e..207af5d59d 100644 --- a/test/UnitTests/TestScopedServiceProvider.cs +++ b/test/UnitTests/TestScopedServiceProvider.cs @@ -1,11 +1,11 @@ -using JsonApiDotNetCore.Services; +using System; +using JsonApiDotNetCore.Configuration; using Microsoft.AspNetCore.Http; using Moq; -using System; namespace UnitTests { - public sealed class TestScopedServiceProvider : IScopedServiceProvider + public sealed class TestScopedServiceProvider : IRequestScopedServiceProvider { private readonly IServiceProvider _serviceProvider; private readonly Mock _httpContextAccessorMock = new Mock(); diff --git a/test/UnitTests/UnitTests.csproj b/test/UnitTests/UnitTests.csproj index f9c5e4b14a..7d4cc30981 100644 --- a/test/UnitTests/UnitTests.csproj +++ b/test/UnitTests/UnitTests.csproj @@ -15,7 +15,9 @@ + + From 4bbd1b6331649b4af2c63085b95ee48141394508 Mon Sep 17 00:00:00 2001 From: maurei Date: Thu, 10 Sep 2020 17:46:26 +0200 Subject: [PATCH 2/5] fix: remove filter --- .../JsonApiApplicationBuilder.cs | 2 - .../AsyncResourceTypeMatchFilter.cs | 75 ------------------- .../IAsyncResourceTypeMatchFilter.cs | 9 --- .../Serialization/JsonApiReader.cs | 66 ++++++++++++++-- .../Spec/ResourceTypeMismatchTests.cs | 24 ++++++ 5 files changed, 84 insertions(+), 92 deletions(-) delete mode 100644 src/JsonApiDotNetCore/Middleware/AsyncResourceTypeMatchFilter.cs delete mode 100644 src/JsonApiDotNetCore/Middleware/IAsyncResourceTypeMatchFilter.cs diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs index a550a06e7e..d26b72dd69 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs @@ -92,7 +92,6 @@ public void ConfigureMvc() { options.EnableEndpointRouting = true; options.Filters.AddService(); - options.Filters.AddService(); options.Filters.AddService(); options.Filters.AddService(); ConfigureMvcOptions?.Invoke(options); @@ -159,7 +158,6 @@ private void AddMiddlewareLayer() _services.AddSingleton(this); _services.TryAddSingleton(); _services.TryAddScoped(); - _services.TryAddScoped(); _services.TryAddScoped(); _services.TryAddScoped(); _services.TryAddSingleton(); diff --git a/src/JsonApiDotNetCore/Middleware/AsyncResourceTypeMatchFilter.cs b/src/JsonApiDotNetCore/Middleware/AsyncResourceTypeMatchFilter.cs deleted file mode 100644 index 087f295e79..0000000000 --- a/src/JsonApiDotNetCore/Middleware/AsyncResourceTypeMatchFilter.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System; -using System.Collections; -using System.Linq; -using System.Net.Http; -using System.Threading.Tasks; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Errors; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc.Filters; - -namespace JsonApiDotNetCore.Middleware -{ - /// - public sealed class AsyncResourceTypeMatchFilter : IAsyncResourceTypeMatchFilter - { - private readonly IResourceContextProvider _provider; - private readonly IJsonApiRequest _jsonApiRequest; - - public AsyncResourceTypeMatchFilter(IResourceContextProvider provider, IJsonApiRequest jsonApiRequest) - { - _provider = provider ?? throw new ArgumentNullException(nameof(provider)); - _jsonApiRequest = jsonApiRequest ?? throw new ArgumentNullException(nameof(provider)); - } - - /// - public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) - { - if (context == null) throw new ArgumentNullException(nameof(context)); - if (next == null) throw new ArgumentNullException(nameof(next)); - - if (context.HttpContext.IsJsonApiRequest() && IsPatchOrPostRequest(context.HttpContext.Request)) - { - var resourceTypeFromEndpoint = GetEndpointResourceType(); - var resourceTypeFromBody = GetBodyResourceType(context); - - if (resourceTypeFromBody != null && resourceTypeFromEndpoint != null && resourceTypeFromBody != resourceTypeFromEndpoint) - { - var resourceFromEndpoint = _provider.GetResourceContext(resourceTypeFromEndpoint); - var resourceFromBody = _provider.GetResourceContext(resourceTypeFromBody); - - throw new ResourceTypeMismatchException(new HttpMethod(context.HttpContext.Request.Method), context.HttpContext.Request.Path, - resourceFromEndpoint, resourceFromBody); - } - } - - await next(); - } - - private bool IsPatchOrPostRequest(HttpRequest request) - { - return request.Method == HttpMethods.Patch || request.Method == HttpMethods.Post; - } - - private Type GetBodyResourceType(ActionExecutingContext context) - { - var deserializedValue = context.ActionArguments.LastOrDefault().Value; - if (deserializedValue is IList resourceCollection && resourceCollection.Count > 0) - { - return resourceCollection[0].GetType(); - } - - return deserializedValue?.GetType(); - } - - private Type GetEndpointResourceType() - { - if (_jsonApiRequest.Kind == EndpointKind.Primary) - { - return _jsonApiRequest.PrimaryResource.ResourceType; - } - - return _jsonApiRequest.SecondaryResource?.ResourceType; - } - } -} diff --git a/src/JsonApiDotNetCore/Middleware/IAsyncResourceTypeMatchFilter.cs b/src/JsonApiDotNetCore/Middleware/IAsyncResourceTypeMatchFilter.cs deleted file mode 100644 index 405c7b30a1..0000000000 --- a/src/JsonApiDotNetCore/Middleware/IAsyncResourceTypeMatchFilter.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Microsoft.AspNetCore.Mvc.Filters; - -namespace JsonApiDotNetCore.Middleware -{ - /// - /// Verifies the incoming resource type in json:api request body matches the resource type at the current endpoint URL. - /// - public interface IAsyncResourceTypeMatchFilter : IAsyncActionFilter { } -} diff --git a/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs b/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs index 12e8c95bd0..5fe4805a9f 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs @@ -1,11 +1,16 @@ using System; using System.Collections; +using System.Collections.Generic; using System.IO; +using System.Net.Http; +using System.Linq; using System.Threading.Tasks; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.Extensions.Logging; @@ -16,17 +21,20 @@ namespace JsonApiDotNetCore.Serialization public class JsonApiReader : IJsonApiReader { private readonly IJsonApiDeserializer _deserializer; - private readonly IJsonApiRequest _request; + private readonly IJsonApiRequest _jsonApiRequest; + private readonly IResourceContextProvider _resourceContextProvider; private readonly TraceLogWriter _traceWriter; public JsonApiReader(IJsonApiDeserializer deserializer, - IJsonApiRequest request, + IJsonApiRequest jsonApiRequest, + IResourceContextProvider resourceContextProvider, ILoggerFactory loggerFactory) { if (loggerFactory == null) throw new ArgumentNullException(nameof(loggerFactory)); _deserializer = deserializer ?? throw new ArgumentNullException(nameof(deserializer)); - _request = request ?? throw new ArgumentNullException(nameof(request)); + _jsonApiRequest = jsonApiRequest ?? throw new ArgumentNullException(nameof(jsonApiRequest)); + _resourceContextProvider = resourceContextProvider; _traceWriter = new TraceLogWriter(loggerFactory); } @@ -63,12 +71,36 @@ public async Task ReadAsync(InputFormatterContext context) ValidatePatchRequestIncludesId(context, model, body); + ValidateIncomingResourceType(context, model); + return await InputFormatterResult.SuccessAsync(model); } + private void ValidateIncomingResourceType(InputFormatterContext context, object model) + { + if (context.HttpContext.IsJsonApiRequest() && IsPatchOrPostRequest(context.HttpContext.Request)) + { + var endPointResourceTypes = GetEndpointResourceType(); + var bodyResourceTypes = GetBodyResourceTypes(model); + + foreach (var bodyResourceType in bodyResourceTypes) + { + if (bodyResourceType != null && endPointResourceTypes != null && bodyResourceType != endPointResourceTypes) + { + var resourceFromEndpoint = _resourceContextProvider.GetResourceContext(endPointResourceTypes); + var resourceFromBody = _resourceContextProvider.GetResourceContext(bodyResourceType); + + throw new ResourceTypeMismatchException(new HttpMethod(context.HttpContext.Request.Method), + context.HttpContext.Request.Path, + resourceFromEndpoint, resourceFromBody); + } + } + } + } + private void ValidatePatchRequestIncludesId(InputFormatterContext context, object model, string body) { - if (context.HttpContext.Request.Method == "PATCH") + if (context.HttpContext.Request.Method == HttpMethods.Patch) { bool hasMissingId = model is IList list ? HasMissingId(list) : HasMissingId(model); if (hasMissingId) @@ -76,9 +108,9 @@ private void ValidatePatchRequestIncludesId(InputFormatterContext context, objec throw new InvalidRequestBodyException("Payload must include 'id' element.", null, body); } - if (_request.Kind == EndpointKind.Primary && TryGetId(model, out var bodyId) && bodyId != _request.PrimaryId) + if (_jsonApiRequest.Kind == EndpointKind.Primary && TryGetId(model, out var bodyId) && bodyId != _jsonApiRequest.PrimaryId) { - throw new ResourceIdMismatchException(bodyId, _request.PrimaryId, context.HttpContext.Request.GetDisplayUrl()); + throw new ResourceIdMismatchException(bodyId, _jsonApiRequest.PrimaryId, context.HttpContext.Request.GetDisplayUrl()); } } } @@ -134,5 +166,27 @@ private async Task GetRequestBody(Stream body) // https://github.com/aspnet/AspNetCore/issues/7644 return await reader.ReadToEndAsync(); } + + private bool IsPatchOrPostRequest(HttpRequest request) + { + return request.Method == HttpMethods.Patch || request.Method == HttpMethods.Post; + } + + private IEnumerable GetBodyResourceTypes(object model) + { + if (model is ICollection resourceCollection) + { + return resourceCollection.Select(r => r.GetType()).Distinct(); + } + + return model == null ? new Type[0] : new[] { model.GetType() }; + } + + private Type GetEndpointResourceType() + { + return _jsonApiRequest.Kind == EndpointKind.Primary + ? _jsonApiRequest.PrimaryResource.ResourceType + : _jsonApiRequest.SecondaryResource?.ResourceType; + } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ResourceTypeMismatchTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ResourceTypeMismatchTests.cs index c6d235d128..141c4ab96c 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ResourceTypeMismatchTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ResourceTypeMismatchTests.cs @@ -80,5 +80,29 @@ public async Task Patching_Through_Relationship_Link_With_Mismatching_Resource_T Assert.Equal("Resource type mismatch between request body and endpoint URL.", errorDocument.Errors[0].Title); Assert.Equal("Expected resource of type 'people' in PATCH request body at endpoint '/api/v1/todoItems/1/relationships/owner', instead of 'todoItems'.", errorDocument.Errors[0].Detail); } + + [Fact] + public async Task Patching_Through_Relationship_Link_With_Multiple_Resources_Types_Returns_Conflict() + { + // Arrange + string content = JsonConvert.SerializeObject(new + { + data = new object[] + { + new { type = "todoItems", id = 1 }, + new { type = "articles", id = 2 }, + } + }); + + // Act + var (body, _) = await Patch("/api/v1/todoItems/1/relationships/childrenTodos", content); + + // Assert + var errorDocument = JsonConvert.DeserializeObject(body); + Assert.Single(errorDocument.Errors); + Assert.Equal(HttpStatusCode.Conflict, errorDocument.Errors[0].StatusCode); + Assert.Equal("Resource type mismatch between request body and endpoint URL.", errorDocument.Errors[0].Title); + Assert.Equal("Expected resource of type 'todoItems' in PATCH request body at endpoint '/api/v1/todoItems/1/relationships/childrenTodos', instead of 'articles'.", errorDocument.Errors[0].Detail); + } } } From f27e020418c5f08762f57a85f221f3aa698019b8 Mon Sep 17 00:00:00 2001 From: maurei Date: Thu, 10 Sep 2020 17:49:20 +0200 Subject: [PATCH 3/5] fix: formatting --- src/JsonApiDotNetCore/Serialization/JsonApiReader.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs b/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs index 5fe4805a9f..0f05e6a816 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs @@ -80,14 +80,14 @@ private void ValidateIncomingResourceType(InputFormatterContext context, object { if (context.HttpContext.IsJsonApiRequest() && IsPatchOrPostRequest(context.HttpContext.Request)) { - var endPointResourceTypes = GetEndpointResourceType(); + var endPointResourceType = GetEndpointResourceType(); var bodyResourceTypes = GetBodyResourceTypes(model); foreach (var bodyResourceType in bodyResourceTypes) { - if (bodyResourceType != null && endPointResourceTypes != null && bodyResourceType != endPointResourceTypes) + if (bodyResourceType != null && endPointResourceType != null && bodyResourceType != endPointResourceType) { - var resourceFromEndpoint = _resourceContextProvider.GetResourceContext(endPointResourceTypes); + var resourceFromEndpoint = _resourceContextProvider.GetResourceContext(endPointResourceType); var resourceFromBody = _resourceContextProvider.GetResourceContext(bodyResourceType); throw new ResourceTypeMismatchException(new HttpMethod(context.HttpContext.Request.Method), From 11a49a9a182ae39d0540ce3696fb3efbea7f4b73 Mon Sep 17 00:00:00 2001 From: maurei Date: Mon, 14 Sep 2020 17:35:59 +0200 Subject: [PATCH 4/5] chore: review --- .../Serialization/JsonApiReader.cs | 30 +++++++++++-------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs b/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs index 0f05e6a816..4d3010aeef 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs @@ -21,20 +21,20 @@ namespace JsonApiDotNetCore.Serialization public class JsonApiReader : IJsonApiReader { private readonly IJsonApiDeserializer _deserializer; - private readonly IJsonApiRequest _jsonApiRequest; + private readonly IJsonApiRequest _request; private readonly IResourceContextProvider _resourceContextProvider; private readonly TraceLogWriter _traceWriter; public JsonApiReader(IJsonApiDeserializer deserializer, - IJsonApiRequest jsonApiRequest, + IJsonApiRequest request, IResourceContextProvider resourceContextProvider, ILoggerFactory loggerFactory) { if (loggerFactory == null) throw new ArgumentNullException(nameof(loggerFactory)); _deserializer = deserializer ?? throw new ArgumentNullException(nameof(deserializer)); - _jsonApiRequest = jsonApiRequest ?? throw new ArgumentNullException(nameof(jsonApiRequest)); - _resourceContextProvider = resourceContextProvider; + _request = request ?? throw new ArgumentNullException(nameof(request)); + _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); _traceWriter = new TraceLogWriter(loggerFactory); } @@ -80,14 +80,18 @@ private void ValidateIncomingResourceType(InputFormatterContext context, object { if (context.HttpContext.IsJsonApiRequest() && IsPatchOrPostRequest(context.HttpContext.Request)) { - var endPointResourceType = GetEndpointResourceType(); + var endpointResourceType = GetEndpointResourceType(); + if (endpointResourceType == null) + { + return; + } + var bodyResourceTypes = GetBodyResourceTypes(model); - foreach (var bodyResourceType in bodyResourceTypes) { - if (bodyResourceType != null && endPointResourceType != null && bodyResourceType != endPointResourceType) + if (bodyResourceType != endpointResourceType) { - var resourceFromEndpoint = _resourceContextProvider.GetResourceContext(endPointResourceType); + var resourceFromEndpoint = _resourceContextProvider.GetResourceContext(endpointResourceType); var resourceFromBody = _resourceContextProvider.GetResourceContext(bodyResourceType); throw new ResourceTypeMismatchException(new HttpMethod(context.HttpContext.Request.Method), @@ -108,9 +112,9 @@ private void ValidatePatchRequestIncludesId(InputFormatterContext context, objec throw new InvalidRequestBodyException("Payload must include 'id' element.", null, body); } - if (_jsonApiRequest.Kind == EndpointKind.Primary && TryGetId(model, out var bodyId) && bodyId != _jsonApiRequest.PrimaryId) + if (_request.Kind == EndpointKind.Primary && TryGetId(model, out var bodyId) && bodyId != _request.PrimaryId) { - throw new ResourceIdMismatchException(bodyId, _jsonApiRequest.PrimaryId, context.HttpContext.Request.GetDisplayUrl()); + throw new ResourceIdMismatchException(bodyId, _request.PrimaryId, context.HttpContext.Request.GetDisplayUrl()); } } } @@ -184,9 +188,9 @@ private IEnumerable GetBodyResourceTypes(object model) private Type GetEndpointResourceType() { - return _jsonApiRequest.Kind == EndpointKind.Primary - ? _jsonApiRequest.PrimaryResource.ResourceType - : _jsonApiRequest.SecondaryResource?.ResourceType; + return _request.Kind == EndpointKind.Primary + ? _request.PrimaryResource.ResourceType + : _request.SecondaryResource?.ResourceType; } } } From 46e4f4ca0b43292c922e7cd6cdf7e7c7af4dd4eb Mon Sep 17 00:00:00 2001 From: maurei Date: Tue, 15 Sep 2020 12:27:58 +0200 Subject: [PATCH 5/5] fix: icollection --- src/JsonApiDotNetCore/Serialization/JsonApiReader.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs b/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs index 4d3010aeef..3ec623964d 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs @@ -178,7 +178,7 @@ private bool IsPatchOrPostRequest(HttpRequest request) private IEnumerable GetBodyResourceTypes(object model) { - if (model is ICollection resourceCollection) + if (model is IEnumerable resourceCollection) { return resourceCollection.Select(r => r.GetType()).Distinct(); }