From 19f655704c91fa89f5b7561e1dd66586d9b521ee Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Wed, 8 Dec 2021 13:19:03 +0100 Subject: [PATCH] Update to .NET 6 with EF Core 6 (#1122) * Update to .NET 6 with EF Core 6 * Adapt to changes in nullability annotations * Adapt for breaking changes in PostgreSQL provider for EF Core 6 * Cleanup tests for handling special characters * Removed workaround for https://github.com/dotnet/efcore/issues/21026 * Removed workaround for https://github.com/dotnet/aspnetcore/issues/33394 * Removed workaround for https://github.com/dotnet/aspnetcore/issues/32097 * Removed workaround for https://github.com/dotnet/efcore/issues/21234 * Updated to latest Resharper version and removed workarounds for earlier versions * Applied new Resharper suggestions * Package updates * Renamed MSBuild variables * Inlined MSBuild variables that are used only once * Removed .BeCloseTo, now that fakers truncate time to whole milliseconds. Removed runtime casts, because the JSON deserializer now creates the correct types (based on the resource graph). * Narrow service scope lifetime * Enable registered services to dispose asynchronously, where possible * Workaround for bug in cleanupcode * Fixed detection of implicit many-to-many join entity in EF Core 6 * Activate implicit usings * Switched to file-scoped namespaces * Reformat solution * Added [NoResource] to suppress startup warning * Use Minimal Hosting APIs * Removed duplicate code * Corrected terminology for generic type usage * Fixed warning: Type 'KnownResource' does not contain any attributes * Updated roadmap and version table * Fixed: Override IIdentifiable.Id with custom capabilities no longer worked * Review feedback --- .config/dotnet-tools.json | 4 +- Directory.Build.props | 18 +- JsonApiDotNetCore.sln.DotSettings | 5 +- README.md | 41 +- ROADMAP.md | 2 +- benchmarks/Benchmarks.csproj | 2 +- .../DeserializationBenchmarkBase.cs | 155 +- .../OperationsDeserializationBenchmarks.cs | 420 +- .../ResourceDeserializationBenchmarks.cs | 216 +- benchmarks/Program.cs | 25 +- .../QueryStringParserBenchmarks.cs | 129 +- benchmarks/QueryString/QueryableResource.cs | 17 +- .../OperationsSerializationBenchmarks.cs | 221 +- .../ResourceSerializationBenchmarks.cs | 233 +- .../SerializationBenchmarkBase.cs | 363 +- docs/getting-started/step-by-step.md | 88 +- docs/usage/errors.md | 9 +- docs/usage/extensibility/layer-overview.md | 19 +- docs/usage/extensibility/middleware.md | 78 +- docs/usage/extensibility/query-strings.md | 8 +- docs/usage/extensibility/repositories.md | 37 +- .../extensibility/resource-definitions.md | 11 +- docs/usage/extensibility/services.md | 35 +- docs/usage/meta.md | 7 +- docs/usage/options.md | 16 +- docs/usage/resource-graph.md | 41 +- docs/usage/routing.md | 14 +- docs/usage/writing/bulk-batch-operations.md | 5 +- .../GettingStarted/Data/SampleDbContext.cs | 17 +- .../GettingStarted/GettingStarted.csproj | 5 +- src/Examples/GettingStarted/Models/Book.cs | 23 +- src/Examples/GettingStarted/Models/Person.cs | 20 +- src/Examples/GettingStarted/Program.cs | 78 +- src/Examples/GettingStarted/Startup.cs | 73 - .../Controllers/NonJsonApiController.cs | 79 +- .../Controllers/OperationsController.cs | 14 +- .../Data/AppDbContext.cs | 39 +- .../Definitions/TodoItemDefinition.cs | 61 +- .../JsonApiDotNetCoreExample.csproj | 4 +- .../JsonApiDotNetCoreExample/Models/Person.cs | 24 +- .../JsonApiDotNetCoreExample/Models/Tag.cs | 22 +- .../Models/TodoItem.cs | 43 +- .../Models/TodoItemPriority.cs | 15 +- .../JsonApiDotNetCoreExample/Program.cs | 106 +- .../JsonApiDotNetCoreExample/Startup.cs | 92 - .../JsonApiDotNetCoreExample/appsettings.json | 2 +- .../MultiDbContextExample/Data/DbContextA.cs | 17 +- .../MultiDbContextExample/Data/DbContextB.cs | 17 +- .../MultiDbContextExample/Models/ResourceA.cs | 15 +- .../MultiDbContextExample/Models/ResourceB.cs | 15 +- .../MultiDbContextExample.csproj | 2 +- src/Examples/MultiDbContextExample/Program.cs | 83 +- .../Properties/launchSettings.json | 4 +- .../Repositories/DbContextARepository.cs | 21 +- .../Repositories/DbContextBRepository.cs | 21 +- src/Examples/MultiDbContextExample/Startup.cs | 79 - .../Data/AppDbContext.cs | 17 +- .../Models/WorkItem.cs | 28 +- .../NoEntityFrameworkExample.csproj | 6 +- .../NoEntityFrameworkExample/Program.cs | 56 +- .../Properties/launchSettings.json | 4 +- .../Services/WorkItemService.cs | 163 +- .../NoEntityFrameworkExample/Startup.cs | 45 - src/Examples/ReportsExample/Models/Report.cs | 19 +- .../ReportsExample/Models/ReportStatistics.cs | 13 +- src/Examples/ReportsExample/Program.cs | 34 +- .../ReportsExample/ReportsExample.csproj | 4 +- .../ReportsExample/Services/ReportService.cs | 57 +- src/Examples/ReportsExample/Startup.cs | 23 - .../JsonApiDotNetCore.SourceGenerators.csproj | 2 +- .../SourceCodeWriter.cs | 19 +- src/JsonApiDotNetCore/ArgumentGuard.cs | 52 +- src/JsonApiDotNetCore/ArrayFactory.cs | 11 +- .../EntityFrameworkCoreTransaction.cs | 95 +- .../EntityFrameworkCoreTransactionFactory.cs | 49 +- .../AtomicOperations/ILocalIdTracker.cs | 41 +- .../IOperationProcessorAccessor.cs | 19 +- .../AtomicOperations/IOperationsProcessor.cs | 20 +- .../IOperationsTransaction.cs | 46 +- .../IOperationsTransactionFactory.cs | 18 +- .../AtomicOperations/LocalIdTracker.cs | 137 +- .../AtomicOperations/LocalIdValidator.cs | 136 +- .../MissingTransactionFactory.cs | 25 +- .../OperationProcessorAccessor.cs | 112 +- .../AtomicOperations/OperationsProcessor.cs | 211 +- .../Processors/AddToRelationshipProcessor.cs | 44 +- .../Processors/CreateProcessor.cs | 55 +- .../Processors/DeleteProcessor.cs | 41 +- .../Processors/IAddToRelationshipProcessor.cs | 29 +- .../Processors/ICreateProcessor.cs | 29 +- .../Processors/IDeleteProcessor.cs | 29 +- .../Processors/IOperationProcessor.cs | 19 +- .../IRemoveFromRelationshipProcessor.cs | 21 +- .../Processors/ISetRelationshipProcessor.cs | 29 +- .../Processors/IUpdateProcessor.cs | 31 +- .../RemoveFromRelationshipProcessor.cs | 44 +- .../Processors/SetRelationshipProcessor.cs | 67 +- .../Processors/UpdateProcessor.cs | 41 +- .../RevertRequestStateOnDispose.cs | 50 +- src/JsonApiDotNetCore/CollectionConverter.cs | 188 +- src/JsonApiDotNetCore/CollectionExtensions.cs | 124 +- .../ApplicationBuilderExtensions.cs | 65 +- .../IInverseNavigationResolver.cs | 23 +- .../IJsonApiApplicationBuilder.cs | 10 +- .../Configuration/IJsonApiOptions.cs | 322 +- .../Configuration/IResourceGraph.cs | 143 +- .../InverseNavigationResolver.cs | 91 +- .../JsonApiApplicationBuilder.cs | 440 +- .../JsonApiModelMetadataProvider.cs | 57 +- .../Configuration/JsonApiOptions.cs | 164 +- .../Configuration/JsonApiValidationFilter.cs | 107 +- .../Configuration/PageNumber.cs | 70 +- .../Configuration/PageSize.cs | 68 +- .../Configuration/ResourceDescriptor.cs | 23 +- .../ResourceDescriptorAssemblyCache.cs | 74 +- .../Configuration/ResourceGraph.cs | 287 +- .../Configuration/ResourceGraphBuilder.cs | 493 ++- .../Configuration/ResourceNameFormatter.cs | 44 +- .../Configuration/ResourceType.cs | 361 +- .../ServiceCollectionExtensions.cs | 196 +- .../Configuration/ServiceDiscoveryFacade.cs | 250 +- .../Configuration/TypeLocator.cs | 258 +- .../DisableQueryStringAttribute.cs | 88 +- .../DisableRoutingConventionAttribute.cs | 26 +- .../Controllers/BaseJsonApiController.cs | 643 ++- .../BaseJsonApiOperationsController.cs | 321 +- .../Controllers/CoreJsonApiController.cs | 51 +- .../Controllers/JsonApiCommandController.cs | 39 +- .../Controllers/JsonApiController.cs | 196 +- .../Controllers/JsonApiEndpoints.cs | 42 +- .../JsonApiOperationsController.cs | 36 +- .../Controllers/JsonApiQueryController.cs | 37 +- .../Diagnostics/AspNetCodeTimerSession.cs | 118 +- .../Diagnostics/CascadingCodeTimer.cs | 398 +- .../Diagnostics/CodeTimingSessionManager.cs | 121 +- .../Diagnostics/DefaultCodeTimerSession.cs | 68 +- .../Diagnostics/DisabledCodeTimer.cs | 41 +- .../Diagnostics/ICodeTimer.cs | 39 +- .../Diagnostics/ICodeTimerSession.cs | 17 +- .../Diagnostics/MeasurementSettings.cs | 11 +- ...annotClearRequiredRelationshipException.cs | 29 +- .../Errors/DuplicateLocalIdValueException.cs | 27 +- .../Errors/FailedOperationException.cs | 36 +- .../IncompatibleLocalIdTypeException.cs | 27 +- .../Errors/InvalidConfigurationException.cs | 20 +- .../Errors/InvalidModelStateException.cs | 537 ++- .../Errors/InvalidQueryException.cs | 28 +- .../InvalidQueryStringParameterException.cs | 40 +- .../Errors/InvalidRequestBodyException.cs | 55 +- .../Errors/JsonApiException.cs | 84 +- .../Errors/LocalIdSingleOperationException.cs | 27 +- .../Errors/MissingResourceInRelationship.cs | 31 +- .../MissingTransactionSupportException.cs | 27 +- .../NonParticipatingTransactionException.cs | 27 +- .../Errors/RelationshipNotFoundException.cs | 27 +- .../Errors/ResourceAlreadyExistsException.cs | 27 +- .../Errors/ResourceNotFoundException.cs | 27 +- ...sourcesInRelationshipsNotFoundException.cs | 37 +- .../Errors/RouteNotAvailableException.cs | 32 +- .../Errors/UnknownLocalIdValueException.cs | 27 +- .../UnsuccessfulActionResultException.cs | 73 +- .../JsonApiDotNetCore.csproj | 6 +- .../AsyncConvertEmptyActionResultFilter.cs | 34 +- .../Middleware/AsyncJsonApiExceptionFilter.cs | 47 +- .../AsyncQueryStringActionFilter.cs | 42 +- .../Middleware/EndpointKind.cs | 39 +- .../Middleware/ExceptionHandler.cs | 142 +- .../Middleware/FixedQueryFeature.cs | 90 - .../Middleware/FixedQueryHelpers.cs | 102 - .../Middleware/HeaderConstants.cs | 13 +- .../Middleware/HttpContextExtensions.cs | 37 +- .../IAsyncConvertEmptyActionResultFilter.cs | 29 +- .../IAsyncJsonApiExceptionFilter.cs | 15 +- .../IAsyncQueryStringActionFilter.cs | 15 +- .../Middleware/IControllerResourceMapping.cs | 26 +- .../Middleware/IExceptionHandler.cs | 17 +- .../Middleware/IJsonApiInputFormatter.cs | 15 +- .../Middleware/IJsonApiOutputFormatter.cs | 15 +- .../Middleware/IJsonApiRequest.cs | 103 +- .../Middleware/IJsonApiRoutingConvention.cs | 15 +- .../Middleware/JsonApiInputFormatter.cs | 32 +- .../Middleware/JsonApiMiddleware.cs | 417 +- .../Middleware/JsonApiOutputFormatter.cs | 30 +- .../Middleware/JsonApiRequest.cs | 73 +- .../Middleware/JsonApiRoutingConvention.cs | 273 +- .../Middleware/TraceLogWriter.cs | 212 +- .../Middleware/WriteOperationKind.cs | 61 +- src/JsonApiDotNetCore/ObjectExtensions.cs | 49 +- .../Queries/ExpressionInScope.cs | 37 +- .../Queries/Expressions/AnyExpression.cs | 109 +- .../Expressions/ComparisonExpression.cs | 80 +- .../Queries/Expressions/ComparisonOperator.cs | 17 +- .../Queries/Expressions/CountExpression.cs | 69 +- .../Queries/Expressions/FilterExpression.cs | 13 +- .../Queries/Expressions/FunctionExpression.cs | 13 +- .../Queries/Expressions/HasExpression.cs | 94 +- .../Expressions/IdentifierExpression.cs | 13 +- .../Expressions/IncludeChainConverter.cs | 244 +- .../Expressions/IncludeElementExpression.cs | 111 +- .../Queries/Expressions/IncludeExpression.cs | 96 +- .../Expressions/LiteralConstantExpression.cs | 71 +- .../Queries/Expressions/LogicalExpression.cs | 123 +- .../Queries/Expressions/LogicalOperator.cs | 11 +- .../Expressions/MatchTextExpression.cs | 92 +- .../Queries/Expressions/NotExpression.cs | 69 +- .../Expressions/NullConstantExpression.cs | 64 +- ...nationElementQueryStringValueExpression.cs | 72 +- .../Expressions/PaginationExpression.cs | 74 +- .../PaginationQueryStringValueExpression.cs | 79 +- .../Queries/Expressions/QueryExpression.cs | 17 +- .../Expressions/QueryExpressionRewriter.cs | 401 +- .../Expressions/QueryExpressionVisitor.cs | 241 +- .../QueryStringParameterScopeExpression.cs | 74 +- .../Expressions/QueryableHandlerExpression.cs | 87 +- .../ResourceFieldChainExpression.cs | 89 +- .../Expressions/SortElementExpression.cs | 112 +- .../Queries/Expressions/SortExpression.cs | 79 +- .../Expressions/SparseFieldSetExpression.cs | 79 +- .../SparseFieldSetExpressionExtensions.cs | 96 +- .../Expressions/SparseFieldTableExpression.cs | 104 +- .../Queries/Expressions/TextMatchKind.cs | 13 +- .../Queries/IPaginationContext.cs | 55 +- .../Queries/IQueryConstraintProvider.cs | 17 +- .../Queries/IQueryLayerComposer.cs | 94 +- .../Queries/Internal/EvaluatedIncludeCache.cs | 29 +- .../Internal/IEvaluatedIncludeCache.cs | 29 +- .../Queries/Internal/ISparseFieldSetCache.cs | 55 +- .../Parsing/FieldChainRequirements.cs | 48 +- .../Queries/Internal/Parsing/FilterParser.cs | 480 ++- .../Queries/Internal/Parsing/IncludeParser.cs | 92 +- .../Queries/Internal/Parsing/Keywords.cs | 39 +- .../Internal/Parsing/PaginationParser.cs | 139 +- .../Internal/Parsing/QueryExpressionParser.cs | 115 +- .../Internal/Parsing/QueryParseException.cs | 14 +- .../QueryStringParameterScopeParser.cs | 96 +- .../Internal/Parsing/QueryTokenizer.cs | 200 +- .../Parsing/ResourceFieldChainResolver.cs | 375 +- .../Queries/Internal/Parsing/SortParser.cs | 117 +- .../Internal/Parsing/SparseFieldSetParser.cs | 75 +- .../Internal/Parsing/SparseFieldTypeParser.cs | 92 +- .../Queries/Internal/Parsing/Token.cs | 29 +- .../Queries/Internal/Parsing/TokenKind.cs | 25 +- .../Queries/Internal/QueryLayerComposer.cs | 804 ++-- .../QueryableBuilding/IncludeClauseBuilder.cs | 115 +- .../LambdaParameterNameFactory.cs | 70 +- .../LambdaParameterNameScope.cs | 34 +- .../Internal/QueryableBuilding/LambdaScope.cs | 44 +- .../QueryableBuilding/LambdaScopeFactory.cs | 30 +- .../QueryableBuilding/OrderClauseBuilder.cs | 101 +- .../QueryableBuilding/QueryClauseBuilder.cs | 136 +- .../QueryableBuilding/QueryableBuilder.cs | 177 +- .../QueryableBuilding/SelectClauseBuilder.cs | 333 +- .../SkipTakeClauseBuilder.cs | 78 +- .../QueryableBuilding/WhereClauseBuilder.cs | 402 +- .../Queries/Internal/SparseFieldSetCache.cs | 233 +- .../Internal/SystemExpressionExtensions.cs | 33 + .../Queries/PaginationContext.cs | 30 +- src/JsonApiDotNetCore/Queries/QueryLayer.cs | 167 +- .../Queries/TopFieldSelection.cs | 33 +- .../IFilterQueryStringParameterReader.cs | 15 +- .../IIncludeQueryStringParameterReader.cs | 15 +- .../IPaginationQueryStringParameterReader.cs | 15 +- .../IQueryStringParameterReader.cs | 41 +- .../QueryStrings/IQueryStringReader.cs | 23 +- .../IRequestQueryStringAccessor.cs | 15 +- ...ourceDefinitionQueryableParameterReader.cs | 17 +- .../ISortQueryStringParameterReader.cs | 15 +- ...parseFieldSetQueryStringParameterReader.cs | 15 +- .../FilterQueryStringParameterReader.cs | 235 +- .../IncludeQueryStringParameterReader.cs | 116 +- .../Internal/LegacyFilterNotationConverter.cs | 204 +- .../PaginationQueryStringParameterReader.cs | 279 +- .../Internal/QueryStringParameterReader.cs | 69 +- .../Internal/QueryStringReader.cs | 100 +- .../Internal/RequestQueryStringAccessor.cs | 36 +- ...ourceDefinitionQueryableParameterReader.cs | 105 +- .../SortQueryStringParameterReader.cs | 135 +- ...parseFieldSetQueryStringParameterReader.cs | 142 +- .../JsonApiQueryStringParameters.cs | 30 +- .../Repositories/DataStoreUpdateException.cs | 20 +- .../Repositories/DbContextExtensions.cs | 85 +- .../Repositories/DbContextResolver.cs | 39 +- .../EntityFrameworkCoreRepository.cs | 788 ++-- .../Repositories/IDbContextResolver.cs | 15 +- .../IRepositorySupportsTransaction.cs | 19 +- .../Repositories/IResourceReadRepository.cs | 44 +- .../Repositories/IResourceRepository.cs | 29 +- .../IResourceRepositoryAccessor.cs | 118 +- .../Repositories/IResourceWriteRepository.cs | 98 +- .../ResourceRepositoryAccessor.cs | 251 +- .../Resources/Annotations/AttrAttribute.cs | 84 +- .../Resources/Annotations/AttrCapabilities.cs | 57 +- .../Annotations/EagerLoadAttribute.cs | 77 +- .../Resources/Annotations/HasManyAttribute.cs | 67 +- .../Resources/Annotations/HasOneAttribute.cs | 65 +- .../Resources/Annotations/LinkTypes.cs | 21 +- .../Annotations/NoResourceAttribute.cs | 13 + .../Annotations/RelationshipAttribute.cs | 162 +- .../Annotations/ResourceAttribute.cs | 42 +- .../Annotations/ResourceFieldAttribute.cs | 180 +- .../Annotations/ResourceLinksAttribute.cs | 46 +- .../Resources/IIdentifiable.cs | 47 +- .../Resources/IResourceChangeTracker.cs | 53 +- .../Resources/IResourceDefinition.cs | 601 ++- .../Resources/IResourceDefinitionAccessor.cs | 198 +- .../Resources/IResourceFactory.cs | 36 +- .../Resources/ITargetedFields.cs | 34 +- .../Resources/Identifiable.cs | 72 +- .../Resources/IdentifiableComparer.cs | 59 +- .../Resources/IdentifiableExtensions.cs | 46 +- .../Internal/RuntimeTypeConverter.cs | 131 +- .../Resources/JsonApiResourceDefinition.cs | 266 +- .../Resources/OperationContainer.cs | 88 +- .../Resources/QueryStringParameterHandlers.cs | 18 +- .../Resources/ResourceChangeTracker.cs | 136 +- .../Resources/ResourceDefinitionAccessor.cs | 323 +- .../Resources/ResourceFactory.cs | 180 +- .../Resources/TargetedFields.cs | 42 +- .../JsonConverters/JsonObjectConverter.cs | 41 +- .../JsonConverters/ResourceObjectConverter.cs | 393 +- .../SingleOrManyDataConverterFactory.cs | 112 +- .../WriteOnlyDocumentConverter.cs | 126 +- .../WriteOnlyRelationshipObjectConverter.cs | 68 +- .../Objects/AtomicOperationCode.cs | 21 +- .../Objects/AtomicOperationObject.cs | 46 +- .../Serialization/Objects/AtomicReference.cs | 39 +- .../Objects/AtomicResultObject.cs | 28 +- .../Serialization/Objects/Document.cs | 76 +- .../Serialization/Objects/ErrorLinks.cs | 27 +- .../Serialization/Objects/ErrorObject.cs | 110 +- .../Serialization/Objects/ErrorSource.cs | 33 +- .../Objects/IResourceIdentity.cs | 13 +- .../Serialization/Objects/JsonApiObject.cs | 40 +- .../Objects/RelationshipLinks.cs | 33 +- .../Objects/RelationshipObject.cs | 34 +- .../Objects/ResourceIdentifierObject.cs | 40 +- .../Serialization/Objects/ResourceLinks.cs | 27 +- .../Serialization/Objects/ResourceObject.cs | 70 +- .../Serialization/Objects/SingleOrManyData.cs | 65 +- .../Serialization/Objects/TopLevelLinks.cs | 77 +- .../Adapters/AtomicOperationObjectAdapter.cs | 231 +- .../Adapters/AtomicReferenceAdapter.cs | 72 +- .../Request/Adapters/AtomicReferenceResult.cs | 35 +- .../Request/Adapters/BaseAdapter.cs | 81 +- .../Request/Adapters/DocumentAdapter.cs | 59 +- .../DocumentInOperationsRequestAdapter.cs | 97 +- ...tInResourceOrRelationshipRequestAdapter.cs | 106 +- .../Adapters/IAtomicOperationObjectAdapter.cs | 17 +- .../Adapters/IAtomicReferenceAdapter.cs | 19 +- .../Request/Adapters/IDocumentAdapter.cs | 63 +- .../IDocumentInOperationsRequestAdapter.cs | 18 +- ...tInResourceOrRelationshipRequestAdapter.cs | 17 +- .../Adapters/IRelationshipDataAdapter.cs | 30 +- .../Request/Adapters/IResourceDataAdapter.cs | 17 +- ...IResourceDataInOperationsRequestAdapter.cs | 19 +- .../IResourceIdentifierObjectAdapter.cs | 17 +- .../Adapters/IResourceObjectAdapter.cs | 21 +- .../Request/Adapters/JsonElementConstraint.cs | 27 +- .../Adapters/RelationshipDataAdapter.cs | 172 +- .../Adapters/RequestAdapterPosition.cs | 100 +- .../Request/Adapters/RequestAdapterState.cs | 90 +- .../Request/Adapters/ResourceDataAdapter.cs | 64 +- .../ResourceDataInOperationsRequestAdapter.cs | 31 +- .../ResourceIdentifierObjectAdapter.cs | 31 +- .../Adapters/ResourceIdentityAdapter.cs | 305 +- .../Adapters/ResourceIdentityRequirements.cs | 51 +- .../Request/Adapters/ResourceObjectAdapter.cs | 216 +- .../Serialization/Request/IJsonApiReader.cs | 22 +- .../Serialization/Request/JsonApiReader.cs | 151 +- .../Request/JsonInvalidAttributeInfo.cs | 40 +- .../Request/ModelConversionException.cs | 40 +- .../Serialization/Response/ETagGenerator.cs | 33 +- .../Response/EmptyResponseMeta.cs | 13 +- .../Response/FingerprintGenerator.cs | 71 +- .../Serialization/Response/IETagGenerator.cs | 35 +- .../Response/IFingerprintGenerator.cs | 20 +- .../Serialization/Response/IJsonApiWriter.cs | 20 +- .../Serialization/Response/ILinkBuilder.cs | 33 +- .../Serialization/Response/IMetaBuilder.cs | 30 +- .../Serialization/Response/IResponseMeta.cs | 20 +- .../Response/IResponseModelAdapter.cs | 81 +- .../Serialization/Response/JsonApiWriter.cs | 244 +- .../Serialization/Response/LinkBuilder.cs | 480 ++- .../Serialization/Response/MetaBuilder.cs | 83 +- .../Response/ResourceObjectTreeNode.cs | 376 +- .../Response/ResponseModelAdapter.cs | 522 ++- .../Services/AsyncCollectionExtensions.cs | 44 +- .../Services/IAddToRelationshipService.cs | 48 +- .../Services/ICreateService.cs | 21 +- .../Services/IDeleteService.cs | 21 +- .../Services/IGetAllService.cs | 22 +- .../Services/IGetByIdService.cs | 21 +- .../Services/IGetRelationshipService.cs | 21 +- .../Services/IGetSecondaryService.cs | 23 +- .../IRemoveFromRelationshipService.cs | 46 +- .../Services/IResourceCommandService.cs | 31 +- .../Services/IResourceQueryService.cs | 29 +- .../Services/IResourceService.cs | 27 +- .../Services/ISetRelationshipService.cs | 45 +- .../Services/IUpdateService.cs | 23 +- .../Services/JsonApiResourceService.cs | 787 ++-- src/JsonApiDotNetCore/TypeExtensions.cs | 88 +- test/DiscoveryTests/DiscoveryTests.csproj | 4 +- test/DiscoveryTests/PrivateResource.cs | 9 +- .../PrivateResourceDefinition.cs | 13 +- .../PrivateResourceRepository.cs | 18 +- test/DiscoveryTests/PrivateResourceService.cs | 18 +- .../ServiceDiscoveryFacadeTests.cs | 231 +- .../Archiving/ArchiveTests.cs | 972 +++-- .../Archiving/BroadcastComment.cs | 24 +- .../Archiving/TelevisionBroadcast.cs | 33 +- .../TelevisionBroadcastDefinition.cs | 258 +- .../Archiving/TelevisionDbContext.cs | 23 +- .../Archiving/TelevisionFakers.cs | 60 +- .../Archiving/TelevisionNetwork.cs | 20 +- .../Archiving/TelevisionStation.cs | 20 +- ...micConstrainedOperationsControllerTests.cs | 289 +- .../CreateMusicTrackOperationsController.cs | 62 +- .../Creating/AtomicCreateResourceTests.cs | 1354 +++--- ...reateResourceWithClientGeneratedIdTests.cs | 376 +- ...eateResourceWithToManyRelationshipTests.cs | 915 ++--- ...reateResourceWithToOneRelationshipTests.cs | 974 +++-- .../Deleting/AtomicDeleteResourceTests.cs | 874 ++-- ...mplicitlyChangingTextLanguageDefinition.cs | 46 +- .../Links/AtomicAbsoluteLinksTests.cs | 233 +- .../AtomicRelativeLinksWithNamespaceTests.cs | 152 +- .../LocalIds/AtomicLocalIdTests.cs | 3645 ++++++++--------- .../AtomicOperations/Lyric.cs | 32 +- .../Meta/AtomicResourceMetaTests.cs | 231 +- .../Meta/AtomicResponseMeta.cs | 30 +- .../Meta/AtomicResponseMetaTests.cs | 214 +- .../Meta/MusicTrackMetaDefinition.cs | 33 +- .../Meta/TextLanguageMetaDefinition.cs | 36 +- .../Mixed/AtomicLoggingTests.cs | 241 +- .../Mixed/AtomicRequestBodyTests.cs | 304 +- .../Mixed/AtomicSerializationTests.cs | 170 +- .../Mixed/MaximumOperationsPerRequestTests.cs | 220 +- .../AtomicModelStateValidationTests.cs | 805 ++-- .../AtomicOperations/MusicTrack.cs | 51 +- .../AtomicOperations/OperationsController.cs | 13 +- .../AtomicOperations/OperationsDbContext.cs | 47 +- .../AtomicOperations/OperationsFakers.cs | 112 +- .../AtomicOperations/Performer.cs | 20 +- .../AtomicOperations/Playlist.cs | 26 +- .../QueryStrings/AtomicQueryStringTests.cs | 455 +- .../MusicTrackReleaseDefinition.cs | 51 +- .../AtomicOperations/RecordCompany.cs | 28 +- ...micSerializationResourceDefinitionTests.cs | 530 ++- .../Serialization/RecordCompanyDefinition.cs | 43 +- ...icSparseFieldSetResourceDefinitionTests.cs | 264 +- .../LyricPermissionProvider.cs | 9 +- .../SparseFieldSets/LyricTextDefinition.cs | 31 +- .../AtomicOperations/TextLanguage.cs | 25 +- .../Transactions/AtomicRollbackTests.cs | 257 +- .../AtomicTransactionConsistencyTests.cs | 208 +- .../Transactions/ExtraDbContext.cs | 13 +- .../Transactions/LyricRepository.cs | 30 +- .../Transactions/MusicTrackRepository.cs | 23 +- .../Transactions/PerformerRepository.cs | 89 +- .../AtomicAddToToManyRelationshipTests.cs | 1559 ++++--- ...AtomicRemoveFromToManyRelationshipTests.cs | 1504 ++++--- .../AtomicReplaceToManyRelationshipTests.cs | 1635 ++++---- .../AtomicUpdateToOneRelationshipTests.cs | 1905 +++++---- .../AtomicReplaceToManyRelationshipTests.cs | 1053 +++-- .../Resources/AtomicUpdateResourceTests.cs | 2528 ++++++------ .../AtomicUpdateToOneRelationshipTests.cs | 1383 ++++--- .../IntegrationTests/CompositeKeys/Car.cs | 70 +- .../CarCompositeKeyAwareRepository.cs | 61 +- .../CompositeKeys/CarExpressionRewriter.cs | 214 +- .../CompositeKeys/CompositeDbContext.cs | 53 +- .../CompositeKeys/CompositeKeyFakers.cs | 40 +- .../CompositeKeys/CompositeKeyTests.cs | 734 ++-- .../CompositeKeys/Dealership.cs | 20 +- .../IntegrationTests/CompositeKeys/Engine.cs | 19 +- .../ContentNegotiation/AcceptHeaderTests.cs | 368 +- .../ContentTypeHeaderTests.cs | 529 ++- .../OperationsController.cs | 13 +- .../ContentNegotiation/Policy.cs | 15 +- .../ContentNegotiation/PolicyDbContext.cs | 17 +- .../ActionResultDbContext.cs | 17 +- .../ActionResultTests.cs | 219 +- .../ControllerActionResults/Toothbrush.cs | 15 +- .../ToothbrushesController.cs | 91 +- .../ApiControllerAttributeTests.cs | 47 +- .../IntegrationTests/CustomRoutes/Civilian.cs | 15 +- .../CustomRoutes/CiviliansController.cs | 30 +- .../CustomRoutes/CustomRouteDbContext.cs | 19 +- .../CustomRoutes/CustomRouteFakers.cs | 32 +- .../CustomRoutes/CustomRouteTests.cs | 147 +- .../IntegrationTests/CustomRoutes/Town.cs | 28 +- .../CustomRoutes/TownsController.cs | 49 +- .../IntegrationTests/EagerLoading/Building.cs | 90 +- .../EagerLoading/BuildingDefinition.cs | 39 +- .../EagerLoading/BuildingRepository.cs | 40 +- .../IntegrationTests/EagerLoading/City.cs | 18 +- .../IntegrationTests/EagerLoading/Door.cs | 15 +- .../EagerLoading/EagerLoadingDbContext.cs | 53 +- .../EagerLoading/EagerLoadingFakers.cs | 82 +- .../EagerLoading/EagerLoadingTests.cs | 641 ++- .../IntegrationTests/EagerLoading/State.cs | 20 +- .../IntegrationTests/EagerLoading/Street.cs | 39 +- .../IntegrationTests/EagerLoading/Window.cs | 17 +- .../AlternateExceptionHandler.cs | 45 +- .../ExceptionHandling/ConsumerArticle.cs | 15 +- ...umerArticleIsNoLongerAvailableException.cs | 23 +- .../ConsumerArticleService.cs | 43 +- .../ExceptionHandling/ErrorDbContext.cs | 19 +- .../ExceptionHandlerTests.cs | 247 +- .../ExceptionHandling/ThrowingArticle.cs | 18 +- .../HitCountingResourceDefinition.cs | 266 +- .../HostingInIIS/ArtGallery.cs | 20 +- .../HostingInIIS/HostingDbContext.cs | 19 +- .../HostingInIIS/HostingFakers.cs | 28 +- .../HostingInIIS/HostingStartup.cs | 29 +- .../HostingInIIS/HostingTests.cs | 233 +- .../IntegrationTests/HostingInIIS/Painting.cs | 19 +- .../HostingInIIS/PaintingsController.cs | 19 +- .../IdObfuscation/BankAccount.cs | 18 +- .../IdObfuscation/BankAccountsController.cs | 13 +- .../IdObfuscation/DebitCard.cs | 21 +- .../IdObfuscation/DebitCardsController.cs | 13 +- .../IdObfuscation/HexadecimalCodec.cs | 89 +- .../IdObfuscation/IdObfuscationTests.cs | 674 ++- .../IdObfuscation/ObfuscatedIdentifiable.cs | 23 +- .../ObfuscatedIdentifiableController.cs | 142 +- .../IdObfuscation/ObfuscationDbContext.cs | 19 +- .../IdObfuscation/ObfuscationFakers.cs | 30 +- .../ModelState/ModelStateDbContext.cs | 63 +- .../ModelState/ModelStateFakers.cs | 44 +- .../ModelState/ModelStateValidationTests.cs | 1248 +++--- .../ModelState/NoModelStateValidationTests.cs | 184 +- .../ModelState/SystemDirectory.cs | 54 +- .../InputValidation/ModelState/SystemFile.cs | 25 +- .../ModelState/SystemVolume.cs | 19 +- .../InputValidation/RequestBody/Workflow.cs | 16 +- .../RequestBody/WorkflowDbContext.cs | 17 +- .../RequestBody/WorkflowDefinition.cs | 151 +- .../RequestBody/WorkflowStage.cs | 19 +- .../RequestBody/WorkflowTests.cs | 235 +- .../Links/AbsoluteLinksWithNamespaceTests.cs | 701 ++-- .../AbsoluteLinksWithoutNamespaceTests.cs | 701 ++-- .../Links/LinkInclusionTests.cs | 183 +- .../IntegrationTests/Links/LinksDbContext.cs | 35 +- .../IntegrationTests/Links/LinksFakers.cs | 42 +- .../IntegrationTests/Links/Photo.cs | 24 +- .../IntegrationTests/Links/PhotoAlbum.cs | 21 +- .../IntegrationTests/Links/PhotoLocation.cs | 33 +- .../Links/RelativeLinksWithNamespaceTests.cs | 701 ++-- .../RelativeLinksWithoutNamespaceTests.cs | 701 ++-- .../IntegrationTests/Logging/AuditEntry.cs | 20 +- .../Logging/LoggingDbContext.cs | 17 +- .../IntegrationTests/Logging/LoggingFakers.cs | 22 +- .../IntegrationTests/Logging/LoggingTests.cs | 162 +- .../IntegrationTests/Meta/MetaDbContext.cs | 19 +- .../IntegrationTests/Meta/MetaFakers.cs | 28 +- .../IntegrationTests/Meta/ProductFamily.cs | 20 +- .../Meta/ResourceMetaTests.cs | 160 +- .../Meta/ResponseMetaTests.cs | 61 +- .../Meta/SupportResponseMeta.cs | 30 +- .../IntegrationTests/Meta/SupportTicket.cs | 15 +- .../Meta/SupportTicketDefinition.cs | 39 +- .../Meta/TopLevelCountTests.cs | 211 +- .../Microservices/DomainFakers.cs | 30 +- .../Microservices/DomainGroup.cs | 21 +- .../Microservices/DomainUser.cs | 24 +- .../FireForgetDbContext.cs | 19 +- .../FireForgetGroupDefinition.cs | 64 +- .../FireForgetTests.Group.cs | 855 ++-- .../FireForgetTests.User.cs | 960 +++-- .../FireAndForgetDelivery/FireForgetTests.cs | 164 +- .../FireForgetUserDefinition.cs | 64 +- .../FireAndForgetDelivery/MessageBroker.cs | 46 +- .../Messages/GroupCreatedContent.cs | 24 +- .../Messages/GroupDeletedContent.cs | 20 +- .../Messages/GroupRenamedContent.cs | 28 +- .../Microservices/Messages/IMessageContent.cs | 11 +- .../Microservices/Messages/OutgoingMessage.cs | 53 +- .../Messages/UserAddedToGroupContent.cs | 24 +- .../Messages/UserCreatedContent.cs | 28 +- .../Messages/UserDeletedContent.cs | 20 +- .../Messages/UserDisplayNameChangedContent.cs | 28 +- .../Messages/UserLoginNameChangedContent.cs | 28 +- .../Messages/UserMovedToGroupContent.cs | 28 +- .../Messages/UserRemovedFromGroupContent.cs | 24 +- .../Microservices/MessagingGroupDefinition.cs | 242 +- .../Microservices/MessagingUserDefinition.cs | 173 +- .../OutboxDbContext.cs | 21 +- .../OutboxGroupDefinition.cs | 39 +- .../OutboxTests.Group.cs | 938 +++-- .../OutboxTests.User.cs | 1046 +++-- .../TransactionalOutboxPattern/OutboxTests.cs | 139 +- .../OutboxUserDefinition.cs | 39 +- .../MultiTenancy/IHasTenant.cs | 12 +- .../MultiTenancy/ITenantProvider.cs | 13 +- .../MultiTenancy/MultiTenancyDbContext.cs | 43 +- .../MultiTenancy/MultiTenancyFakers.cs | 30 +- .../MultiTenancy/MultiTenancyTests.cs | 1533 ++++--- .../MultiTenantResourceService.cs | 108 +- .../MultiTenancy/RouteTenantProvider.cs | 45 +- .../MultiTenancy/WebProduct.cs | 23 +- .../MultiTenancy/WebProductsController.cs | 19 +- .../IntegrationTests/MultiTenancy/WebShop.cs | 23 +- .../MultiTenancy/WebShopsController.cs | 19 +- .../NamingConventions/DivingBoard.cs | 15 +- .../DivingBoardsController.cs | 13 +- .../JsonKebabCaseNamingPolicy.cs | 106 +- .../KebabCasingConventionStartup.cs | 25 +- .../NamingConventions/KebabCasingTests.cs | 318 +- .../NamingConventions/NamingDbContext.cs | 21 +- .../NamingConventions/NamingFakers.cs | 38 +- .../PascalCasingConventionStartup.cs | 25 +- .../NamingConventions/PascalCasingTests.cs | 317 +- .../NamingConventions/SwimmingPool.cs | 22 +- .../SwimmingPoolsController.cs | 13 +- .../NamingConventions/WaterSlide.cs | 13 +- .../DuplicateKnownResourcesController.cs | 13 +- .../DuplicateResourceControllerTests.cs | 38 +- .../NonJsonApiControllers/EmptyDbContext.cs | 13 +- .../NonJsonApiControllers/KnownDbContext.cs | 17 +- .../NonJsonApiControllers/KnownResource.cs | 14 +- .../NonJsonApiController.cs | 79 +- .../NonJsonApiControllerTests.cs | 219 +- .../NonJsonApiControllers/UnknownResource.cs | 13 +- .../UnknownResourceControllerTests.cs | 38 +- .../QueryStrings/AccountPreferences.cs | 13 +- .../QueryStrings/Appointment.cs | 26 +- .../IntegrationTests/QueryStrings/Blog.cs | 33 +- .../IntegrationTests/QueryStrings/BlogPost.cs | 40 +- .../IntegrationTests/QueryStrings/Calendar.cs | 28 +- .../IntegrationTests/QueryStrings/Comment.cs | 28 +- .../Filtering/FilterDataTypeTests.cs | 603 +-- .../QueryStrings/Filtering/FilterDbContext.cs | 24 +- .../Filtering/FilterDepthTests.cs | 849 ++-- .../Filtering/FilterOperatorTests.cs | 1010 ++--- .../QueryStrings/Filtering/FilterTests.cs | 166 +- .../Filtering/FilterableResource.cs | 116 +- .../QueryStrings/Includes/IncludeTests.cs | 1628 ++++---- .../IntegrationTests/QueryStrings/Label.cs | 22 +- .../QueryStrings/LabelColor.cs | 17 +- .../QueryStrings/LoginAttempt.cs | 18 +- .../PaginationWithTotalCountTests.cs | 1014 +++-- .../PaginationWithoutTotalCountTests.cs | 316 +- .../Pagination/RangeValidationTests.cs | 228 +- .../RangeValidationWithMaximumTests.cs | 283 +- .../QueryStrings/QueryStringDbContext.cs | 45 +- .../QueryStrings/QueryStringFakers.cs | 131 +- .../QueryStrings/QueryStringTests.cs | 131 +- .../SerializerIgnoreConditionTests.cs | 118 +- .../QueryStrings/Sorting/SortTests.cs | 963 +++-- .../SparseFieldSets/ResourceCaptureStore.cs | 24 +- .../ResultCapturingRepository.cs | 46 +- .../SparseFieldSets/SparseFieldSetTests.cs | 1579 ++++--- .../QueryStrings/WebAccount.cs | 45 +- .../ReadWrite/Creating/CreateResourceTests.cs | 1360 +++--- ...reateResourceWithClientGeneratedIdTests.cs | 361 +- ...eateResourceWithToManyRelationshipTests.cs | 1100 +++-- ...reateResourceWithToOneRelationshipTests.cs | 1064 +++-- .../ReadWrite/Deleting/DeleteResourceTests.cs | 301 +- .../Fetching/FetchRelationshipTests.cs | 346 +- .../ReadWrite/Fetching/FetchResourceTests.cs | 599 ++- .../ImplicitlyChangingWorkItemDefinition.cs | 41 +- ...plicitlyChangingWorkItemGroupDefinition.cs | 42 +- .../ReadWrite/ReadWriteDbContext.cs | 85 +- .../ReadWrite/ReadWriteFakers.cs | 82 +- .../IntegrationTests/ReadWrite/RgbColor.cs | 19 +- .../AddToToManyRelationshipTests.cs | 1380 ++++--- .../RemoveFromToManyRelationshipTests.cs | 1647 ++++---- .../ReplaceToManyRelationshipTests.cs | 1494 ++++--- .../UpdateToOneRelationshipTests.cs | 1129 +++-- .../ReplaceToManyRelationshipTests.cs | 1586 ++++--- .../Updating/Resources/UpdateResourceTests.cs | 2268 +++++----- .../Resources/UpdateToOneRelationshipTests.cs | 1323 +++--- .../IntegrationTests/ReadWrite/UserAccount.cs | 24 +- .../IntegrationTests/ReadWrite/WorkItem.cs | 71 +- .../ReadWrite/WorkItemGroup.cs | 35 +- .../ReadWrite/WorkItemPriority.cs | 17 +- .../ReadWrite/WorkItemToWorkItem.cs | 15 +- .../IntegrationTests/ReadWrite/WorkTag.cs | 22 +- .../RequiredRelationships/Customer.cs | 20 +- .../DefaultBehaviorDbContext.cs | 51 +- .../DefaultBehaviorFakers.cs | 42 +- .../DefaultBehaviorTests.cs | 712 ++-- .../RequiredRelationships/Order.cs | 23 +- .../RequiredRelationships/Shipment.cs | 24 +- .../GiftCertificate.cs | 34 +- .../InjectionDbContext.cs | 25 +- .../InjectionFakers.cs | 70 +- .../PostOffice.cs | 45 +- .../ResourceInjectionTests.cs | 522 ++- .../ResourceDefinitionExtensibilityPoints.cs | 64 +- .../ResourceDefinitionHitCounter.cs | 34 +- .../Reading/IClientSettingsProvider.cs | 13 +- .../ResourceDefinitions/Reading/Moon.cs | 23 +- .../Reading/MoonDefinition.cs | 72 +- .../ResourceDefinitions/Reading/Planet.cs | 36 +- .../Reading/PlanetDefinition.cs | 71 +- .../Reading/ResourceDefinitionReadTests.cs | 1695 ++++---- .../ResourceDefinitions/Reading/Star.cs | 36 +- .../Reading/StarDefinition.cs | 85 +- .../ResourceDefinitions/Reading/StarKind.cs | 19 +- .../Reading/TestClientSettingsProvider.cs | 47 +- .../Reading/UniverseDbContext.cs | 21 +- .../Reading/UniverseFakers.cs | 52 +- .../Serialization/AesEncryptionService.cs | 64 +- .../Serialization/IEncryptionService.cs | 11 +- .../ResourceDefinitionSerializationTests.cs | 1196 +++--- .../Serialization/Scholarship.cs | 28 +- .../Serialization/SerializationDbContext.cs | 31 +- .../Serialization/SerializationFakers.cs | 32 +- .../Serialization/Student.cs | 23 +- .../Serialization/StudentDefinition.cs | 53 +- .../ResourceInheritance/Book.cs | 13 +- .../CompanyHealthInsurance.cs | 13 +- .../ResourceInheritance/ContentItem.cs | 13 +- .../FamilyHealthInsurance.cs | 13 +- .../ResourceInheritance/HealthInsurance.cs | 13 +- .../ResourceInheritance/Human.cs | 30 +- .../InheritanceDbContext.cs | 53 +- .../ResourceInheritance/InheritanceFakers.cs | 96 +- .../ResourceInheritance/InheritanceTests.cs | 665 ++- .../ResourceInheritance/Man.cs | 15 +- .../ResourceInheritance/Video.cs | 13 +- .../ResourceInheritance/Woman.cs | 13 +- .../RestrictedControllers/Bed.cs | 24 +- .../RestrictedControllers/Chair.cs | 28 +- .../DisableQueryStringTests.cs | 155 +- .../NoRelationshipsControllerTests.cs | 448 +- .../RestrictedControllers/Pillow.cs | 15 +- .../PillowsController.cs | 17 +- .../ReadOnlyControllerTests.cs | 448 +- .../RestrictionDbContext.cs | 23 +- .../RestrictionFakers.cs | 82 +- .../RestrictedControllers/Room.cs | 13 +- .../SkipCacheQueryStringParameterReader.cs | 37 +- .../RestrictedControllers/Sofa.cs | 15 +- .../RestrictedControllers/SofasController.cs | 17 +- .../RestrictedControllers/Table.cs | 24 +- .../WriteOnlyControllerTests.cs | 438 +- .../Serialization/ETagTests.cs | 347 +- .../IntegrationTests/Serialization/Meeting.cs | 63 +- .../Serialization/MeetingAttendee.cs | 20 +- .../Serialization/MeetingLocation.cs | 17 +- .../Serialization/SerializationDbContext.cs | 19 +- .../Serialization/SerializationFakers.cs | 50 +- .../Serialization/SerializationTests.cs | 735 ++-- .../IntegrationTests/SoftDeletion/Company.cs | 23 +- .../SoftDeletion/Department.cs | 22 +- .../SoftDeletion/ISoftDeletable.cs | 12 +- .../SoftDeletionAwareResourceService.cs | 149 +- .../SoftDeletion/SoftDeletionDbContext.cs | 33 +- .../SoftDeletion/SoftDeletionFakers.cs | 28 +- .../SoftDeletion/SoftDeletionTests.cs | 1485 ++++--- .../ZeroKeys/EmptyGuidAsKeyTests.cs | 852 ++-- .../IntegrationTests/ZeroKeys/Game.cs | 35 +- .../IntegrationTests/ZeroKeys/Map.cs | 20 +- .../IntegrationTests/ZeroKeys/Player.cs | 24 +- .../ZeroKeys/ZeroAsKeyTests.cs | 856 ++-- .../ZeroKeys/ZeroKeyDbContext.cs | 39 +- .../ZeroKeys/ZeroKeyFakers.cs | 42 +- .../JsonApiDotNetCoreTests.csproj | 4 +- .../AbsoluteLinksInApiNamespaceStartup.cs | 19 +- .../AbsoluteLinksNoNamespaceStartup.cs | 19 +- .../Startups/NoModelStateValidationStartup.cs | 17 +- .../RelativeLinksInApiNamespaceStartup.cs | 19 +- .../RelativeLinksNoNamespaceStartup.cs | 19 +- .../DependencyContainerRegistrationTests.cs | 197 +- .../UnitTests/Links/LinkInclusionTests.cs | 714 ++-- .../ModelStateValidationTests.cs | 248 +- .../QueryStringParameters/BaseParseTests.cs | 55 +- .../QueryStringParameters/FilterParseTests.cs | 287 +- .../IncludeParseTests.cs | 150 +- .../LegacyFilterParseTests.cs | 156 +- .../PaginationParseTests.cs | 260 +- .../QueryStringParameters/SortParseTests.cs | 189 +- .../SparseFieldSetParseTests.cs | 160 +- .../ResourceGraphBuilderTests.cs | 595 +-- .../Serialization/InputConversionTests.cs | 595 ++- .../Serialization/Response/Models/Article.cs | 21 +- .../Serialization/Response/Models/Blog.cs | 21 +- .../Serialization/Response/Models/Food.cs | 13 +- .../Serialization/Response/Models/Person.cs | 26 +- .../Serialization/Response/Models/Song.cs | 13 +- .../Response/ResponseModelAdapterTests.cs | 454 +- .../Response/ResponseSerializationFakers.cs | 75 +- .../MultiDbContextTests.csproj | 4 +- test/MultiDbContextTests/ResourceTests.cs | 87 +- .../NoEntityFrameworkTests.csproj | 4 +- test/NoEntityFrameworkTests/WorkItemTests.cs | 233 +- .../Controllers/ArticlesController.cs | 16 +- .../Controllers/CustomersController.cs | 15 +- .../JsonApiDotNetCore/ArgumentGuard.cs | 19 +- .../JsonApiDotNetCore/AttrAttribute.cs | 18 +- .../BaseJsonApiController.cs | 53 +- .../IAddToRelationshipService.cs | 19 +- .../JsonApiDotNetCore/ICreateService.cs | 19 +- .../JsonApiDotNetCore/IDeleteService.cs | 19 +- .../JsonApiDotNetCore/IGetAllService.cs | 19 +- .../JsonApiDotNetCore/IGetByIdService.cs | 19 +- .../IGetRelationshipService.cs | 19 +- .../JsonApiDotNetCore/IGetSecondaryService.cs | 19 +- .../JsonApiDotNetCore/IIdentifiable.cs | 31 +- .../JsonApiDotNetCore/IJsonApiOptions.cs | 17 +- .../IRemoveFromRelationshipService.cs | 19 +- .../IResourceCommandService.cs | 23 +- .../JsonApiDotNetCore/IResourceGraph.cs | 17 +- .../IResourceQueryService.cs | 21 +- .../ISetRelationshipService.cs | 19 +- .../JsonApiDotNetCore/IUpdateService.cs | 19 +- .../JsonApiDotNetCore/Identifiable.cs | 23 +- .../JsonApiCommandController.cs | 25 +- .../JsonApiDotNetCore/JsonApiController.cs | 45 +- .../JsonApiDotNetCore/JsonApiOptions.cs | 17 +- .../JsonApiQueryController.cs | 25 +- .../JsonApiResourceService.cs | 19 +- .../JsonApiDotNetCore/ResourceGraph.cs | 17 +- .../ResourceServiceInterfaces.cs | 19 +- .../SourceGeneratorDebugger/Models/Account.cs | 15 +- .../SourceGeneratorDebugger/Models/Article.cs | 16 +- .../Models/Customer.cs | 13 +- test/SourceGeneratorDebugger/Models/Login.cs | 1 - test/SourceGeneratorDebugger/Models/Order.cs | 15 +- .../Models/SimpleNamespace.cs | 15 +- test/SourceGeneratorDebugger/Program.cs | 100 +- .../SourceGeneratorDebugger.csproj | 13 +- .../CompilationBuilder.cs | 109 +- .../ControllerGenerationTests.cs | 947 +++-- .../GeneratorDriverRunResultExtensions.cs | 101 +- .../JsonApiEndpointsCopyTests.cs | 80 +- .../SourceGeneratorTests/SourceCodeBuilder.cs | 83 +- .../SourceGeneratorTests.csproj | 2 +- test/TestBuildingBlocks/DateTimeExtensions.cs | 44 +- .../TestBuildingBlocks/DbContextExtensions.cs | 91 +- test/TestBuildingBlocks/DummyTest.cs | 15 +- test/TestBuildingBlocks/FakeLoggerFactory.cs | 123 +- test/TestBuildingBlocks/FakerContainer.cs | 97 +- test/TestBuildingBlocks/FrozenSystemClock.cs | 13 +- .../HttpResponseMessageExtensions.cs | 44 +- test/TestBuildingBlocks/IntegrationTest.cs | 160 +- .../IntegrationTestContext.cs | 281 +- .../JsonApiStringConverter.cs | 25 +- .../NeverSameResourceChangeTracker.cs | 37 +- .../NullabilityAssertionExtensions.cs | 91 +- .../ObjectAssertionsExtensions.cs | 124 +- .../TestBuildingBlocks/QueryableExtensions.cs | 29 +- .../ServiceCollectionExtensions.cs | 42 +- .../TestBuildingBlocks.csproj | 18 +- .../TestControllerProvider.cs | 29 +- test/TestBuildingBlocks/TestableStartup.cs | 39 +- test/TestBuildingBlocks/Unknown.cs | 114 +- .../ServiceCollectionExtensionsTests.cs | 1026 +++-- test/UnitTests/Graph/BaseType.cs | 7 +- test/UnitTests/Graph/DerivedType.cs | 7 +- test/UnitTests/Graph/IGenericInterface.cs | 7 +- test/UnitTests/Graph/Implementation.cs | 7 +- test/UnitTests/Graph/Model.cs | 7 +- .../ResourceDescriptorAssemblyCacheTests.cs | 72 +- test/UnitTests/Graph/TypeLocatorTests.cs | 161 +- test/UnitTests/Internal/ErrorObjectTests.cs | 36 +- .../Internal/RuntimeTypeConverterTests.cs | 223 +- .../UnitTests/Internal/TypeExtensionsTests.cs | 66 +- .../Middleware/JsonApiRequestTests.cs | 297 +- .../UnitTests/Models/AttributesEqualsTests.cs | 281 +- test/UnitTests/Models/IdentifiableTests.cs | 59 +- .../ResourceConstructionExpressionTests.cs | 74 +- test/UnitTests/UnitTests.csproj | 6 +- 865 files changed, 65110 insertions(+), 67472 deletions(-) delete mode 100644 src/Examples/GettingStarted/Startup.cs delete mode 100644 src/Examples/JsonApiDotNetCoreExample/Startup.cs delete mode 100644 src/Examples/MultiDbContextExample/Startup.cs delete mode 100644 src/Examples/NoEntityFrameworkExample/Startup.cs delete mode 100644 src/Examples/ReportsExample/Startup.cs delete mode 100644 src/JsonApiDotNetCore/Middleware/FixedQueryFeature.cs delete mode 100644 src/JsonApiDotNetCore/Middleware/FixedQueryHelpers.cs create mode 100644 src/JsonApiDotNetCore/Queries/Internal/SystemExpressionExtensions.cs create mode 100644 src/JsonApiDotNetCore/Resources/Annotations/NoResourceAttribute.cs diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 0a05541b2e..8c08ac9c20 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "jetbrains.resharper.globaltools": { - "version": "2021.2.2", + "version": "2021.3.0", "commands": [ "jb" ] @@ -21,7 +21,7 @@ ] }, "dotnet-reportgenerator-globaltool": { - "version": "4.8.12", + "version": "5.0.0", "commands": [ "reportgenerator" ] diff --git a/Directory.Build.props b/Directory.Build.props index da45eb941e..36c9ede075 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,22 +1,23 @@ - net5.0 - 5.0.* - 5.0.* - 5.0.* - 3.* - 2.11.10 + net6.0 + 6.0.* + 6.0.* + 6.0.* + 4.* + 2.* 5.0.0 $(MSBuildThisFileDirectory)CodingGuidelines.ruleset 9999 enable + enable false false - + @@ -28,11 +29,8 @@ - 33.1.1 3.1.0 - 6.2.0 4.16.1 - 2.4.* 17.0.0 diff --git a/JsonApiDotNetCore.sln.DotSettings b/JsonApiDotNetCore.sln.DotSettings index 595cae92d8..0ca7c35d77 100644 --- a/JsonApiDotNetCore.sln.DotSettings +++ b/JsonApiDotNetCore.sln.DotSettings @@ -27,6 +27,7 @@ JsonApiDotNetCore.ArgumentGuard.NotNull($EXPR$, $NAME$); SUGGESTION SUGGESTION SUGGESTION + WARNING SUGGESTION SUGGESTION SUGGESTION @@ -83,7 +84,7 @@ JsonApiDotNetCore.ArgumentGuard.NotNull($EXPR$, $NAME$); WARNING WARNING WARNING - <?xml version="1.0" encoding="utf-16"?><Profile name="JADNC Full Cleanup"><XMLReformatCode>True</XMLReformatCode><CSCodeStyleAttributes ArrangeTypeAccessModifier="True" ArrangeTypeMemberAccessModifier="True" SortModifiers="True" RemoveRedundantParentheses="True" AddMissingParentheses="True" ArrangeBraces="True" ArrangeAttributes="True" ArrangeArgumentsStyle="True" ArrangeCodeBodyStyle="True" ArrangeVarStyle="True" ArrangeTrailingCommas="True" ArrangeObjectCreation="True" ArrangeDefaultValue="True" /><CssAlphabetizeProperties>True</CssAlphabetizeProperties><JsInsertSemicolon>True</JsInsertSemicolon><FormatAttributeQuoteDescriptor>True</FormatAttributeQuoteDescriptor><CorrectVariableKindsDescriptor>True</CorrectVariableKindsDescriptor><VariablesToInnerScopesDescriptor>True</VariablesToInnerScopesDescriptor><StringToTemplatesDescriptor>True</StringToTemplatesDescriptor><JsReformatCode>True</JsReformatCode><JsFormatDocComments>True</JsFormatDocComments><RemoveRedundantQualifiersTs>True</RemoveRedundantQualifiersTs><OptimizeImportsTs>True</OptimizeImportsTs><OptimizeReferenceCommentsTs>True</OptimizeReferenceCommentsTs><PublicModifierStyleTs>True</PublicModifierStyleTs><ExplicitAnyTs>True</ExplicitAnyTs><TypeAnnotationStyleTs>True</TypeAnnotationStyleTs><RelativePathStyleTs>True</RelativePathStyleTs><AsInsteadOfCastTs>True</AsInsteadOfCastTs><HtmlReformatCode>True</HtmlReformatCode><AspOptimizeRegisterDirectives>True</AspOptimizeRegisterDirectives><RemoveCodeRedundancies>True</RemoveCodeRedundancies><CSUseAutoProperty>True</CSUseAutoProperty><CSMakeFieldReadonly>True</CSMakeFieldReadonly><CSMakeAutoPropertyGetOnly>True</CSMakeAutoPropertyGetOnly><CSArrangeQualifiers>True</CSArrangeQualifiers><CSFixBuiltinTypeReferences>True</CSFixBuiltinTypeReferences><CssReformatCode>True</CssReformatCode><CSOptimizeUsings><OptimizeUsings>True</OptimizeUsings><EmbraceInRegion>False</EmbraceInRegion><RegionName></RegionName></CSOptimizeUsings><CSShortenReferences>True</CSShortenReferences><CSReformatCode>True</CSReformatCode><CSharpFormatDocComments>True</CSharpFormatDocComments><CSReorderTypeMembers>True</CSReorderTypeMembers><XAMLCollapseEmptyTags>False</XAMLCollapseEmptyTags></Profile> + <?xml version="1.0" encoding="utf-16"?><Profile name="JADNC Full Cleanup"><XMLReformatCode>True</XMLReformatCode><CSCodeStyleAttributes ArrangeTypeAccessModifier="True" ArrangeTypeMemberAccessModifier="True" SortModifiers="True" RemoveRedundantParentheses="True" AddMissingParentheses="True" ArrangeBraces="True" ArrangeAttributes="True" ArrangeArgumentsStyle="True" ArrangeCodeBodyStyle="True" ArrangeVarStyle="True" ArrangeTrailingCommas="True" ArrangeObjectCreation="True" ArrangeDefaultValue="True" ArrangeNamespaces="True" /><CssAlphabetizeProperties>True</CssAlphabetizeProperties><JsInsertSemicolon>True</JsInsertSemicolon><FormatAttributeQuoteDescriptor>True</FormatAttributeQuoteDescriptor><CorrectVariableKindsDescriptor>True</CorrectVariableKindsDescriptor><VariablesToInnerScopesDescriptor>True</VariablesToInnerScopesDescriptor><StringToTemplatesDescriptor>True</StringToTemplatesDescriptor><JsReformatCode>True</JsReformatCode><JsFormatDocComments>True</JsFormatDocComments><RemoveRedundantQualifiersTs>True</RemoveRedundantQualifiersTs><OptimizeImportsTs>True</OptimizeImportsTs><OptimizeReferenceCommentsTs>True</OptimizeReferenceCommentsTs><PublicModifierStyleTs>True</PublicModifierStyleTs><ExplicitAnyTs>True</ExplicitAnyTs><TypeAnnotationStyleTs>True</TypeAnnotationStyleTs><RelativePathStyleTs>True</RelativePathStyleTs><AsInsteadOfCastTs>True</AsInsteadOfCastTs><HtmlReformatCode>True</HtmlReformatCode><AspOptimizeRegisterDirectives>True</AspOptimizeRegisterDirectives><RemoveCodeRedundancies>True</RemoveCodeRedundancies><CSUseAutoProperty>True</CSUseAutoProperty><CSMakeFieldReadonly>True</CSMakeFieldReadonly><CSMakeAutoPropertyGetOnly>True</CSMakeAutoPropertyGetOnly><CSArrangeQualifiers>True</CSArrangeQualifiers><CSFixBuiltinTypeReferences>True</CSFixBuiltinTypeReferences><CssReformatCode>True</CssReformatCode><CSOptimizeUsings><OptimizeUsings>True</OptimizeUsings></CSOptimizeUsings><CSShortenReferences>True</CSShortenReferences><CSReformatCode>True</CSReformatCode><CSharpFormatDocComments>True</CSharpFormatDocComments><CSReorderTypeMembers>True</CSReorderTypeMembers><XAMLCollapseEmptyTags>False</XAMLCollapseEmptyTags></Profile> JADNC Full Cleanup Required Required @@ -91,10 +92,12 @@ JsonApiDotNetCore.ArgumentGuard.NotNull($EXPR$, $NAME$); Required Conditional False + False 1 1 1 1 + False True True True diff --git a/README.md b/README.md index 5df6ed6b9f..71e5f523e5 100644 --- a/README.md +++ b/README.md @@ -56,20 +56,15 @@ public class Article : Identifiable ### Middleware ```c# -public class Startup -{ - public IServiceProvider ConfigureServices(IServiceCollection services) - { - services.AddJsonApi(); - } - - public void Configure(IApplicationBuilder app) - { - app.UseRouting(); - app.UseJsonApi(); - app.UseEndpoints(endpoints => endpoints.MapControllers()); - } -} +// Program.cs + +builder.Services.AddJsonApi(); + +// ... + +app.UseRouting(); +app.UseJsonApi(); +app.MapControllers(); ``` ## Compatibility @@ -77,16 +72,14 @@ public class Startup The following chart should help you pick the best version, based on your environment. See also our [versioning policy](./VERSIONING_POLICY.md). -| JsonApiDotNetCore | .NET | Entity Framework Core | Status | -| ----------------- | -------- | --------------------- | -------------------------- | -| 3.x | Core 2.x | 2.x | Released | -| 4.x | Core 3.1 | 3.1 | Released | -| | Core 3.1 | 5 | | -| | 5 | 5 | | -| | 6 | 5 | | -| v5.x (pending) | 5 | 5 | On AppVeyor, to-be-dropped | -| | 6 | 5 | On AppVeyor, to-be-dropped | -| | 6 | 6 | Requires build from master | +| JsonApiDotNetCore | Status | .NET | Entity Framework Core | +| ----------------- | ----------- | -------- | --------------------- | +| 3.x | Stable | Core 2.x | 2.x | +| 4.x | Stable | Core 3.1 | 3.1 | +| | | Core 3.1 | 5 | +| | | 5 | 5 | +| | | 6 | 5 | +| v5.x | Pre-release | 6 | 6 | ## Contributing diff --git a/ROADMAP.md b/ROADMAP.md index a1fc9267f3..9a890f2014 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -25,7 +25,7 @@ The need for breaking changes has blocked several efforts in the v4.x release, s - [x] Improved paging links [#1010](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1010) - [x] Configuration validation [#170](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/170) - [x] Auto-generated controllers [#732](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/732) [#365](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/365) -- [ ] Support .NET 6 with EF Core 6 [#1109](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1109) +- [x] Support .NET 6 with EF Core 6 [#1109](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1109) Aside from the list above, we have interest in the following topics. It's too soon yet to decide whether they'll make it into v5.x or in a later major version. diff --git a/benchmarks/Benchmarks.csproj b/benchmarks/Benchmarks.csproj index 225c3a75d7..4bde435c15 100644 --- a/benchmarks/Benchmarks.csproj +++ b/benchmarks/Benchmarks.csproj @@ -1,7 +1,7 @@ Exe - $(NetCoreAppVersion) + $(TargetFrameworkName) diff --git a/benchmarks/Deserialization/DeserializationBenchmarkBase.cs b/benchmarks/Deserialization/DeserializationBenchmarkBase.cs index b21d7c85e7..bbf746d1a8 100644 --- a/benchmarks/Deserialization/DeserializationBenchmarkBase.cs +++ b/benchmarks/Deserialization/DeserializationBenchmarkBase.cs @@ -1,5 +1,3 @@ -using System; -using System.Collections.Generic; using System.ComponentModel.Design; using System.Text.Json; using JetBrains.Annotations; @@ -11,114 +9,111 @@ using JsonApiDotNetCore.Serialization.Request.Adapters; using Microsoft.Extensions.Logging.Abstractions; -namespace Benchmarks.Deserialization -{ - public abstract class DeserializationBenchmarkBase - { - protected readonly JsonSerializerOptions SerializerReadOptions; - protected readonly DocumentAdapter DocumentAdapter; +namespace Benchmarks.Deserialization; - protected DeserializationBenchmarkBase() - { - var options = new JsonApiOptions(); - IResourceGraph resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add().Build(); - options.SerializerOptions.Converters.Add(new ResourceObjectConverter(resourceGraph)); - SerializerReadOptions = ((IJsonApiOptions)options).SerializerReadOptions; +public abstract class DeserializationBenchmarkBase +{ + protected readonly JsonSerializerOptions SerializerReadOptions; + protected readonly DocumentAdapter DocumentAdapter; - var serviceContainer = new ServiceContainer(); - var resourceFactory = new ResourceFactory(serviceContainer); - var resourceDefinitionAccessor = new ResourceDefinitionAccessor(resourceGraph, serviceContainer); + protected DeserializationBenchmarkBase() + { + var options = new JsonApiOptions(); + IResourceGraph resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add().Build(); + options.SerializerOptions.Converters.Add(new ResourceObjectConverter(resourceGraph)); + SerializerReadOptions = ((IJsonApiOptions)options).SerializerReadOptions; - serviceContainer.AddService(typeof(IResourceDefinitionAccessor), resourceDefinitionAccessor); + var serviceContainer = new ServiceContainer(); + var resourceFactory = new ResourceFactory(serviceContainer); + var resourceDefinitionAccessor = new ResourceDefinitionAccessor(resourceGraph, serviceContainer); - serviceContainer.AddService(typeof(IResourceDefinition), - new JsonApiResourceDefinition(resourceGraph)); + serviceContainer.AddService(typeof(IResourceDefinitionAccessor), resourceDefinitionAccessor); + serviceContainer.AddService(typeof(IResourceDefinition), new JsonApiResourceDefinition(resourceGraph)); - // ReSharper disable once VirtualMemberCallInConstructor - JsonApiRequest request = CreateJsonApiRequest(resourceGraph); - var targetedFields = new TargetedFields(); + // ReSharper disable once VirtualMemberCallInConstructor + JsonApiRequest request = CreateJsonApiRequest(resourceGraph); + var targetedFields = new TargetedFields(); - var resourceIdentifierObjectAdapter = new ResourceIdentifierObjectAdapter(resourceGraph, resourceFactory); - var relationshipDataAdapter = new RelationshipDataAdapter(resourceIdentifierObjectAdapter); - var resourceObjectAdapter = new ResourceObjectAdapter(resourceGraph, resourceFactory, options, relationshipDataAdapter); - var resourceDataAdapter = new ResourceDataAdapter(resourceDefinitionAccessor, resourceObjectAdapter); + var resourceIdentifierObjectAdapter = new ResourceIdentifierObjectAdapter(resourceGraph, resourceFactory); + var relationshipDataAdapter = new RelationshipDataAdapter(resourceIdentifierObjectAdapter); + var resourceObjectAdapter = new ResourceObjectAdapter(resourceGraph, resourceFactory, options, relationshipDataAdapter); + var resourceDataAdapter = new ResourceDataAdapter(resourceDefinitionAccessor, resourceObjectAdapter); - var atomicReferenceAdapter = new AtomicReferenceAdapter(resourceGraph, resourceFactory); - var atomicOperationResourceDataAdapter = new ResourceDataInOperationsRequestAdapter(resourceDefinitionAccessor, resourceObjectAdapter); + var atomicReferenceAdapter = new AtomicReferenceAdapter(resourceGraph, resourceFactory); + var atomicOperationResourceDataAdapter = new ResourceDataInOperationsRequestAdapter(resourceDefinitionAccessor, resourceObjectAdapter); - var atomicOperationObjectAdapter = new AtomicOperationObjectAdapter(options, atomicReferenceAdapter, - atomicOperationResourceDataAdapter, relationshipDataAdapter); + var atomicOperationObjectAdapter = new AtomicOperationObjectAdapter(options, atomicReferenceAdapter, + atomicOperationResourceDataAdapter, relationshipDataAdapter); - var resourceDocumentAdapter = new DocumentInResourceOrRelationshipRequestAdapter(options, resourceDataAdapter, relationshipDataAdapter); - var operationsDocumentAdapter = new DocumentInOperationsRequestAdapter(options, atomicOperationObjectAdapter); + var resourceDocumentAdapter = new DocumentInResourceOrRelationshipRequestAdapter(options, resourceDataAdapter, relationshipDataAdapter); + var operationsDocumentAdapter = new DocumentInOperationsRequestAdapter(options, atomicOperationObjectAdapter); - DocumentAdapter = new DocumentAdapter(request, targetedFields, resourceDocumentAdapter, operationsDocumentAdapter); - } + DocumentAdapter = new DocumentAdapter(request, targetedFields, resourceDocumentAdapter, operationsDocumentAdapter); + } - protected abstract JsonApiRequest CreateJsonApiRequest(IResourceGraph resourceGraph); + protected abstract JsonApiRequest CreateJsonApiRequest(IResourceGraph resourceGraph); - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class IncomingResource : Identifiable - { - [Attr] - public bool Attribute01 { get; set; } + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class IncomingResource : Identifiable + { + [Attr] + public bool Attribute01 { get; set; } - [Attr] - public char Attribute02 { get; set; } + [Attr] + public char Attribute02 { get; set; } - [Attr] - public ulong? Attribute03 { get; set; } + [Attr] + public ulong? Attribute03 { get; set; } - [Attr] - public decimal Attribute04 { get; set; } + [Attr] + public decimal Attribute04 { get; set; } - [Attr] - public float? Attribute05 { get; set; } + [Attr] + public float? Attribute05 { get; set; } - [Attr] - public string Attribute06 { get; set; } = null!; + [Attr] + public string Attribute06 { get; set; } = null!; - [Attr] - public DateTime? Attribute07 { get; set; } + [Attr] + public DateTime? Attribute07 { get; set; } - [Attr] - public DateTimeOffset? Attribute08 { get; set; } + [Attr] + public DateTimeOffset? Attribute08 { get; set; } - [Attr] - public TimeSpan? Attribute09 { get; set; } + [Attr] + public TimeSpan? Attribute09 { get; set; } - [Attr] - public DayOfWeek Attribute10 { get; set; } + [Attr] + public DayOfWeek Attribute10 { get; set; } - [HasOne] - public IncomingResource Single1 { get; set; } = null!; + [HasOne] + public IncomingResource Single1 { get; set; } = null!; - [HasOne] - public IncomingResource Single2 { get; set; } = null!; + [HasOne] + public IncomingResource Single2 { get; set; } = null!; - [HasOne] - public IncomingResource Single3 { get; set; } = null!; + [HasOne] + public IncomingResource Single3 { get; set; } = null!; - [HasOne] - public IncomingResource Single4 { get; set; } = null!; + [HasOne] + public IncomingResource Single4 { get; set; } = null!; - [HasOne] - public IncomingResource Single5 { get; set; } = null!; + [HasOne] + public IncomingResource Single5 { get; set; } = null!; - [HasMany] - public ISet Multi1 { get; set; } = null!; + [HasMany] + public ISet Multi1 { get; set; } = null!; - [HasMany] - public ISet Multi2 { get; set; } = null!; + [HasMany] + public ISet Multi2 { get; set; } = null!; - [HasMany] - public ISet Multi3 { get; set; } = null!; + [HasMany] + public ISet Multi3 { get; set; } = null!; - [HasMany] - public ISet Multi4 { get; set; } = null!; + [HasMany] + public ISet Multi4 { get; set; } = null!; - [HasMany] - public ISet Multi5 { get; set; } = null!; - } + [HasMany] + public ISet Multi5 { get; set; } = null!; } } diff --git a/benchmarks/Deserialization/OperationsDeserializationBenchmarks.cs b/benchmarks/Deserialization/OperationsDeserializationBenchmarks.cs index 0181f4ccbc..d28684e27b 100644 --- a/benchmarks/Deserialization/OperationsDeserializationBenchmarks.cs +++ b/benchmarks/Deserialization/OperationsDeserializationBenchmarks.cs @@ -1,285 +1,283 @@ -using System; using System.Text.Json; using BenchmarkDotNet.Attributes; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Serialization.Objects; -namespace Benchmarks.Deserialization +namespace Benchmarks.Deserialization; + +[MarkdownExporter] +// ReSharper disable once ClassCanBeSealed.Global +public class OperationsDeserializationBenchmarks : DeserializationBenchmarkBase { - [MarkdownExporter] - // ReSharper disable once ClassCanBeSealed.Global - public class OperationsDeserializationBenchmarks : DeserializationBenchmarkBase + private static readonly string RequestBody = JsonSerializer.Serialize(new { - private static readonly string RequestBody = JsonSerializer.Serialize(new + atomic__operations = new object[] { - atomic__operations = new object[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "incomingResources", + lid = "a-1", + attributes = new + { + attribute01 = true, + attribute02 = 'A', + attribute03 = 100UL, + attribute04 = 100.001m, + attribute05 = 200.002f, + attribute06 = "text", + attribute07 = DateTime.MaxValue, + attribute08 = DateTimeOffset.MaxValue, + attribute09 = TimeSpan.MaxValue, + attribute10 = DayOfWeek.Friday + }, + relationships = new { - type = "incomingResources", - lid = "a-1", - attributes = new + single1 = new { - attribute01 = true, - attribute02 = 'A', - attribute03 = 100UL, - attribute04 = 100.001m, - attribute05 = 200.002f, - attribute06 = "text", - attribute07 = DateTime.MaxValue, - attribute08 = DateTimeOffset.MaxValue, - attribute09 = TimeSpan.MaxValue, - attribute10 = DayOfWeek.Friday + data = new + { + type = "incomingResources", + id = "101" + } }, - relationships = new + single2 = new { - single1 = new + data = new { - data = new - { - type = "incomingResources", - id = "101" - } - }, - single2 = new + type = "incomingResources", + id = "102" + } + }, + single3 = new + { + data = new { - data = new - { - type = "incomingResources", - id = "102" - } - }, - single3 = new + type = "incomingResources", + id = "103" + } + }, + single4 = new + { + data = new { - data = new - { - type = "incomingResources", - id = "103" - } - }, - single4 = new + type = "incomingResources", + id = "104" + } + }, + single5 = new + { + data = new { - data = new - { - type = "incomingResources", - id = "104" - } - }, - single5 = new + type = "incomingResources", + id = "105" + } + }, + multi1 = new + { + data = new[] { - data = new + new { type = "incomingResources", - id = "105" - } - }, - multi1 = new - { - data = new[] - { - new - { - type = "incomingResources", - id = "201" - } + id = "201" } - }, - multi2 = new + } + }, + multi2 = new + { + data = new[] { - data = new[] + new { - new - { - type = "incomingResources", - id = "202" - } + type = "incomingResources", + id = "202" } - }, - multi3 = new + } + }, + multi3 = new + { + data = new[] { - data = new[] + new { - new - { - type = "incomingResources", - id = "203" - } + type = "incomingResources", + id = "203" } - }, - multi4 = new + } + }, + multi4 = new + { + data = new[] { - data = new[] + new { - new - { - type = "incomingResources", - id = "204" - } + type = "incomingResources", + id = "204" } - }, - multi5 = new + } + }, + multi5 = new + { + data = new[] { - data = new[] + new { - new - { - type = "incomingResources", - id = "205" - } + type = "incomingResources", + id = "205" } } } } - }, - new + } + }, + new + { + op = "update", + data = new { - op = "update", - data = new + type = "incomingResources", + id = "1", + attributes = new + { + attribute01 = true, + attribute02 = 'A', + attribute03 = 100UL, + attribute04 = 100.001m, + attribute05 = 200.002f, + attribute06 = "text", + attribute07 = DateTime.MaxValue, + attribute08 = DateTimeOffset.MaxValue, + attribute09 = TimeSpan.MaxValue, + attribute10 = DayOfWeek.Friday + }, + relationships = new { - type = "incomingResources", - id = "1", - attributes = new + single1 = new { - attribute01 = true, - attribute02 = 'A', - attribute03 = 100UL, - attribute04 = 100.001m, - attribute05 = 200.002f, - attribute06 = "text", - attribute07 = DateTime.MaxValue, - attribute08 = DateTimeOffset.MaxValue, - attribute09 = TimeSpan.MaxValue, - attribute10 = DayOfWeek.Friday + data = new + { + type = "incomingResources", + id = "101" + } }, - relationships = new + single2 = new { - single1 = new + data = new { - data = new - { - type = "incomingResources", - id = "101" - } - }, - single2 = new + type = "incomingResources", + id = "102" + } + }, + single3 = new + { + data = new { - data = new - { - type = "incomingResources", - id = "102" - } - }, - single3 = new + type = "incomingResources", + id = "103" + } + }, + single4 = new + { + data = new { - data = new - { - type = "incomingResources", - id = "103" - } - }, - single4 = new + type = "incomingResources", + id = "104" + } + }, + single5 = new + { + data = new { - data = new - { - type = "incomingResources", - id = "104" - } - }, - single5 = new + type = "incomingResources", + id = "105" + } + }, + multi1 = new + { + data = new[] { - data = new + new { type = "incomingResources", - id = "105" - } - }, - multi1 = new - { - data = new[] - { - new - { - type = "incomingResources", - id = "201" - } + id = "201" } - }, - multi2 = new + } + }, + multi2 = new + { + data = new[] { - data = new[] + new { - new - { - type = "incomingResources", - id = "202" - } + type = "incomingResources", + id = "202" } - }, - multi3 = new + } + }, + multi3 = new + { + data = new[] { - data = new[] + new { - new - { - type = "incomingResources", - id = "203" - } + type = "incomingResources", + id = "203" } - }, - multi4 = new + } + }, + multi4 = new + { + data = new[] { - data = new[] + new { - new - { - type = "incomingResources", - id = "204" - } + type = "incomingResources", + id = "204" } - }, - multi5 = new + } + }, + multi5 = new + { + data = new[] { - data = new[] + new { - new - { - type = "incomingResources", - id = "205" - } + type = "incomingResources", + id = "205" } } } } - }, - new + } + }, + new + { + op = "remove", + @ref = new { - op = "remove", - @ref = new - { - type = "incomingResources", - lid = "a-1" - } + type = "incomingResources", + lid = "a-1" } } - }).Replace("atomic__operations", "atomic:operations"); - - [Benchmark] - public object? DeserializeOperationsRequest() - { - var document = JsonSerializer.Deserialize(RequestBody, SerializerReadOptions)!; - return DocumentAdapter.Convert(document); } + }).Replace("atomic__operations", "atomic:operations"); - protected override JsonApiRequest CreateJsonApiRequest(IResourceGraph resourceGraph) + [Benchmark] + public object? DeserializeOperationsRequest() + { + var document = JsonSerializer.Deserialize(RequestBody, SerializerReadOptions)!; + return DocumentAdapter.Convert(document); + } + + protected override JsonApiRequest CreateJsonApiRequest(IResourceGraph resourceGraph) + { + return new JsonApiRequest { - return new JsonApiRequest - { - Kind = EndpointKind.AtomicOperations - }; - } + Kind = EndpointKind.AtomicOperations + }; } } diff --git a/benchmarks/Deserialization/ResourceDeserializationBenchmarks.cs b/benchmarks/Deserialization/ResourceDeserializationBenchmarks.cs index e154306819..23a6205bf5 100644 --- a/benchmarks/Deserialization/ResourceDeserializationBenchmarks.cs +++ b/benchmarks/Deserialization/ResourceDeserializationBenchmarks.cs @@ -1,150 +1,148 @@ -using System; using System.Text.Json; using BenchmarkDotNet.Attributes; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Serialization.Objects; -namespace Benchmarks.Deserialization +namespace Benchmarks.Deserialization; + +[MarkdownExporter] +// ReSharper disable once ClassCanBeSealed.Global +public class ResourceDeserializationBenchmarks : DeserializationBenchmarkBase { - [MarkdownExporter] - // ReSharper disable once ClassCanBeSealed.Global - public class ResourceDeserializationBenchmarks : DeserializationBenchmarkBase + private static readonly string RequestBody = JsonSerializer.Serialize(new { - private static readonly string RequestBody = JsonSerializer.Serialize(new + data = new { - data = new + type = "incomingResources", + attributes = new + { + attribute01 = true, + attribute02 = 'A', + attribute03 = 100UL, + attribute04 = 100.001m, + attribute05 = 200.002f, + attribute06 = "text", + attribute07 = DateTime.MaxValue, + attribute08 = DateTimeOffset.MaxValue, + attribute09 = TimeSpan.MaxValue, + attribute10 = DayOfWeek.Friday + }, + relationships = new { - type = "incomingResources", - attributes = new + single1 = new { - attribute01 = true, - attribute02 = 'A', - attribute03 = 100UL, - attribute04 = 100.001m, - attribute05 = 200.002f, - attribute06 = "text", - attribute07 = DateTime.MaxValue, - attribute08 = DateTimeOffset.MaxValue, - attribute09 = TimeSpan.MaxValue, - attribute10 = DayOfWeek.Friday + data = new + { + type = "incomingResources", + id = "101" + } }, - relationships = new + single2 = new { - single1 = new + data = new { - data = new - { - type = "incomingResources", - id = "101" - } - }, - single2 = new + type = "incomingResources", + id = "102" + } + }, + single3 = new + { + data = new { - data = new - { - type = "incomingResources", - id = "102" - } - }, - single3 = new + type = "incomingResources", + id = "103" + } + }, + single4 = new + { + data = new { - data = new - { - type = "incomingResources", - id = "103" - } - }, - single4 = new + type = "incomingResources", + id = "104" + } + }, + single5 = new + { + data = new { - data = new - { - type = "incomingResources", - id = "104" - } - }, - single5 = new + type = "incomingResources", + id = "105" + } + }, + multi1 = new + { + data = new[] { - data = new + new { type = "incomingResources", - id = "105" + id = "201" } - }, - multi1 = new - { - data = new[] - { - new - { - type = "incomingResources", - id = "201" - } - } - }, - multi2 = new + } + }, + multi2 = new + { + data = new[] { - data = new[] + new { - new - { - type = "incomingResources", - id = "202" - } + type = "incomingResources", + id = "202" } - }, - multi3 = new + } + }, + multi3 = new + { + data = new[] { - data = new[] + new { - new - { - type = "incomingResources", - id = "203" - } + type = "incomingResources", + id = "203" } - }, - multi4 = new + } + }, + multi4 = new + { + data = new[] { - data = new[] + new { - new - { - type = "incomingResources", - id = "204" - } + type = "incomingResources", + id = "204" } - }, - multi5 = new + } + }, + multi5 = new + { + data = new[] { - data = new[] + new { - new - { - type = "incomingResources", - id = "205" - } + type = "incomingResources", + id = "205" } } } } - }); - - [Benchmark] - public object? DeserializeResourceRequest() - { - var document = JsonSerializer.Deserialize(RequestBody, SerializerReadOptions)!; - return DocumentAdapter.Convert(document); } + }); - protected override JsonApiRequest CreateJsonApiRequest(IResourceGraph resourceGraph) + [Benchmark] + public object? DeserializeResourceRequest() + { + var document = JsonSerializer.Deserialize(RequestBody, SerializerReadOptions)!; + return DocumentAdapter.Convert(document); + } + + protected override JsonApiRequest CreateJsonApiRequest(IResourceGraph resourceGraph) + { + return new JsonApiRequest { - return new JsonApiRequest - { - Kind = EndpointKind.Primary, - PrimaryResourceType = resourceGraph.GetResourceType(), - WriteOperation = WriteOperationKind.CreateResource - }; - } + Kind = EndpointKind.Primary, + PrimaryResourceType = resourceGraph.GetResourceType(), + WriteOperation = WriteOperationKind.CreateResource + }; } } diff --git a/benchmarks/Program.cs b/benchmarks/Program.cs index 45406133dd..818b9ab5e5 100644 --- a/benchmarks/Program.cs +++ b/benchmarks/Program.cs @@ -3,22 +3,21 @@ using Benchmarks.QueryString; using Benchmarks.Serialization; -namespace Benchmarks +namespace Benchmarks; + +internal static class Program { - internal static class Program + private static void Main(string[] args) { - private static void Main(string[] args) + var switcher = new BenchmarkSwitcher(new[] { - var switcher = new BenchmarkSwitcher(new[] - { - typeof(ResourceDeserializationBenchmarks), - typeof(OperationsDeserializationBenchmarks), - typeof(ResourceSerializationBenchmarks), - typeof(OperationsSerializationBenchmarks), - typeof(QueryStringParserBenchmarks) - }); + typeof(ResourceDeserializationBenchmarks), + typeof(OperationsDeserializationBenchmarks), + typeof(ResourceSerializationBenchmarks), + typeof(OperationsSerializationBenchmarks), + typeof(QueryStringParserBenchmarks) + }); - switcher.Run(args); - } + switcher.Run(args); } } diff --git a/benchmarks/QueryString/QueryStringParserBenchmarks.cs b/benchmarks/QueryString/QueryStringParserBenchmarks.cs index 42d34f8ce4..efa4f12659 100644 --- a/benchmarks/QueryString/QueryStringParserBenchmarks.cs +++ b/benchmarks/QueryString/QueryStringParserBenchmarks.cs @@ -1,4 +1,3 @@ -using System; using System.ComponentModel.Design; using BenchmarkDotNet.Attributes; using JsonApiDotNetCore; @@ -11,94 +10,92 @@ using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.Logging.Abstractions; -namespace Benchmarks.QueryString +namespace Benchmarks.QueryString; + +// ReSharper disable once ClassCanBeSealed.Global +[MarkdownExporter] +[SimpleJob(3, 10, 20)] +[MemoryDiagnoser] +public class QueryStringParserBenchmarks { - // ReSharper disable once ClassCanBeSealed.Global - [MarkdownExporter] - [SimpleJob(3, 10, 20)] - [MemoryDiagnoser] - public class QueryStringParserBenchmarks + private readonly FakeRequestQueryStringAccessor _queryStringAccessor = new(); + private readonly QueryStringReader _queryStringReader; + + public QueryStringParserBenchmarks() { - private readonly FakeRequestQueryStringAccessor _queryStringAccessor = new(); - private readonly QueryStringReader _queryStringReader; + IJsonApiOptions options = new JsonApiOptions + { + EnableLegacyFilterNotation = true + }; - public QueryStringParserBenchmarks() + IResourceGraph resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add("alt-resource-name").Build(); + + var request = new JsonApiRequest { - IJsonApiOptions options = new JsonApiOptions - { - EnableLegacyFilterNotation = true - }; + PrimaryResourceType = resourceGraph.GetResourceType(typeof(QueryableResource)), + IsCollection = true + }; - IResourceGraph resourceGraph = - new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add("alt-resource-name").Build(); + var resourceFactory = new ResourceFactory(new ServiceContainer()); - var request = new JsonApiRequest - { - PrimaryResourceType = resourceGraph.GetResourceType(typeof(QueryableResource)), - IsCollection = true - }; + var includeReader = new IncludeQueryStringParameterReader(request, resourceGraph, options); + 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 resourceFactory = new ResourceFactory(new ServiceContainer()); + IQueryStringParameterReader[] readers = ArrayFactory.Create(includeReader, filterReader, sortReader, + sparseFieldSetReader, paginationReader); - var includeReader = new IncludeQueryStringParameterReader(request, resourceGraph, options); - 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); + _queryStringReader = new QueryStringReader(options, _queryStringAccessor, readers, NullLoggerFactory.Instance); + } - IQueryStringParameterReader[] readers = ArrayFactory.Create(includeReader, filterReader, sortReader, - sparseFieldSetReader, paginationReader); + [Benchmark] + public void AscendingSort() + { + const string queryString = "?sort=alt-attr-name"; - _queryStringReader = new QueryStringReader(options, _queryStringAccessor, readers, NullLoggerFactory.Instance); - } + _queryStringAccessor.SetQueryString(queryString); + _queryStringReader.ReadAll(null); + } - [Benchmark] - public void AscendingSort() - { - const string queryString = "?sort=alt-attr-name"; + [Benchmark] + public void DescendingSort() + { + const string queryString = "?sort=-alt-attr-name"; - _queryStringAccessor.SetQueryString(queryString); - _queryStringReader.ReadAll(null); - } + _queryStringAccessor.SetQueryString(queryString); + _queryStringReader.ReadAll(null); + } - [Benchmark] - public void DescendingSort() + [Benchmark] + public void ComplexQuery() + { + Run(100, () => { - const string queryString = "?sort=-alt-attr-name"; + const string queryString = + "?filter[alt-attr-name]=abc,eq:abc&sort=-alt-attr-name&include=child&page[size]=1&fields[alt-resource-name]=alt-attr-name"; _queryStringAccessor.SetQueryString(queryString); _queryStringReader.ReadAll(null); - } + }); + } - [Benchmark] - public void ComplexQuery() + private void Run(int iterations, Action action) + { + for (int index = 0; index < iterations; index++) { - Run(100, () => - { - const string queryString = - "?filter[alt-attr-name]=abc,eq:abc&sort=-alt-attr-name&include=child&page[size]=1&fields[alt-resource-name]=alt-attr-name"; - - _queryStringAccessor.SetQueryString(queryString); - _queryStringReader.ReadAll(null); - }); + action(); } + } - private void Run(int iterations, Action action) - { - for (int index = 0; index < iterations; index++) - { - action(); - } - } + private sealed class FakeRequestQueryStringAccessor : IRequestQueryStringAccessor + { + public IQueryCollection Query { get; private set; } = new QueryCollection(); - private sealed class FakeRequestQueryStringAccessor : IRequestQueryStringAccessor + public void SetQueryString(string queryString) { - public IQueryCollection Query { get; private set; } = new QueryCollection(); - - public void SetQueryString(string queryString) - { - Query = new QueryCollection(QueryHelpers.ParseQuery(queryString)); - } + Query = new QueryCollection(QueryHelpers.ParseQuery(queryString)); } } } diff --git a/benchmarks/QueryString/QueryableResource.cs b/benchmarks/QueryString/QueryableResource.cs index bcf0a5075a..7c26474ae4 100644 --- a/benchmarks/QueryString/QueryableResource.cs +++ b/benchmarks/QueryString/QueryableResource.cs @@ -2,15 +2,14 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace Benchmarks.QueryString +namespace Benchmarks.QueryString; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class QueryableResource : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class QueryableResource : Identifiable - { - [Attr(PublicName = "alt-attr-name")] - public string? Name { get; set; } + [Attr(PublicName = "alt-attr-name")] + public string? Name { get; set; } - [HasOne] - public QueryableResource? Child { get; set; } - } + [HasOne] + public QueryableResource? Child { get; set; } } diff --git a/benchmarks/Serialization/OperationsSerializationBenchmarks.cs b/benchmarks/Serialization/OperationsSerializationBenchmarks.cs index fef0d67a12..7076ca5cb8 100644 --- a/benchmarks/Serialization/OperationsSerializationBenchmarks.cs +++ b/benchmarks/Serialization/OperationsSerializationBenchmarks.cs @@ -1,5 +1,3 @@ -using System; -using System.Collections.Generic; using System.Text.Json; using BenchmarkDotNet.Attributes; using JsonApiDotNetCore.Configuration; @@ -8,130 +6,129 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; -namespace Benchmarks.Serialization +namespace Benchmarks.Serialization; + +[MarkdownExporter] +// ReSharper disable once ClassCanBeSealed.Global +public class OperationsSerializationBenchmarks : SerializationBenchmarkBase { - [MarkdownExporter] - // ReSharper disable once ClassCanBeSealed.Global - public class OperationsSerializationBenchmarks : SerializationBenchmarkBase - { - private readonly IEnumerable _responseOperations; + private readonly IEnumerable _responseOperations; - public OperationsSerializationBenchmarks() - { - // ReSharper disable once VirtualMemberCallInConstructor - JsonApiRequest request = CreateJsonApiRequest(ResourceGraph); + public OperationsSerializationBenchmarks() + { + // ReSharper disable once VirtualMemberCallInConstructor + JsonApiRequest request = CreateJsonApiRequest(ResourceGraph); - _responseOperations = CreateResponseOperations(request); - } + _responseOperations = CreateResponseOperations(request); + } - private static IEnumerable CreateResponseOperations(IJsonApiRequest request) + private static IEnumerable CreateResponseOperations(IJsonApiRequest request) + { + var resource1 = new OutgoingResource { - var resource1 = new OutgoingResource - { - Id = 1, - Attribute01 = true, - Attribute02 = 'A', - Attribute03 = 100UL, - Attribute04 = 100.001m, - Attribute05 = 100.002f, - Attribute06 = "text1", - Attribute07 = new DateTime(2001, 1, 1), - Attribute08 = new DateTimeOffset(2001, 1, 1, 0, 0, 0, TimeSpan.FromHours(1)), - Attribute09 = new TimeSpan(1, 0, 0), - Attribute10 = DayOfWeek.Sunday - }; - - var resource2 = new OutgoingResource - { - Id = 2, - Attribute01 = false, - Attribute02 = 'B', - Attribute03 = 200UL, - Attribute04 = 200.001m, - Attribute05 = 200.002f, - Attribute06 = "text2", - Attribute07 = new DateTime(2002, 2, 2), - Attribute08 = new DateTimeOffset(2002, 2, 2, 0, 0, 0, TimeSpan.FromHours(2)), - Attribute09 = new TimeSpan(2, 0, 0), - Attribute10 = DayOfWeek.Monday - }; + Id = 1, + Attribute01 = true, + Attribute02 = 'A', + Attribute03 = 100UL, + Attribute04 = 100.001m, + Attribute05 = 100.002f, + Attribute06 = "text1", + Attribute07 = new DateTime(2001, 1, 1), + Attribute08 = new DateTimeOffset(2001, 1, 1, 0, 0, 0, TimeSpan.FromHours(1)), + Attribute09 = new TimeSpan(1, 0, 0), + Attribute10 = DayOfWeek.Sunday + }; - var resource3 = new OutgoingResource - { - Id = 3, - Attribute01 = true, - Attribute02 = 'C', - Attribute03 = 300UL, - Attribute04 = 300.001m, - Attribute05 = 300.002f, - Attribute06 = "text3", - Attribute07 = new DateTime(2003, 3, 3), - Attribute08 = new DateTimeOffset(2003, 3, 3, 0, 0, 0, TimeSpan.FromHours(3)), - Attribute09 = new TimeSpan(3, 0, 0), - Attribute10 = DayOfWeek.Tuesday - }; + var resource2 = new OutgoingResource + { + Id = 2, + Attribute01 = false, + Attribute02 = 'B', + Attribute03 = 200UL, + Attribute04 = 200.001m, + Attribute05 = 200.002f, + Attribute06 = "text2", + Attribute07 = new DateTime(2002, 2, 2), + Attribute08 = new DateTimeOffset(2002, 2, 2, 0, 0, 0, TimeSpan.FromHours(2)), + Attribute09 = new TimeSpan(2, 0, 0), + Attribute10 = DayOfWeek.Monday + }; - var resource4 = new OutgoingResource - { - Id = 4, - Attribute01 = false, - Attribute02 = 'D', - Attribute03 = 400UL, - Attribute04 = 400.001m, - Attribute05 = 400.002f, - Attribute06 = "text4", - Attribute07 = new DateTime(2004, 4, 4), - Attribute08 = new DateTimeOffset(2004, 4, 4, 0, 0, 0, TimeSpan.FromHours(4)), - Attribute09 = new TimeSpan(4, 0, 0), - Attribute10 = DayOfWeek.Wednesday - }; + var resource3 = new OutgoingResource + { + Id = 3, + Attribute01 = true, + Attribute02 = 'C', + Attribute03 = 300UL, + Attribute04 = 300.001m, + Attribute05 = 300.002f, + Attribute06 = "text3", + Attribute07 = new DateTime(2003, 3, 3), + Attribute08 = new DateTimeOffset(2003, 3, 3, 0, 0, 0, TimeSpan.FromHours(3)), + Attribute09 = new TimeSpan(3, 0, 0), + Attribute10 = DayOfWeek.Tuesday + }; - var resource5 = new OutgoingResource - { - Id = 5, - Attribute01 = true, - Attribute02 = 'E', - Attribute03 = 500UL, - Attribute04 = 500.001m, - Attribute05 = 500.002f, - Attribute06 = "text5", - Attribute07 = new DateTime(2005, 5, 5), - Attribute08 = new DateTimeOffset(2005, 5, 5, 0, 0, 0, TimeSpan.FromHours(5)), - Attribute09 = new TimeSpan(5, 0, 0), - Attribute10 = DayOfWeek.Thursday - }; + var resource4 = new OutgoingResource + { + Id = 4, + Attribute01 = false, + Attribute02 = 'D', + Attribute03 = 400UL, + Attribute04 = 400.001m, + Attribute05 = 400.002f, + Attribute06 = "text4", + Attribute07 = new DateTime(2004, 4, 4), + Attribute08 = new DateTimeOffset(2004, 4, 4, 0, 0, 0, TimeSpan.FromHours(4)), + Attribute09 = new TimeSpan(4, 0, 0), + Attribute10 = DayOfWeek.Wednesday + }; - var targetedFields = new TargetedFields(); + var resource5 = new OutgoingResource + { + Id = 5, + Attribute01 = true, + Attribute02 = 'E', + Attribute03 = 500UL, + Attribute04 = 500.001m, + Attribute05 = 500.002f, + Attribute06 = "text5", + Attribute07 = new DateTime(2005, 5, 5), + Attribute08 = new DateTimeOffset(2005, 5, 5, 0, 0, 0, TimeSpan.FromHours(5)), + Attribute09 = new TimeSpan(5, 0, 0), + Attribute10 = DayOfWeek.Thursday + }; - return new List - { - new(resource1, targetedFields, request), - new(resource2, targetedFields, request), - new(resource3, targetedFields, request), - new(resource4, targetedFields, request), - new(resource5, targetedFields, request) - }; - } + var targetedFields = new TargetedFields(); - [Benchmark] - public string SerializeOperationsResponse() + return new List { - Document responseDocument = ResponseModelAdapter.Convert(_responseOperations); - return JsonSerializer.Serialize(responseDocument, SerializerWriteOptions); - } + new(resource1, targetedFields, request), + new(resource2, targetedFields, request), + new(resource3, targetedFields, request), + new(resource4, targetedFields, request), + new(resource5, targetedFields, request) + }; + } - protected override JsonApiRequest CreateJsonApiRequest(IResourceGraph resourceGraph) - { - return new JsonApiRequest - { - Kind = EndpointKind.AtomicOperations, - PrimaryResourceType = resourceGraph.GetResourceType() - }; - } + [Benchmark] + public string SerializeOperationsResponse() + { + Document responseDocument = ResponseModelAdapter.Convert(_responseOperations); + return JsonSerializer.Serialize(responseDocument, SerializerWriteOptions); + } - protected override IEvaluatedIncludeCache CreateEvaluatedIncludeCache(IResourceGraph resourceGraph) + protected override JsonApiRequest CreateJsonApiRequest(IResourceGraph resourceGraph) + { + return new JsonApiRequest { - return new EvaluatedIncludeCache(); - } + Kind = EndpointKind.AtomicOperations, + PrimaryResourceType = resourceGraph.GetResourceType() + }; + } + + protected override IEvaluatedIncludeCache CreateEvaluatedIncludeCache(IResourceGraph resourceGraph) + { + return new EvaluatedIncludeCache(); } } diff --git a/benchmarks/Serialization/ResourceSerializationBenchmarks.cs b/benchmarks/Serialization/ResourceSerializationBenchmarks.cs index 3435265262..6b716a5401 100644 --- a/benchmarks/Serialization/ResourceSerializationBenchmarks.cs +++ b/benchmarks/Serialization/ResourceSerializationBenchmarks.cs @@ -1,5 +1,3 @@ -using System; -using System.Collections.Generic; using System.Collections.Immutable; using System.Text.Json; using BenchmarkDotNet.Attributes; @@ -11,133 +9,132 @@ using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.Objects; -namespace Benchmarks.Serialization +namespace Benchmarks.Serialization; + +[MarkdownExporter] +// ReSharper disable once ClassCanBeSealed.Global +public class ResourceSerializationBenchmarks : SerializationBenchmarkBase { - [MarkdownExporter] - // ReSharper disable once ClassCanBeSealed.Global - public class ResourceSerializationBenchmarks : SerializationBenchmarkBase + private static readonly OutgoingResource ResponseResource = CreateResponseResource(); + + private static OutgoingResource CreateResponseResource() { - private static readonly OutgoingResource ResponseResource = CreateResponseResource(); + var resource1 = new OutgoingResource + { + Id = 1, + Attribute01 = true, + Attribute02 = 'A', + Attribute03 = 100UL, + Attribute04 = 100.001m, + Attribute05 = 100.002f, + Attribute06 = "text1", + Attribute07 = new DateTime(2001, 1, 1), + Attribute08 = new DateTimeOffset(2001, 1, 1, 0, 0, 0, TimeSpan.FromHours(1)), + Attribute09 = new TimeSpan(1, 0, 0), + Attribute10 = DayOfWeek.Sunday + }; - private static OutgoingResource CreateResponseResource() + var resource2 = new OutgoingResource { - var resource1 = new OutgoingResource - { - Id = 1, - Attribute01 = true, - Attribute02 = 'A', - Attribute03 = 100UL, - Attribute04 = 100.001m, - Attribute05 = 100.002f, - Attribute06 = "text1", - Attribute07 = new DateTime(2001, 1, 1), - Attribute08 = new DateTimeOffset(2001, 1, 1, 0, 0, 0, TimeSpan.FromHours(1)), - Attribute09 = new TimeSpan(1, 0, 0), - Attribute10 = DayOfWeek.Sunday - }; - - var resource2 = new OutgoingResource - { - Id = 2, - Attribute01 = false, - Attribute02 = 'B', - Attribute03 = 200UL, - Attribute04 = 200.001m, - Attribute05 = 200.002f, - Attribute06 = "text2", - Attribute07 = new DateTime(2002, 2, 2), - Attribute08 = new DateTimeOffset(2002, 2, 2, 0, 0, 0, TimeSpan.FromHours(2)), - Attribute09 = new TimeSpan(2, 0, 0), - Attribute10 = DayOfWeek.Monday - }; - - var resource3 = new OutgoingResource - { - Id = 3, - Attribute01 = true, - Attribute02 = 'C', - Attribute03 = 300UL, - Attribute04 = 300.001m, - Attribute05 = 300.002f, - Attribute06 = "text3", - Attribute07 = new DateTime(2003, 3, 3), - Attribute08 = new DateTimeOffset(2003, 3, 3, 0, 0, 0, TimeSpan.FromHours(3)), - Attribute09 = new TimeSpan(3, 0, 0), - Attribute10 = DayOfWeek.Tuesday - }; - - var resource4 = new OutgoingResource - { - Id = 4, - Attribute01 = false, - Attribute02 = 'D', - Attribute03 = 400UL, - Attribute04 = 400.001m, - Attribute05 = 400.002f, - Attribute06 = "text4", - Attribute07 = new DateTime(2004, 4, 4), - Attribute08 = new DateTimeOffset(2004, 4, 4, 0, 0, 0, TimeSpan.FromHours(4)), - Attribute09 = new TimeSpan(4, 0, 0), - Attribute10 = DayOfWeek.Wednesday - }; - - var resource5 = new OutgoingResource - { - Id = 5, - Attribute01 = true, - Attribute02 = 'E', - Attribute03 = 500UL, - Attribute04 = 500.001m, - Attribute05 = 500.002f, - Attribute06 = "text5", - Attribute07 = new DateTime(2005, 5, 5), - Attribute08 = new DateTimeOffset(2005, 5, 5, 0, 0, 0, TimeSpan.FromHours(5)), - Attribute09 = new TimeSpan(5, 0, 0), - Attribute10 = DayOfWeek.Thursday - }; - - resource1.Single2 = resource2; - resource2.Single3 = resource3; - resource3.Multi4 = resource4.AsHashSet(); - resource4.Multi5 = resource5.AsHashSet(); - - return resource1; - } - - [Benchmark] - public string SerializeResourceResponse() + Id = 2, + Attribute01 = false, + Attribute02 = 'B', + Attribute03 = 200UL, + Attribute04 = 200.001m, + Attribute05 = 200.002f, + Attribute06 = "text2", + Attribute07 = new DateTime(2002, 2, 2), + Attribute08 = new DateTimeOffset(2002, 2, 2, 0, 0, 0, TimeSpan.FromHours(2)), + Attribute09 = new TimeSpan(2, 0, 0), + Attribute10 = DayOfWeek.Monday + }; + + var resource3 = new OutgoingResource { - Document responseDocument = ResponseModelAdapter.Convert(ResponseResource); - return JsonSerializer.Serialize(responseDocument, SerializerWriteOptions); - } + Id = 3, + Attribute01 = true, + Attribute02 = 'C', + Attribute03 = 300UL, + Attribute04 = 300.001m, + Attribute05 = 300.002f, + Attribute06 = "text3", + Attribute07 = new DateTime(2003, 3, 3), + Attribute08 = new DateTimeOffset(2003, 3, 3, 0, 0, 0, TimeSpan.FromHours(3)), + Attribute09 = new TimeSpan(3, 0, 0), + Attribute10 = DayOfWeek.Tuesday + }; - protected override JsonApiRequest CreateJsonApiRequest(IResourceGraph resourceGraph) + var resource4 = new OutgoingResource { - return new JsonApiRequest - { - Kind = EndpointKind.Primary, - PrimaryResourceType = resourceGraph.GetResourceType() - }; - } - - protected override IEvaluatedIncludeCache CreateEvaluatedIncludeCache(IResourceGraph resourceGraph) + Id = 4, + Attribute01 = false, + Attribute02 = 'D', + Attribute03 = 400UL, + Attribute04 = 400.001m, + Attribute05 = 400.002f, + Attribute06 = "text4", + Attribute07 = new DateTime(2004, 4, 4), + Attribute08 = new DateTimeOffset(2004, 4, 4, 0, 0, 0, TimeSpan.FromHours(4)), + Attribute09 = new TimeSpan(4, 0, 0), + Attribute10 = DayOfWeek.Wednesday + }; + + var resource5 = new OutgoingResource { - ResourceType resourceAType = resourceGraph.GetResourceType(); + Id = 5, + Attribute01 = true, + Attribute02 = 'E', + Attribute03 = 500UL, + Attribute04 = 500.001m, + Attribute05 = 500.002f, + Attribute06 = "text5", + Attribute07 = new DateTime(2005, 5, 5), + Attribute08 = new DateTimeOffset(2005, 5, 5, 0, 0, 0, TimeSpan.FromHours(5)), + Attribute09 = new TimeSpan(5, 0, 0), + Attribute10 = DayOfWeek.Thursday + }; + + resource1.Single2 = resource2; + resource2.Single3 = resource3; + resource3.Multi4 = resource4.AsHashSet(); + resource4.Multi5 = resource5.AsHashSet(); + + return resource1; + } + + [Benchmark] + public string SerializeResourceResponse() + { + Document responseDocument = ResponseModelAdapter.Convert(ResponseResource); + return JsonSerializer.Serialize(responseDocument, SerializerWriteOptions); + } + + protected override JsonApiRequest CreateJsonApiRequest(IResourceGraph resourceGraph) + { + return new JsonApiRequest + { + Kind = EndpointKind.Primary, + PrimaryResourceType = resourceGraph.GetResourceType() + }; + } + + protected override IEvaluatedIncludeCache CreateEvaluatedIncludeCache(IResourceGraph resourceGraph) + { + ResourceType resourceAType = resourceGraph.GetResourceType(); - RelationshipAttribute single2 = resourceAType.GetRelationshipByPropertyName(nameof(OutgoingResource.Single2)); - RelationshipAttribute single3 = resourceAType.GetRelationshipByPropertyName(nameof(OutgoingResource.Single3)); - RelationshipAttribute multi4 = resourceAType.GetRelationshipByPropertyName(nameof(OutgoingResource.Multi4)); - RelationshipAttribute multi5 = resourceAType.GetRelationshipByPropertyName(nameof(OutgoingResource.Multi5)); + RelationshipAttribute single2 = resourceAType.GetRelationshipByPropertyName(nameof(OutgoingResource.Single2)); + RelationshipAttribute single3 = resourceAType.GetRelationshipByPropertyName(nameof(OutgoingResource.Single3)); + RelationshipAttribute multi4 = resourceAType.GetRelationshipByPropertyName(nameof(OutgoingResource.Multi4)); + RelationshipAttribute multi5 = resourceAType.GetRelationshipByPropertyName(nameof(OutgoingResource.Multi5)); - ImmutableArray chain = ImmutableArray.Create(single2, single3, multi4, multi5); - IEnumerable chains = new ResourceFieldChainExpression(chain).AsEnumerable(); + ImmutableArray chain = ImmutableArray.Create(single2, single3, multi4, multi5); + IEnumerable chains = new ResourceFieldChainExpression(chain).AsEnumerable(); - var converter = new IncludeChainConverter(); - IncludeExpression include = converter.FromRelationshipChains(chains); + var converter = new IncludeChainConverter(); + IncludeExpression include = converter.FromRelationshipChains(chains); - var cache = new EvaluatedIncludeCache(); - cache.Set(include); - return cache; - } + var cache = new EvaluatedIncludeCache(); + cache.Set(include); + return cache; } } diff --git a/benchmarks/Serialization/SerializationBenchmarkBase.cs b/benchmarks/Serialization/SerializationBenchmarkBase.cs index 84d28c22ab..befa5049e8 100644 --- a/benchmarks/Serialization/SerializationBenchmarkBase.cs +++ b/benchmarks/Serialization/SerializationBenchmarkBase.cs @@ -1,10 +1,6 @@ -using System; -using System.Collections.Generic; using System.Collections.Immutable; using System.Text.Json; using System.Text.Json.Serialization; -using System.Threading; -using System.Threading.Tasks; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; @@ -19,249 +15,248 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging.Abstractions; -namespace Benchmarks.Serialization +namespace Benchmarks.Serialization; + +public abstract class SerializationBenchmarkBase { - public abstract class SerializationBenchmarkBase - { - protected readonly JsonSerializerOptions SerializerWriteOptions; - protected readonly IResponseModelAdapter ResponseModelAdapter; - protected readonly IResourceGraph ResourceGraph; + protected readonly JsonSerializerOptions SerializerWriteOptions; + protected readonly IResponseModelAdapter ResponseModelAdapter; + protected readonly IResourceGraph ResourceGraph; - protected SerializationBenchmarkBase() + protected SerializationBenchmarkBase() + { + var options = new JsonApiOptions { - var options = new JsonApiOptions + SerializerOptions = { - SerializerOptions = + Converters = { - Converters = - { - new JsonStringEnumConverter() - } + new JsonStringEnumConverter() } - }; + } + }; - ResourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add().Build(); - SerializerWriteOptions = ((IJsonApiOptions)options).SerializerWriteOptions; + ResourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add().Build(); + SerializerWriteOptions = ((IJsonApiOptions)options).SerializerWriteOptions; - // ReSharper disable VirtualMemberCallInConstructor - JsonApiRequest request = CreateJsonApiRequest(ResourceGraph); - IEvaluatedIncludeCache evaluatedIncludeCache = CreateEvaluatedIncludeCache(ResourceGraph); - // ReSharper restore VirtualMemberCallInConstructor + // ReSharper disable VirtualMemberCallInConstructor + JsonApiRequest request = CreateJsonApiRequest(ResourceGraph); + IEvaluatedIncludeCache evaluatedIncludeCache = CreateEvaluatedIncludeCache(ResourceGraph); + // ReSharper restore VirtualMemberCallInConstructor - var linkBuilder = new FakeLinkBuilder(); - var metaBuilder = new FakeMetaBuilder(); - IQueryConstraintProvider[] constraintProviders = Array.Empty(); - var resourceDefinitionAccessor = new FakeResourceDefinitionAccessor(); - var sparseFieldSetCache = new SparseFieldSetCache(constraintProviders, resourceDefinitionAccessor); - var requestQueryStringAccessor = new FakeRequestQueryStringAccessor(); + var linkBuilder = new FakeLinkBuilder(); + var metaBuilder = new FakeMetaBuilder(); + IQueryConstraintProvider[] constraintProviders = Array.Empty(); + var resourceDefinitionAccessor = new FakeResourceDefinitionAccessor(); + var sparseFieldSetCache = new SparseFieldSetCache(constraintProviders, resourceDefinitionAccessor); + var requestQueryStringAccessor = new FakeRequestQueryStringAccessor(); - ResponseModelAdapter = new ResponseModelAdapter(request, options, linkBuilder, metaBuilder, resourceDefinitionAccessor, evaluatedIncludeCache, - sparseFieldSetCache, requestQueryStringAccessor); - } + ResponseModelAdapter = new ResponseModelAdapter(request, options, linkBuilder, metaBuilder, resourceDefinitionAccessor, evaluatedIncludeCache, + sparseFieldSetCache, requestQueryStringAccessor); + } - protected abstract JsonApiRequest CreateJsonApiRequest(IResourceGraph resourceGraph); + protected abstract JsonApiRequest CreateJsonApiRequest(IResourceGraph resourceGraph); - protected abstract IEvaluatedIncludeCache CreateEvaluatedIncludeCache(IResourceGraph resourceGraph); + protected abstract IEvaluatedIncludeCache CreateEvaluatedIncludeCache(IResourceGraph resourceGraph); - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class OutgoingResource : Identifiable - { - [Attr] - public bool Attribute01 { get; set; } + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class OutgoingResource : Identifiable + { + [Attr] + public bool Attribute01 { get; set; } - [Attr] - public char Attribute02 { get; set; } + [Attr] + public char Attribute02 { get; set; } - [Attr] - public ulong? Attribute03 { get; set; } + [Attr] + public ulong? Attribute03 { get; set; } - [Attr] - public decimal Attribute04 { get; set; } + [Attr] + public decimal Attribute04 { get; set; } - [Attr] - public float? Attribute05 { get; set; } + [Attr] + public float? Attribute05 { get; set; } - [Attr] - public string Attribute06 { get; set; } = null!; + [Attr] + public string Attribute06 { get; set; } = null!; - [Attr] - public DateTime? Attribute07 { get; set; } + [Attr] + public DateTime? Attribute07 { get; set; } - [Attr] - public DateTimeOffset? Attribute08 { get; set; } + [Attr] + public DateTimeOffset? Attribute08 { get; set; } - [Attr] - public TimeSpan? Attribute09 { get; set; } + [Attr] + public TimeSpan? Attribute09 { get; set; } - [Attr] - public DayOfWeek Attribute10 { get; set; } + [Attr] + public DayOfWeek Attribute10 { get; set; } - [HasOne] - public OutgoingResource Single1 { get; set; } = null!; + [HasOne] + public OutgoingResource Single1 { get; set; } = null!; - [HasOne] - public OutgoingResource Single2 { get; set; } = null!; + [HasOne] + public OutgoingResource Single2 { get; set; } = null!; - [HasOne] - public OutgoingResource Single3 { get; set; } = null!; + [HasOne] + public OutgoingResource Single3 { get; set; } = null!; - [HasOne] - public OutgoingResource Single4 { get; set; } = null!; + [HasOne] + public OutgoingResource Single4 { get; set; } = null!; - [HasOne] - public OutgoingResource Single5 { get; set; } = null!; + [HasOne] + public OutgoingResource Single5 { get; set; } = null!; - [HasMany] - public ISet Multi1 { get; set; } = null!; + [HasMany] + public ISet Multi1 { get; set; } = null!; - [HasMany] - public ISet Multi2 { get; set; } = null!; + [HasMany] + public ISet Multi2 { get; set; } = null!; - [HasMany] - public ISet Multi3 { get; set; } = null!; + [HasMany] + public ISet Multi3 { get; set; } = null!; - [HasMany] - public ISet Multi4 { get; set; } = null!; + [HasMany] + public ISet Multi4 { get; set; } = null!; - [HasMany] - public ISet Multi5 { get; set; } = null!; - } + [HasMany] + public ISet Multi5 { get; set; } = null!; + } - private sealed class FakeResourceDefinitionAccessor : IResourceDefinitionAccessor + private sealed class FakeResourceDefinitionAccessor : IResourceDefinitionAccessor + { + public IImmutableSet OnApplyIncludes(ResourceType resourceType, IImmutableSet existingIncludes) { - public IImmutableSet OnApplyIncludes(ResourceType resourceType, IImmutableSet existingIncludes) - { - return existingIncludes; - } - - public FilterExpression? OnApplyFilter(ResourceType resourceType, FilterExpression? existingFilter) - { - return existingFilter; - } + return existingIncludes; + } - public SortExpression? OnApplySort(ResourceType resourceType, SortExpression? existingSort) - { - return existingSort; - } + public FilterExpression? OnApplyFilter(ResourceType resourceType, FilterExpression? existingFilter) + { + return existingFilter; + } - public PaginationExpression? OnApplyPagination(ResourceType resourceType, PaginationExpression? existingPagination) - { - return existingPagination; - } + public SortExpression? OnApplySort(ResourceType resourceType, SortExpression? existingSort) + { + return existingSort; + } - public SparseFieldSetExpression? OnApplySparseFieldSet(ResourceType resourceType, SparseFieldSetExpression? existingSparseFieldSet) - { - return existingSparseFieldSet; - } + public PaginationExpression? OnApplyPagination(ResourceType resourceType, PaginationExpression? existingPagination) + { + return existingPagination; + } - public object? GetQueryableHandlerForQueryStringParameter(Type resourceClrType, string parameterName) - { - return null; - } + public SparseFieldSetExpression? OnApplySparseFieldSet(ResourceType resourceType, SparseFieldSetExpression? existingSparseFieldSet) + { + return existingSparseFieldSet; + } - public IDictionary? GetMeta(ResourceType resourceType, IIdentifiable resourceInstance) - { - return null; - } + public object? GetQueryableHandlerForQueryStringParameter(Type resourceClrType, string parameterName) + { + return null; + } - public Task OnPrepareWriteAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { - return Task.CompletedTask; - } + public IDictionary? GetMeta(ResourceType resourceType, IIdentifiable resourceInstance) + { + return null; + } - public Task OnSetToOneRelationshipAsync(TResource leftResource, HasOneAttribute hasOneRelationship, - IIdentifiable? rightResourceId, WriteOperationKind writeOperation, CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { - return Task.FromResult(rightResourceId); - } + public Task OnPrepareWriteAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + return Task.CompletedTask; + } - public Task OnSetToManyRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, - ISet rightResourceIds, WriteOperationKind writeOperation, CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { - return Task.CompletedTask; - } + public Task OnSetToOneRelationshipAsync(TResource leftResource, HasOneAttribute hasOneRelationship, + IIdentifiable? rightResourceId, WriteOperationKind writeOperation, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + return Task.FromResult(rightResourceId); + } - public Task OnAddToRelationshipAsync(TId leftResourceId, HasManyAttribute hasManyRelationship, ISet rightResourceIds, - CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { - return Task.CompletedTask; - } + public Task OnSetToManyRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + WriteOperationKind writeOperation, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + return Task.CompletedTask; + } - public Task OnRemoveFromRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, - ISet rightResourceIds, CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { - return Task.CompletedTask; - } + public Task OnAddToRelationshipAsync(TId leftResourceId, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + return Task.CompletedTask; + } - public Task OnWritingAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { - return Task.CompletedTask; - } + public Task OnRemoveFromRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + return Task.CompletedTask; + } - public Task OnWriteSucceededAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { - return Task.CompletedTask; - } + public Task OnWritingAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + return Task.CompletedTask; + } - public void OnDeserialize(IIdentifiable resource) - { - } + public Task OnWriteSucceededAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + return Task.CompletedTask; + } - public void OnSerialize(IIdentifiable resource) - { - } + public void OnDeserialize(IIdentifiable resource) + { } - private sealed class FakeLinkBuilder : ILinkBuilder + public void OnSerialize(IIdentifiable resource) { - public TopLevelLinks GetTopLevelLinks() - { - return new TopLevelLinks - { - Self = "TopLevel:Self" - }; - } + } + } - public ResourceLinks GetResourceLinks(ResourceType resourceType, IIdentifiable resource) + private sealed class FakeLinkBuilder : ILinkBuilder + { + public TopLevelLinks GetTopLevelLinks() + { + return new TopLevelLinks { - return new ResourceLinks - { - Self = "Resource:Self" - }; - } + Self = "TopLevel:Self" + }; + } - public RelationshipLinks GetRelationshipLinks(RelationshipAttribute relationship, IIdentifiable leftResource) + public ResourceLinks GetResourceLinks(ResourceType resourceType, IIdentifiable resource) + { + return new ResourceLinks { - return new RelationshipLinks - { - Self = "Relationship:Self", - Related = "Relationship:Related" - }; - } + Self = "Resource:Self" + }; } - private sealed class FakeMetaBuilder : IMetaBuilder + public RelationshipLinks GetRelationshipLinks(RelationshipAttribute relationship, IIdentifiable leftResource) { - public void Add(IReadOnlyDictionary values) + return new RelationshipLinks { - } + Self = "Relationship:Self", + Related = "Relationship:Related" + }; + } + } - public IDictionary? Build() - { - return null; - } + private sealed class FakeMetaBuilder : IMetaBuilder + { + public void Add(IReadOnlyDictionary values) + { } - private sealed class FakeRequestQueryStringAccessor : IRequestQueryStringAccessor + public IDictionary? Build() { - public IQueryCollection Query { get; } = new QueryCollection(0); + return null; } } + + private sealed class FakeRequestQueryStringAccessor : IRequestQueryStringAccessor + { + public IQueryCollection Query { get; } = new QueryCollection(0); + } } diff --git a/docs/getting-started/step-by-step.md b/docs/getting-started/step-by-step.md index 697546561e..57090d2d09 100644 --- a/docs/getting-started/step-by-step.md +++ b/docs/getting-started/step-by-step.md @@ -3,21 +3,21 @@ The most basic use case leverages Entity Framework Core. The shortest path to a running API looks like: -- Create a new web app +- Create a new API project - Install - Define models - Define the DbContext -- Add Middleware and Services +- Add services and middleware - Seed the database -- Start the app +- Start the API This page will walk you through the **simplest** use case. More detailed examples can be found in the detailed usage subsections. -### Create A New Web App +### Create a new API project ``` -mkdir MyApp -cd MyApp +mkdir MyApi +cd MyApi dotnet new webapi ``` @@ -31,7 +31,7 @@ dotnet add package JsonApiDotNetCore Install-Package JsonApiDotNetCore ``` -### Define Models +### Define models Define your domain models such that they implement `IIdentifiable`. The easiest way to do this is to inherit from `Identifiable`. @@ -47,7 +47,7 @@ public class Person : Identifiable } ``` -### Define DbContext +### Define the DbContext Nothing special here, just an ordinary `DbContext`. @@ -63,46 +63,56 @@ public class AppDbContext : DbContext } ``` -### Middleware and Services +### Add services and middleware -Finally, add the services by adding the following to your Startup.ConfigureServices: +Finally, register the services and middleware by adding them to your Program.cs: ```c# -// This method gets called by the runtime. Use this method to add services to the container. -public void ConfigureServices(IServiceCollection services) +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +// Add services to the container. + +// Add the Entity Framework Core DbContext like you normally would. +builder.Services.AddDbContext(options => { - // Add the Entity Framework Core DbContext like you normally would - services.AddDbContext(options => - { - // Use whatever provider you want, this is just an example - options.UseNpgsql(GetDbConnectionString()); - }); + string connectionString = GetConnectionString(); - // Add JsonApiDotNetCore - services.AddJsonApi(); -} -``` + // Use whatever provider you want, this is just an example. + options.UseNpgsql(connectionString); +}); -Add the middleware to the Startup.Configure method. +// Add JsonApiDotNetCore services. +builder.Services.AddJsonApi(); -```c# -// This method gets called by the runtime. Use this method to configure the HTTP request pipeline. -public void Configure(IApplicationBuilder app) -{ - app.UseRouting(); - app.UseJsonApi(); - app.UseEndpoints(endpoints => endpoints.MapControllers()); -} +WebApplication app = builder.Build(); + +// Configure the HTTP request pipeline. + +app.UseRouting(); + +// Add JsonApiDotNetCore middleware. +app.UseJsonApi(); + +app.MapControllers(); + +app.Run(); ``` -### Seeding the Database +### Seed the database -One way to seed the database is in your Configure method: +One way to seed the database is from your Program.cs: ```c# -public void Configure(IApplicationBuilder app, AppDbContext dbContext) +await CreateDatabaseAsync(app.Services); + +app.Run(); + +static async Task CreateDatabaseAsync(IServiceProvider serviceProvider) { - dbContext.Database.EnsureCreated(); + await using AsyncServiceScope scope = serviceProvider.CreateAsyncScope(); + + var dbContext = scope.ServiceProvider.GetRequiredService(); + await dbContext.Database.EnsureCreatedAsync(); if (!dbContext.People.Any()) { @@ -111,16 +121,12 @@ public void Configure(IApplicationBuilder app, AppDbContext dbContext) Name = "John Doe" }); - dbContext.SaveChanges(); + await dbContext.SaveChangesAsync(); } - - app.UseRouting(); - app.UseJsonApi(); - app.UseEndpoints(endpoints => endpoints.MapControllers()); } ``` -### Start the App +### Start the API ``` dotnet run diff --git a/docs/usage/errors.md b/docs/usage/errors.md index 3278526e6c..67ce6fde17 100644 --- a/docs/usage/errors.md +++ b/docs/usage/errors.md @@ -88,11 +88,6 @@ public class CustomExceptionHandler : ExceptionHandler } } -public class Startup -{ - public void ConfigureServices(IServiceCollection services) - { - services.AddScoped(); - } -} +// Program.cs +builder.Services.AddScoped(); ``` diff --git a/docs/usage/extensibility/layer-overview.md b/docs/usage/extensibility/layer-overview.md index 8afd1b38bb..2fe99e2fbd 100644 --- a/docs/usage/extensibility/layer-overview.md +++ b/docs/usage/extensibility/layer-overview.md @@ -25,18 +25,15 @@ on your needs, you may want to replace other parts by deriving from the built-in **Note:** If you're using [auto-discovery](~/usage/resource-graph.md#auto-discovery), then resource services, repositories and resource definitions will be automatically registered for you. -Replacing built-in services is done on a per-resource basis and can be done through dependency injection in your Startup.cs file. +Replacing built-in services is done on a per-resource basis and can be done at startup. For convenience, extension methods are provided to register layers on all their implemented interfaces. ```c# -// Startup.cs -public void ConfigureServices(IServiceCollection services) -{ - services.AddResourceService(); - services.AddResourceRepository(); - services.AddResourceDefinition(); - - services.AddScoped(); - services.AddScoped(); -} +// Program.cs +builder.Services.AddResourceService(); +builder.Services.AddResourceRepository(); +builder.Services.AddResourceDefinition(); + +builder.Services.AddScoped(); +builder.Services.AddScoped(); ``` diff --git a/docs/usage/extensibility/middleware.md b/docs/usage/extensibility/middleware.md index 9be350250a..62528893d3 100644 --- a/docs/usage/extensibility/middleware.md +++ b/docs/usage/extensibility/middleware.md @@ -10,46 +10,54 @@ It is possible to replace the built-in middleware components by configuring the The following example replaces the internal exception filter with a custom implementation. ```c# -/// In Startup.ConfigureServices -services.AddService(); +// Program.cs +builder.Services.AddService(); ``` ## Configuring `MvcOptions` -The following example replaces all internal filters with a custom filter. +The following example replaces the built-in query string action filter with a custom filter. ```c# -public class Startup +// Program.cs + +// Add services to the container. + +builder.Services.AddScoped(); + +IMvcCoreBuilder mvcCoreBuilder = builder.Services.AddMvcCore(); +builder.Services.AddJsonApi(mvcBuilder: mvcCoreBuilder); + +Action? postConfigureMvcOptions = null; + +// Ensure this is placed after the AddJsonApi() call. +mvcCoreBuilder.AddMvcOptions(mvcOptions => +{ + postConfigureMvcOptions?.Invoke(mvcOptions); +}); + +// Configure the HTTP request pipeline. + +// Ensure this is placed before the MapControllers() call. +postConfigureMvcOptions = mvcOptions => { - private Action _postConfigureMvcOptions; - - public void ConfigureServices(IServiceCollection services) - { - services.AddSingleton(); - - IMvcCoreBuilder builder = services.AddMvcCore(); - services.AddJsonApi(mvcBuilder: builder); - - // Ensure this call is placed after the AddJsonApi call. - builder.AddMvcOptions(mvcOptions => - { - _postConfigureMvcOptions.Invoke(mvcOptions); - }); - } - - public void Configure(IApplicationBuilder app, IWebHostEnvironment env) - { - // Ensure this call is placed before the UseEndpoints call. - _postConfigureMvcOptions = mvcOptions => - { - mvcOptions.Filters.Clear(); - mvcOptions.Filters.Insert(0, - app.ApplicationServices.GetService()); - }; - - app.UseRouting(); - app.UseJsonApi(); - app.UseEndpoints(endpoints => endpoints.MapControllers()); - } -} + IFilterMetadata existingFilter = mvcOptions.Filters.Single(filter => + filter is ServiceFilterAttribute serviceFilter && + serviceFilter.ServiceType == typeof(IAsyncQueryStringActionFilter)); + + mvcOptions.Filters.Remove(existingFilter); + + using IServiceScope scope = app.Services.CreateScope(); + + var newFilter = + scope.ServiceProvider.GetRequiredService(); + + mvcOptions.Filters.Insert(0, newFilter); +}; + +app.UseRouting(); +app.UseJsonApi(); +app.MapControllers(); + +app.Run(); ``` diff --git a/docs/usage/extensibility/query-strings.md b/docs/usage/extensibility/query-strings.md index 1411717ffd..83d9f2e0ea 100644 --- a/docs/usage/extensibility/query-strings.md +++ b/docs/usage/extensibility/query-strings.md @@ -24,15 +24,17 @@ See [here](~/usage/extensibility/resource-definitions.md#custom-query-string-par In order to add parsing of custom query string parameters, you can implement the `IQueryStringParameterReader` interface and register your reader. ```c# -public class YourQueryStringParameterReader : IQueryStringParameterReader +public class CustomQueryStringParameterReader : IQueryStringParameterReader { // ... } ``` ```c# -services.AddScoped(); -services.AddScoped(sp => sp.GetService()); +// Program.cs +builder.Services.AddScoped(); +builder.Services.AddScoped(serviceProvider => + serviceProvider.GetRequiredService()); ``` Now you can inject your custom reader in resource services, repositories, resource definitions etc. diff --git a/docs/usage/extensibility/repositories.md b/docs/usage/extensibility/repositories.md index 7d76f2389a..102406e71b 100644 --- a/docs/usage/extensibility/repositories.md +++ b/docs/usage/extensibility/repositories.md @@ -3,15 +3,11 @@ 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 registered in Startup.cs. - ```c# -public void ConfigureServices(IServiceCollection services) -{ - services.AddScoped, ArticleRepository>(); - services.AddScoped, ArticleRepository>(); - services.AddScoped, ArticleRepository>(); -} +// Program.cs +builder.Services.AddScoped, ArticleRepository>(); +builder.Services.AddScoped, ArticleRepository>(); +builder.Services.AddScoped, ArticleRepository>(); ``` In v4.0 we introduced an extension method that you can use to register a resource repository on all of its JsonApiDotNetCore interfaces. @@ -20,13 +16,8 @@ This is helpful when you implement (a subset of) the resource interfaces and wan **Note:** If you're using [auto-discovery](~/usage/resource-graph.md#auto-discovery), this happens automatically. ```c# -public class Startup -{ - public void ConfigureServices(IServiceCollection services) - { - services.AddResourceRepository(); - } -} +// Program.cs +builder.Services.AddResourceRepository(); ``` A sample implementation that performs authorization might look like this. @@ -64,7 +55,8 @@ If you need to use multiple Entity Framework Core DbContexts, first create a rep This example shows a single `DbContextARepository` for all entities that are members of `DbContextA`. ```c# -public class DbContextARepository : EntityFrameworkCoreRepository +public class DbContextARepository + : EntityFrameworkCoreRepository where TResource : class, IIdentifiable { public DbContextARepository(ITargetedFields targetedFields, @@ -83,13 +75,12 @@ public class DbContextARepository : EntityFrameworkCoreRepositor Then register the added types and use the non-generic overload of `AddJsonApi` to add their resources to the graph. ```c# -// In Startup.ConfigureServices - -services.AddDbContext(options => options.UseSqlite("Data Source=A.db")); -services.AddDbContext(options => options.UseSqlite("Data Source=B.db")); +// Program.cs +builder.Services.AddDbContext(options => options.UseSqlite("Data Source=A.db")); +builder.Services.AddDbContext(options => options.UseSqlite("Data Source=B.db")); -services.AddScoped, DbContextARepository>(); -services.AddScoped, DbContextBRepository>(); +builder.Services.AddJsonApi(dbContextTypes: new[] { typeof(DbContextA), typeof(DbContextB) }); -services.AddJsonApi(dbContextTypes: new[] { typeof(DbContextA), typeof(DbContextB) }); +builder.Services.AddScoped, DbContextARepository>(); +builder.Services.AddScoped, DbContextBRepository>(); ``` diff --git a/docs/usage/extensibility/resource-definitions.md b/docs/usage/extensibility/resource-definitions.md index 4c9eeeb8a6..8696811e16 100644 --- a/docs/usage/extensibility/resource-definitions.md +++ b/docs/usage/extensibility/resource-definitions.md @@ -10,20 +10,15 @@ In v4.2 we introduced an extension method that you can use to register your reso **Note:** If you're using [auto-discovery](~/usage/resource-graph.md#auto-discovery), this happens automatically. ```c# -public class Startup -{ - public void ConfigureServices(IServiceCollection services) - { - services.AddResourceDefinition(); - } -} +// Program.cs +builder.Services.AddResourceDefinition(); ``` **Note:** Prior to the introduction of auto-discovery (in v3), you needed to register the resource definition on the container yourself: ```c# -services.AddScoped, ProductDefinition>(); +builder.Services.AddScoped, ArticleDefinition>(); ``` ## Customizing queries diff --git a/docs/usage/extensibility/services.md b/docs/usage/extensibility/services.md index 5270f4bbd8..bc3dd5bff8 100644 --- a/docs/usage/extensibility/services.md +++ b/docs/usage/extensibility/services.md @@ -48,17 +48,16 @@ As previously discussed, this library uses Entity Framework Core by default. 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 -public void ConfigureServices(IServiceCollection services) -{ - // add the service override for Product - services.AddScoped, ProductService>(); +// Program.cs - // add your own Data Access Object - services.AddScoped(); -} +// Add the service override for Product. +builder.Services.AddScoped, ProductService>(); + +// Add your own Data Access Object. +builder.Services.AddScoped(); // ProductService.cs + public class ProductService : IResourceService { private readonly IProductDao _dao; @@ -128,14 +127,9 @@ public class ArticleService : ICreateService, IDeleteService, ArticleService>(); - services.AddScoped, ArticleService>(); - } -} +// Program.cs +builder.Services.AddScoped, ArticleService>(); +builder.Services.AddScoped, ArticleService>(); ``` In v3.0 we introduced an extension method that you can use to register a resource service on all of its JsonApiDotNetCore interfaces. @@ -144,13 +138,8 @@ This is helpful when you implement (a subset of) the resource interfaces and wan **Note:** If you're using [auto-discovery](~/usage/resource-graph.md#auto-discovery), this happens automatically. ```c# -public class Startup -{ - public void ConfigureServices(IServiceCollection services) - { - services.AddResourceService(); - } -} +// Program.cs +builder.Services.AddResourceService(); ``` Then on your model, pass in the set of endpoints to expose (the ones that you've registered services for): diff --git a/docs/usage/meta.md b/docs/usage/meta.md index 29c074b8b6..a115e25740 100644 --- a/docs/usage/meta.md +++ b/docs/usage/meta.md @@ -8,11 +8,12 @@ Global metadata can be added to the root of the response document by registering This is useful if you need access to other registered services to build the meta object. ```c# -#nullable enable +// Program.cs +builder.Services.AddSingleton(); -// In Startup.ConfigureServices -services.AddSingleton(); +// CopyrightResponseMeta.cs +#nullable enable public sealed class CopyrightResponseMeta : IResponseMeta { public IReadOnlyDictionary GetMeta() diff --git a/docs/usage/options.md b/docs/usage/options.md index 2f350b8bf9..83d535bce4 100644 --- a/docs/usage/options.md +++ b/docs/usage/options.md @@ -1,19 +1,13 @@ # Global Options -Configuration can be applied when adding the services to the dependency injection container. +Configuration can be applied when adding services to the dependency injection container at startup. ```c# -public class Startup +// Program.cs +builder.Services.AddJsonApi(options => { - // This method gets called by the runtime. Use this method to add services to the container. - public void ConfigureServices(IServiceCollection services) - { - services.AddJsonApi(options => - { - // configure the options here - }); - } -} + // Configure the options here... +}); ``` ## Client Generated IDs diff --git a/docs/usage/resource-graph.md b/docs/usage/resource-graph.md index 4232419bc6..4010cbea5f 100644 --- a/docs/usage/resource-graph.md +++ b/docs/usage/resource-graph.md @@ -23,36 +23,27 @@ Auto-discovery refers to the process of reflecting on an assembly and detecting all of the JSON:API resources, resource definitions, resource services and repositories. The following command builds the resource graph using all `IIdentifiable` implementations and registers the services mentioned. -You can enable auto-discovery for the current assembly by adding the following to your `Startup` class. +You can enable auto-discovery for the current assembly by adding the following at startup. ```c# -// Startup.cs -public void ConfigureServices(IServiceCollection services) -{ - services.AddJsonApi(discovery => discovery.AddCurrentAssembly()); -} +// Program.cs +builder.Services.AddJsonApi(discovery => discovery.AddCurrentAssembly()); ``` ### Specifying an Entity Framework Core DbContext -If you are using Entity Framework Core as your ORM, you can add all the models of a `DbContext` to the resource graph. +If you are using Entity Framework Core as your ORM, you can add all the models of a `DbContext` to the resource graph. ```c# -// Startup.cs -public void ConfigureServices(IServiceCollection services) -{ - services.AddJsonApi(); -} +// Program.cs +builder.Services.AddJsonApi(); ``` Be aware that this does not register resource definitions, resource services and repositories. You can combine it with auto-discovery to achieve this. ```c# -// Startup.cs -public void ConfigureServices(IServiceCollection services) -{ - services.AddJsonApi(discovery => discovery.AddCurrentAssembly()); -} +// Program.cs +builder.Services.AddJsonApi(discovery => discovery.AddCurrentAssembly()); ``` ### Manual Specification @@ -60,14 +51,9 @@ public void ConfigureServices(IServiceCollection services) You can manually construct the graph. ```c# -// Startup.cs -public void ConfigureServices(IServiceCollection services) -{ - services.AddJsonApi(resources: builder => - { - builder.Add(); - }); -} +// Program.cs +builder.Services.AddJsonApi(resources: resourceGraphBuilder => + resourceGraphBuilder.Add()); ``` ## Resource Name @@ -76,9 +62,10 @@ The public resource name is exposed through the `type` member in the JSON:API pa 1. The `publicName` parameter when manually adding a resource to the graph. ```c# -services.AddJsonApi(resources: builder => +// Program.cs +builder.Services.AddJsonApi(resources: resourceGraphBuilder => { - builder.Add(publicName: "individuals"); + resourceGraphBuilder.Add(publicName: "individuals"); }); ``` diff --git a/docs/usage/routing.md b/docs/usage/routing.md index c4eb8ae0ba..a264622931 100644 --- a/docs/usage/routing.md +++ b/docs/usage/routing.md @@ -7,13 +7,11 @@ An endpoint URL provides access to a resource or a relationship. Resource endpoi In the relationship endpoint "/articles/1/relationships/comments", "articles" is the left side of the relationship and "comments" the right side. ## Namespacing and versioning of URLs -You can add a namespace to all URLs by specifying it in ConfigureServices. +You can add a namespace to all URLs by specifying it at startup. ```c# -public void ConfigureServices(IServiceCollection services) -{ - services.AddJsonApi(options => options.Namespace = "api/v1"); -} +// Program.cs +builder.Services.AddJsonApi(options => options.Namespace = "api/v1"); ``` Which results in URLs like: https://yourdomain.com/api/v1/people @@ -91,8 +89,6 @@ public class OrderLineController : JsonApiController It is possible to replace the built-in routing convention with a [custom routing convention](https://docs.microsoft.com/en-us/aspnet/core/mvc/controllers/application-model?view=aspnetcore-3.1#sample-custom-routing-convention) by registering an implementation of `IJsonApiRoutingConvention`. ```c# -public void ConfigureServices(IServiceCollection services) -{ - services.AddSingleton(); -} +// Program.cs +builder.Services.AddSingleton(); ``` diff --git a/docs/usage/writing/bulk-batch-operations.md b/docs/usage/writing/bulk-batch-operations.md index 21fe04b636..1ac35fd3fc 100644 --- a/docs/usage/writing/bulk-batch-operations.md +++ b/docs/usage/writing/bulk-batch-operations.md @@ -85,10 +85,11 @@ For example requests, see our suite of tests in JsonApiDotNetCoreTests.Integrati ## Configuration -The maximum number of operations per request defaults to 10, which you can change from Startup.cs: +The maximum number of operations per request defaults to 10, which you can change at startup: ```c# -services.AddJsonApi(options => options.MaximumOperationsPerRequest = 250); +// Program.cs +builder.Services.AddJsonApi(options => options.MaximumOperationsPerRequest = 250); ``` Or, if you want to allow unconstrained, set it to `null` instead. diff --git a/src/Examples/GettingStarted/Data/SampleDbContext.cs b/src/Examples/GettingStarted/Data/SampleDbContext.cs index c5460db810..44662c388b 100644 --- a/src/Examples/GettingStarted/Data/SampleDbContext.cs +++ b/src/Examples/GettingStarted/Data/SampleDbContext.cs @@ -2,16 +2,15 @@ using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; -namespace GettingStarted.Data +namespace GettingStarted.Data; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public class SampleDbContext : DbContext { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public class SampleDbContext : DbContext - { - public DbSet Books => Set(); + public DbSet Books => Set(); - public SampleDbContext(DbContextOptions options) - : base(options) - { - } + public SampleDbContext(DbContextOptions options) + : base(options) + { } } diff --git a/src/Examples/GettingStarted/GettingStarted.csproj b/src/Examples/GettingStarted/GettingStarted.csproj index ffffc7b2ee..ab152b79d5 100644 --- a/src/Examples/GettingStarted/GettingStarted.csproj +++ b/src/Examples/GettingStarted/GettingStarted.csproj @@ -1,11 +1,12 @@ - $(NetCoreAppVersion) + $(TargetFrameworkName) - + diff --git a/src/Examples/GettingStarted/Models/Book.cs b/src/Examples/GettingStarted/Models/Book.cs index 1e1cb42b2b..66beed1072 100644 --- a/src/Examples/GettingStarted/Models/Book.cs +++ b/src/Examples/GettingStarted/Models/Book.cs @@ -2,19 +2,18 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace GettingStarted.Models +namespace GettingStarted.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource] +public sealed class Book : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - [Resource] - public sealed class Book : Identifiable - { - [Attr] - public string Title { get; set; } = null!; + [Attr] + public string Title { get; set; } = null!; - [Attr] - public int PublishYear { get; set; } + [Attr] + public int PublishYear { get; set; } - [HasOne] - public Person Author { get; set; } = null!; - } + [HasOne] + public Person Author { get; set; } = null!; } diff --git a/src/Examples/GettingStarted/Models/Person.cs b/src/Examples/GettingStarted/Models/Person.cs index 056d3b0522..89ca4c5a69 100644 --- a/src/Examples/GettingStarted/Models/Person.cs +++ b/src/Examples/GettingStarted/Models/Person.cs @@ -1,18 +1,16 @@ -using System.Collections.Generic; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace GettingStarted.Models +namespace GettingStarted.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource] +public sealed class Person : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - [Resource] - public sealed class Person : Identifiable - { - [Attr] - public string Name { get; set; } = null!; + [Attr] + public string Name { get; set; } = null!; - [HasMany] - public ICollection Books { get; set; } = new List(); - } + [HasMany] + public ICollection Books { get; set; } = new List(); } diff --git a/src/Examples/GettingStarted/Program.cs b/src/Examples/GettingStarted/Program.cs index 68bca0ae86..cf19380c6c 100644 --- a/src/Examples/GettingStarted/Program.cs +++ b/src/Examples/GettingStarted/Program.cs @@ -1,21 +1,73 @@ -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.Hosting; +using GettingStarted.Data; +using GettingStarted.Models; +using JsonApiDotNetCore.Configuration; -namespace GettingStarted +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +// Add services to the container. + +builder.Services.AddSqlite("Data Source=sample.db;Pooling=False"); + +builder.Services.AddJsonApi(options => +{ + options.Namespace = "api"; + options.UseRelativeLinks = true; + options.IncludeTotalResourceCount = true; + options.SerializerOptions.WriteIndented = true; +}); + +WebApplication app = builder.Build(); + +// Configure the HTTP request pipeline. + +app.UseRouting(); +app.UseJsonApi(); +app.MapControllers(); + +await CreateDatabaseAsync(app.Services); + +app.Run(); + +static async Task CreateDatabaseAsync(IServiceProvider serviceProvider) +{ + await using AsyncServiceScope scope = serviceProvider.CreateAsyncScope(); + + var dbContext = scope.ServiceProvider.GetRequiredService(); + await dbContext.Database.EnsureDeletedAsync(); + await dbContext.Database.EnsureCreatedAsync(); + + await CreateSampleDataAsync(dbContext); +} + +static async Task CreateSampleDataAsync(SampleDbContext dbContext) { - internal static class Program + // Note: The generate-examples.ps1 script (to create example requests in documentation) depends on these. + + dbContext.Books.AddRange(new Book { - public static void Main(string[] args) + Title = "Frankenstein", + PublishYear = 1818, + Author = new Person { - CreateHostBuilder(args).Build().Run(); + Name = "Mary Shelley" } - - private static IHostBuilder CreateHostBuilder(string[] args) + }, new Book + { + Title = "Robinson Crusoe", + PublishYear = 1719, + Author = new Person { - return Host.CreateDefaultBuilder(args).ConfigureWebHostDefaults(webBuilder => - { - webBuilder.UseStartup(); - }); + Name = "Daniel Defoe" } - } + }, new Book + { + Title = "Gulliver's Travels", + PublishYear = 1726, + Author = new Person + { + Name = "Jonathan Swift" + } + }); + + await dbContext.SaveChangesAsync(); } diff --git a/src/Examples/GettingStarted/Startup.cs b/src/Examples/GettingStarted/Startup.cs deleted file mode 100644 index 13beab63fe..0000000000 --- a/src/Examples/GettingStarted/Startup.cs +++ /dev/null @@ -1,73 +0,0 @@ -using GettingStarted.Data; -using GettingStarted.Models; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using Microsoft.AspNetCore.Builder; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; - -namespace GettingStarted -{ - public sealed class Startup - { - // This method gets called by the runtime. Use this method to add services to the container. - public void ConfigureServices(IServiceCollection services) - { - services.AddDbContext(options => options.UseSqlite("Data Source=sample.db")); - - services.AddJsonApi(options => - { - options.Namespace = "api"; - options.UseRelativeLinks = true; - options.IncludeTotalResourceCount = true; - options.SerializerOptions.WriteIndented = true; - }); - } - - // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. - [UsedImplicitly] - public void Configure(IApplicationBuilder app, SampleDbContext dbContext) - { - dbContext.Database.EnsureDeleted(); - dbContext.Database.EnsureCreated(); - CreateSampleData(dbContext); - - app.UseRouting(); - app.UseJsonApi(); - app.UseEndpoints(endpoints => endpoints.MapControllers()); - } - - private static void CreateSampleData(SampleDbContext dbContext) - { - // Note: The generate-examples.ps1 script (to create example requests in documentation) depends on these. - - dbContext.Books.AddRange(new Book - { - Title = "Frankenstein", - PublishYear = 1818, - Author = new Person - { - Name = "Mary Shelley" - } - }, new Book - { - Title = "Robinson Crusoe", - PublishYear = 1719, - Author = new Person - { - Name = "Daniel Defoe" - } - }, new Book - { - Title = "Gulliver's Travels", - PublishYear = 1726, - Author = new Person - { - Name = "Jonathan Swift" - } - }); - - dbContext.SaveChanges(); - } - } -} diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/NonJsonApiController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/NonJsonApiController.cs index b26b82d27d..be5e01b7a9 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/NonJsonApiController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/NonJsonApiController.cs @@ -1,55 +1,52 @@ -using System.IO; -using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; -namespace JsonApiDotNetCoreExample.Controllers +namespace JsonApiDotNetCoreExample.Controllers; + +[Route("[controller]")] +public sealed class NonJsonApiController : ControllerBase { - [Route("[controller]")] - public sealed class NonJsonApiController : ControllerBase + [HttpGet] + public IActionResult Get() { - [HttpGet] - public IActionResult Get() - { - string[] result = - { - "Welcome!" - }; - - return Ok(result); - } - - [HttpPost] - public async Task PostAsync() + string[] result = { - string name = await new StreamReader(Request.Body).ReadToEndAsync(); + "Welcome!" + }; - if (string.IsNullOrEmpty(name)) - { - return BadRequest("Please send your name."); - } + return Ok(result); + } - string result = $"Hello, {name}"; - return Ok(result); - } + [HttpPost] + public async Task PostAsync() + { + string name = await new StreamReader(Request.Body).ReadToEndAsync(); - [HttpPut] - public IActionResult Put([FromBody] string name) + if (string.IsNullOrEmpty(name)) { - string result = $"Hi, {name}"; - return Ok(result); + return BadRequest("Please send your name."); } - [HttpPatch] - public IActionResult Patch(string name) - { - string result = $"Good day, {name}"; - return Ok(result); - } + string result = $"Hello, {name}"; + return Ok(result); + } - [HttpDelete] - public IActionResult Delete() - { - return Ok("Bye."); - } + [HttpPut] + public IActionResult Put([FromBody] string name) + { + string result = $"Hi, {name}"; + return Ok(result); + } + + [HttpPatch] + public IActionResult Patch(string name) + { + string result = $"Good day, {name}"; + return Ok(result); + } + + [HttpDelete] + public IActionResult Delete() + { + return Ok("Bye."); } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/OperationsController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/OperationsController.cs index 3d29f72af1..6dd2bcb9ba 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/OperationsController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/OperationsController.cs @@ -3,16 +3,14 @@ using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources; -using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCoreExample.Controllers +namespace JsonApiDotNetCoreExample.Controllers; + +public sealed class OperationsController : JsonApiOperationsController { - public sealed class OperationsController : JsonApiOperationsController + public OperationsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IOperationsProcessor processor, + IJsonApiRequest request, ITargetedFields targetedFields) + : base(options, resourceGraph, loggerFactory, processor, request, targetedFields) { - public OperationsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IOperationsProcessor processor, - IJsonApiRequest request, ITargetedFields targetedFields) - : base(options, resourceGraph, loggerFactory, processor, request, targetedFields) - { - } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs index f9f6752990..24378e3182 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs @@ -4,29 +4,28 @@ // @formatter:wrap_chained_method_calls chop_always -namespace JsonApiDotNetCoreExample.Data +namespace JsonApiDotNetCoreExample.Data; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class AppDbContext : DbContext { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class AppDbContext : DbContext - { - public DbSet TodoItems => Set(); + public DbSet TodoItems => Set(); - public AppDbContext(DbContextOptions options) - : base(options) - { - } + public AppDbContext(DbContextOptions options) + : base(options) + { + } - protected override void OnModelCreating(ModelBuilder builder) - { - // When deleting a person, un-assign him/her from existing todo items. - builder.Entity() - .HasMany(person => person.AssignedTodoItems) - .WithOne(todoItem => todoItem.Assignee!); + protected override void OnModelCreating(ModelBuilder builder) + { + // When deleting a person, un-assign him/her from existing todo items. + builder.Entity() + .HasMany(person => person.AssignedTodoItems) + .WithOne(todoItem => todoItem.Assignee!); - // When deleting a person, the todo items he/she owns are deleted too. - builder.Entity() - .HasOne(todoItem => todoItem.Owner) - .WithMany(); - } + // When deleting a person, the todo items he/she owns are deleted too. + builder.Entity() + .HasOne(todoItem => todoItem.Owner) + .WithMany(); } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Definitions/TodoItemDefinition.cs b/src/Examples/JsonApiDotNetCoreExample/Definitions/TodoItemDefinition.cs index 306315d05f..ee7b874fc4 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Definitions/TodoItemDefinition.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Definitions/TodoItemDefinition.cs @@ -1,6 +1,4 @@ using System.ComponentModel; -using System.Threading; -using System.Threading.Tasks; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; @@ -9,45 +7,44 @@ using JsonApiDotNetCoreExample.Models; using Microsoft.AspNetCore.Authentication; -namespace JsonApiDotNetCoreExample.Definitions +namespace JsonApiDotNetCoreExample.Definitions; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class TodoItemDefinition : JsonApiResourceDefinition { - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class TodoItemDefinition : JsonApiResourceDefinition + private readonly ISystemClock _systemClock; + + public TodoItemDefinition(IResourceGraph resourceGraph, ISystemClock systemClock) + : base(resourceGraph) { - private readonly ISystemClock _systemClock; + _systemClock = systemClock; + } - public TodoItemDefinition(IResourceGraph resourceGraph, ISystemClock systemClock) - : base(resourceGraph) - { - _systemClock = systemClock; - } + public override SortExpression OnApplySort(SortExpression? existingSort) + { + return existingSort ?? GetDefaultSortOrder(); + } - public override SortExpression OnApplySort(SortExpression? existingSort) + private SortExpression GetDefaultSortOrder() + { + return CreateSortExpressionFromLambda(new PropertySortOrder { - return existingSort ?? GetDefaultSortOrder(); - } + (todoItem => todoItem.Priority, ListSortDirection.Descending), + (todoItem => todoItem.LastModifiedAt, ListSortDirection.Descending) + }); + } - private SortExpression GetDefaultSortOrder() + public override Task OnWritingAsync(TodoItem resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + if (writeOperation == WriteOperationKind.CreateResource) { - return CreateSortExpressionFromLambda(new PropertySortOrder - { - (todoItem => todoItem.Priority, ListSortDirection.Descending), - (todoItem => todoItem.LastModifiedAt, ListSortDirection.Descending) - }); + resource.CreatedAt = _systemClock.UtcNow; } - - public override Task OnWritingAsync(TodoItem resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) + else if (writeOperation == WriteOperationKind.UpdateResource) { - if (writeOperation == WriteOperationKind.CreateResource) - { - resource.CreatedAt = _systemClock.UtcNow; - } - else if (writeOperation == WriteOperationKind.UpdateResource) - { - resource.LastModifiedAt = _systemClock.UtcNow; - } - - return Task.CompletedTask; + resource.LastModifiedAt = _systemClock.UtcNow; } + + return Task.CompletedTask; } } diff --git a/src/Examples/JsonApiDotNetCoreExample/JsonApiDotNetCoreExample.csproj b/src/Examples/JsonApiDotNetCoreExample/JsonApiDotNetCoreExample.csproj index 8e2baedf85..b243e99ec2 100644 --- a/src/Examples/JsonApiDotNetCoreExample/JsonApiDotNetCoreExample.csproj +++ b/src/Examples/JsonApiDotNetCoreExample/JsonApiDotNetCoreExample.csproj @@ -1,6 +1,6 @@ - $(NetCoreAppVersion) + $(TargetFrameworkName) @@ -11,6 +11,6 @@ - + diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs index 6814db7f1c..5415d37bb3 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs @@ -1,21 +1,19 @@ -using System.Collections.Generic; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreExample.Models +namespace JsonApiDotNetCoreExample.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource] +public sealed class Person : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - [Resource] - public sealed class Person : Identifiable - { - [Attr] - public string? FirstName { get; set; } + [Attr] + public string? FirstName { get; set; } - [Attr] - public string LastName { get; set; } = null!; + [Attr] + public string LastName { get; set; } = null!; - [HasMany] - public ISet AssignedTodoItems { get; set; } = new HashSet(); - } + [HasMany] + public ISet AssignedTodoItems { get; set; } = new HashSet(); } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs index a620c31759..9095b0af80 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs @@ -1,20 +1,18 @@ -using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreExample.Models +namespace JsonApiDotNetCoreExample.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource] +public sealed class Tag : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - [Resource] - public sealed class Tag : Identifiable - { - [Attr] - [MinLength(1)] - public string Name { get; set; } = null!; + [Attr] + [MinLength(1)] + public string Name { get; set; } = null!; - [HasMany] - public ISet TodoItems { get; set; } = new HashSet(); - } + [HasMany] + public ISet TodoItems { get; set; } = new HashSet(); } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs index 872322b0b3..9be7e6e64e 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs @@ -1,36 +1,33 @@ -using System; -using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreExample.Models +namespace JsonApiDotNetCoreExample.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource] +public sealed class TodoItem : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - [Resource] - public sealed class TodoItem : Identifiable - { - [Attr] - public string Description { get; set; } = null!; + [Attr] + public string Description { get; set; } = null!; - [Attr] - [Required] - public TodoItemPriority? Priority { get; set; } + [Attr] + [Required] + public TodoItemPriority? Priority { get; set; } - [Attr(Capabilities = AttrCapabilities.AllowFilter | AttrCapabilities.AllowSort | AttrCapabilities.AllowView)] - public DateTimeOffset CreatedAt { get; set; } + [Attr(Capabilities = AttrCapabilities.AllowFilter | AttrCapabilities.AllowSort | AttrCapabilities.AllowView)] + public DateTimeOffset CreatedAt { get; set; } - [Attr(PublicName = "modifiedAt", Capabilities = AttrCapabilities.AllowFilter | AttrCapabilities.AllowSort | AttrCapabilities.AllowView)] - public DateTimeOffset? LastModifiedAt { get; set; } + [Attr(PublicName = "modifiedAt", Capabilities = AttrCapabilities.AllowFilter | AttrCapabilities.AllowSort | AttrCapabilities.AllowView)] + public DateTimeOffset? LastModifiedAt { get; set; } - [HasOne] - public Person Owner { get; set; } = null!; + [HasOne] + public Person Owner { get; set; } = null!; - [HasOne] - public Person? Assignee { get; set; } + [HasOne] + public Person? Assignee { get; set; } - [HasMany] - public ISet Tags { get; set; } = new HashSet(); - } + [HasMany] + public ISet Tags { get; set; } = new HashSet(); } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItemPriority.cs b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItemPriority.cs index 5f8687a064..9ef85348f1 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItemPriority.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItemPriority.cs @@ -1,12 +1,11 @@ using JetBrains.Annotations; -namespace JsonApiDotNetCoreExample.Models +namespace JsonApiDotNetCoreExample.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public enum TodoItemPriority { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public enum TodoItemPriority - { - Low, - Medium, - High - } + Low, + Medium, + High } diff --git a/src/Examples/JsonApiDotNetCoreExample/Program.cs b/src/Examples/JsonApiDotNetCoreExample/Program.cs index e185c3b4c2..482e42cdf7 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Program.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Program.cs @@ -1,21 +1,99 @@ -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.Hosting; +using System.Text.Json.Serialization; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Diagnostics; +using JsonApiDotNetCoreExample.Data; +using Microsoft.AspNetCore.Authentication; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection.Extensions; -namespace JsonApiDotNetCoreExample +WebApplication app = CreateWebApplication(args); + +await CreateDatabaseAsync(app.Services); + +app.Run(); + +static WebApplication CreateWebApplication(string[] args) { - internal static class Program + using ICodeTimerSession codeTimerSession = new DefaultCodeTimerSession(); + CodeTimingSessionManager.Capture(codeTimerSession); + + WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + + // Add services to the container. + ConfigureServices(builder); + + WebApplication webApplication = builder.Build(); + + // Configure the HTTP request pipeline. + ConfigurePipeline(webApplication); + + if (CodeTimingSessionManager.IsEnabled) { - public static void Main(string[] args) - { - CreateHostBuilder(args).Build().Run(); - } + string timingResults = CodeTimingSessionManager.Current.GetResults(); + webApplication.Logger.LogInformation($"Measurement results for application startup:{Environment.NewLine}{timingResults}"); + } + + return webApplication; +} + +static void ConfigureServices(WebApplicationBuilder builder) +{ + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Configure services"); + + builder.Services.TryAddSingleton(); + + builder.Services.AddDbContext(options => + { + string connectionString = GetConnectionString(builder.Configuration); - private static IHostBuilder CreateHostBuilder(string[] args) + options.UseNpgsql(connectionString); +#if DEBUG + options.EnableSensitiveDataLogging(); + options.EnableDetailedErrors(); +#endif + }); + + using (CodeTimingSessionManager.Current.Measure("AddJsonApi()")) + { + builder.Services.AddJsonApi(options => { - return Host.CreateDefaultBuilder(args).ConfigureWebHostDefaults(webBuilder => - { - webBuilder.UseStartup(); - }); - } + options.Namespace = "api/v1"; + options.UseRelativeLinks = true; + options.IncludeTotalResourceCount = true; + options.SerializerOptions.WriteIndented = true; + options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); +#if DEBUG + options.IncludeExceptionStackTraceInErrors = true; + options.IncludeRequestBodyInErrors = true; +#endif + }, discovery => discovery.AddCurrentAssembly()); } } + +static string GetConnectionString(IConfiguration configuration) +{ + string postgresPassword = Environment.GetEnvironmentVariable("PGPASSWORD") ?? "postgres"; + return configuration["Data:DefaultConnection"].Replace("###", postgresPassword); +} + +static void ConfigurePipeline(WebApplication webApplication) +{ + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Configure pipeline"); + + webApplication.UseRouting(); + + using (CodeTimingSessionManager.Current.Measure("UseJsonApi()")) + { + webApplication.UseJsonApi(); + } + + webApplication.MapControllers(); +} + +static async Task CreateDatabaseAsync(IServiceProvider serviceProvider) +{ + await using AsyncServiceScope scope = serviceProvider.CreateAsyncScope(); + + var dbContext = scope.ServiceProvider.GetRequiredService(); + await dbContext.Database.EnsureCreatedAsync(); +} diff --git a/src/Examples/JsonApiDotNetCoreExample/Startup.cs b/src/Examples/JsonApiDotNetCoreExample/Startup.cs deleted file mode 100644 index 9e0b608f85..0000000000 --- a/src/Examples/JsonApiDotNetCoreExample/Startup.cs +++ /dev/null @@ -1,92 +0,0 @@ -using System; -using System.Text.Json.Serialization; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Diagnostics; -using JsonApiDotNetCoreExample.Data; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExample -{ - public sealed class Startup - { - private readonly ICodeTimerSession _codeTimingSession; - private readonly string _connectionString; - - public Startup(IConfiguration configuration) - { - _codeTimingSession = new DefaultCodeTimerSession(); - CodeTimingSessionManager.Capture(_codeTimingSession); - - string postgresPassword = Environment.GetEnvironmentVariable("PGPASSWORD") ?? "postgres"; - _connectionString = configuration["Data:DefaultConnection"].Replace("###", postgresPassword); - } - - // This method gets called by the runtime. Use this method to add services to the container. - public void ConfigureServices(IServiceCollection services) - { - using (CodeTimingSessionManager.Current.Measure("Configure other (startup)")) - { - services.AddSingleton(); - - services.AddDbContext(options => - { - options.UseNpgsql(_connectionString); -#if DEBUG - options.EnableSensitiveDataLogging(); - options.EnableDetailedErrors(); -#endif - }); - - using (CodeTimingSessionManager.Current.Measure("Configure JSON:API (startup)")) - { - services.AddJsonApi(options => - { - options.Namespace = "api/v1"; - options.UseRelativeLinks = true; - options.IncludeTotalResourceCount = true; - options.SerializerOptions.WriteIndented = true; - options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); -#if DEBUG - options.IncludeExceptionStackTraceInErrors = true; - options.IncludeRequestBodyInErrors = true; -#endif - }, discovery => discovery.AddCurrentAssembly()); - } - } - } - - // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. - public void Configure(IApplicationBuilder app, IWebHostEnvironment environment, ILoggerFactory loggerFactory, AppDbContext dbContext) - { - ILogger logger = loggerFactory.CreateLogger(); - - using (CodeTimingSessionManager.Current.Measure("Initialize other (startup)")) - { - dbContext.Database.EnsureCreated(); - - app.UseRouting(); - - using (CodeTimingSessionManager.Current.Measure("Initialize JSON:API (startup)")) - { - app.UseJsonApi(); - } - - app.UseEndpoints(endpoints => endpoints.MapControllers()); - } - - if (CodeTimingSessionManager.IsEnabled) - { - string timingResults = CodeTimingSessionManager.Current.GetResults(); - logger.LogInformation($"Measurement results for application startup:{Environment.NewLine}{timingResults}"); - } - - _codeTimingSession.Dispose(); - } - } -} diff --git a/src/Examples/JsonApiDotNetCoreExample/appsettings.json b/src/Examples/JsonApiDotNetCoreExample/appsettings.json index ec2ea30102..0e63c6a380 100644 --- a/src/Examples/JsonApiDotNetCoreExample/appsettings.json +++ b/src/Examples/JsonApiDotNetCoreExample/appsettings.json @@ -9,7 +9,7 @@ "Microsoft.EntityFrameworkCore.Update": "Critical", "Microsoft.EntityFrameworkCore.Database.Command": "Critical", "JsonApiDotNetCore.Middleware.JsonApiMiddleware": "Information", - "JsonApiDotNetCoreExample": "Information" + "Program": "Information" } }, "AllowedHosts": "*" diff --git a/src/Examples/MultiDbContextExample/Data/DbContextA.cs b/src/Examples/MultiDbContextExample/Data/DbContextA.cs index 23b2f4a37c..b21e69f2ff 100644 --- a/src/Examples/MultiDbContextExample/Data/DbContextA.cs +++ b/src/Examples/MultiDbContextExample/Data/DbContextA.cs @@ -2,16 +2,15 @@ using Microsoft.EntityFrameworkCore; using MultiDbContextExample.Models; -namespace MultiDbContextExample.Data +namespace MultiDbContextExample.Data; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class DbContextA : DbContext { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class DbContextA : DbContext - { - public DbSet ResourceAs => Set(); + public DbSet ResourceAs => Set(); - public DbContextA(DbContextOptions options) - : base(options) - { - } + public DbContextA(DbContextOptions options) + : base(options) + { } } diff --git a/src/Examples/MultiDbContextExample/Data/DbContextB.cs b/src/Examples/MultiDbContextExample/Data/DbContextB.cs index bf9c575fa9..9bc82c5257 100644 --- a/src/Examples/MultiDbContextExample/Data/DbContextB.cs +++ b/src/Examples/MultiDbContextExample/Data/DbContextB.cs @@ -2,16 +2,15 @@ using Microsoft.EntityFrameworkCore; using MultiDbContextExample.Models; -namespace MultiDbContextExample.Data +namespace MultiDbContextExample.Data; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class DbContextB : DbContext { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class DbContextB : DbContext - { - public DbSet ResourceBs => Set(); + public DbSet ResourceBs => Set(); - public DbContextB(DbContextOptions options) - : base(options) - { - } + public DbContextB(DbContextOptions options) + : base(options) + { } } diff --git a/src/Examples/MultiDbContextExample/Models/ResourceA.cs b/src/Examples/MultiDbContextExample/Models/ResourceA.cs index bed097b083..44a313a32f 100644 --- a/src/Examples/MultiDbContextExample/Models/ResourceA.cs +++ b/src/Examples/MultiDbContextExample/Models/ResourceA.cs @@ -2,13 +2,12 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace MultiDbContextExample.Models +namespace MultiDbContextExample.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource] +public sealed class ResourceA : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - [Resource] - public sealed class ResourceA : Identifiable - { - [Attr] - public string? NameA { get; set; } - } + [Attr] + public string? NameA { get; set; } } diff --git a/src/Examples/MultiDbContextExample/Models/ResourceB.cs b/src/Examples/MultiDbContextExample/Models/ResourceB.cs index 751b3859df..3a6bc7316e 100644 --- a/src/Examples/MultiDbContextExample/Models/ResourceB.cs +++ b/src/Examples/MultiDbContextExample/Models/ResourceB.cs @@ -2,13 +2,12 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace MultiDbContextExample.Models +namespace MultiDbContextExample.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource] +public sealed class ResourceB : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - [Resource] - public sealed class ResourceB : Identifiable - { - [Attr] - public string? NameB { get; set; } - } + [Attr] + public string? NameB { get; set; } } diff --git a/src/Examples/MultiDbContextExample/MultiDbContextExample.csproj b/src/Examples/MultiDbContextExample/MultiDbContextExample.csproj index 0216243bd6..ab152b79d5 100644 --- a/src/Examples/MultiDbContextExample/MultiDbContextExample.csproj +++ b/src/Examples/MultiDbContextExample/MultiDbContextExample.csproj @@ -1,6 +1,6 @@ - $(NetCoreAppVersion) + $(TargetFrameworkName) diff --git a/src/Examples/MultiDbContextExample/Program.cs b/src/Examples/MultiDbContextExample/Program.cs index d5800d95e8..f8a99654de 100644 --- a/src/Examples/MultiDbContextExample/Program.cs +++ b/src/Examples/MultiDbContextExample/Program.cs @@ -1,18 +1,73 @@ -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.Hosting; +using JsonApiDotNetCore.Configuration; +using MultiDbContextExample.Data; +using MultiDbContextExample.Models; +using MultiDbContextExample.Repositories; -namespace MultiDbContextExample +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +// Add services to the container. + +builder.Services.AddSqlite("Data Source=A.db;Pooling=False"); +builder.Services.AddSqlite("Data Source=B.db;Pooling=False"); + +builder.Services.AddJsonApi(options => +{ + options.IncludeExceptionStackTraceInErrors = true; + options.IncludeRequestBodyInErrors = true; +}, dbContextTypes: new[] { - internal static class Program + typeof(DbContextA), + typeof(DbContextB) +}); + +builder.Services.AddResourceRepository>(); +builder.Services.AddResourceRepository>(); + +WebApplication app = builder.Build(); + +// Configure the HTTP request pipeline. + +app.UseRouting(); +app.UseJsonApi(); +app.MapControllers(); + +await CreateDatabaseAsync(app.Services); + +app.Run(); + +static async Task CreateDatabaseAsync(IServiceProvider serviceProvider) +{ + await using AsyncServiceScope scope = serviceProvider.CreateAsyncScope(); + + var dbContextA = scope.ServiceProvider.GetRequiredService(); + await CreateSampleDataAAsync(dbContextA); + + var dbContextB = scope.ServiceProvider.GetRequiredService(); + await CreateSampleDataBAsync(dbContextB); +} + +static async Task CreateSampleDataAAsync(DbContextA dbContextA) +{ + await dbContextA.Database.EnsureDeletedAsync(); + await dbContextA.Database.EnsureCreatedAsync(); + + dbContextA.ResourceAs.Add(new ResourceA + { + NameA = "SampleA" + }); + + await dbContextA.SaveChangesAsync(); +} + +static async Task CreateSampleDataBAsync(DbContextB dbContextB) +{ + await dbContextB.Database.EnsureDeletedAsync(); + await dbContextB.Database.EnsureCreatedAsync(); + + dbContextB.ResourceBs.Add(new ResourceB { - public static void Main(string[] args) - { - CreateHostBuilder(args).Build().Run(); - } - - private static IHostBuilder CreateHostBuilder(string[] args) - { - return Host.CreateDefaultBuilder(args).ConfigureWebHostDefaults(webBuilder => webBuilder.UseStartup()); - } - } + NameB = "SampleB" + }); + + await dbContextB.SaveChangesAsync(); } diff --git a/src/Examples/MultiDbContextExample/Properties/launchSettings.json b/src/Examples/MultiDbContextExample/Properties/launchSettings.json index e328cc07be..a77f78562b 100644 --- a/src/Examples/MultiDbContextExample/Properties/launchSettings.json +++ b/src/Examples/MultiDbContextExample/Properties/launchSettings.json @@ -11,7 +11,7 @@ "profiles": { "IIS Express": { "commandName": "IISExpress", - "launchBrowser": false, + "launchBrowser": true, "launchUrl": "resourceBs", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" @@ -19,7 +19,7 @@ }, "Kestrel": { "commandName": "Project", - "launchBrowser": false, + "launchBrowser": true, "launchUrl": "resourceBs", "applicationUrl": "https://localhost:44350;http://localhost:14150", "environmentVariables": { diff --git a/src/Examples/MultiDbContextExample/Repositories/DbContextARepository.cs b/src/Examples/MultiDbContextExample/Repositories/DbContextARepository.cs index 820c78f241..aadeb889cc 100644 --- a/src/Examples/MultiDbContextExample/Repositories/DbContextARepository.cs +++ b/src/Examples/MultiDbContextExample/Repositories/DbContextARepository.cs @@ -1,23 +1,20 @@ -using System.Collections.Generic; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Resources; -using Microsoft.Extensions.Logging; using MultiDbContextExample.Data; -namespace MultiDbContextExample.Repositories +namespace MultiDbContextExample.Repositories; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class DbContextARepository : EntityFrameworkCoreRepository + where TResource : class, IIdentifiable { - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class DbContextARepository : EntityFrameworkCoreRepository - where TResource : class, IIdentifiable + public DbContextARepository(ITargetedFields targetedFields, DbContextResolver dbContextResolver, IResourceGraph resourceGraph, + IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory, + IResourceDefinitionAccessor resourceDefinitionAccessor) + : base(targetedFields, dbContextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor) { - public DbContextARepository(ITargetedFields targetedFields, DbContextResolver dbContextResolver, IResourceGraph resourceGraph, - IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory, - IResourceDefinitionAccessor resourceDefinitionAccessor) - : base(targetedFields, dbContextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor) - { - } } } diff --git a/src/Examples/MultiDbContextExample/Repositories/DbContextBRepository.cs b/src/Examples/MultiDbContextExample/Repositories/DbContextBRepository.cs index 98156a7295..ac4ce8789c 100644 --- a/src/Examples/MultiDbContextExample/Repositories/DbContextBRepository.cs +++ b/src/Examples/MultiDbContextExample/Repositories/DbContextBRepository.cs @@ -1,23 +1,20 @@ -using System.Collections.Generic; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Resources; -using Microsoft.Extensions.Logging; using MultiDbContextExample.Data; -namespace MultiDbContextExample.Repositories +namespace MultiDbContextExample.Repositories; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class DbContextBRepository : EntityFrameworkCoreRepository + where TResource : class, IIdentifiable { - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class DbContextBRepository : EntityFrameworkCoreRepository - where TResource : class, IIdentifiable + public DbContextBRepository(ITargetedFields targetedFields, DbContextResolver dbContextResolver, IResourceGraph resourceGraph, + IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory, + IResourceDefinitionAccessor resourceDefinitionAccessor) + : base(targetedFields, dbContextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor) { - public DbContextBRepository(ITargetedFields targetedFields, DbContextResolver dbContextResolver, IResourceGraph resourceGraph, - IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory, - IResourceDefinitionAccessor resourceDefinitionAccessor) - : base(targetedFields, dbContextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor) - { - } } } diff --git a/src/Examples/MultiDbContextExample/Startup.cs b/src/Examples/MultiDbContextExample/Startup.cs deleted file mode 100644 index 705bf8ef4c..0000000000 --- a/src/Examples/MultiDbContextExample/Startup.cs +++ /dev/null @@ -1,79 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using MultiDbContextExample.Data; -using MultiDbContextExample.Models; -using MultiDbContextExample.Repositories; - -namespace MultiDbContextExample -{ - public sealed class Startup - { - // This method gets called by the runtime. Use this method to add services to the container. - public void ConfigureServices(IServiceCollection services) - { - services.AddDbContext(options => options.UseSqlite("Data Source=A.db")); - services.AddDbContext(options => options.UseSqlite("Data Source=B.db")); - - services.AddResourceRepository>(); - services.AddResourceRepository>(); - - services.AddJsonApi(options => - { - options.IncludeExceptionStackTraceInErrors = true; - options.IncludeRequestBodyInErrors = true; - }, dbContextTypes: new[] - { - typeof(DbContextA), - typeof(DbContextB) - }); - } - - // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. - [UsedImplicitly] - public void Configure(IApplicationBuilder app, IWebHostEnvironment env, DbContextA dbContextA, DbContextB dbContextB) - { - if (env.IsDevelopment()) - { - app.UseDeveloperExceptionPage(); - } - - EnsureSampleDataA(dbContextA); - EnsureSampleDataB(dbContextB); - - app.UseRouting(); - app.UseJsonApi(); - app.UseEndpoints(endpoints => endpoints.MapControllers()); - } - - private static void EnsureSampleDataA(DbContextA dbContextA) - { - dbContextA.Database.EnsureDeleted(); - dbContextA.Database.EnsureCreated(); - - dbContextA.ResourceAs.Add(new ResourceA - { - NameA = "SampleA" - }); - - dbContextA.SaveChanges(); - } - - private static void EnsureSampleDataB(DbContextB dbContextB) - { - dbContextB.Database.EnsureDeleted(); - dbContextB.Database.EnsureCreated(); - - dbContextB.ResourceBs.Add(new ResourceB - { - NameB = "SampleB" - }); - - dbContextB.SaveChanges(); - } - } -} diff --git a/src/Examples/NoEntityFrameworkExample/Data/AppDbContext.cs b/src/Examples/NoEntityFrameworkExample/Data/AppDbContext.cs index bfe2115f7d..c10cda8e6c 100644 --- a/src/Examples/NoEntityFrameworkExample/Data/AppDbContext.cs +++ b/src/Examples/NoEntityFrameworkExample/Data/AppDbContext.cs @@ -2,16 +2,15 @@ using Microsoft.EntityFrameworkCore; using NoEntityFrameworkExample.Models; -namespace NoEntityFrameworkExample.Data +namespace NoEntityFrameworkExample.Data; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class AppDbContext : DbContext { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class AppDbContext : DbContext - { - public DbSet WorkItems => Set(); + public DbSet WorkItems => Set(); - public AppDbContext(DbContextOptions options) - : base(options) - { - } + public AppDbContext(DbContextOptions options) + : base(options) + { } } diff --git a/src/Examples/NoEntityFrameworkExample/Models/WorkItem.cs b/src/Examples/NoEntityFrameworkExample/Models/WorkItem.cs index a64feb70bb..43bf1f422e 100644 --- a/src/Examples/NoEntityFrameworkExample/Models/WorkItem.cs +++ b/src/Examples/NoEntityFrameworkExample/Models/WorkItem.cs @@ -1,24 +1,22 @@ -using System; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace NoEntityFrameworkExample.Models +namespace NoEntityFrameworkExample.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource] +public sealed class WorkItem : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - [Resource] - public sealed class WorkItem : Identifiable - { - [Attr] - public bool IsBlocked { get; set; } + [Attr] + public bool IsBlocked { get; set; } - [Attr] - public string Title { get; set; } = null!; + [Attr] + public string Title { get; set; } = null!; - [Attr] - public long DurationInHours { get; set; } + [Attr] + public long DurationInHours { get; set; } - [Attr] - public Guid ProjectId { get; set; } = Guid.NewGuid(); - } + [Attr] + public Guid ProjectId { get; set; } = Guid.NewGuid(); } diff --git a/src/Examples/NoEntityFrameworkExample/NoEntityFrameworkExample.csproj b/src/Examples/NoEntityFrameworkExample/NoEntityFrameworkExample.csproj index 14ef3721e7..358d82c02f 100644 --- a/src/Examples/NoEntityFrameworkExample/NoEntityFrameworkExample.csproj +++ b/src/Examples/NoEntityFrameworkExample/NoEntityFrameworkExample.csproj @@ -1,6 +1,6 @@ - $(NetCoreAppVersion) + $(TargetFrameworkName) @@ -10,8 +10,8 @@ - + - + diff --git a/src/Examples/NoEntityFrameworkExample/Program.cs b/src/Examples/NoEntityFrameworkExample/Program.cs index 6653408dc7..43a14f7896 100755 --- a/src/Examples/NoEntityFrameworkExample/Program.cs +++ b/src/Examples/NoEntityFrameworkExample/Program.cs @@ -1,21 +1,41 @@ -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.Hosting; +using JsonApiDotNetCore.Configuration; +using NoEntityFrameworkExample.Data; +using NoEntityFrameworkExample.Models; +using NoEntityFrameworkExample.Services; -namespace NoEntityFrameworkExample +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +// Add services to the container. + +string connectionString = GetConnectionString(builder.Configuration); +builder.Services.AddNpgsql(connectionString); + +builder.Services.AddJsonApi(options => options.Namespace = "api/v1", resources: resourceGraphBuilder => resourceGraphBuilder.Add()); + +builder.Services.AddResourceService(); + +WebApplication app = builder.Build(); + +// Configure the HTTP request pipeline. + +app.UseRouting(); +app.UseJsonApi(); +app.MapControllers(); + +await CreateDatabaseAsync(app.Services); + +app.Run(); + +static string GetConnectionString(IConfiguration configuration) { - internal static class Program - { - public static void Main(string[] args) - { - CreateHostBuilder(args).Build().Run(); - } - - private static IHostBuilder CreateHostBuilder(string[] args) - { - return Host.CreateDefaultBuilder(args).ConfigureWebHostDefaults(webBuilder => - { - webBuilder.UseStartup(); - }); - } - } + string postgresPassword = Environment.GetEnvironmentVariable("PGPASSWORD") ?? "postgres"; + return configuration["Data:DefaultConnection"].Replace("###", postgresPassword); +} + +static async Task CreateDatabaseAsync(IServiceProvider serviceProvider) +{ + await using AsyncServiceScope scope = serviceProvider.CreateAsyncScope(); + + var dbContext = scope.ServiceProvider.GetRequiredService(); + await dbContext.Database.EnsureCreatedAsync(); } diff --git a/src/Examples/NoEntityFrameworkExample/Properties/launchSettings.json b/src/Examples/NoEntityFrameworkExample/Properties/launchSettings.json index d28c050bd8..82c88ace03 100644 --- a/src/Examples/NoEntityFrameworkExample/Properties/launchSettings.json +++ b/src/Examples/NoEntityFrameworkExample/Properties/launchSettings.json @@ -11,7 +11,7 @@ "profiles": { "IIS Express": { "commandName": "IISExpress", - "launchBrowser": false, + "launchBrowser": true, "launchUrl": "api/v1/workItems", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" @@ -19,7 +19,7 @@ }, "Kestrel": { "commandName": "Project", - "launchBrowser": false, + "launchBrowser": true, "launchUrl": "api/v1/workItems", "applicationUrl": "https://localhost:44349;http://localhost:14149", "environmentVariables": { diff --git a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs b/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs index 5fbd062b11..6df109e5ba 100644 --- a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs +++ b/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs @@ -1,116 +1,109 @@ -using System; -using System.Collections.Generic; using System.Data; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; using Dapper; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Services; -using Microsoft.Extensions.Configuration; using NoEntityFrameworkExample.Models; using Npgsql; -namespace NoEntityFrameworkExample.Services +namespace NoEntityFrameworkExample.Services; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class WorkItemService : IResourceService { - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class WorkItemService : IResourceService + private readonly string _connectionString; + + public WorkItemService(IConfiguration configuration) { - private readonly string _connectionString; + string postgresPassword = Environment.GetEnvironmentVariable("PGPASSWORD") ?? "postgres"; + _connectionString = configuration["Data:DefaultConnection"].Replace("###", postgresPassword); + } - public WorkItemService(IConfiguration configuration) - { - string postgresPassword = Environment.GetEnvironmentVariable("PGPASSWORD") ?? "postgres"; - _connectionString = configuration["Data:DefaultConnection"].Replace("###", postgresPassword); - } + public async Task> GetAsync(CancellationToken cancellationToken) + { + const string commandText = @"select * from ""WorkItems"""; + var commandDefinition = new CommandDefinition(commandText, cancellationToken: cancellationToken); - public async Task> GetAsync(CancellationToken cancellationToken) - { - const string commandText = @"select * from ""WorkItems"""; - var commandDefinition = new CommandDefinition(commandText, cancellationToken: cancellationToken); + return await QueryAsync(async connection => await connection.QueryAsync(commandDefinition)); + } - return await QueryAsync(async connection => await connection.QueryAsync(commandDefinition)); - } + public async Task GetAsync(int id, CancellationToken cancellationToken) + { + const string commandText = @"select * from ""WorkItems"" where ""Id""=@id"; - public async Task GetAsync(int id, CancellationToken cancellationToken) + var commandDefinition = new CommandDefinition(commandText, new { - const string commandText = @"select * from ""WorkItems"" where ""Id""=@id"; + id + }, cancellationToken: cancellationToken); - var commandDefinition = new CommandDefinition(commandText, new - { - id - }, cancellationToken: cancellationToken); + IReadOnlyCollection workItems = await QueryAsync(async connection => await connection.QueryAsync(commandDefinition)); + return workItems.Single(); + } - IReadOnlyCollection workItems = await QueryAsync(async connection => await connection.QueryAsync(commandDefinition)); - return workItems.Single(); - } + public Task GetSecondaryAsync(int id, string relationshipName, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } - public Task GetSecondaryAsync(int id, string relationshipName, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } + public Task GetRelationshipAsync(int id, string relationshipName, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } - public Task GetRelationshipAsync(int id, string relationshipName, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } + public async Task CreateAsync(WorkItem resource, CancellationToken cancellationToken) + { + const string commandText = @"insert into ""WorkItems"" (""Title"", ""IsBlocked"", ""DurationInHours"", ""ProjectId"") values " + + @"(@title, @isBlocked, @durationInHours, @projectId) returning ""Id"", ""Title"", ""IsBlocked"", ""DurationInHours"", ""ProjectId"""; - public async Task CreateAsync(WorkItem resource, CancellationToken cancellationToken) - { - const string commandText = @"insert into ""WorkItems"" (""Title"", ""IsBlocked"", ""DurationInHours"", ""ProjectId"") values " + - @"(@title, @isBlocked, @durationInHours, @projectId) returning ""Id"", ""Title"", ""IsBlocked"", ""DurationInHours"", ""ProjectId"""; - - var commandDefinition = new CommandDefinition(commandText, new - { - title = resource.Title, - isBlocked = resource.IsBlocked, - durationInHours = resource.DurationInHours, - projectId = resource.ProjectId - }, cancellationToken: cancellationToken); - - IReadOnlyCollection workItems = await QueryAsync(async connection => await connection.QueryAsync(commandDefinition)); - return workItems.Single(); - } - - public Task AddToToManyRelationshipAsync(int leftId, string relationshipName, ISet rightResourceIds, CancellationToken cancellationToken) + var commandDefinition = new CommandDefinition(commandText, new { - throw new NotImplementedException(); - } + title = resource.Title, + isBlocked = resource.IsBlocked, + durationInHours = resource.DurationInHours, + projectId = resource.ProjectId + }, cancellationToken: cancellationToken); + + IReadOnlyCollection workItems = await QueryAsync(async connection => await connection.QueryAsync(commandDefinition)); + return workItems.Single(); + } - public Task UpdateAsync(int id, WorkItem resource, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } + public Task AddToToManyRelationshipAsync(int leftId, string relationshipName, ISet rightResourceIds, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } - public Task SetRelationshipAsync(int leftId, string relationshipName, object? rightValue, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } + public Task UpdateAsync(int id, WorkItem resource, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } - public async Task DeleteAsync(int id, CancellationToken cancellationToken) - { - const string commandText = @"delete from ""WorkItems"" where ""Id""=@id"; + public Task SetRelationshipAsync(int leftId, string relationshipName, object? rightValue, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } - await QueryAsync(async connection => await connection.QueryAsync(new CommandDefinition(commandText, new - { - id - }, cancellationToken: cancellationToken))); - } + public async Task DeleteAsync(int id, CancellationToken cancellationToken) + { + const string commandText = @"delete from ""WorkItems"" where ""Id""=@id"; - public Task RemoveFromToManyRelationshipAsync(int leftId, string relationshipName, ISet rightResourceIds, - CancellationToken cancellationToken) + await QueryAsync(async connection => await connection.QueryAsync(new CommandDefinition(commandText, new { - throw new NotImplementedException(); - } + id + }, cancellationToken: cancellationToken))); + } - private async Task> QueryAsync(Func>> query) - { - using IDbConnection dbConnection = new NpgsqlConnection(_connectionString); - dbConnection.Open(); + public Task RemoveFromToManyRelationshipAsync(int leftId, string relationshipName, ISet rightResourceIds, + CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + private async Task> QueryAsync(Func>> query) + { + using IDbConnection dbConnection = new NpgsqlConnection(_connectionString); + dbConnection.Open(); - IEnumerable resources = await query(dbConnection); - return resources.ToList(); - } + IEnumerable resources = await query(dbConnection); + return resources.ToList(); } } diff --git a/src/Examples/NoEntityFrameworkExample/Startup.cs b/src/Examples/NoEntityFrameworkExample/Startup.cs deleted file mode 100644 index dc86192832..0000000000 --- a/src/Examples/NoEntityFrameworkExample/Startup.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using Microsoft.AspNetCore.Builder; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using NoEntityFrameworkExample.Data; -using NoEntityFrameworkExample.Models; -using NoEntityFrameworkExample.Services; - -namespace NoEntityFrameworkExample -{ - public sealed class Startup - { - private readonly string _connectionString; - - public Startup(IConfiguration configuration) - { - string postgresPassword = Environment.GetEnvironmentVariable("PGPASSWORD") ?? "postgres"; - _connectionString = configuration["Data:DefaultConnection"].Replace("###", postgresPassword); - } - - // This method gets called by the runtime. Use this method to add services to the container. - public void ConfigureServices(IServiceCollection services) - { - services.AddJsonApi(options => options.Namespace = "api/v1", resources: builder => builder.Add("workItems")); - - services.AddResourceService(); - - services.AddDbContext(options => options.UseNpgsql(_connectionString)); - } - - // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. - [UsedImplicitly] - public void Configure(IApplicationBuilder app, AppDbContext dbContext) - { - dbContext.Database.EnsureCreated(); - - app.UseRouting(); - app.UseJsonApi(); - app.UseEndpoints(endpoints => endpoints.MapControllers()); - } - } -} diff --git a/src/Examples/ReportsExample/Models/Report.cs b/src/Examples/ReportsExample/Models/Report.cs index 384cc36c94..5344025d2e 100644 --- a/src/Examples/ReportsExample/Models/Report.cs +++ b/src/Examples/ReportsExample/Models/Report.cs @@ -3,16 +3,15 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace ReportsExample.Models +namespace ReportsExample.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(GenerateControllerEndpoints = JsonApiEndpoints.GetCollection)] +public sealed class Report : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - [Resource(GenerateControllerEndpoints = JsonApiEndpoints.GetCollection)] - public sealed class Report : Identifiable - { - [Attr] - public string Title { get; set; } = null!; + [Attr] + public string Title { get; set; } = null!; - [Attr] - public ReportStatistics Statistics { get; set; } = null!; - } + [Attr] + public ReportStatistics Statistics { get; set; } = null!; } diff --git a/src/Examples/ReportsExample/Models/ReportStatistics.cs b/src/Examples/ReportsExample/Models/ReportStatistics.cs index 7c520eded8..2df01f9b5e 100644 --- a/src/Examples/ReportsExample/Models/ReportStatistics.cs +++ b/src/Examples/ReportsExample/Models/ReportStatistics.cs @@ -1,11 +1,10 @@ using JetBrains.Annotations; -namespace ReportsExample.Models +namespace ReportsExample.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class ReportStatistics { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class ReportStatistics - { - public string ProgressIndication { get; set; } = null!; - public int HoursSpent { get; set; } - } + public string ProgressIndication { get; set; } = null!; + public int HoursSpent { get; set; } } diff --git a/src/Examples/ReportsExample/Program.cs b/src/Examples/ReportsExample/Program.cs index 6356b0f52a..16abac381f 100644 --- a/src/Examples/ReportsExample/Program.cs +++ b/src/Examples/ReportsExample/Program.cs @@ -1,21 +1,17 @@ -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.Hosting; +using JsonApiDotNetCore.Configuration; -namespace ReportsExample -{ - internal static class Program - { - public static void Main(string[] args) - { - CreateHostBuilder(args).Build().Run(); - } +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); - private static IHostBuilder CreateHostBuilder(string[] args) - { - return Host.CreateDefaultBuilder(args).ConfigureWebHostDefaults(webBuilder => - { - webBuilder.UseStartup(); - }); - } - } -} +// Add services to the container. + +builder.Services.AddJsonApi(options => options.Namespace = "api", discovery => discovery.AddCurrentAssembly()); + +WebApplication app = builder.Build(); + +// Configure the HTTP request pipeline. + +app.UseRouting(); +app.UseJsonApi(); +app.MapControllers(); + +app.Run(); diff --git a/src/Examples/ReportsExample/ReportsExample.csproj b/src/Examples/ReportsExample/ReportsExample.csproj index 8e2baedf85..b243e99ec2 100644 --- a/src/Examples/ReportsExample/ReportsExample.csproj +++ b/src/Examples/ReportsExample/ReportsExample.csproj @@ -1,6 +1,6 @@ - $(NetCoreAppVersion) + $(TargetFrameworkName) @@ -11,6 +11,6 @@ - + diff --git a/src/Examples/ReportsExample/Services/ReportService.cs b/src/Examples/ReportsExample/Services/ReportService.cs index 19f18bfed3..61a97ecde5 100644 --- a/src/Examples/ReportsExample/Services/ReportService.cs +++ b/src/Examples/ReportsExample/Services/ReportService.cs @@ -1,47 +1,42 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using JetBrains.Annotations; using JsonApiDotNetCore.Services; -using Microsoft.Extensions.Logging; using ReportsExample.Models; -namespace ReportsExample.Services +namespace ReportsExample.Services; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public class ReportService : IGetAllService { - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public class ReportService : IGetAllService - { - private readonly ILogger _logger; + private readonly ILogger _logger; - public ReportService(ILoggerFactory loggerFactory) - { - _logger = loggerFactory.CreateLogger(); - } + public ReportService(ILoggerFactory loggerFactory) + { + _logger = loggerFactory.CreateLogger(); + } - public Task> GetAsync(CancellationToken cancellationToken) - { - _logger.LogInformation("GetAsync"); + public Task> GetAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("GetAsync"); - IReadOnlyCollection reports = GetReports(); + IReadOnlyCollection reports = GetReports(); - return Task.FromResult(reports); - } + return Task.FromResult(reports); + } - private IReadOnlyCollection GetReports() + private IReadOnlyCollection GetReports() + { + return new List { - return new List + new() { - new() + Id = 1, + Title = "Status Report", + Statistics = new ReportStatistics { - Id = 1, - Title = "Status Report", - Statistics = new ReportStatistics - { - ProgressIndication = "Almost done", - HoursSpent = 24 - } + ProgressIndication = "Almost done", + HoursSpent = 24 } - }; - } + } + }; } } diff --git a/src/Examples/ReportsExample/Startup.cs b/src/Examples/ReportsExample/Startup.cs deleted file mode 100644 index a030441258..0000000000 --- a/src/Examples/ReportsExample/Startup.cs +++ /dev/null @@ -1,23 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using Microsoft.AspNetCore.Builder; -using Microsoft.Extensions.DependencyInjection; - -namespace ReportsExample -{ - public sealed class Startup - { - // This method gets called by the runtime. Use this method to add services to the container. - public void ConfigureServices(IServiceCollection services) - { - services.AddJsonApi(options => options.Namespace = "api", discovery => discovery.AddCurrentAssembly()); - } - - // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. - public void Configure(IApplicationBuilder app) - { - app.UseRouting(); - app.UseJsonApi(); - app.UseEndpoints(endpoints => endpoints.MapControllers()); - } - } -} diff --git a/src/JsonApiDotNetCore.SourceGenerators/JsonApiDotNetCore.SourceGenerators.csproj b/src/JsonApiDotNetCore.SourceGenerators/JsonApiDotNetCore.SourceGenerators.csproj index c94885e7d9..9d18d91aa1 100644 --- a/src/JsonApiDotNetCore.SourceGenerators/JsonApiDotNetCore.SourceGenerators.csproj +++ b/src/JsonApiDotNetCore.SourceGenerators/JsonApiDotNetCore.SourceGenerators.csproj @@ -7,6 +7,7 @@ false $(NoWarn);NU5128 disable + disable true @@ -47,7 +48,6 @@ - diff --git a/src/JsonApiDotNetCore.SourceGenerators/SourceCodeWriter.cs b/src/JsonApiDotNetCore.SourceGenerators/SourceCodeWriter.cs index a5425931be..e03e3cbad2 100644 --- a/src/JsonApiDotNetCore.SourceGenerators/SourceCodeWriter.cs +++ b/src/JsonApiDotNetCore.SourceGenerators/SourceCodeWriter.cs @@ -16,8 +16,7 @@ internal sealed class SourceCodeWriter [0] = string.Empty, [1] = new string(' ', 1 * SpacesPerIndent), [2] = new string(' ', 2 * SpacesPerIndent), - [3] = new string(' ', 3 * SpacesPerIndent), - [4] = new string(' ', 4 * SpacesPerIndent) + [3] = new string(' ', 3 * SpacesPerIndent) }; private static readonly IDictionary AggregateEndpointToServiceNameMap = @@ -70,8 +69,7 @@ public string Write(INamedTypeSymbol resourceType, ITypeSymbol idType, JsonApiEn if (controllerNamespace != null) { - WriteOpenNamespaceDeclaration(controllerNamespace); - _depth++; + WriteNamespaceDeclaration(controllerNamespace); } WriteOpenClassDeclaration(controllerName, endpointsToGenerate, resourceType, idType); @@ -82,12 +80,6 @@ public string Write(INamedTypeSymbol resourceType, ITypeSymbol idType, JsonApiEn _depth--; WriteCloseCurly(); - if (controllerNamespace != null) - { - _depth--; - WriteCloseCurly(); - } - return _sourceBuilder.ToString(); } @@ -113,11 +105,10 @@ private void WriteNamespaceImports(INamedTypeSymbol loggerFactoryInterface, INam _sourceBuilder.AppendLine(); } - private void WriteOpenNamespaceDeclaration(string controllerNamespace) + private void WriteNamespaceDeclaration(string controllerNamespace) { - _sourceBuilder.AppendLine($"namespace {controllerNamespace}"); - - WriteOpenCurly(); + _sourceBuilder.AppendLine($"namespace {controllerNamespace};"); + _sourceBuilder.AppendLine(); } private void WriteOpenClassDeclaration(string controllerName, JsonApiEndpointsCopy endpointsToGenerate, INamedTypeSymbol resourceType, diff --git a/src/JsonApiDotNetCore/ArgumentGuard.cs b/src/JsonApiDotNetCore/ArgumentGuard.cs index 1877078df9..23b64d1044 100644 --- a/src/JsonApiDotNetCore/ArgumentGuard.cs +++ b/src/JsonApiDotNetCore/ArgumentGuard.cs @@ -1,45 +1,41 @@ -using System; -using System.Collections.Generic; -using System.Linq; using JetBrains.Annotations; using SysNotNull = System.Diagnostics.CodeAnalysis.NotNullAttribute; #pragma warning disable AV1008 // Class should not be static -namespace JsonApiDotNetCore +namespace JsonApiDotNetCore; + +internal static class ArgumentGuard { - internal static class ArgumentGuard + [AssertionMethod] + public static void NotNull([NoEnumeration] [SysNotNull] T? value, [InvokerParameterName] string name) + where T : class { - [AssertionMethod] - public static void NotNull([NoEnumeration] [SysNotNull] T? value, [InvokerParameterName] string name) - where T : class + if (value is null) { - if (value is null) - { - throw new ArgumentNullException(name); - } + throw new ArgumentNullException(name); } + } - [AssertionMethod] - public static void NotNullNorEmpty([SysNotNull] IEnumerable? value, [InvokerParameterName] string name, string? collectionName = null) - { - NotNull(value, name); + [AssertionMethod] + public static void NotNullNorEmpty([SysNotNull] IEnumerable? value, [InvokerParameterName] string name, string? collectionName = null) + { + NotNull(value, name); - if (!value.Any()) - { - throw new ArgumentException($"Must have one or more {collectionName ?? name}.", name); - } + if (!value.Any()) + { + throw new ArgumentException($"Must have one or more {collectionName ?? name}.", name); } + } - [AssertionMethod] - public static void NotNullNorEmpty([SysNotNull] string? value, [InvokerParameterName] string name) - { - NotNull(value, name); + [AssertionMethod] + public static void NotNullNorEmpty([SysNotNull] string? value, [InvokerParameterName] string name) + { + NotNull(value, name); - if (value == string.Empty) - { - throw new ArgumentException("String cannot be null or empty.", name); - } + if (value == string.Empty) + { + throw new ArgumentException("String cannot be null or empty.", name); } } } diff --git a/src/JsonApiDotNetCore/ArrayFactory.cs b/src/JsonApiDotNetCore/ArrayFactory.cs index a33102cbdd..2969f34d17 100644 --- a/src/JsonApiDotNetCore/ArrayFactory.cs +++ b/src/JsonApiDotNetCore/ArrayFactory.cs @@ -1,13 +1,12 @@ #pragma warning disable AV1008 // Class should not be static #pragma warning disable AV1130 // Return type in method signature should be a collection interface instead of a concrete type -namespace JsonApiDotNetCore +namespace JsonApiDotNetCore; + +internal static class ArrayFactory { - internal static class ArrayFactory + public static T[] Create(params T[] items) { - public static T[] Create(params T[] items) - { - return items; - } + return items; } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/EntityFrameworkCoreTransaction.cs b/src/JsonApiDotNetCore/AtomicOperations/EntityFrameworkCoreTransaction.cs index df87d1c546..be5125c414 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/EntityFrameworkCoreTransaction.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/EntityFrameworkCoreTransaction.cs @@ -1,60 +1,57 @@ -using System.Threading; -using System.Threading.Tasks; using JetBrains.Annotations; using JsonApiDotNetCore.Repositories; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage; -namespace JsonApiDotNetCore.AtomicOperations +namespace JsonApiDotNetCore.AtomicOperations; + +/// +/// Represents an Entity Framework Core transaction in an atomic:operations request. +/// +[PublicAPI] +public sealed class EntityFrameworkCoreTransaction : IOperationsTransaction { + private readonly IDbContextTransaction _transaction; + private readonly DbContext _dbContext; + + /// + public string TransactionId => _transaction.TransactionId.ToString(); + + public EntityFrameworkCoreTransaction(IDbContextTransaction transaction, DbContext dbContext) + { + ArgumentGuard.NotNull(transaction, nameof(transaction)); + ArgumentGuard.NotNull(dbContext, nameof(dbContext)); + + _transaction = transaction; + _dbContext = dbContext; + } + + /// + /// Detaches all entities from the Entity Framework Core change tracker. + /// + public Task BeforeProcessOperationAsync(CancellationToken cancellationToken) + { + _dbContext.ResetChangeTracker(); + return Task.CompletedTask; + } + /// - /// Represents an Entity Framework Core transaction in an atomic:operations request. + /// Does nothing. /// - [PublicAPI] - public sealed class EntityFrameworkCoreTransaction : IOperationsTransaction + public Task AfterProcessOperationAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + /// + public Task CommitAsync(CancellationToken cancellationToken) + { + return _transaction.CommitAsync(cancellationToken); + } + + /// + public ValueTask DisposeAsync() { - private readonly IDbContextTransaction _transaction; - private readonly DbContext _dbContext; - - /// - public string TransactionId => _transaction.TransactionId.ToString(); - - public EntityFrameworkCoreTransaction(IDbContextTransaction transaction, DbContext dbContext) - { - ArgumentGuard.NotNull(transaction, nameof(transaction)); - ArgumentGuard.NotNull(dbContext, nameof(dbContext)); - - _transaction = transaction; - _dbContext = dbContext; - } - - /// - /// Detaches all entities from the Entity Framework Core change tracker. - /// - public Task BeforeProcessOperationAsync(CancellationToken cancellationToken) - { - _dbContext.ResetChangeTracker(); - return Task.CompletedTask; - } - - /// - /// Does nothing. - /// - public Task AfterProcessOperationAsync(CancellationToken cancellationToken) - { - return Task.CompletedTask; - } - - /// - public Task CommitAsync(CancellationToken cancellationToken) - { - return _transaction.CommitAsync(cancellationToken); - } - - /// - public ValueTask DisposeAsync() - { - return _transaction.DisposeAsync(); - } + return _transaction.DisposeAsync(); } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/EntityFrameworkCoreTransactionFactory.cs b/src/JsonApiDotNetCore/AtomicOperations/EntityFrameworkCoreTransactionFactory.cs index 3d30ebe089..96c66e12ab 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/EntityFrameworkCoreTransactionFactory.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/EntityFrameworkCoreTransactionFactory.cs @@ -1,39 +1,36 @@ -using System.Threading; -using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Repositories; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage; -namespace JsonApiDotNetCore.AtomicOperations +namespace JsonApiDotNetCore.AtomicOperations; + +/// +/// Provides transaction support for atomic:operation requests using Entity Framework Core. +/// +public sealed class EntityFrameworkCoreTransactionFactory : IOperationsTransactionFactory { - /// - /// Provides transaction support for atomic:operation requests using Entity Framework Core. - /// - public sealed class EntityFrameworkCoreTransactionFactory : IOperationsTransactionFactory - { - private readonly IDbContextResolver _dbContextResolver; - private readonly IJsonApiOptions _options; + private readonly IDbContextResolver _dbContextResolver; + private readonly IJsonApiOptions _options; - public EntityFrameworkCoreTransactionFactory(IDbContextResolver dbContextResolver, IJsonApiOptions options) - { - ArgumentGuard.NotNull(dbContextResolver, nameof(dbContextResolver)); - ArgumentGuard.NotNull(options, nameof(options)); + public EntityFrameworkCoreTransactionFactory(IDbContextResolver dbContextResolver, IJsonApiOptions options) + { + ArgumentGuard.NotNull(dbContextResolver, nameof(dbContextResolver)); + ArgumentGuard.NotNull(options, nameof(options)); - _dbContextResolver = dbContextResolver; - _options = options; - } + _dbContextResolver = dbContextResolver; + _options = options; + } - /// - public async Task BeginTransactionAsync(CancellationToken cancellationToken) - { - DbContext dbContext = _dbContextResolver.GetContext(); + /// + public async Task BeginTransactionAsync(CancellationToken cancellationToken) + { + DbContext dbContext = _dbContextResolver.GetContext(); - IDbContextTransaction transaction = _options.TransactionIsolationLevel != null - ? await dbContext.Database.BeginTransactionAsync(_options.TransactionIsolationLevel.Value, cancellationToken) - : await dbContext.Database.BeginTransactionAsync(cancellationToken); + IDbContextTransaction transaction = _options.TransactionIsolationLevel != null + ? await dbContext.Database.BeginTransactionAsync(_options.TransactionIsolationLevel.Value, cancellationToken) + : await dbContext.Database.BeginTransactionAsync(cancellationToken); - return new EntityFrameworkCoreTransaction(transaction, dbContext); - } + return new EntityFrameworkCoreTransaction(transaction, dbContext); } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/ILocalIdTracker.cs b/src/JsonApiDotNetCore/AtomicOperations/ILocalIdTracker.cs index 1f6eda010d..c6311013af 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/ILocalIdTracker.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/ILocalIdTracker.cs @@ -1,30 +1,29 @@ using JsonApiDotNetCore.Configuration; -namespace JsonApiDotNetCore.AtomicOperations +namespace JsonApiDotNetCore.AtomicOperations; + +/// +/// Used to track declarations, assignments and references to local IDs an in atomic:operations request. +/// +public interface ILocalIdTracker { /// - /// Used to track declarations, assignments and references to local IDs an in atomic:operations request. + /// Removes all declared and assigned values. /// - public interface ILocalIdTracker - { - /// - /// Removes all declared and assigned values. - /// - void Reset(); + void Reset(); - /// - /// Declares a local ID without assigning a server-generated value. - /// - void Declare(string localId, ResourceType resourceType); + /// + /// Declares a local ID without assigning a server-generated value. + /// + void Declare(string localId, ResourceType resourceType); - /// - /// Assigns a server-generated ID value to a previously declared local ID. - /// - void Assign(string localId, ResourceType resourceType, string stringId); + /// + /// Assigns a server-generated ID value to a previously declared local ID. + /// + void Assign(string localId, ResourceType resourceType, string stringId); - /// - /// Gets the server-assigned ID for the specified local ID. - /// - string GetValue(string localId, ResourceType resourceType); - } + /// + /// Gets the server-assigned ID for the specified local ID. + /// + string GetValue(string localId, ResourceType resourceType); } diff --git a/src/JsonApiDotNetCore/AtomicOperations/IOperationProcessorAccessor.cs b/src/JsonApiDotNetCore/AtomicOperations/IOperationProcessorAccessor.cs index d35d6e6154..fec9039619 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/IOperationProcessorAccessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/IOperationProcessorAccessor.cs @@ -1,18 +1,15 @@ -using System.Threading; -using System.Threading.Tasks; using JsonApiDotNetCore.AtomicOperations.Processors; using JsonApiDotNetCore.Resources; -namespace JsonApiDotNetCore.AtomicOperations +namespace JsonApiDotNetCore.AtomicOperations; + +/// +/// Retrieves an instance from the D/I container and invokes a method on it. +/// +public interface IOperationProcessorAccessor { /// - /// Retrieves an instance from the D/I container and invokes a method on it. + /// Invokes on a processor compatible with the operation kind. /// - public interface IOperationProcessorAccessor - { - /// - /// Invokes on a processor compatible with the operation kind. - /// - Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken); - } + Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken); } diff --git a/src/JsonApiDotNetCore/AtomicOperations/IOperationsProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/IOperationsProcessor.cs index f6c736ee9d..74927f0147 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/IOperationsProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/IOperationsProcessor.cs @@ -1,18 +1,14 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using JsonApiDotNetCore.Resources; -namespace JsonApiDotNetCore.AtomicOperations +namespace JsonApiDotNetCore.AtomicOperations; + +/// +/// Atomically processes a request that contains a list of operations. +/// +public interface IOperationsProcessor { /// - /// Atomically processes a request that contains a list of operations. + /// Processes the list of specified operations. /// - public interface IOperationsProcessor - { - /// - /// Processes the list of specified operations. - /// - Task> ProcessAsync(IList operations, CancellationToken cancellationToken); - } + Task> ProcessAsync(IList operations, CancellationToken cancellationToken); } diff --git a/src/JsonApiDotNetCore/AtomicOperations/IOperationsTransaction.cs b/src/JsonApiDotNetCore/AtomicOperations/IOperationsTransaction.cs index 4eed23455d..d7e4ee4b2c 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/IOperationsTransaction.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/IOperationsTransaction.cs @@ -1,34 +1,30 @@ -using System; -using System.Threading; -using System.Threading.Tasks; using JetBrains.Annotations; -namespace JsonApiDotNetCore.AtomicOperations +namespace JsonApiDotNetCore.AtomicOperations; + +/// +/// Represents the overarching transaction in an atomic:operations request. +/// +[PublicAPI] +public interface IOperationsTransaction : IAsyncDisposable { /// - /// Represents the overarching transaction in an atomic:operations request. + /// Identifies the active transaction. /// - [PublicAPI] - public interface IOperationsTransaction : IAsyncDisposable - { - /// - /// Identifies the active transaction. - /// - string TransactionId { get; } + string TransactionId { get; } - /// - /// Enables to execute custom logic before processing of an operation starts. - /// - Task BeforeProcessOperationAsync(CancellationToken cancellationToken); + /// + /// Enables to execute custom logic before processing of an operation starts. + /// + Task BeforeProcessOperationAsync(CancellationToken cancellationToken); - /// - /// Enables to execute custom logic after processing of an operation succeeds. - /// - Task AfterProcessOperationAsync(CancellationToken cancellationToken); + /// + /// Enables to execute custom logic after processing of an operation succeeds. + /// + Task AfterProcessOperationAsync(CancellationToken cancellationToken); - /// - /// Commits all changes made to the underlying data store. - /// - Task CommitAsync(CancellationToken cancellationToken); - } + /// + /// Commits all changes made to the underlying data store. + /// + Task CommitAsync(CancellationToken cancellationToken); } diff --git a/src/JsonApiDotNetCore/AtomicOperations/IOperationsTransactionFactory.cs b/src/JsonApiDotNetCore/AtomicOperations/IOperationsTransactionFactory.cs index f9b752381b..7f2da4675b 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/IOperationsTransactionFactory.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/IOperationsTransactionFactory.cs @@ -1,16 +1,12 @@ -using System.Threading; -using System.Threading.Tasks; +namespace JsonApiDotNetCore.AtomicOperations; -namespace JsonApiDotNetCore.AtomicOperations +/// +/// Provides a method to start the overarching transaction for an atomic:operations request. +/// +public interface IOperationsTransactionFactory { /// - /// Provides a method to start the overarching transaction for an atomic:operations request. + /// Starts a new transaction. /// - public interface IOperationsTransactionFactory - { - /// - /// Starts a new transaction. - /// - Task BeginTransactionAsync(CancellationToken cancellationToken); - } + Task BeginTransactionAsync(CancellationToken cancellationToken); } diff --git a/src/JsonApiDotNetCore/AtomicOperations/LocalIdTracker.cs b/src/JsonApiDotNetCore/AtomicOperations/LocalIdTracker.cs index 9b24ff4e18..b0a1b3ee2f 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/LocalIdTracker.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/LocalIdTracker.cs @@ -1,106 +1,103 @@ -using System; -using System.Collections.Generic; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Errors; -namespace JsonApiDotNetCore.AtomicOperations +namespace JsonApiDotNetCore.AtomicOperations; + +/// +public sealed class LocalIdTracker : ILocalIdTracker { + private readonly IDictionary _idsTracked = new Dictionary(); + /// - public sealed class LocalIdTracker : ILocalIdTracker + public void Reset() { - private readonly IDictionary _idsTracked = new Dictionary(); - - /// - public void Reset() - { - _idsTracked.Clear(); - } + _idsTracked.Clear(); + } - /// - public void Declare(string localId, ResourceType resourceType) - { - ArgumentGuard.NotNullNorEmpty(localId, nameof(localId)); - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + /// + public void Declare(string localId, ResourceType resourceType) + { + ArgumentGuard.NotNullNorEmpty(localId, nameof(localId)); + ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - AssertIsNotDeclared(localId); + AssertIsNotDeclared(localId); - _idsTracked[localId] = new LocalIdState(resourceType); - } + _idsTracked[localId] = new LocalIdState(resourceType); + } - private void AssertIsNotDeclared(string localId) + private void AssertIsNotDeclared(string localId) + { + if (_idsTracked.ContainsKey(localId)) { - if (_idsTracked.ContainsKey(localId)) - { - throw new DuplicateLocalIdValueException(localId); - } + throw new DuplicateLocalIdValueException(localId); } + } - /// - public void Assign(string localId, ResourceType resourceType, string stringId) - { - ArgumentGuard.NotNullNorEmpty(localId, nameof(localId)); - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - ArgumentGuard.NotNullNorEmpty(stringId, nameof(stringId)); - - AssertIsDeclared(localId); + /// + public void Assign(string localId, ResourceType resourceType, string stringId) + { + ArgumentGuard.NotNullNorEmpty(localId, nameof(localId)); + ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + ArgumentGuard.NotNullNorEmpty(stringId, nameof(stringId)); - LocalIdState item = _idsTracked[localId]; + AssertIsDeclared(localId); - AssertSameResourceType(resourceType, item.ResourceType, localId); + LocalIdState item = _idsTracked[localId]; - if (item.ServerId != null) - { - throw new InvalidOperationException($"Cannot reassign to existing local ID '{localId}'."); - } + AssertSameResourceType(resourceType, item.ResourceType, localId); - item.ServerId = stringId; + if (item.ServerId != null) + { + throw new InvalidOperationException($"Cannot reassign to existing local ID '{localId}'."); } - /// - public string GetValue(string localId, ResourceType resourceType) - { - ArgumentGuard.NotNullNorEmpty(localId, nameof(localId)); - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + item.ServerId = stringId; + } - AssertIsDeclared(localId); + /// + public string GetValue(string localId, ResourceType resourceType) + { + ArgumentGuard.NotNullNorEmpty(localId, nameof(localId)); + ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - LocalIdState item = _idsTracked[localId]; + AssertIsDeclared(localId); - AssertSameResourceType(resourceType, item.ResourceType, localId); + LocalIdState item = _idsTracked[localId]; - if (item.ServerId == null) - { - throw new LocalIdSingleOperationException(localId); - } + AssertSameResourceType(resourceType, item.ResourceType, localId); - return item.ServerId; + if (item.ServerId == null) + { + throw new LocalIdSingleOperationException(localId); } - private void AssertIsDeclared(string localId) + return item.ServerId; + } + + private void AssertIsDeclared(string localId) + { + if (!_idsTracked.ContainsKey(localId)) { - if (!_idsTracked.ContainsKey(localId)) - { - throw new UnknownLocalIdValueException(localId); - } + throw new UnknownLocalIdValueException(localId); } + } - private static void AssertSameResourceType(ResourceType currentType, ResourceType declaredType, string localId) + private static void AssertSameResourceType(ResourceType currentType, ResourceType declaredType, string localId) + { + if (!declaredType.Equals(currentType)) { - if (!declaredType.Equals(currentType)) - { - throw new IncompatibleLocalIdTypeException(localId, declaredType.PublicName, currentType.PublicName); - } + throw new IncompatibleLocalIdTypeException(localId, declaredType.PublicName, currentType.PublicName); } + } - private sealed class LocalIdState - { - public ResourceType ResourceType { get; } - public string? ServerId { get; set; } + private sealed class LocalIdState + { + public ResourceType ResourceType { get; } + public string? ServerId { get; set; } - public LocalIdState(ResourceType resourceType) - { - ResourceType = resourceType; - } + public LocalIdState(ResourceType resourceType) + { + ResourceType = resourceType; } } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/LocalIdValidator.cs b/src/JsonApiDotNetCore/AtomicOperations/LocalIdValidator.cs index 670280e59b..cbbc702d0c 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/LocalIdValidator.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/LocalIdValidator.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Errors; @@ -6,100 +5,99 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.AtomicOperations +namespace JsonApiDotNetCore.AtomicOperations; + +/// +/// Validates declaration, assignment and reference of local IDs within a list of operations. +/// +[PublicAPI] +public sealed class LocalIdValidator { - /// - /// Validates declaration, assignment and reference of local IDs within a list of operations. - /// - [PublicAPI] - public sealed class LocalIdValidator - { - private readonly ILocalIdTracker _localIdTracker; - private readonly IResourceGraph _resourceGraph; + private readonly ILocalIdTracker _localIdTracker; + private readonly IResourceGraph _resourceGraph; - public LocalIdValidator(ILocalIdTracker localIdTracker, IResourceGraph resourceGraph) - { - ArgumentGuard.NotNull(localIdTracker, nameof(localIdTracker)); - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); + public LocalIdValidator(ILocalIdTracker localIdTracker, IResourceGraph resourceGraph) + { + ArgumentGuard.NotNull(localIdTracker, nameof(localIdTracker)); + ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); - _localIdTracker = localIdTracker; - _resourceGraph = resourceGraph; - } + _localIdTracker = localIdTracker; + _resourceGraph = resourceGraph; + } - public void Validate(IEnumerable operations) - { - ArgumentGuard.NotNull(operations, nameof(operations)); + public void Validate(IEnumerable operations) + { + ArgumentGuard.NotNull(operations, nameof(operations)); - _localIdTracker.Reset(); + _localIdTracker.Reset(); - int operationIndex = 0; + int operationIndex = 0; - try + try + { + foreach (OperationContainer operation in operations) { - foreach (OperationContainer operation in operations) - { - ValidateOperation(operation); + ValidateOperation(operation); - operationIndex++; - } + operationIndex++; } - catch (JsonApiException exception) + } + catch (JsonApiException exception) + { + foreach (ErrorObject error in exception.Errors) { - foreach (ErrorObject error in exception.Errors) - { - error.Source ??= new ErrorSource(); - error.Source.Pointer = $"/atomic:operations[{operationIndex}]{error.Source.Pointer}"; - } - - throw; + error.Source ??= new ErrorSource(); + error.Source.Pointer = $"/atomic:operations[{operationIndex}]{error.Source.Pointer}"; } + + throw; } + } - private void ValidateOperation(OperationContainer operation) + private void ValidateOperation(OperationContainer operation) + { + if (operation.Request.WriteOperation == WriteOperationKind.CreateResource) { - if (operation.Request.WriteOperation == WriteOperationKind.CreateResource) - { - DeclareLocalId(operation.Resource, operation.Request.PrimaryResourceType!); - } - else - { - AssertLocalIdIsAssigned(operation.Resource); - } + DeclareLocalId(operation.Resource, operation.Request.PrimaryResourceType!); + } + else + { + AssertLocalIdIsAssigned(operation.Resource); + } - foreach (IIdentifiable secondaryResource in operation.GetSecondaryResources()) - { - AssertLocalIdIsAssigned(secondaryResource); - } + foreach (IIdentifiable secondaryResource in operation.GetSecondaryResources()) + { + AssertLocalIdIsAssigned(secondaryResource); + } - if (operation.Request.WriteOperation == WriteOperationKind.CreateResource) - { - AssignLocalId(operation, operation.Request.PrimaryResourceType!); - } + if (operation.Request.WriteOperation == WriteOperationKind.CreateResource) + { + AssignLocalId(operation, operation.Request.PrimaryResourceType!); } + } - private void DeclareLocalId(IIdentifiable resource, ResourceType resourceType) + private void DeclareLocalId(IIdentifiable resource, ResourceType resourceType) + { + if (resource.LocalId != null) { - if (resource.LocalId != null) - { - _localIdTracker.Declare(resource.LocalId, resourceType); - } + _localIdTracker.Declare(resource.LocalId, resourceType); } + } - private void AssignLocalId(OperationContainer operation, ResourceType resourceType) + private void AssignLocalId(OperationContainer operation, ResourceType resourceType) + { + if (operation.Resource.LocalId != null) { - if (operation.Resource.LocalId != null) - { - _localIdTracker.Assign(operation.Resource.LocalId, resourceType, "placeholder"); - } + _localIdTracker.Assign(operation.Resource.LocalId, resourceType, "placeholder"); } + } - private void AssertLocalIdIsAssigned(IIdentifiable resource) + private void AssertLocalIdIsAssigned(IIdentifiable resource) + { + if (resource.LocalId != null) { - if (resource.LocalId != null) - { - ResourceType resourceType = _resourceGraph.GetResourceType(resource.GetType()); - _localIdTracker.GetValue(resource.LocalId, resourceType); - } + ResourceType resourceType = _resourceGraph.GetResourceType(resource.GetType()); + _localIdTracker.GetValue(resource.LocalId, resourceType); } } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/MissingTransactionFactory.cs b/src/JsonApiDotNetCore/AtomicOperations/MissingTransactionFactory.cs index 75c327c0f2..450636481f 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/MissingTransactionFactory.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/MissingTransactionFactory.cs @@ -1,20 +1,15 @@ -using System; -using System.Threading; -using System.Threading.Tasks; +namespace JsonApiDotNetCore.AtomicOperations; -namespace JsonApiDotNetCore.AtomicOperations +/// +/// A transaction factory that throws when used in an atomic:operations request, because no transaction support is available. +/// +public sealed class MissingTransactionFactory : IOperationsTransactionFactory { - /// - /// A transaction factory that throws when used in an atomic:operations request, because no transaction support is available. - /// - public sealed class MissingTransactionFactory : IOperationsTransactionFactory + /// + public Task BeginTransactionAsync(CancellationToken cancellationToken) { - /// - public Task BeginTransactionAsync(CancellationToken cancellationToken) - { - // When using a data store other than Entity Framework Core, replace this type with your custom implementation - // by overwriting the IoC container registration. - throw new NotImplementedException("No transaction support is available."); - } + // When using a data store other than Entity Framework Core, replace this type with your custom implementation + // by overwriting the IoC container registration. + throw new NotImplementedException("No transaction support is available."); } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/OperationProcessorAccessor.cs b/src/JsonApiDotNetCore/AtomicOperations/OperationProcessorAccessor.cs index 67596ee697..c032f78f8d 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/OperationProcessorAccessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/OperationProcessorAccessor.cs @@ -1,6 +1,3 @@ -using System; -using System.Threading; -using System.Threading.Tasks; using JetBrains.Annotations; using JsonApiDotNetCore.AtomicOperations.Processors; using JsonApiDotNetCore.Configuration; @@ -8,71 +5,70 @@ using JsonApiDotNetCore.Resources; using Microsoft.Extensions.DependencyInjection; -namespace JsonApiDotNetCore.AtomicOperations +namespace JsonApiDotNetCore.AtomicOperations; + +/// +[PublicAPI] +public class OperationProcessorAccessor : IOperationProcessorAccessor { - /// - [PublicAPI] - public class OperationProcessorAccessor : IOperationProcessorAccessor - { - private readonly IServiceProvider _serviceProvider; + private readonly IServiceProvider _serviceProvider; - public OperationProcessorAccessor(IServiceProvider serviceProvider) - { - ArgumentGuard.NotNull(serviceProvider, nameof(serviceProvider)); + public OperationProcessorAccessor(IServiceProvider serviceProvider) + { + ArgumentGuard.NotNull(serviceProvider, nameof(serviceProvider)); - _serviceProvider = serviceProvider; - } + _serviceProvider = serviceProvider; + } - /// - public Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) - { - ArgumentGuard.NotNull(operation, nameof(operation)); + /// + public Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) + { + ArgumentGuard.NotNull(operation, nameof(operation)); - IOperationProcessor processor = ResolveProcessor(operation); - return processor.ProcessAsync(operation, cancellationToken); - } + IOperationProcessor processor = ResolveProcessor(operation); + return processor.ProcessAsync(operation, cancellationToken); + } - protected virtual IOperationProcessor ResolveProcessor(OperationContainer operation) - { - Type processorInterface = GetProcessorInterface(operation.Request.WriteOperation!.Value); - ResourceType resourceType = operation.Request.PrimaryResourceType!; + protected virtual IOperationProcessor ResolveProcessor(OperationContainer operation) + { + Type processorInterface = GetProcessorInterface(operation.Request.WriteOperation!.Value); + ResourceType resourceType = operation.Request.PrimaryResourceType!; - Type processorType = processorInterface.MakeGenericType(resourceType.ClrType, resourceType.IdentityClrType); - return (IOperationProcessor)_serviceProvider.GetRequiredService(processorType); - } + Type processorType = processorInterface.MakeGenericType(resourceType.ClrType, resourceType.IdentityClrType); + return (IOperationProcessor)_serviceProvider.GetRequiredService(processorType); + } - private static Type GetProcessorInterface(WriteOperationKind writeOperation) + private static Type GetProcessorInterface(WriteOperationKind writeOperation) + { + switch (writeOperation) { - switch (writeOperation) + case WriteOperationKind.CreateResource: + { + return typeof(ICreateProcessor<,>); + } + case WriteOperationKind.UpdateResource: + { + return typeof(IUpdateProcessor<,>); + } + case WriteOperationKind.DeleteResource: + { + return typeof(IDeleteProcessor<,>); + } + case WriteOperationKind.SetRelationship: + { + return typeof(ISetRelationshipProcessor<,>); + } + case WriteOperationKind.AddToRelationship: + { + return typeof(IAddToRelationshipProcessor<,>); + } + case WriteOperationKind.RemoveFromRelationship: + { + return typeof(IRemoveFromRelationshipProcessor<,>); + } + default: { - case WriteOperationKind.CreateResource: - { - return typeof(ICreateProcessor<,>); - } - case WriteOperationKind.UpdateResource: - { - return typeof(IUpdateProcessor<,>); - } - case WriteOperationKind.DeleteResource: - { - return typeof(IDeleteProcessor<,>); - } - case WriteOperationKind.SetRelationship: - { - return typeof(ISetRelationshipProcessor<,>); - } - case WriteOperationKind.AddToRelationship: - { - return typeof(IAddToRelationshipProcessor<,>); - } - case WriteOperationKind.RemoveFromRelationship: - { - return typeof(IRemoveFromRelationshipProcessor<,>); - } - default: - { - throw new NotSupportedException($"Unknown write operation kind '{writeOperation}'."); - } + throw new NotSupportedException($"Unknown write operation kind '{writeOperation}'."); } } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs index ceca03ccdf..4a26e36710 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs @@ -1,7 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Errors; @@ -10,143 +6,142 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.AtomicOperations +namespace JsonApiDotNetCore.AtomicOperations; + +/// +[PublicAPI] +public class OperationsProcessor : IOperationsProcessor { + private readonly IOperationProcessorAccessor _operationProcessorAccessor; + private readonly IOperationsTransactionFactory _operationsTransactionFactory; + private readonly ILocalIdTracker _localIdTracker; + private readonly IResourceGraph _resourceGraph; + private readonly IJsonApiRequest _request; + private readonly ITargetedFields _targetedFields; + private readonly ISparseFieldSetCache _sparseFieldSetCache; + private readonly LocalIdValidator _localIdValidator; + + public OperationsProcessor(IOperationProcessorAccessor operationProcessorAccessor, IOperationsTransactionFactory operationsTransactionFactory, + ILocalIdTracker localIdTracker, IResourceGraph resourceGraph, IJsonApiRequest request, ITargetedFields targetedFields, + ISparseFieldSetCache sparseFieldSetCache) + { + ArgumentGuard.NotNull(operationProcessorAccessor, nameof(operationProcessorAccessor)); + ArgumentGuard.NotNull(operationsTransactionFactory, nameof(operationsTransactionFactory)); + ArgumentGuard.NotNull(localIdTracker, nameof(localIdTracker)); + ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); + ArgumentGuard.NotNull(request, nameof(request)); + ArgumentGuard.NotNull(targetedFields, nameof(targetedFields)); + ArgumentGuard.NotNull(sparseFieldSetCache, nameof(sparseFieldSetCache)); + + _operationProcessorAccessor = operationProcessorAccessor; + _operationsTransactionFactory = operationsTransactionFactory; + _localIdTracker = localIdTracker; + _resourceGraph = resourceGraph; + _request = request; + _targetedFields = targetedFields; + _sparseFieldSetCache = sparseFieldSetCache; + _localIdValidator = new LocalIdValidator(_localIdTracker, _resourceGraph); + } + /// - [PublicAPI] - public class OperationsProcessor : IOperationsProcessor + public virtual async Task> ProcessAsync(IList operations, CancellationToken cancellationToken) { - private readonly IOperationProcessorAccessor _operationProcessorAccessor; - private readonly IOperationsTransactionFactory _operationsTransactionFactory; - private readonly ILocalIdTracker _localIdTracker; - private readonly IResourceGraph _resourceGraph; - private readonly IJsonApiRequest _request; - private readonly ITargetedFields _targetedFields; - private readonly ISparseFieldSetCache _sparseFieldSetCache; - private readonly LocalIdValidator _localIdValidator; - - public OperationsProcessor(IOperationProcessorAccessor operationProcessorAccessor, IOperationsTransactionFactory operationsTransactionFactory, - ILocalIdTracker localIdTracker, IResourceGraph resourceGraph, IJsonApiRequest request, ITargetedFields targetedFields, - ISparseFieldSetCache sparseFieldSetCache) - { - ArgumentGuard.NotNull(operationProcessorAccessor, nameof(operationProcessorAccessor)); - ArgumentGuard.NotNull(operationsTransactionFactory, nameof(operationsTransactionFactory)); - ArgumentGuard.NotNull(localIdTracker, nameof(localIdTracker)); - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); - ArgumentGuard.NotNull(request, nameof(request)); - ArgumentGuard.NotNull(targetedFields, nameof(targetedFields)); - ArgumentGuard.NotNull(sparseFieldSetCache, nameof(sparseFieldSetCache)); - - _operationProcessorAccessor = operationProcessorAccessor; - _operationsTransactionFactory = operationsTransactionFactory; - _localIdTracker = localIdTracker; - _resourceGraph = resourceGraph; - _request = request; - _targetedFields = targetedFields; - _sparseFieldSetCache = sparseFieldSetCache; - _localIdValidator = new LocalIdValidator(_localIdTracker, _resourceGraph); - } + ArgumentGuard.NotNull(operations, nameof(operations)); - /// - public virtual async Task> ProcessAsync(IList operations, CancellationToken cancellationToken) - { - ArgumentGuard.NotNull(operations, nameof(operations)); + _localIdValidator.Validate(operations); + _localIdTracker.Reset(); - _localIdValidator.Validate(operations); - _localIdTracker.Reset(); + var results = new List(); - var results = new List(); + await using IOperationsTransaction transaction = await _operationsTransactionFactory.BeginTransactionAsync(cancellationToken); - await using IOperationsTransaction transaction = await _operationsTransactionFactory.BeginTransactionAsync(cancellationToken); + try + { + using IDisposable _ = new RevertRequestStateOnDispose(_request, _targetedFields); - try + foreach (OperationContainer operation in operations) { - using IDisposable _ = new RevertRequestStateOnDispose(_request, _targetedFields); - - foreach (OperationContainer operation in operations) - { - operation.SetTransactionId(transaction.TransactionId); + operation.SetTransactionId(transaction.TransactionId); - await transaction.BeforeProcessOperationAsync(cancellationToken); + await transaction.BeforeProcessOperationAsync(cancellationToken); - OperationContainer? result = await ProcessOperationAsync(operation, cancellationToken); - results.Add(result); + OperationContainer? result = await ProcessOperationAsync(operation, cancellationToken); + results.Add(result); - await transaction.AfterProcessOperationAsync(cancellationToken); + await transaction.AfterProcessOperationAsync(cancellationToken); - _sparseFieldSetCache.Reset(); - } - - await transaction.CommitAsync(cancellationToken); + _sparseFieldSetCache.Reset(); } - catch (OperationCanceledException) + + await transaction.CommitAsync(cancellationToken); + } + catch (OperationCanceledException) + { + throw; + } + catch (JsonApiException exception) + { + foreach (ErrorObject error in exception.Errors) { - throw; + error.Source ??= new ErrorSource(); + error.Source.Pointer = $"/atomic:operations[{results.Count}]{error.Source.Pointer}"; } - catch (JsonApiException exception) - { - foreach (ErrorObject error in exception.Errors) - { - error.Source ??= new ErrorSource(); - error.Source.Pointer = $"/atomic:operations[{results.Count}]{error.Source.Pointer}"; - } - throw; - } + throw; + } #pragma warning disable AV1210 // Catch a specific exception instead of Exception, SystemException or ApplicationException - catch (Exception exception) + catch (Exception exception) #pragma warning restore AV1210 // Catch a specific exception instead of Exception, SystemException or ApplicationException - { - throw new FailedOperationException(results.Count, exception); - } - - return results; + { + throw new FailedOperationException(results.Count, exception); } - protected virtual async Task ProcessOperationAsync(OperationContainer operation, CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); + return results; + } - TrackLocalIdsForOperation(operation); + protected virtual async Task ProcessOperationAsync(OperationContainer operation, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); - _targetedFields.CopyFrom(operation.TargetedFields); - _request.CopyFrom(operation.Request); + TrackLocalIdsForOperation(operation); - return await _operationProcessorAccessor.ProcessAsync(operation, cancellationToken); - } + _targetedFields.CopyFrom(operation.TargetedFields); + _request.CopyFrom(operation.Request); + + return await _operationProcessorAccessor.ProcessAsync(operation, cancellationToken); + } - protected void TrackLocalIdsForOperation(OperationContainer operation) + protected void TrackLocalIdsForOperation(OperationContainer operation) + { + if (operation.Request.WriteOperation == WriteOperationKind.CreateResource) { - if (operation.Request.WriteOperation == WriteOperationKind.CreateResource) - { - DeclareLocalId(operation.Resource, operation.Request.PrimaryResourceType!); - } - else - { - AssignStringId(operation.Resource); - } + DeclareLocalId(operation.Resource, operation.Request.PrimaryResourceType!); + } + else + { + AssignStringId(operation.Resource); + } - foreach (IIdentifiable secondaryResource in operation.GetSecondaryResources()) - { - AssignStringId(secondaryResource); - } + foreach (IIdentifiable secondaryResource in operation.GetSecondaryResources()) + { + AssignStringId(secondaryResource); } + } - private void DeclareLocalId(IIdentifiable resource, ResourceType resourceType) + private void DeclareLocalId(IIdentifiable resource, ResourceType resourceType) + { + if (resource.LocalId != null) { - if (resource.LocalId != null) - { - _localIdTracker.Declare(resource.LocalId, resourceType); - } + _localIdTracker.Declare(resource.LocalId, resourceType); } + } - private void AssignStringId(IIdentifiable resource) + private void AssignStringId(IIdentifiable resource) + { + if (resource.LocalId != null) { - if (resource.LocalId != null) - { - ResourceType resourceType = _resourceGraph.GetResourceType(resource.GetType()); - resource.StringId = _localIdTracker.GetValue(resource.LocalId, resourceType); - } + ResourceType resourceType = _resourceGraph.GetResourceType(resource.GetType()); + resource.StringId = _localIdTracker.GetValue(resource.LocalId, resourceType); } } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/AddToRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/AddToRelationshipProcessor.cs index 1b6025cf40..fc16847eec 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/AddToRelationshipProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/AddToRelationshipProcessor.cs @@ -1,37 +1,33 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Services; -namespace JsonApiDotNetCore.AtomicOperations.Processors +namespace JsonApiDotNetCore.AtomicOperations.Processors; + +/// +[PublicAPI] +public class AddToRelationshipProcessor : IAddToRelationshipProcessor + where TResource : class, IIdentifiable { - /// - [PublicAPI] - public class AddToRelationshipProcessor : IAddToRelationshipProcessor - where TResource : class, IIdentifiable - { - private readonly IAddToRelationshipService _service; + private readonly IAddToRelationshipService _service; - public AddToRelationshipProcessor(IAddToRelationshipService service) - { - ArgumentGuard.NotNull(service, nameof(service)); + public AddToRelationshipProcessor(IAddToRelationshipService service) + { + ArgumentGuard.NotNull(service, nameof(service)); - _service = service; - } + _service = service; + } - /// - public virtual async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) - { - ArgumentGuard.NotNull(operation, nameof(operation)); + /// + public virtual async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) + { + ArgumentGuard.NotNull(operation, nameof(operation)); - var leftId = (TId)operation.Resource.GetTypedId(); - ISet rightResourceIds = operation.GetSecondaryResources(); + var leftId = (TId)operation.Resource.GetTypedId(); + ISet rightResourceIds = operation.GetSecondaryResources(); - await _service.AddToToManyRelationshipAsync(leftId, operation.Request.Relationship!.PublicName, rightResourceIds, cancellationToken); + await _service.AddToToManyRelationshipAsync(leftId, operation.Request.Relationship!.PublicName, rightResourceIds, cancellationToken); - return null; - } + return null; } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateProcessor.cs index 06d9ae485a..b06ebd626e 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateProcessor.cs @@ -1,42 +1,39 @@ -using System.Threading; -using System.Threading.Tasks; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Services; -namespace JsonApiDotNetCore.AtomicOperations.Processors -{ - /// - [PublicAPI] - public class CreateProcessor : ICreateProcessor - where TResource : class, IIdentifiable - { - private readonly ICreateService _service; - private readonly ILocalIdTracker _localIdTracker; +namespace JsonApiDotNetCore.AtomicOperations.Processors; - public CreateProcessor(ICreateService service, ILocalIdTracker localIdTracker) - { - ArgumentGuard.NotNull(service, nameof(service)); - ArgumentGuard.NotNull(localIdTracker, nameof(localIdTracker)); +/// +[PublicAPI] +public class CreateProcessor : ICreateProcessor + where TResource : class, IIdentifiable +{ + private readonly ICreateService _service; + private readonly ILocalIdTracker _localIdTracker; - _service = service; - _localIdTracker = localIdTracker; - } + public CreateProcessor(ICreateService service, ILocalIdTracker localIdTracker) + { + ArgumentGuard.NotNull(service, nameof(service)); + ArgumentGuard.NotNull(localIdTracker, nameof(localIdTracker)); - /// - public virtual async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) - { - ArgumentGuard.NotNull(operation, nameof(operation)); + _service = service; + _localIdTracker = localIdTracker; + } - TResource? newResource = await _service.CreateAsync((TResource)operation.Resource, cancellationToken); + /// + public virtual async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) + { + ArgumentGuard.NotNull(operation, nameof(operation)); - if (operation.Resource.LocalId != null) - { - string serverId = newResource != null ? newResource.StringId! : operation.Resource.StringId!; - _localIdTracker.Assign(operation.Resource.LocalId, operation.Request.PrimaryResourceType!, serverId); - } + TResource? newResource = await _service.CreateAsync((TResource)operation.Resource, cancellationToken); - return newResource == null ? null : operation.WithResource(newResource); + if (operation.Resource.LocalId != null) + { + string serverId = newResource != null ? newResource.StringId! : operation.Resource.StringId!; + _localIdTracker.Assign(operation.Resource.LocalId, operation.Request.PrimaryResourceType!, serverId); } + + return newResource == null ? null : operation.WithResource(newResource); } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/DeleteProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/DeleteProcessor.cs index 29750b395b..e4001b75c1 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/DeleteProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/DeleteProcessor.cs @@ -1,34 +1,31 @@ -using System.Threading; -using System.Threading.Tasks; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Services; -namespace JsonApiDotNetCore.AtomicOperations.Processors +namespace JsonApiDotNetCore.AtomicOperations.Processors; + +/// +[PublicAPI] +public class DeleteProcessor : IDeleteProcessor + where TResource : class, IIdentifiable { - /// - [PublicAPI] - public class DeleteProcessor : IDeleteProcessor - where TResource : class, IIdentifiable - { - private readonly IDeleteService _service; + private readonly IDeleteService _service; - public DeleteProcessor(IDeleteService service) - { - ArgumentGuard.NotNull(service, nameof(service)); + public DeleteProcessor(IDeleteService service) + { + ArgumentGuard.NotNull(service, nameof(service)); - _service = service; - } + _service = service; + } - /// - public virtual async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) - { - ArgumentGuard.NotNull(operation, nameof(operation)); + /// + public virtual async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) + { + ArgumentGuard.NotNull(operation, nameof(operation)); - var id = (TId)operation.Resource.GetTypedId(); - await _service.DeleteAsync(id, cancellationToken); + var id = (TId)operation.Resource.GetTypedId(); + await _service.DeleteAsync(id, cancellationToken); - return null; - } + return null; } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/IAddToRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/IAddToRelationshipProcessor.cs index 2f7c10a3f7..8b9990342a 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/IAddToRelationshipProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/IAddToRelationshipProcessor.cs @@ -3,20 +3,19 @@ // ReSharper disable UnusedTypeParameter -namespace JsonApiDotNetCore.AtomicOperations.Processors +namespace JsonApiDotNetCore.AtomicOperations.Processors; + +/// +/// Processes a single operation to add resources to a to-many relationship. +/// +/// +/// The resource type. +/// +/// +/// The resource identifier type. +/// +[PublicAPI] +public interface IAddToRelationshipProcessor : IOperationProcessor + where TResource : class, IIdentifiable { - /// - /// Processes a single operation to add resources to a to-many relationship. - /// - /// - /// The resource type. - /// - /// - /// The resource identifier type. - /// - [PublicAPI] - public interface IAddToRelationshipProcessor : IOperationProcessor - where TResource : class, IIdentifiable - { - } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/ICreateProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/ICreateProcessor.cs index 0f747a9dd0..9fd1de2186 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/ICreateProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/ICreateProcessor.cs @@ -3,20 +3,19 @@ // ReSharper disable UnusedTypeParameter -namespace JsonApiDotNetCore.AtomicOperations.Processors +namespace JsonApiDotNetCore.AtomicOperations.Processors; + +/// +/// Processes a single operation to create a new resource with attributes, relationships or both. +/// +/// +/// The resource type. +/// +/// +/// The resource identifier type. +/// +[PublicAPI] +public interface ICreateProcessor : IOperationProcessor + where TResource : class, IIdentifiable { - /// - /// Processes a single operation to create a new resource with attributes, relationships or both. - /// - /// - /// The resource type. - /// - /// - /// The resource identifier type. - /// - [PublicAPI] - public interface ICreateProcessor : IOperationProcessor - where TResource : class, IIdentifiable - { - } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/IDeleteProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/IDeleteProcessor.cs index 4e5206054d..67627cd8c0 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/IDeleteProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/IDeleteProcessor.cs @@ -3,20 +3,19 @@ // ReSharper disable UnusedTypeParameter -namespace JsonApiDotNetCore.AtomicOperations.Processors +namespace JsonApiDotNetCore.AtomicOperations.Processors; + +/// +/// Processes a single operation to delete an existing resource. +/// +/// +/// The resource type. +/// +/// +/// The resource identifier type. +/// +[PublicAPI] +public interface IDeleteProcessor : IOperationProcessor + where TResource : class, IIdentifiable { - /// - /// Processes a single operation to delete an existing resource. - /// - /// - /// The resource type. - /// - /// - /// The resource identifier type. - /// - [PublicAPI] - public interface IDeleteProcessor : IOperationProcessor - where TResource : class, IIdentifiable - { - } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/IOperationProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/IOperationProcessor.cs index 559bd4cbf4..4df297469e 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/IOperationProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/IOperationProcessor.cs @@ -1,17 +1,14 @@ -using System.Threading; -using System.Threading.Tasks; using JsonApiDotNetCore.Resources; -namespace JsonApiDotNetCore.AtomicOperations.Processors +namespace JsonApiDotNetCore.AtomicOperations.Processors; + +/// +/// Processes a single entry in a list of operations. +/// +public interface IOperationProcessor { /// - /// Processes a single entry in a list of operations. + /// Processes the specified operation. /// - public interface IOperationProcessor - { - /// - /// Processes the specified operation. - /// - Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken); - } + Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken); } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/IRemoveFromRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/IRemoveFromRelationshipProcessor.cs index 02ce98d21d..6492c992f1 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/IRemoveFromRelationshipProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/IRemoveFromRelationshipProcessor.cs @@ -3,16 +3,15 @@ // ReSharper disable UnusedTypeParameter -namespace JsonApiDotNetCore.AtomicOperations.Processors +namespace JsonApiDotNetCore.AtomicOperations.Processors; + +/// +/// Processes a single operation to remove resources from a to-many relationship. +/// +/// +/// +[PublicAPI] +public interface IRemoveFromRelationshipProcessor : IOperationProcessor + where TResource : class, IIdentifiable { - /// - /// Processes a single operation to remove resources from a to-many relationship. - /// - /// - /// - [PublicAPI] - public interface IRemoveFromRelationshipProcessor : IOperationProcessor - where TResource : class, IIdentifiable - { - } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/ISetRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/ISetRelationshipProcessor.cs index 8dafc839a4..dd950d203d 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/ISetRelationshipProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/ISetRelationshipProcessor.cs @@ -3,20 +3,19 @@ // ReSharper disable UnusedTypeParameter -namespace JsonApiDotNetCore.AtomicOperations.Processors +namespace JsonApiDotNetCore.AtomicOperations.Processors; + +/// +/// Processes a single operation to perform a complete replacement of a relationship on an existing resource. +/// +/// +/// The resource type. +/// +/// +/// The resource identifier type. +/// +[PublicAPI] +public interface ISetRelationshipProcessor : IOperationProcessor + where TResource : class, IIdentifiable { - /// - /// Processes a single operation to perform a complete replacement of a relationship on an existing resource. - /// - /// - /// The resource type. - /// - /// - /// The resource identifier type. - /// - [PublicAPI] - public interface ISetRelationshipProcessor : IOperationProcessor - where TResource : class, IIdentifiable - { - } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/IUpdateProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/IUpdateProcessor.cs index 48847f6ddb..6051837749 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/IUpdateProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/IUpdateProcessor.cs @@ -3,21 +3,20 @@ // ReSharper disable UnusedTypeParameter -namespace JsonApiDotNetCore.AtomicOperations.Processors +namespace JsonApiDotNetCore.AtomicOperations.Processors; + +/// +/// Processes a single operation to update the attributes and/or relationships of an existing resource. Only the values of sent attributes are replaced. +/// And only the values of sent relationships are replaced. +/// +/// +/// The resource type. +/// +/// +/// The resource identifier type. +/// +[PublicAPI] +public interface IUpdateProcessor : IOperationProcessor + where TResource : class, IIdentifiable { - /// - /// Processes a single operation to update the attributes and/or relationships of an existing resource. Only the values of sent attributes are replaced. - /// And only the values of sent relationships are replaced. - /// - /// - /// The resource type. - /// - /// - /// The resource identifier type. - /// - [PublicAPI] - public interface IUpdateProcessor : IOperationProcessor - where TResource : class, IIdentifiable - { - } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveFromRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveFromRelationshipProcessor.cs index a186967cf0..493ed2066f 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveFromRelationshipProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveFromRelationshipProcessor.cs @@ -1,37 +1,33 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Services; -namespace JsonApiDotNetCore.AtomicOperations.Processors +namespace JsonApiDotNetCore.AtomicOperations.Processors; + +/// +[PublicAPI] +public class RemoveFromRelationshipProcessor : IRemoveFromRelationshipProcessor + where TResource : class, IIdentifiable { - /// - [PublicAPI] - public class RemoveFromRelationshipProcessor : IRemoveFromRelationshipProcessor - where TResource : class, IIdentifiable - { - private readonly IRemoveFromRelationshipService _service; + private readonly IRemoveFromRelationshipService _service; - public RemoveFromRelationshipProcessor(IRemoveFromRelationshipService service) - { - ArgumentGuard.NotNull(service, nameof(service)); + public RemoveFromRelationshipProcessor(IRemoveFromRelationshipService service) + { + ArgumentGuard.NotNull(service, nameof(service)); - _service = service; - } + _service = service; + } - /// - public virtual async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) - { - ArgumentGuard.NotNull(operation, nameof(operation)); + /// + public virtual async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) + { + ArgumentGuard.NotNull(operation, nameof(operation)); - var leftId = (TId)operation.Resource.GetTypedId(); - ISet rightResourceIds = operation.GetSecondaryResources(); + var leftId = (TId)operation.Resource.GetTypedId(); + ISet rightResourceIds = operation.GetSecondaryResources(); - await _service.RemoveFromToManyRelationshipAsync(leftId, operation.Request.Relationship!.PublicName, rightResourceIds, cancellationToken); + await _service.RemoveFromToManyRelationshipAsync(leftId, operation.Request.Relationship!.PublicName, rightResourceIds, cancellationToken); - return null; - } + return null; } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs index bec2a47854..1f15ea293f 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs @@ -1,54 +1,49 @@ -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Services; -namespace JsonApiDotNetCore.AtomicOperations.Processors +namespace JsonApiDotNetCore.AtomicOperations.Processors; + +/// +[PublicAPI] +public class SetRelationshipProcessor : ISetRelationshipProcessor + where TResource : class, IIdentifiable { - /// - [PublicAPI] - public class SetRelationshipProcessor : ISetRelationshipProcessor - where TResource : class, IIdentifiable + private readonly CollectionConverter _collectionConverter = new(); + private readonly ISetRelationshipService _service; + + public SetRelationshipProcessor(ISetRelationshipService service) { - private readonly CollectionConverter _collectionConverter = new(); - private readonly ISetRelationshipService _service; + ArgumentGuard.NotNull(service, nameof(service)); - public SetRelationshipProcessor(ISetRelationshipService service) - { - ArgumentGuard.NotNull(service, nameof(service)); + _service = service; + } - _service = service; - } + /// + public virtual async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) + { + ArgumentGuard.NotNull(operation, nameof(operation)); - /// - public virtual async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) - { - ArgumentGuard.NotNull(operation, nameof(operation)); + var leftId = (TId)operation.Resource.GetTypedId(); + object? rightValue = GetRelationshipRightValue(operation); - var leftId = (TId)operation.Resource.GetTypedId(); - object? rightValue = GetRelationshipRightValue(operation); + await _service.SetRelationshipAsync(leftId, operation.Request.Relationship!.PublicName, rightValue, cancellationToken); - await _service.SetRelationshipAsync(leftId, operation.Request.Relationship!.PublicName, rightValue, cancellationToken); + return null; + } - return null; - } + private object? GetRelationshipRightValue(OperationContainer operation) + { + RelationshipAttribute relationship = operation.Request.Relationship!; + object? rightValue = relationship.GetValue(operation.Resource); - private object? GetRelationshipRightValue(OperationContainer operation) + if (relationship is HasManyAttribute) { - RelationshipAttribute relationship = operation.Request.Relationship!; - object? rightValue = relationship.GetValue(operation.Resource); - - if (relationship is HasManyAttribute) - { - ICollection rightResources = _collectionConverter.ExtractResources(rightValue); - return rightResources.ToHashSet(IdentifiableComparer.Instance); - } - - return rightValue; + ICollection rightResources = _collectionConverter.ExtractResources(rightValue); + return rightResources.ToHashSet(IdentifiableComparer.Instance); } + + return rightValue; } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateProcessor.cs index f88bf086df..a02ac2d3ff 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateProcessor.cs @@ -1,34 +1,31 @@ -using System.Threading; -using System.Threading.Tasks; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Services; -namespace JsonApiDotNetCore.AtomicOperations.Processors +namespace JsonApiDotNetCore.AtomicOperations.Processors; + +/// +[PublicAPI] +public class UpdateProcessor : IUpdateProcessor + where TResource : class, IIdentifiable { - /// - [PublicAPI] - public class UpdateProcessor : IUpdateProcessor - where TResource : class, IIdentifiable - { - private readonly IUpdateService _service; + private readonly IUpdateService _service; - public UpdateProcessor(IUpdateService service) - { - ArgumentGuard.NotNull(service, nameof(service)); + public UpdateProcessor(IUpdateService service) + { + ArgumentGuard.NotNull(service, nameof(service)); - _service = service; - } + _service = service; + } - /// - public virtual async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) - { - ArgumentGuard.NotNull(operation, nameof(operation)); + /// + public virtual async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) + { + ArgumentGuard.NotNull(operation, nameof(operation)); - var resource = (TResource)operation.Resource; - TResource? updated = await _service.UpdateAsync(resource.Id, resource, cancellationToken); + var resource = (TResource)operation.Resource; + TResource? updated = await _service.UpdateAsync(resource.Id, resource, cancellationToken); - return updated == null ? null : operation.WithResource(updated); - } + return updated == null ? null : operation.WithResource(updated); } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/RevertRequestStateOnDispose.cs b/src/JsonApiDotNetCore/AtomicOperations/RevertRequestStateOnDispose.cs index b2a1a8daa5..453f78f1f2 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/RevertRequestStateOnDispose.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/RevertRequestStateOnDispose.cs @@ -1,38 +1,36 @@ -using System; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources; -namespace JsonApiDotNetCore.AtomicOperations +namespace JsonApiDotNetCore.AtomicOperations; + +/// +/// Copies the current request state into a backup, which is restored on dispose. +/// +internal sealed class RevertRequestStateOnDispose : IDisposable { - /// - /// Copies the current request state into a backup, which is restored on dispose. - /// - internal sealed class RevertRequestStateOnDispose : IDisposable - { - private readonly IJsonApiRequest _sourceRequest; - private readonly ITargetedFields? _sourceTargetedFields; + private readonly IJsonApiRequest _sourceRequest; + private readonly ITargetedFields? _sourceTargetedFields; - private readonly IJsonApiRequest _backupRequest = new JsonApiRequest(); - private readonly ITargetedFields _backupTargetedFields = new TargetedFields(); + private readonly IJsonApiRequest _backupRequest = new JsonApiRequest(); + private readonly ITargetedFields _backupTargetedFields = new TargetedFields(); - public RevertRequestStateOnDispose(IJsonApiRequest request, ITargetedFields? targetedFields) - { - ArgumentGuard.NotNull(request, nameof(request)); + public RevertRequestStateOnDispose(IJsonApiRequest request, ITargetedFields? targetedFields) + { + ArgumentGuard.NotNull(request, nameof(request)); - _sourceRequest = request; - _backupRequest.CopyFrom(request); + _sourceRequest = request; + _backupRequest.CopyFrom(request); - if (targetedFields != null) - { - _sourceTargetedFields = targetedFields; - _backupTargetedFields.CopyFrom(targetedFields); - } - } - - public void Dispose() + if (targetedFields != null) { - _sourceRequest.CopyFrom(_backupRequest); - _sourceTargetedFields?.CopyFrom(_backupTargetedFields); + _sourceTargetedFields = targetedFields; + _backupTargetedFields.CopyFrom(targetedFields); } } + + public void Dispose() + { + _sourceRequest.CopyFrom(_backupRequest); + _sourceTargetedFields?.CopyFrom(_backupTargetedFields); + } } diff --git a/src/JsonApiDotNetCore/CollectionConverter.cs b/src/JsonApiDotNetCore/CollectionConverter.cs index 1ab1768cb9..88037e66e3 100644 --- a/src/JsonApiDotNetCore/CollectionConverter.cs +++ b/src/JsonApiDotNetCore/CollectionConverter.cs @@ -1,128 +1,124 @@ -using System; using System.Collections; -using System.Collections.Generic; -using System.Linq; using JsonApiDotNetCore.Resources; -namespace JsonApiDotNetCore +namespace JsonApiDotNetCore; + +internal sealed class CollectionConverter { - internal sealed class CollectionConverter + private static readonly ISet HashSetCompatibleCollectionTypes = new HashSet { - private static readonly ISet HashSetCompatibleCollectionTypes = new HashSet - { - typeof(HashSet<>), - typeof(ISet<>), - typeof(IReadOnlySet<>), - typeof(ICollection<>), - typeof(IReadOnlyCollection<>), - typeof(IEnumerable<>) - }; - - /// - /// 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 IEnumerable CopyToTypedCollection(IEnumerable source, Type collectionType) - { - ArgumentGuard.NotNull(source, nameof(source)); - ArgumentGuard.NotNull(collectionType, nameof(collectionType)); - - Type concreteCollectionType = ToConcreteCollectionType(collectionType); - dynamic concreteCollectionInstance = Activator.CreateInstance(concreteCollectionType)!; - - foreach (object item in source) - { - concreteCollectionInstance.Add((dynamic)item); - } + typeof(HashSet<>), + typeof(ISet<>), + typeof(IReadOnlySet<>), + typeof(ICollection<>), + typeof(IReadOnlyCollection<>), + typeof(IEnumerable<>) + }; + + /// + /// 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 IEnumerable CopyToTypedCollection(IEnumerable source, Type collectionType) + { + ArgumentGuard.NotNull(source, nameof(source)); + ArgumentGuard.NotNull(collectionType, nameof(collectionType)); - return concreteCollectionInstance; - } + Type concreteCollectionType = ToConcreteCollectionType(collectionType); + dynamic concreteCollectionInstance = Activator.CreateInstance(concreteCollectionType)!; - /// - /// Returns a compatible collection type that can be instantiated, for example IList{Article} -> List{Article} or ISet{Article} -> HashSet{Article} - /// - private Type ToConcreteCollectionType(Type collectionType) + foreach (object item in source) { - if (collectionType.IsInterface && collectionType.IsGenericType) - { - Type openCollectionType = collectionType.GetGenericTypeDefinition(); - - if (HashSetCompatibleCollectionTypes.Contains(openCollectionType)) - { - return typeof(HashSet<>).MakeGenericType(collectionType.GenericTypeArguments[0]); - } - - if (openCollectionType == typeof(IList<>) || openCollectionType == typeof(IReadOnlyList<>)) - { - return typeof(List<>).MakeGenericType(collectionType.GenericTypeArguments[0]); - } - } - - return collectionType; + concreteCollectionInstance.Add((dynamic)item); } - /// - /// Returns a collection that contains zero, one or multiple resources, depending on the specified value. - /// - public ICollection ExtractResources(object? value) + return concreteCollectionInstance; + } + + /// + /// Returns a compatible collection type that can be instantiated, for example IList{Article} -> List{Article} or ISet{Article} -> HashSet{Article} + /// + private Type ToConcreteCollectionType(Type collectionType) + { + if (collectionType.IsInterface && collectionType.IsGenericType) { - if (value is ICollection resourceCollection) - { - return resourceCollection; - } + Type openCollectionType = collectionType.GetGenericTypeDefinition(); - if (value is IEnumerable resources) + if (HashSetCompatibleCollectionTypes.Contains(openCollectionType)) { - return resources.ToList(); + return typeof(HashSet<>).MakeGenericType(collectionType.GenericTypeArguments[0]); } - if (value is IIdentifiable resource) + if (openCollectionType == typeof(IList<>) || openCollectionType == typeof(IReadOnlyList<>)) { - return resource.AsArray(); + return typeof(List<>).MakeGenericType(collectionType.GenericTypeArguments[0]); } + } + + return collectionType; + } - return Array.Empty(); + /// + /// Returns a collection that contains zero, one or multiple resources, depending on the specified value. + /// + public ICollection ExtractResources(object? value) + { + if (value is ICollection resourceCollection) + { + return resourceCollection; + } + + if (value is IEnumerable resources) + { + return resources.ToList(); + } + + if (value is IIdentifiable resource) + { + return resource.AsArray(); } - /// - /// Returns the element type if the specified type is a generic collection, for example: IList{string} -> string or IList -> null. - /// - public Type? FindCollectionElementType(Type? type) + return Array.Empty(); + } + + /// + /// Returns the element type if the specified type is a generic collection, for example: IList{string} -> string or IList -> null. + /// + public Type? FindCollectionElementType(Type? type) + { + if (type != null) { - if (type != null) + if (type.IsGenericType && type.GenericTypeArguments.Length == 1) { - if (type.IsGenericType && type.GenericTypeArguments.Length == 1) + if (type.IsOrImplementsInterface()) { - if (type.IsOrImplementsInterface()) - { - return type.GenericTypeArguments[0]; - } + return type.GenericTypeArguments[0]; } } - - return null; } - /// - /// Indicates whether a instance can be assigned to the specified type, for example IList{Article} -> false or ISet{Article} -> - /// true. - /// - public bool TypeCanContainHashSet(Type collectionType) - { - ArgumentGuard.NotNull(collectionType, nameof(collectionType)); + return null; + } - if (collectionType.IsGenericType) - { - Type openCollectionType = collectionType.GetGenericTypeDefinition(); - return HashSetCompatibleCollectionTypes.Contains(openCollectionType); - } + /// + /// Indicates whether a instance can be assigned to the specified type, for example IList{Article} -> false or ISet{Article} -> + /// true. + /// + public bool TypeCanContainHashSet(Type collectionType) + { + ArgumentGuard.NotNull(collectionType, nameof(collectionType)); - return false; + if (collectionType.IsGenericType) + { + Type openCollectionType = collectionType.GetGenericTypeDefinition(); + return HashSetCompatibleCollectionTypes.Contains(openCollectionType); } + + return false; } } diff --git a/src/JsonApiDotNetCore/CollectionExtensions.cs b/src/JsonApiDotNetCore/CollectionExtensions.cs index ea3f0dd3a7..a7f5e72ab6 100644 --- a/src/JsonApiDotNetCore/CollectionExtensions.cs +++ b/src/JsonApiDotNetCore/CollectionExtensions.cs @@ -1,97 +1,93 @@ -using System; -using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Contracts; -using System.Linq; -namespace JsonApiDotNetCore +namespace JsonApiDotNetCore; + +internal static class CollectionExtensions { - internal static class CollectionExtensions + [Pure] + public static bool IsNullOrEmpty([NotNullWhen(false)] this IEnumerable? source) { - [Pure] - public static bool IsNullOrEmpty([NotNullWhen(false)] this IEnumerable? source) + if (source == null) { - if (source == null) - { - return true; - } - - return !source.Any(); + return true; } - public static int FindIndex(this IReadOnlyList source, Predicate match) - { - ArgumentGuard.NotNull(source, nameof(source)); - ArgumentGuard.NotNull(match, nameof(match)); + return !source.Any(); + } + + public static int FindIndex(this IReadOnlyList source, Predicate match) + { + ArgumentGuard.NotNull(source, nameof(source)); + ArgumentGuard.NotNull(match, nameof(match)); - for (int index = 0; index < source.Count; index++) + for (int index = 0; index < source.Count; index++) + { + if (match(source[index])) { - if (match(source[index])) - { - return index; - } + return index; } + } - return -1; + return -1; + } + + public static bool DictionaryEqual(this IReadOnlyDictionary? first, IReadOnlyDictionary? second, + IEqualityComparer? valueComparer = null) + { + if (ReferenceEquals(first, second)) + { + return true; } - public static bool DictionaryEqual(this IReadOnlyDictionary? first, IReadOnlyDictionary? second, - IEqualityComparer? valueComparer = null) + if (first == null || second == null) { - if (ReferenceEquals(first, second)) - { - return true; - } + return false; + } - if (first == null || second == null) - { - return false; - } + if (first.Count != second.Count) + { + return false; + } + + IEqualityComparer effectiveValueComparer = valueComparer ?? EqualityComparer.Default; - if (first.Count != second.Count) + foreach ((TKey firstKey, TValue firstValue) in first) + { + if (!second.TryGetValue(firstKey, out TValue? secondValue)) { return false; } - IEqualityComparer effectiveValueComparer = valueComparer ?? EqualityComparer.Default; - - foreach ((TKey firstKey, TValue firstValue) in first) + if (!effectiveValueComparer.Equals(firstValue, secondValue)) { - if (!second.TryGetValue(firstKey, out TValue? secondValue)) - { - return false; - } - - if (!effectiveValueComparer.Equals(firstValue, secondValue)) - { - return false; - } + return false; } - - return true; } - public static IEnumerable EmptyIfNull(this IEnumerable? source) - { - return source ?? Enumerable.Empty(); - } + return true; + } - public static IEnumerable WhereNotNull(this IEnumerable source) - { + public static IEnumerable EmptyIfNull(this IEnumerable? source) + { + return source ?? Enumerable.Empty(); + } + + public static IEnumerable WhereNotNull(this IEnumerable source) + { #pragma warning disable AV1250 // Evaluate LINQ query before returning it - return source.Where(element => element is not null)!; + return source.Where(element => element is not null)!; #pragma warning restore AV1250 // Evaluate LINQ query before returning it - } + } - public static void AddRange(this ICollection source, IEnumerable itemsToAdd) - { - ArgumentGuard.NotNull(source, nameof(source)); - ArgumentGuard.NotNull(itemsToAdd, nameof(itemsToAdd)); + public static void AddRange(this ICollection source, IEnumerable itemsToAdd) + { + ArgumentGuard.NotNull(source, nameof(source)); + ArgumentGuard.NotNull(itemsToAdd, nameof(itemsToAdd)); - foreach (T item in itemsToAdd) - { - source.Add(item); - } + foreach (T item in itemsToAdd) + { + source.Add(item); } } } diff --git a/src/JsonApiDotNetCore/Configuration/ApplicationBuilderExtensions.cs b/src/JsonApiDotNetCore/Configuration/ApplicationBuilderExtensions.cs index 740cbdac0f..a941e27218 100644 --- a/src/JsonApiDotNetCore/Configuration/ApplicationBuilderExtensions.cs +++ b/src/JsonApiDotNetCore/Configuration/ApplicationBuilderExtensions.cs @@ -2,47 +2,48 @@ using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; -namespace JsonApiDotNetCore.Configuration +namespace JsonApiDotNetCore.Configuration; + +public static class ApplicationBuilderExtensions { - public static class ApplicationBuilderExtensions + /// + /// Registers the JsonApiDotNetCore middleware. + /// + /// + /// The to add the middleware to. + /// + /// + /// The code below is the minimal that is required for proper activation, which should be added to your Startup.Configure method. + /// endpoints.MapControllers()); + /// ]]> + /// + public static void UseJsonApi(this IApplicationBuilder builder) { - /// - /// Registers the JsonApiDotNetCore middleware. - /// - /// - /// The to add the middleware to. - /// - /// - /// The code below is the minimal that is required for proper activation, which should be added to your Startup.Configure method. - /// endpoints.MapControllers()); - /// ]]> - /// - public static void UseJsonApi(this IApplicationBuilder builder) - { - ArgumentGuard.NotNull(builder, nameof(builder)); + ArgumentGuard.NotNull(builder, nameof(builder)); - using IServiceScope scope = builder.ApplicationServices.GetRequiredService().CreateScope(); + using (IServiceScope scope = builder.ApplicationServices.CreateScope()) + { var inverseNavigationResolver = scope.ServiceProvider.GetRequiredService(); inverseNavigationResolver.Resolve(); + } - var jsonApiApplicationBuilder = builder.ApplicationServices.GetRequiredService(); + var jsonApiApplicationBuilder = builder.ApplicationServices.GetRequiredService(); - jsonApiApplicationBuilder.ConfigureMvcOptions = options => - { - var inputFormatter = builder.ApplicationServices.GetRequiredService(); - options.InputFormatters.Insert(0, inputFormatter); + jsonApiApplicationBuilder.ConfigureMvcOptions = options => + { + var inputFormatter = builder.ApplicationServices.GetRequiredService(); + options.InputFormatters.Insert(0, inputFormatter); - var outputFormatter = builder.ApplicationServices.GetRequiredService(); - options.OutputFormatters.Insert(0, outputFormatter); + var outputFormatter = builder.ApplicationServices.GetRequiredService(); + options.OutputFormatters.Insert(0, outputFormatter); - var routingConvention = builder.ApplicationServices.GetRequiredService(); - options.Conventions.Insert(0, routingConvention); - }; + var routingConvention = builder.ApplicationServices.GetRequiredService(); + options.Conventions.Insert(0, routingConvention); + }; - builder.UseMiddleware(); - } + builder.UseMiddleware(); } } diff --git a/src/JsonApiDotNetCore/Configuration/IInverseNavigationResolver.cs b/src/JsonApiDotNetCore/Configuration/IInverseNavigationResolver.cs index aa7c77d6e0..0146386313 100644 --- a/src/JsonApiDotNetCore/Configuration/IInverseNavigationResolver.cs +++ b/src/JsonApiDotNetCore/Configuration/IInverseNavigationResolver.cs @@ -1,19 +1,18 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Configuration +namespace JsonApiDotNetCore.Configuration; + +/// +/// Responsible for populating . This service is instantiated in the configure phase of the +/// application. When using a data access layer different from Entity Framework Core, you will need to implement and register this service, or set +/// explicitly. +/// +[PublicAPI] +public interface IInverseNavigationResolver { /// - /// Responsible for populating . This service is instantiated in the configure phase of the - /// application. When using a data access layer different from Entity Framework Core, you will need to implement and register this service, or set - /// explicitly. + /// This method is called upon startup by JsonApiDotNetCore. It resolves inverse navigations. /// - [PublicAPI] - public interface IInverseNavigationResolver - { - /// - /// This method is called upon startup by JsonApiDotNetCore. It resolves inverse navigations. - /// - void Resolve(); - } + void Resolve(); } diff --git a/src/JsonApiDotNetCore/Configuration/IJsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/IJsonApiApplicationBuilder.cs index b3f33a1b38..459e5be291 100644 --- a/src/JsonApiDotNetCore/Configuration/IJsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/IJsonApiApplicationBuilder.cs @@ -1,10 +1,8 @@ -using System; using Microsoft.AspNetCore.Mvc; -namespace JsonApiDotNetCore.Configuration +namespace JsonApiDotNetCore.Configuration; + +internal interface IJsonApiApplicationBuilder { - internal interface IJsonApiApplicationBuilder - { - public Action? ConfigureMvcOptions { set; } - } + public Action? ConfigureMvcOptions { set; } } diff --git a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs index 15f46e7e79..350b8e0132 100644 --- a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs @@ -1,170 +1,168 @@ -using System; using System.Data; using System.Text.Json; using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Configuration +namespace JsonApiDotNetCore.Configuration; + +/// +/// Global options that configure the behavior of JsonApiDotNetCore. +/// +public interface IJsonApiOptions { /// - /// 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; } - - /// - /// Indicates whether responses should contain a jsonapi object that contains the highest JSON:API version supported. False by default. - /// - bool IncludeJsonApiVersion { get; } - - /// - /// Whether or not stack traces should be included in . False by default. - /// - bool IncludeExceptionStackTraceInErrors { get; } - - /// - /// Whether or not the request body should be included in when it is invalid. False by default. - /// - bool IncludeRequestBodyInErrors { get; } - - /// - /// Use relative links for all resources. False by default. - /// - /// - /// - /// 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 which links to show in the object. Defaults to . This - /// setting can be overruled per resource type by adding on the class definition of a resource. - /// - LinkTypes TopLevelLinks { get; } - - /// - /// Configures which links to show in the object. Defaults to . This - /// setting can be overruled per resource type by adding on the class definition of a resource. - /// - LinkTypes ResourceLinks { get; } - - /// - /// Configures which links to show in the object. Defaults to . This - /// setting can be overruled for all relationships per resource type by adding on the class definition of a - /// resource. This can be further overruled per relationship by setting . - /// - LinkTypes RelationshipLinks { get; } - - /// - /// Whether or not the total resource count should be included in top-level meta objects. This requires an additional database query. 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 ModelState validation. True 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; } - - /// - /// Whether or not to produce an error on unknown query string parameters. False by default. - /// - bool AllowUnknownQueryStringParameters { get; } - - /// - /// Whether or not to produce an error on unknown attribute and relationship keys in request bodies. False by default. - /// - bool AllowUnknownFieldsInRequestBody { get; } - - /// - /// Determines whether legacy filter notation in query strings, such as =eq:, =like:, and =in: is enabled. False by default. - /// - bool EnableLegacyFilterNotation { 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; } - - /// - /// Limits the maximum number of operations allowed per atomic:operations request. Defaults to 10. Set to null for unlimited. - /// - int? MaximumOperationsPerRequest { get; } - - /// - /// Enables to override the default isolation level for database transactions, enabling to balance between consistency and performance. Defaults to - /// null, which leaves this up to Entity Framework Core to choose (and then it varies per database provider). - /// - IsolationLevel? TransactionIsolationLevel { get; } - - /// - /// Enables to customize the settings that are used by the . - /// - /// - /// The next example sets the naming convention to camel casing. - /// - /// - JsonSerializerOptions SerializerOptions { get; } - - /// - /// Gets the settings used for deserializing request bodies. This value is based on and is intended for internal use. - /// - JsonSerializerOptions SerializerReadOptions { get; } - - /// - /// Gets the settings used for serializing response bodies. This value is based on and is intended for internal use. - /// - JsonSerializerOptions SerializerWriteOptions { get; } - } + /// 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; } + + /// + /// Indicates whether responses should contain a jsonapi object that contains the highest JSON:API version supported. False by default. + /// + bool IncludeJsonApiVersion { get; } + + /// + /// Whether or not stack traces should be included in . False by default. + /// + bool IncludeExceptionStackTraceInErrors { get; } + + /// + /// Whether or not the request body should be included in when it is invalid. False by default. + /// + bool IncludeRequestBodyInErrors { get; } + + /// + /// Use relative links for all resources. False by default. + /// + /// + /// + /// 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 which links to show in the object. Defaults to . This + /// setting can be overruled per resource type by adding on the class definition of a resource. + /// + LinkTypes TopLevelLinks { get; } + + /// + /// Configures which links to show in the object. Defaults to . This + /// setting can be overruled per resource type by adding on the class definition of a resource. + /// + LinkTypes ResourceLinks { get; } + + /// + /// Configures which links to show in the object. Defaults to . This + /// setting can be overruled for all relationships per resource type by adding on the class definition of a + /// resource. This can be further overruled per relationship by setting . + /// + LinkTypes RelationshipLinks { get; } + + /// + /// Whether or not the total resource count should be included in top-level meta objects. This requires an additional database query. 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 ModelState validation. True 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; } + + /// + /// Whether or not to produce an error on unknown query string parameters. False by default. + /// + bool AllowUnknownQueryStringParameters { get; } + + /// + /// Whether or not to produce an error on unknown attribute and relationship keys in request bodies. False by default. + /// + bool AllowUnknownFieldsInRequestBody { get; } + + /// + /// Determines whether legacy filter notation in query strings, such as =eq:, =like:, and =in: is enabled. False by default. + /// + bool EnableLegacyFilterNotation { 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; } + + /// + /// Limits the maximum number of operations allowed per atomic:operations request. Defaults to 10. Set to null for unlimited. + /// + int? MaximumOperationsPerRequest { get; } + + /// + /// Enables to override the default isolation level for database transactions, enabling to balance between consistency and performance. Defaults to + /// null, which leaves this up to Entity Framework Core to choose (and then it varies per database provider). + /// + IsolationLevel? TransactionIsolationLevel { get; } + + /// + /// Enables to customize the settings that are used by the . + /// + /// + /// The next example sets the naming convention to camel casing. + /// + /// + JsonSerializerOptions SerializerOptions { get; } + + /// + /// Gets the settings used for deserializing request bodies. This value is based on and is intended for internal use. + /// + JsonSerializerOptions SerializerReadOptions { get; } + + /// + /// Gets the settings used for serializing response bodies. This value is based on and is intended for internal use. + /// + JsonSerializerOptions SerializerWriteOptions { get; } } diff --git a/src/JsonApiDotNetCore/Configuration/IResourceGraph.cs b/src/JsonApiDotNetCore/Configuration/IResourceGraph.cs index 89684e5d86..9ccce6c60a 100644 --- a/src/JsonApiDotNetCore/Configuration/IResourceGraph.cs +++ b/src/JsonApiDotNetCore/Configuration/IResourceGraph.cs @@ -1,90 +1,87 @@ -using System; -using System.Collections.Generic; using System.Linq.Expressions; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Configuration +namespace JsonApiDotNetCore.Configuration; + +/// +/// Metadata about the shape of JSON:API resources that your API serves and the relationships between them. The resource graph is built at application +/// startup and is exposed as a singleton through Dependency Injection. +/// +[PublicAPI] +public interface IResourceGraph { /// - /// Metadata about the shape of JSON:API resources that your API serves and the relationships between them. The resource graph is built at application - /// startup and is exposed as a singleton through Dependency Injection. + /// Gets the metadata for all registered resources. /// - [PublicAPI] - public interface IResourceGraph - { - /// - /// Gets the metadata for all registered resources. - /// - IReadOnlySet GetResourceTypes(); + IReadOnlySet GetResourceTypes(); - /// - /// Gets the metadata for the resource that is publicly exposed by the specified name. Throws an when not found. - /// - ResourceType GetResourceType(string publicName); + /// + /// Gets the metadata for the resource that is publicly exposed by the specified name. Throws an when not found. + /// + ResourceType GetResourceType(string publicName); - /// - /// Gets the metadata for the resource of the specified CLR type. Throws an when not found. - /// - ResourceType GetResourceType(Type resourceClrType); + /// + /// Gets the metadata for the resource of the specified CLR type. Throws an when not found. + /// + ResourceType GetResourceType(Type resourceClrType); - /// - /// Gets the metadata for the resource of the specified CLR type. Throws an when not found. - /// - ResourceType GetResourceType() - where TResource : class, IIdentifiable; + /// + /// Gets the metadata for the resource of the specified CLR type. Throws an when not found. + /// + ResourceType GetResourceType() + where TResource : class, IIdentifiable; - /// - /// Attempts to get the metadata for the resource that is publicly exposed by the specified name. Returns null when not found. - /// - ResourceType? FindResourceType(string publicName); + /// + /// Attempts to get the metadata for the resource that is publicly exposed by the specified name. Returns null when not found. + /// + ResourceType? FindResourceType(string publicName); - /// - /// Attempts to get metadata for the resource of the specified CLR type. Returns null when not found. - /// - ResourceType? FindResourceType(Type resourceClrType); + /// + /// Attempts to get metadata for the resource of the specified CLR type. Returns null when not found. + /// + ResourceType? FindResourceType(Type resourceClrType); - /// - /// Gets the fields (attributes and relationships) for that are targeted by the selector. - /// - /// - /// The resource CLR type for which to retrieve fields. - /// - /// - /// Should be of the form: new { resource.Attribute1, resource.Relationship2 } - /// ]]> - /// - IReadOnlyCollection GetFields(Expression> selector) - where TResource : class, IIdentifiable; + /// + /// Gets the fields (attributes and relationships) for that are targeted by the selector. + /// + /// + /// The resource CLR type for which to retrieve fields. + /// + /// + /// Should be of the form: new { resource.Attribute1, resource.Relationship2 } + /// ]]> + /// + IReadOnlyCollection GetFields(Expression> selector) + where TResource : class, IIdentifiable; - /// - /// Gets the attributes for that are targeted by the selector. - /// - /// - /// The resource CLR type for which to retrieve attributes. - /// - /// - /// Should be of the form: new { resource.attribute1, resource.Attribute2 } - /// ]]> - /// - IReadOnlyCollection GetAttributes(Expression> selector) - where TResource : class, IIdentifiable; + /// + /// Gets the attributes for that are targeted by the selector. + /// + /// + /// The resource CLR type for which to retrieve attributes. + /// + /// + /// Should be of the form: new { resource.attribute1, resource.Attribute2 } + /// ]]> + /// + IReadOnlyCollection GetAttributes(Expression> selector) + where TResource : class, IIdentifiable; - /// - /// Gets the relationships for that are targeted by the selector. - /// - /// - /// The resource CLR type for which to retrieve relationships. - /// - /// - /// Should be of the form: new { resource.Relationship1, resource.Relationship2 } - /// ]]> - /// - IReadOnlyCollection GetRelationships(Expression> selector) - where TResource : class, IIdentifiable; - } + /// + /// Gets the relationships for that are targeted by the selector. + /// + /// + /// The resource CLR type for which to retrieve relationships. + /// + /// + /// Should be of the form: new { resource.Relationship1, resource.Relationship2 } + /// ]]> + /// + IReadOnlyCollection GetRelationships(Expression> selector) + where TResource : class, IIdentifiable; } diff --git a/src/JsonApiDotNetCore/Configuration/InverseNavigationResolver.cs b/src/JsonApiDotNetCore/Configuration/InverseNavigationResolver.cs index 58d0919984..8e5f3f15a3 100644 --- a/src/JsonApiDotNetCore/Configuration/InverseNavigationResolver.cs +++ b/src/JsonApiDotNetCore/Configuration/InverseNavigationResolver.cs @@ -1,73 +1,70 @@ -using System.Collections.Generic; -using System.Linq; using JetBrains.Annotations; using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Resources.Annotations; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata; -namespace JsonApiDotNetCore.Configuration +namespace JsonApiDotNetCore.Configuration; + +/// +[PublicAPI] +public sealed class InverseNavigationResolver : IInverseNavigationResolver { - /// - [PublicAPI] - public sealed class InverseNavigationResolver : IInverseNavigationResolver - { - private readonly IResourceGraph _resourceGraph; - private readonly IEnumerable _dbContextResolvers; + private readonly IResourceGraph _resourceGraph; + private readonly IEnumerable _dbContextResolvers; - public InverseNavigationResolver(IResourceGraph resourceGraph, IEnumerable dbContextResolvers) - { - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); - ArgumentGuard.NotNull(dbContextResolvers, nameof(dbContextResolvers)); + public InverseNavigationResolver(IResourceGraph resourceGraph, IEnumerable dbContextResolvers) + { + ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); + ArgumentGuard.NotNull(dbContextResolvers, nameof(dbContextResolvers)); - _resourceGraph = resourceGraph; - _dbContextResolvers = dbContextResolvers; - } + _resourceGraph = resourceGraph; + _dbContextResolvers = dbContextResolvers; + } - /// - public void Resolve() + /// + public void Resolve() + { + foreach (IDbContextResolver dbContextResolver in _dbContextResolvers) { - foreach (IDbContextResolver dbContextResolver in _dbContextResolvers) - { - DbContext dbContext = dbContextResolver.GetContext(); - Resolve(dbContext); - } + DbContext dbContext = dbContextResolver.GetContext(); + Resolve(dbContext); } + } - private void Resolve(DbContext dbContext) + private void Resolve(DbContext dbContext) + { + foreach (ResourceType resourceType in _resourceGraph.GetResourceTypes().Where(resourceType => resourceType.Relationships.Any())) { - foreach (ResourceType resourceType in _resourceGraph.GetResourceTypes().Where(resourceType => resourceType.Relationships.Any())) - { - IEntityType? entityType = dbContext.Model.FindEntityType(resourceType.ClrType); + IEntityType? entityType = dbContext.Model.FindEntityType(resourceType.ClrType); - if (entityType != null) - { - IDictionary navigationMap = GetNavigations(entityType); - ResolveRelationships(resourceType.Relationships, navigationMap); - } + if (entityType != null) + { + IDictionary navigationMap = GetNavigations(entityType); + ResolveRelationships(resourceType.Relationships, navigationMap); } } + } - private static IDictionary GetNavigations(IEntityType entityType) - { - // @formatter:wrap_chained_method_calls chop_always + private static IDictionary GetNavigations(IEntityType entityType) + { + // @formatter:wrap_chained_method_calls chop_always - return entityType.GetNavigations() - .Cast() - .Concat(entityType.GetSkipNavigations()) - .ToDictionary(navigation => navigation.Name); + return entityType.GetNavigations() + .Cast() + .Concat(entityType.GetSkipNavigations()) + .ToDictionary(navigation => navigation.Name); - // @formatter:wrap_chained_method_calls restore - } + // @formatter:wrap_chained_method_calls restore + } - private void ResolveRelationships(IReadOnlyCollection relationships, IDictionary navigationMap) + private void ResolveRelationships(IReadOnlyCollection relationships, IDictionary navigationMap) + { + foreach (RelationshipAttribute relationship in relationships) { - foreach (RelationshipAttribute relationship in relationships) + if (navigationMap.TryGetValue(relationship.Property.Name, out INavigationBase? navigation)) { - if (navigationMap.TryGetValue(relationship.Property.Name, out INavigationBase? navigation)) - { - relationship.InverseNavigationProperty = navigation.Inverse?.PropertyInfo; - } + relationship.InverseNavigationProperty = navigation.Inverse?.PropertyInfo; } } } diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs index 8bc921af78..7cd4307ad1 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs @@ -1,6 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; using JsonApiDotNetCore.AtomicOperations; using JsonApiDotNetCore.AtomicOperations.Processors; using JsonApiDotNetCore.Middleware; @@ -23,266 +20,265 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCore.Configuration +namespace JsonApiDotNetCore.Configuration; + +/// +/// A utility class that builds a JsonApi application. It registers all required services and allows the user to override parts of the startup +/// configuration. +/// +internal sealed class JsonApiApplicationBuilder : IJsonApiApplicationBuilder, IDisposable { - /// - /// A utility class that builds a JsonApi application. It registers all required services and allows the user to override parts of the startup - /// configuration. - /// - internal sealed class JsonApiApplicationBuilder : IJsonApiApplicationBuilder, IDisposable - { - private readonly JsonApiOptions _options = new(); - private readonly IServiceCollection _services; - private readonly IMvcCoreBuilder _mvcBuilder; - private readonly ResourceGraphBuilder _resourceGraphBuilder; - private readonly ServiceDiscoveryFacade _serviceDiscoveryFacade; - private readonly ServiceProvider _intermediateProvider; + private readonly JsonApiOptions _options = new(); + private readonly IServiceCollection _services; + private readonly IMvcCoreBuilder _mvcBuilder; + private readonly ResourceGraphBuilder _resourceGraphBuilder; + private readonly ServiceDiscoveryFacade _serviceDiscoveryFacade; + private readonly ServiceProvider _intermediateProvider; - public Action? ConfigureMvcOptions { get; set; } + public Action? ConfigureMvcOptions { get; set; } - public JsonApiApplicationBuilder(IServiceCollection services, IMvcCoreBuilder mvcBuilder) - { - ArgumentGuard.NotNull(services, nameof(services)); - ArgumentGuard.NotNull(mvcBuilder, nameof(mvcBuilder)); + public JsonApiApplicationBuilder(IServiceCollection services, IMvcCoreBuilder mvcBuilder) + { + ArgumentGuard.NotNull(services, nameof(services)); + ArgumentGuard.NotNull(mvcBuilder, nameof(mvcBuilder)); - _services = services; - _mvcBuilder = mvcBuilder; - _intermediateProvider = services.BuildServiceProvider(); + _services = services; + _mvcBuilder = mvcBuilder; + _intermediateProvider = services.BuildServiceProvider(); - var loggerFactory = _intermediateProvider.GetRequiredService(); + var loggerFactory = _intermediateProvider.GetRequiredService(); - _resourceGraphBuilder = new ResourceGraphBuilder(_options, loggerFactory); - _serviceDiscoveryFacade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, loggerFactory); - } + _resourceGraphBuilder = new ResourceGraphBuilder(_options, loggerFactory); + _serviceDiscoveryFacade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, loggerFactory); + } - /// - /// Executes the action provided by the user to configure . - /// - public void ConfigureJsonApiOptions(Action? configureOptions) - { - configureOptions?.Invoke(_options); - } + /// + /// Executes the action provided by the user to configure . + /// + public void ConfigureJsonApiOptions(Action? configureOptions) + { + configureOptions?.Invoke(_options); + } - /// - /// Executes the action provided by the user to configure . - /// - public void ConfigureAutoDiscovery(Action? configureAutoDiscovery) - { - configureAutoDiscovery?.Invoke(_serviceDiscoveryFacade); - } + /// + /// Executes the action provided by the user to configure . + /// + public void ConfigureAutoDiscovery(Action? configureAutoDiscovery) + { + configureAutoDiscovery?.Invoke(_serviceDiscoveryFacade); + } - /// - /// Configures and builds the resource graph with resources from the provided sources and adds it to the DI container. - /// - public void ConfigureResourceGraph(ICollection dbContextTypes, Action? configureResourceGraph) - { - ArgumentGuard.NotNull(dbContextTypes, nameof(dbContextTypes)); + /// + /// Configures and builds the resource graph with resources from the provided sources and adds it to the DI container. + /// + public void ConfigureResourceGraph(ICollection dbContextTypes, Action? configureResourceGraph) + { + ArgumentGuard.NotNull(dbContextTypes, nameof(dbContextTypes)); - _serviceDiscoveryFacade.DiscoverResources(); + _serviceDiscoveryFacade.DiscoverResources(); - foreach (Type dbContextType in dbContextTypes) - { - var dbContext = (DbContext)_intermediateProvider.GetRequiredService(dbContextType); - _resourceGraphBuilder.Add(dbContext); - } + foreach (Type dbContextType in dbContextTypes) + { + var dbContext = (DbContext)_intermediateProvider.GetRequiredService(dbContextType); + _resourceGraphBuilder.Add(dbContext); + } - configureResourceGraph?.Invoke(_resourceGraphBuilder); + configureResourceGraph?.Invoke(_resourceGraphBuilder); - IResourceGraph resourceGraph = _resourceGraphBuilder.Build(); + IResourceGraph resourceGraph = _resourceGraphBuilder.Build(); - _options.SerializerOptions.Converters.Add(new ResourceObjectConverter(resourceGraph)); + _options.SerializerOptions.Converters.Add(new ResourceObjectConverter(resourceGraph)); - _services.AddSingleton(resourceGraph); - } + _services.AddSingleton(resourceGraph); + } - /// - /// Configures built-in ASP.NET MVC components. Most of this configuration can be adjusted for the developers' need. - /// - public void ConfigureMvc() + /// + /// Configures built-in ASP.NET MVC components. Most of this configuration can be adjusted for the developers' need. + /// + public void ConfigureMvc() + { + _mvcBuilder.AddMvcOptions(options => { - _mvcBuilder.AddMvcOptions(options => - { - options.EnableEndpointRouting = true; - options.Filters.AddService(); - options.Filters.AddService(); - options.Filters.AddService(); - ConfigureMvcOptions?.Invoke(options); - }); - - if (_options.ValidateModelState) - { - _mvcBuilder.AddDataAnnotations(); - _services.AddSingleton(); - } - } - - /// - /// Discovers DI registrable services in the assemblies marked for discovery. - /// - public void DiscoverInjectables() + options.EnableEndpointRouting = true; + options.Filters.AddService(); + options.Filters.AddService(); + options.Filters.AddService(); + ConfigureMvcOptions?.Invoke(options); + }); + + if (_options.ValidateModelState) { - _serviceDiscoveryFacade.DiscoverInjectables(); + _mvcBuilder.AddDataAnnotations(); + _services.AddSingleton(); } + } - /// - /// Registers the remaining internals. - /// - public void ConfigureServiceContainer(ICollection dbContextTypes) - { - ArgumentGuard.NotNull(dbContextTypes, nameof(dbContextTypes)); + /// + /// Discovers DI registrable services in the assemblies marked for discovery. + /// + public void DiscoverInjectables() + { + _serviceDiscoveryFacade.DiscoverInjectables(); + } - if (dbContextTypes.Any()) - { - _services.AddScoped(typeof(DbContextResolver<>)); + /// + /// Registers the remaining internals. + /// + public void ConfigureServiceContainer(ICollection dbContextTypes) + { + ArgumentGuard.NotNull(dbContextTypes, nameof(dbContextTypes)); - foreach (Type dbContextType in dbContextTypes) - { - Type dbContextResolverType = typeof(DbContextResolver<>).MakeGenericType(dbContextType); - _services.AddScoped(typeof(IDbContextResolver), dbContextResolverType); - } + if (dbContextTypes.Any()) + { + _services.AddScoped(typeof(DbContextResolver<>)); - _services.AddScoped(); - } - else + foreach (Type dbContextType in dbContextTypes) { - _services.AddScoped(); + Type dbContextResolverClosedType = typeof(DbContextResolver<>).MakeGenericType(dbContextType); + _services.AddScoped(typeof(IDbContextResolver), dbContextResolverClosedType); } - AddResourceLayer(); - AddRepositoryLayer(); - AddServiceLayer(); - AddMiddlewareLayer(); - AddSerializationLayer(); - AddQueryStringLayer(); - AddOperationsLayer(); - - _services.AddScoped(typeof(IResourceChangeTracker<>), typeof(ResourceChangeTracker<>)); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); + _services.AddScoped(); } - - private void AddMiddlewareLayer() + else { - _services.AddSingleton(_options); - _services.AddSingleton(this); - _services.AddSingleton(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddSingleton(); - _services.AddSingleton(); - _services.AddSingleton(); - _services.AddSingleton(sp => sp.GetRequiredService()); - _services.AddSingleton(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); + _services.AddScoped(); } - private void AddResourceLayer() - { - RegisterImplementationForOpenInterfaces(ServiceDiscoveryFacade.ResourceDefinitionInterfaces, typeof(JsonApiResourceDefinition<,>)); + AddResourceLayer(); + AddRepositoryLayer(); + AddServiceLayer(); + AddMiddlewareLayer(); + AddSerializationLayer(); + AddQueryStringLayer(); + AddOperationsLayer(); + + _services.AddScoped(typeof(IResourceChangeTracker<>), typeof(ResourceChangeTracker<>)); + _services.AddScoped(); + _services.AddScoped(); + _services.AddScoped(); + _services.AddScoped(); + _services.AddScoped(); + } - _services.AddScoped(); - _services.AddScoped(); - } + private void AddMiddlewareLayer() + { + _services.AddSingleton(_options); + _services.AddSingleton(this); + _services.AddSingleton(); + _services.AddScoped(); + _services.AddScoped(); + _services.AddScoped(); + _services.AddSingleton(); + _services.AddSingleton(); + _services.AddSingleton(); + _services.AddSingleton(sp => sp.GetRequiredService()); + _services.AddSingleton(); + _services.AddScoped(); + _services.AddScoped(); + _services.AddScoped(); + _services.AddScoped(); + } - private void AddRepositoryLayer() - { - RegisterImplementationForOpenInterfaces(ServiceDiscoveryFacade.RepositoryInterfaces, typeof(EntityFrameworkCoreRepository<,>)); + private void AddResourceLayer() + { + RegisterImplementationForInterfaces(ServiceDiscoveryFacade.ResourceDefinitionUnboundInterfaces, typeof(JsonApiResourceDefinition<,>)); - _services.AddScoped(); - } + _services.AddScoped(); + _services.AddScoped(); + } - private void AddServiceLayer() - { - RegisterImplementationForOpenInterfaces(ServiceDiscoveryFacade.ServiceInterfaces, typeof(JsonApiResourceService<,>)); - } + private void AddRepositoryLayer() + { + RegisterImplementationForInterfaces(ServiceDiscoveryFacade.RepositoryUnboundInterfaces, typeof(EntityFrameworkCoreRepository<,>)); - private void RegisterImplementationForOpenInterfaces(HashSet openGenericInterfaces, Type implementationType) - { - foreach (Type openGenericInterface in openGenericInterfaces) - { - _services.TryAddScoped(openGenericInterface, implementationType); - } - } + _services.AddScoped(); + } - private void AddQueryStringLayer() - { - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - - RegisterDependentService(); - RegisterDependentService(); - RegisterDependentService(); - RegisterDependentService(); - RegisterDependentService(); - RegisterDependentService(); - - RegisterDependentService(); - RegisterDependentService(); - RegisterDependentService(); - RegisterDependentService(); - RegisterDependentService(); - RegisterDependentService(); - - _services.AddScoped(); - _services.AddSingleton(); - } + private void AddServiceLayer() + { + RegisterImplementationForInterfaces(ServiceDiscoveryFacade.ServiceUnboundInterfaces, typeof(JsonApiResourceService<,>)); + } - private void RegisterDependentService() - where TCollectionElement : class - where TElementToAdd : TCollectionElement + private void RegisterImplementationForInterfaces(HashSet unboundInterfaces, Type unboundImplementationType) + { + foreach (Type unboundInterface in unboundInterfaces) { - _services.AddScoped(serviceProvider => serviceProvider.GetRequiredService()); + _services.TryAddScoped(unboundInterface, unboundImplementationType); } + } - private void AddSerializationLayer() - { - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddSingleton(); - _services.AddSingleton(); - _services.AddScoped(); - } + private void AddQueryStringLayer() + { + _services.AddScoped(); + _services.AddScoped(); + _services.AddScoped(); + _services.AddScoped(); + _services.AddScoped(); + _services.AddScoped(); + + RegisterDependentService(); + RegisterDependentService(); + RegisterDependentService(); + RegisterDependentService(); + RegisterDependentService(); + RegisterDependentService(); + + RegisterDependentService(); + RegisterDependentService(); + RegisterDependentService(); + RegisterDependentService(); + RegisterDependentService(); + RegisterDependentService(); + + _services.AddScoped(); + _services.AddSingleton(); + } - private void AddOperationsLayer() - { - _services.AddScoped(typeof(ICreateProcessor<,>), typeof(CreateProcessor<,>)); - _services.AddScoped(typeof(IUpdateProcessor<,>), typeof(UpdateProcessor<,>)); - _services.AddScoped(typeof(IDeleteProcessor<,>), typeof(DeleteProcessor<,>)); - _services.AddScoped(typeof(IAddToRelationshipProcessor<,>), typeof(AddToRelationshipProcessor<,>)); - _services.AddScoped(typeof(ISetRelationshipProcessor<,>), typeof(SetRelationshipProcessor<,>)); - _services.AddScoped(typeof(IRemoveFromRelationshipProcessor<,>), typeof(RemoveFromRelationshipProcessor<,>)); - - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - } + private void RegisterDependentService() + where TCollectionElement : class + where TElementToAdd : TCollectionElement + { + _services.AddScoped(serviceProvider => serviceProvider.GetRequiredService()); + } - public void Dispose() - { - _intermediateProvider.Dispose(); - } + private void AddSerializationLayer() + { + _services.AddScoped(); + _services.AddScoped(); + _services.AddScoped(); + _services.AddScoped(); + _services.AddScoped(); + _services.AddScoped(); + _services.AddScoped(); + _services.AddScoped(); + _services.AddScoped(); + _services.AddScoped(); + + _services.AddScoped(); + _services.AddScoped(); + _services.AddScoped(); + _services.AddSingleton(); + _services.AddSingleton(); + _services.AddScoped(); + } + + private void AddOperationsLayer() + { + _services.AddScoped(typeof(ICreateProcessor<,>), typeof(CreateProcessor<,>)); + _services.AddScoped(typeof(IUpdateProcessor<,>), typeof(UpdateProcessor<,>)); + _services.AddScoped(typeof(IDeleteProcessor<,>), typeof(DeleteProcessor<,>)); + _services.AddScoped(typeof(IAddToRelationshipProcessor<,>), typeof(AddToRelationshipProcessor<,>)); + _services.AddScoped(typeof(ISetRelationshipProcessor<,>), typeof(SetRelationshipProcessor<,>)); + _services.AddScoped(typeof(IRemoveFromRelationshipProcessor<,>), typeof(RemoveFromRelationshipProcessor<,>)); + + _services.AddScoped(); + _services.AddScoped(); + _services.AddScoped(); + } + + public void Dispose() + { + _intermediateProvider.Dispose(); } } diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiModelMetadataProvider.cs b/src/JsonApiDotNetCore/Configuration/JsonApiModelMetadataProvider.cs index 07f15db8a6..9ffe2f7641 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiModelMetadataProvider.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiModelMetadataProvider.cs @@ -4,41 +4,40 @@ using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; using Microsoft.Extensions.Options; -namespace JsonApiDotNetCore.Configuration +namespace JsonApiDotNetCore.Configuration; + +/// +/// Custom implementation of to support JSON:API partial patching. +/// +internal sealed class JsonApiModelMetadataProvider : DefaultModelMetadataProvider { - /// - /// Custom implementation of to support JSON:API partial patching. - /// - internal sealed class JsonApiModelMetadataProvider : DefaultModelMetadataProvider + private readonly JsonApiValidationFilter _jsonApiValidationFilter; + + /// + public JsonApiModelMetadataProvider(ICompositeMetadataDetailsProvider detailsProvider, IHttpContextAccessor httpContextAccessor) + : base(detailsProvider) { - private readonly JsonApiValidationFilter _jsonApiValidationFilter; + _jsonApiValidationFilter = new JsonApiValidationFilter(httpContextAccessor); + } - /// - public JsonApiModelMetadataProvider(ICompositeMetadataDetailsProvider detailsProvider, IHttpContextAccessor httpContextAccessor) - : base(detailsProvider) - { - _jsonApiValidationFilter = new JsonApiValidationFilter(httpContextAccessor); - } + /// + public JsonApiModelMetadataProvider(ICompositeMetadataDetailsProvider detailsProvider, IOptions optionsAccessor, + IHttpContextAccessor httpContextAccessor) + : base(detailsProvider, optionsAccessor) + { + _jsonApiValidationFilter = new JsonApiValidationFilter(httpContextAccessor); + } - /// - public JsonApiModelMetadataProvider(ICompositeMetadataDetailsProvider detailsProvider, IOptions optionsAccessor, - IHttpContextAccessor httpContextAccessor) - : base(detailsProvider, optionsAccessor) - { - _jsonApiValidationFilter = new JsonApiValidationFilter(httpContextAccessor); - } + /// + protected override ModelMetadata CreateModelMetadata(DefaultMetadataDetails entry) + { + var metadata = (DefaultModelMetadata)base.CreateModelMetadata(entry); - /// - protected override ModelMetadata CreateModelMetadata(DefaultMetadataDetails entry) + if (metadata.ValidationMetadata.IsRequired == true) { - var metadata = (DefaultModelMetadata)base.CreateModelMetadata(entry); - - if (metadata.ValidationMetadata.IsRequired == true) - { - metadata.ValidationMetadata.PropertyValidationFilter = _jsonApiValidationFilter; - } - - return metadata; + metadata.ValidationMetadata.PropertyValidationFilter = _jsonApiValidationFilter; } + + return metadata; } } diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs index 39a5e197f2..aa7ed0d434 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs @@ -1,126 +1,118 @@ -using System; using System.Data; using System.Text.Encodings.Web; using System.Text.Json; -using System.Threading; using JetBrains.Annotations; using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.JsonConverters; -namespace JsonApiDotNetCore.Configuration +namespace JsonApiDotNetCore.Configuration; + +/// +[PublicAPI] +public sealed class JsonApiOptions : IJsonApiOptions { + private Lazy _lazySerializerWriteOptions; + private Lazy _lazySerializerReadOptions; + /// - [PublicAPI] - public sealed class JsonApiOptions : IJsonApiOptions - { - private Lazy _lazySerializerWriteOptions; - private Lazy _lazySerializerReadOptions; + JsonSerializerOptions IJsonApiOptions.SerializerReadOptions => _lazySerializerReadOptions.Value; - /// - JsonSerializerOptions IJsonApiOptions.SerializerReadOptions => _lazySerializerReadOptions.Value; + /// + JsonSerializerOptions IJsonApiOptions.SerializerWriteOptions => _lazySerializerWriteOptions.Value; - /// - JsonSerializerOptions IJsonApiOptions.SerializerWriteOptions => _lazySerializerWriteOptions.Value; + /// + public string? Namespace { get; set; } - // Workaround for https://github.com/dotnet/efcore/issues/21026 - internal bool DisableTopPagination { get; set; } - internal bool DisableChildrenPagination { get; set; } + /// + public AttrCapabilities DefaultAttrCapabilities { get; set; } = AttrCapabilities.All; - /// - public string? Namespace { get; set; } + /// + public bool IncludeJsonApiVersion { get; set; } - /// - public AttrCapabilities DefaultAttrCapabilities { get; set; } = AttrCapabilities.All; + /// + public bool IncludeExceptionStackTraceInErrors { get; set; } - /// - public bool IncludeJsonApiVersion { get; set; } + /// + public bool IncludeRequestBodyInErrors { get; set; } - /// - public bool IncludeExceptionStackTraceInErrors { get; set; } + /// + public bool UseRelativeLinks { get; set; } - /// - public bool IncludeRequestBodyInErrors { get; set; } + /// + public LinkTypes TopLevelLinks { get; set; } = LinkTypes.All; - /// - public bool UseRelativeLinks { get; set; } + /// + public LinkTypes ResourceLinks { get; set; } = LinkTypes.All; - /// - public LinkTypes TopLevelLinks { get; set; } = LinkTypes.All; + /// + public LinkTypes RelationshipLinks { get; set; } = LinkTypes.All; - /// - public LinkTypes ResourceLinks { get; set; } = LinkTypes.All; + /// + public bool IncludeTotalResourceCount { get; set; } - /// - public LinkTypes RelationshipLinks { get; set; } = LinkTypes.All; + /// + public PageSize? DefaultPageSize { get; set; } = new(10); - /// - public bool IncludeTotalResourceCount { get; set; } + /// + public PageSize? MaximumPageSize { get; set; } - /// - public PageSize? DefaultPageSize { get; set; } = new(10); + /// + public PageNumber? MaximumPageNumber { get; set; } - /// - public PageSize? MaximumPageSize { get; set; } + /// + public bool ValidateModelState { get; set; } = true; - /// - public PageNumber? MaximumPageNumber { get; set; } + /// + public bool AllowClientGeneratedIds { get; set; } - /// - public bool ValidateModelState { get; set; } = true; + /// + public bool AllowUnknownQueryStringParameters { get; set; } - /// - public bool AllowClientGeneratedIds { get; set; } + /// + public bool AllowUnknownFieldsInRequestBody { get; set; } - /// - public bool AllowUnknownQueryStringParameters { get; set; } + /// + public bool EnableLegacyFilterNotation { get; set; } - /// - public bool AllowUnknownFieldsInRequestBody { get; set; } + /// + public int? MaximumIncludeDepth { get; set; } - /// - public bool EnableLegacyFilterNotation { get; set; } + /// + public int? MaximumOperationsPerRequest { get; set; } = 10; - /// - public int? MaximumIncludeDepth { get; set; } + /// + public IsolationLevel? TransactionIsolationLevel { get; set; } - /// - public int? MaximumOperationsPerRequest { get; set; } = 10; + /// + public JsonSerializerOptions SerializerOptions { get; } = new() + { + // These are the options common to serialization and deserialization. + // At runtime, we actually use SerializerReadOptions and SerializerWriteOptions, which are customized copies of these settings, + // to overcome the limitation in System.Text.Json that the JsonPath is incorrect when using custom converters. + // Therefore we try to avoid using custom converters has much as possible. + // https://github.com/Tarmil/FSharp.SystemTextJson/issues/37 + // https://github.com/dotnet/runtime/issues/50205#issuecomment-808401245 + + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DictionaryKeyPolicy = JsonNamingPolicy.CamelCase, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + Converters = + { + new SingleOrManyDataConverterFactory() + } + }; - /// - public IsolationLevel? TransactionIsolationLevel { get; set; } + public JsonApiOptions() + { + _lazySerializerReadOptions = new Lazy(() => new JsonSerializerOptions(SerializerOptions), LazyThreadSafetyMode.PublicationOnly); - /// - public JsonSerializerOptions SerializerOptions { get; } = new() + _lazySerializerWriteOptions = new Lazy(() => new JsonSerializerOptions(SerializerOptions) { - // These are the options common to serialization and deserialization. - // At runtime, we actually use SerializerReadOptions and SerializerWriteOptions, which are customized copies of these settings, - // to overcome the limitation in System.Text.Json that the JsonPath is incorrect when using custom converters. - // Therefore we try to avoid using custom converters has much as possible. - // https://github.com/Tarmil/FSharp.SystemTextJson/issues/37 - // https://github.com/dotnet/runtime/issues/50205#issuecomment-808401245 - - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - DictionaryKeyPolicy = JsonNamingPolicy.CamelCase, - Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, Converters = { - new SingleOrManyDataConverterFactory() + new WriteOnlyDocumentConverter(), + new WriteOnlyRelationshipObjectConverter() } - }; - - public JsonApiOptions() - { - _lazySerializerReadOptions = - new Lazy(() => new JsonSerializerOptions(SerializerOptions), LazyThreadSafetyMode.PublicationOnly); - - _lazySerializerWriteOptions = new Lazy(() => new JsonSerializerOptions(SerializerOptions) - { - Converters = - { - new WriteOnlyDocumentConverter(), - new WriteOnlyRelationshipObjectConverter() - } - }, LazyThreadSafetyMode.PublicationOnly); - } + }, LazyThreadSafetyMode.PublicationOnly); } } diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiValidationFilter.cs b/src/JsonApiDotNetCore/Configuration/JsonApiValidationFilter.cs index beb978b137..6b193bdc6f 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiValidationFilter.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiValidationFilter.cs @@ -1,81 +1,78 @@ -using System; -using System.Linq; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; using Microsoft.Extensions.DependencyInjection; -namespace JsonApiDotNetCore.Configuration -{ - /// - /// Validation filter that blocks ASP.NET ModelState validation on data according to the JSON:API spec. - /// - internal sealed class JsonApiValidationFilter : IPropertyValidationFilter - { - private readonly IHttpContextAccessor _httpContextAccessor; - - public JsonApiValidationFilter(IHttpContextAccessor httpContextAccessor) - { - ArgumentGuard.NotNull(httpContextAccessor, nameof(httpContextAccessor)); - - _httpContextAccessor = httpContextAccessor; - } - - /// - public bool ShouldValidateEntry(ValidationEntry entry, ValidationEntry parentEntry) - { - IServiceProvider serviceProvider = GetScopedServiceProvider(); +namespace JsonApiDotNetCore.Configuration; - var request = serviceProvider.GetRequiredService(); +/// +/// Validation filter that blocks ASP.NET ModelState validation on data according to the JSON:API spec. +/// +internal sealed class JsonApiValidationFilter : IPropertyValidationFilter +{ + private readonly IHttpContextAccessor _httpContextAccessor; - if (IsId(entry.Key)) - { - return true; - } + public JsonApiValidationFilter(IHttpContextAccessor httpContextAccessor) + { + ArgumentGuard.NotNull(httpContextAccessor, nameof(httpContextAccessor)); - bool isTopResourceInPrimaryRequest = string.IsNullOrEmpty(parentEntry.Key) && IsAtPrimaryEndpoint(request); + _httpContextAccessor = httpContextAccessor; + } - if (!isTopResourceInPrimaryRequest) - { - return false; - } + /// + public bool ShouldValidateEntry(ValidationEntry entry, ValidationEntry parentEntry) + { + IServiceProvider serviceProvider = GetScopedServiceProvider(); - if (request.WriteOperation == WriteOperationKind.UpdateResource) - { - var targetedFields = serviceProvider.GetRequiredService(); - return IsFieldTargeted(entry, targetedFields); - } + var request = serviceProvider.GetRequiredService(); + if (IsId(entry.Key)) + { return true; } - private IServiceProvider GetScopedServiceProvider() - { - HttpContext? httpContext = _httpContextAccessor.HttpContext; - - if (httpContext == null) - { - throw new InvalidOperationException("Cannot resolve scoped services outside the context of an HTTP request."); - } + bool isTopResourceInPrimaryRequest = string.IsNullOrEmpty(parentEntry.Key) && IsAtPrimaryEndpoint(request); - return httpContext.RequestServices; - } - - private static bool IsId(string key) + if (!isTopResourceInPrimaryRequest) { - return key == nameof(Identifiable.Id) || key.EndsWith($".{nameof(Identifiable.Id)}", StringComparison.Ordinal); + return false; } - private static bool IsAtPrimaryEndpoint(IJsonApiRequest request) + if (request.WriteOperation == WriteOperationKind.UpdateResource) { - return request.Kind is EndpointKind.Primary or EndpointKind.AtomicOperations; + var targetedFields = serviceProvider.GetRequiredService(); + return IsFieldTargeted(entry, targetedFields); } - private static bool IsFieldTargeted(ValidationEntry entry, ITargetedFields targetedFields) + return true; + } + + private IServiceProvider GetScopedServiceProvider() + { + HttpContext? httpContext = _httpContextAccessor.HttpContext; + + if (httpContext == null) { - return targetedFields.Attributes.Any(attribute => attribute.Property.Name == entry.Key) || - targetedFields.Relationships.Any(relationship => relationship.Property.Name == entry.Key); + throw new InvalidOperationException("Cannot resolve scoped services outside the context of an HTTP request."); } + + return httpContext.RequestServices; + } + + private static bool IsId(string key) + { + return key == nameof(Identifiable.Id) || key.EndsWith($".{nameof(Identifiable.Id)}", StringComparison.Ordinal); + } + + private static bool IsAtPrimaryEndpoint(IJsonApiRequest request) + { + return request.Kind is EndpointKind.Primary or EndpointKind.AtomicOperations; + } + + private static bool IsFieldTargeted(ValidationEntry entry, ITargetedFields targetedFields) + { + return targetedFields.Attributes.Any(attribute => attribute.Property.Name == entry.Key) || + targetedFields.Relationships.Any(relationship => relationship.Property.Name == entry.Key); } } diff --git a/src/JsonApiDotNetCore/Configuration/PageNumber.cs b/src/JsonApiDotNetCore/Configuration/PageNumber.cs index 729000e6f1..a2d4c0cba0 100644 --- a/src/JsonApiDotNetCore/Configuration/PageNumber.cs +++ b/src/JsonApiDotNetCore/Configuration/PageNumber.cs @@ -1,53 +1,51 @@ -using System; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Configuration +namespace JsonApiDotNetCore.Configuration; + +[PublicAPI] +public sealed class PageNumber : IEquatable { - [PublicAPI] - public sealed class PageNumber : IEquatable - { - public static readonly PageNumber ValueOne = new(1); + public static readonly PageNumber ValueOne = new(1); - public int OneBasedValue { get; } + public int OneBasedValue { get; } - public PageNumber(int oneBasedValue) + public PageNumber(int oneBasedValue) + { + if (oneBasedValue < 1) { - if (oneBasedValue < 1) - { - throw new ArgumentOutOfRangeException(nameof(oneBasedValue)); - } - - OneBasedValue = oneBasedValue; + throw new ArgumentOutOfRangeException(nameof(oneBasedValue)); } - public bool Equals(PageNumber? other) - { - if (ReferenceEquals(null, other)) - { - return false; - } - - if (ReferenceEquals(this, other)) - { - return true; - } - - return OneBasedValue == other.OneBasedValue; - } + OneBasedValue = oneBasedValue; + } - public override bool Equals(object? other) + public bool Equals(PageNumber? other) + { + if (ReferenceEquals(null, other)) { - return Equals(other as PageNumber); + return false; } - public override int GetHashCode() + if (ReferenceEquals(this, other)) { - return OneBasedValue.GetHashCode(); + return true; } - public override string ToString() - { - return OneBasedValue.ToString(); - } + 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 index 460658e064..7f926f519e 100644 --- a/src/JsonApiDotNetCore/Configuration/PageSize.cs +++ b/src/JsonApiDotNetCore/Configuration/PageSize.cs @@ -1,51 +1,49 @@ -using System; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Configuration +namespace JsonApiDotNetCore.Configuration; + +[PublicAPI] +public sealed class PageSize : IEquatable { - [PublicAPI] - public sealed class PageSize : IEquatable - { - public int Value { get; } + public int Value { get; } - public PageSize(int value) + public PageSize(int value) + { + if (value < 1) { - if (value < 1) - { - throw new ArgumentOutOfRangeException(nameof(value)); - } - - Value = value; + throw new ArgumentOutOfRangeException(nameof(value)); } - public bool Equals(PageSize? other) - { - if (ReferenceEquals(null, other)) - { - return false; - } - - if (ReferenceEquals(this, other)) - { - return true; - } - - return Value == other.Value; - } + Value = value; + } - public override bool Equals(object? other) + public bool Equals(PageSize? other) + { + if (ReferenceEquals(null, other)) { - return Equals(other as PageSize); + return false; } - public override int GetHashCode() + if (ReferenceEquals(this, other)) { - return Value.GetHashCode(); + return true; } - public override string ToString() - { - return Value.ToString(); - } + 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/ResourceDescriptor.cs b/src/JsonApiDotNetCore/Configuration/ResourceDescriptor.cs index aaad96abb8..8747cdd18f 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceDescriptor.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceDescriptor.cs @@ -1,19 +1,16 @@ -using System; +namespace JsonApiDotNetCore.Configuration; -namespace JsonApiDotNetCore.Configuration +internal sealed class ResourceDescriptor { - internal sealed class ResourceDescriptor - { - public Type ResourceClrType { get; } - public Type IdClrType { get; } + public Type ResourceClrType { get; } + public Type IdClrType { get; } - public ResourceDescriptor(Type resourceClrType, Type idClrType) - { - ArgumentGuard.NotNull(resourceClrType, nameof(resourceClrType)); - ArgumentGuard.NotNull(idClrType, nameof(idClrType)); + public ResourceDescriptor(Type resourceClrType, Type idClrType) + { + ArgumentGuard.NotNull(resourceClrType, nameof(resourceClrType)); + ArgumentGuard.NotNull(idClrType, nameof(idClrType)); - ResourceClrType = resourceClrType; - IdClrType = idClrType; - } + ResourceClrType = resourceClrType; + IdClrType = idClrType; } } diff --git a/src/JsonApiDotNetCore/Configuration/ResourceDescriptorAssemblyCache.cs b/src/JsonApiDotNetCore/Configuration/ResourceDescriptorAssemblyCache.cs index 67a0329f9f..e73b48ee3d 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceDescriptorAssemblyCache.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceDescriptorAssemblyCache.cs @@ -1,58 +1,54 @@ -using System; -using System.Collections.Generic; -using System.Linq; using System.Reflection; -namespace JsonApiDotNetCore.Configuration +namespace JsonApiDotNetCore.Configuration; + +/// +/// Used to scan assemblies for types and cache them, to facilitate resource auto-discovery. +/// +internal sealed class ResourceDescriptorAssemblyCache { - /// - /// Used to scan assemblies for types and cache them, to facilitate resource auto-discovery. - /// - internal sealed class ResourceDescriptorAssemblyCache - { - private readonly TypeLocator _typeLocator = new(); - private readonly Dictionary?> _resourceDescriptorsPerAssembly = new(); + private readonly TypeLocator _typeLocator = new(); + private readonly Dictionary?> _resourceDescriptorsPerAssembly = new(); - public void RegisterAssembly(Assembly assembly) + public void RegisterAssembly(Assembly assembly) + { + if (!_resourceDescriptorsPerAssembly.ContainsKey(assembly)) { - if (!_resourceDescriptorsPerAssembly.ContainsKey(assembly)) - { - _resourceDescriptorsPerAssembly[assembly] = null; - } + _resourceDescriptorsPerAssembly[assembly] = null; } + } - public IReadOnlyCollection GetResourceDescriptors() - { - EnsureAssembliesScanned(); + public IReadOnlyCollection GetResourceDescriptors() + { + EnsureAssembliesScanned(); - return _resourceDescriptorsPerAssembly.SelectMany(pair => pair.Value!).ToArray(); - } + return _resourceDescriptorsPerAssembly.SelectMany(pair => pair.Value!).ToArray(); + } - public IReadOnlyCollection GetAssemblies() - { - EnsureAssembliesScanned(); + public IReadOnlyCollection GetAssemblies() + { + EnsureAssembliesScanned(); - return _resourceDescriptorsPerAssembly.Keys; - } + return _resourceDescriptorsPerAssembly.Keys; + } - private void EnsureAssembliesScanned() + private void EnsureAssembliesScanned() + { + foreach (Assembly assemblyToScan in _resourceDescriptorsPerAssembly.Where(pair => pair.Value == null).Select(pair => pair.Key).ToArray()) { - foreach (Assembly assemblyToScan in _resourceDescriptorsPerAssembly.Where(pair => pair.Value == null).Select(pair => pair.Key).ToArray()) - { - _resourceDescriptorsPerAssembly[assemblyToScan] = ScanForResourceDescriptors(assemblyToScan).ToArray(); - } + _resourceDescriptorsPerAssembly[assemblyToScan] = ScanForResourceDescriptors(assemblyToScan).ToArray(); } + } - private IEnumerable ScanForResourceDescriptors(Assembly assembly) + private IEnumerable ScanForResourceDescriptors(Assembly assembly) + { + foreach (Type type in assembly.GetTypes()) { - foreach (Type type in assembly.GetTypes()) - { - ResourceDescriptor? resourceDescriptor = _typeLocator.ResolveResourceDescriptor(type); + ResourceDescriptor? resourceDescriptor = _typeLocator.ResolveResourceDescriptor(type); - if (resourceDescriptor != null) - { - yield return resourceDescriptor; - } + if (resourceDescriptor != null) + { + yield return resourceDescriptor; } } } diff --git a/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs index 418c1ba0b7..d5acc8a1f9 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs @@ -1,206 +1,201 @@ -using System; -using System.Collections.Generic; -using System.Linq; using System.Linq.Expressions; using System.Reflection; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Configuration +namespace JsonApiDotNetCore.Configuration; + +/// +[PublicAPI] +public sealed class ResourceGraph : IResourceGraph { - /// - [PublicAPI] - public sealed class ResourceGraph : IResourceGraph + private static readonly Type? ProxyTargetAccessorType = Type.GetType("Castle.DynamicProxy.IProxyTargetAccessor, Castle.Core"); + + private readonly IReadOnlySet _resourceTypeSet; + private readonly Dictionary _resourceTypesByClrType = new(); + private readonly Dictionary _resourceTypesByPublicName = new(); + + public ResourceGraph(IReadOnlySet resourceTypeSet) { - private static readonly Type? ProxyTargetAccessorType = Type.GetType("Castle.DynamicProxy.IProxyTargetAccessor, Castle.Core"); + ArgumentGuard.NotNull(resourceTypeSet, nameof(resourceTypeSet)); - private readonly IReadOnlySet _resourceTypeSet; - private readonly Dictionary _resourceTypesByClrType = new(); - private readonly Dictionary _resourceTypesByPublicName = new(); + _resourceTypeSet = resourceTypeSet; - public ResourceGraph(IReadOnlySet resourceTypeSet) + foreach (ResourceType resourceType in resourceTypeSet) { - ArgumentGuard.NotNull(resourceTypeSet, nameof(resourceTypeSet)); + _resourceTypesByClrType.Add(resourceType.ClrType, resourceType); + _resourceTypesByPublicName.Add(resourceType.PublicName, resourceType); + } + } - _resourceTypeSet = resourceTypeSet; + /// + public IReadOnlySet GetResourceTypes() + { + return _resourceTypeSet; + } - foreach (ResourceType resourceType in resourceTypeSet) - { - _resourceTypesByClrType.Add(resourceType.ClrType, resourceType); - _resourceTypesByPublicName.Add(resourceType.PublicName, resourceType); - } - } + /// + public ResourceType GetResourceType(string publicName) + { + ResourceType? resourceType = FindResourceType(publicName); - /// - public IReadOnlySet GetResourceTypes() + if (resourceType == null) { - return _resourceTypeSet; + throw new InvalidOperationException($"Resource type '{publicName}' does not exist."); } - /// - public ResourceType GetResourceType(string publicName) - { - ResourceType? resourceType = FindResourceType(publicName); + return resourceType; + } - if (resourceType == null) - { - throw new InvalidOperationException($"Resource type '{publicName}' does not exist."); - } + /// + public ResourceType? FindResourceType(string publicName) + { + ArgumentGuard.NotNull(publicName, nameof(publicName)); - return resourceType; - } + return _resourceTypesByPublicName.TryGetValue(publicName, out ResourceType? resourceType) ? resourceType : null; + } - /// - public ResourceType? FindResourceType(string publicName) - { - ArgumentGuard.NotNull(publicName, nameof(publicName)); + /// + public ResourceType GetResourceType(Type resourceClrType) + { + ResourceType? resourceType = FindResourceType(resourceClrType); - return _resourceTypesByPublicName.TryGetValue(publicName, out ResourceType? resourceType) ? resourceType : null; + if (resourceType == null) + { + throw new InvalidOperationException($"Resource of type '{resourceClrType.Name}' does not exist."); } - /// - public ResourceType GetResourceType(Type resourceClrType) - { - ResourceType? resourceType = FindResourceType(resourceClrType); + return resourceType; + } - if (resourceType == null) - { - throw new InvalidOperationException($"Resource of type '{resourceClrType.Name}' does not exist."); - } + /// + public ResourceType? FindResourceType(Type resourceClrType) + { + ArgumentGuard.NotNull(resourceClrType, nameof(resourceClrType)); - return resourceType; - } + Type typeToFind = IsLazyLoadingProxyForResourceType(resourceClrType) ? resourceClrType.BaseType! : resourceClrType; + return _resourceTypesByClrType.TryGetValue(typeToFind, out ResourceType? resourceType) ? resourceType : null; + } - /// - public ResourceType? FindResourceType(Type resourceClrType) - { - ArgumentGuard.NotNull(resourceClrType, nameof(resourceClrType)); + private bool IsLazyLoadingProxyForResourceType(Type resourceClrType) + { + return ProxyTargetAccessorType?.IsAssignableFrom(resourceClrType) ?? false; + } - Type typeToFind = IsLazyLoadingProxyForResourceType(resourceClrType) ? resourceClrType.BaseType! : resourceClrType; - return _resourceTypesByClrType.TryGetValue(typeToFind, out ResourceType? resourceType) ? resourceType : null; - } + /// + public ResourceType GetResourceType() + where TResource : class, IIdentifiable + { + return GetResourceType(typeof(TResource)); + } - private bool IsLazyLoadingProxyForResourceType(Type resourceClrType) - { - return ProxyTargetAccessorType?.IsAssignableFrom(resourceClrType) ?? false; - } + /// + public IReadOnlyCollection GetFields(Expression> selector) + where TResource : class, IIdentifiable + { + ArgumentGuard.NotNull(selector, nameof(selector)); - /// - public ResourceType GetResourceType() - where TResource : class, IIdentifiable - { - return GetResourceType(typeof(TResource)); - } + return FilterFields(selector); + } - /// - public IReadOnlyCollection GetFields(Expression> selector) - where TResource : class, IIdentifiable - { - ArgumentGuard.NotNull(selector, nameof(selector)); + /// + public IReadOnlyCollection GetAttributes(Expression> selector) + where TResource : class, IIdentifiable + { + ArgumentGuard.NotNull(selector, nameof(selector)); - return FilterFields(selector); - } + return FilterFields(selector); + } - /// - public IReadOnlyCollection GetAttributes(Expression> selector) - where TResource : class, IIdentifiable + /// + public IReadOnlyCollection GetRelationships(Expression> selector) + where TResource : class, IIdentifiable + { + ArgumentGuard.NotNull(selector, nameof(selector)); + + return FilterFields(selector); + } + + private IReadOnlyCollection FilterFields(Expression> selector) + where TResource : class, IIdentifiable + where TField : ResourceFieldAttribute + { + IReadOnlyCollection source = GetFieldsOfType(); + var matches = new List(); + + foreach (string memberName in ToMemberNames(selector)) { - ArgumentGuard.NotNull(selector, nameof(selector)); + TField? matchingField = source.FirstOrDefault(field => field.Property.Name == memberName); + + if (matchingField == null) + { + throw new ArgumentException($"Member '{memberName}' is not exposed as a JSON:API field."); + } - return FilterFields(selector); + matches.Add(matchingField); } - /// - public IReadOnlyCollection GetRelationships(Expression> selector) - where TResource : class, IIdentifiable - { - ArgumentGuard.NotNull(selector, nameof(selector)); + return matches; + } - return FilterFields(selector); + private IReadOnlyCollection GetFieldsOfType() + where TKind : ResourceFieldAttribute + { + ResourceType resourceType = GetResourceType(typeof(TResource)); + + if (typeof(TKind) == typeof(AttrAttribute)) + { + return (IReadOnlyCollection)resourceType.Attributes; } - private IReadOnlyCollection FilterFields(Expression> selector) - where TResource : class, IIdentifiable - where TField : ResourceFieldAttribute + if (typeof(TKind) == typeof(RelationshipAttribute)) { - IReadOnlyCollection source = GetFieldsOfType(); - var matches = new List(); + return (IReadOnlyCollection)resourceType.Relationships; + } - foreach (string memberName in ToMemberNames(selector)) - { - TField? matchingField = source.FirstOrDefault(field => field.Property.Name == memberName); + return (IReadOnlyCollection)resourceType.Fields; + } - if (matchingField == null) - { - throw new ArgumentException($"Member '{memberName}' is not exposed as a JSON:API field."); - } + private IEnumerable ToMemberNames(Expression> selector) + { + Expression selectorBody = RemoveConvert(selector.Body); - matches.Add(matchingField); - } + if (selectorBody is MemberExpression memberExpression) + { + // model => model.Field1 - return matches; + yield return memberExpression.Member.Name; } - - private IReadOnlyCollection GetFieldsOfType() - where TKind : ResourceFieldAttribute + else if (selectorBody is NewExpression newExpression) { - ResourceType resourceType = GetResourceType(typeof(TResource)); - - if (typeof(TKind) == typeof(AttrAttribute)) - { - return (IReadOnlyCollection)resourceType.Attributes; - } + // model => new { model.Field1, model.Field2 } - if (typeof(TKind) == typeof(RelationshipAttribute)) + foreach (MemberInfo member in newExpression.Members ?? Enumerable.Empty()) { - return (IReadOnlyCollection)resourceType.Relationships; + yield return member.Name; } - - return (IReadOnlyCollection)resourceType.Fields; } - - private IEnumerable ToMemberNames(Expression> selector) + else { - Expression selectorBody = RemoveConvert(selector.Body); + 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 }'."); + } + } - if (selectorBody is MemberExpression memberExpression) - { - // model => model.Field1 + private static Expression RemoveConvert(Expression expression) + { + Expression innerExpression = expression; - yield return memberExpression.Member.Name; - } - else if (selectorBody is NewExpression newExpression) + while (true) + { + if (innerExpression is UnaryExpression { NodeType: ExpressionType.Convert } unaryExpression) { - // model => new { model.Field1, model.Field2 } - - foreach (MemberInfo member in newExpression.Members ?? Enumerable.Empty()) - { - yield return member.Name; - } + innerExpression = unaryExpression.Operand; } else { - 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 innerExpression = expression; - - while (true) - { - if (innerExpression is UnaryExpression { NodeType: ExpressionType.Convert } unaryExpression) - { - innerExpression = unaryExpression.Operand; - } - else - { - return innerExpression; - } + return innerExpression; } } } diff --git a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs index 5228795326..1352160f11 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs @@ -1,6 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; using System.Reflection; using JetBrains.Annotations; using JsonApiDotNetCore.Errors; @@ -8,349 +5,343 @@ using JsonApiDotNetCore.Resources.Annotations; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Metadata.Internal; using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCore.Configuration +namespace JsonApiDotNetCore.Configuration; + +/// +/// Builds and configures the . +/// +[PublicAPI] +public class ResourceGraphBuilder { + private readonly IJsonApiOptions _options; + private readonly ILogger _logger; + private readonly Dictionary _resourceTypesByClrType = new(); + private readonly TypeLocator _typeLocator = new(); + + public ResourceGraphBuilder(IJsonApiOptions options, ILoggerFactory loggerFactory) + { + ArgumentGuard.NotNull(options, nameof(options)); + ArgumentGuard.NotNull(loggerFactory, nameof(loggerFactory)); + + _options = options; + _logger = loggerFactory.CreateLogger(); + } + /// - /// Builds and configures the . + /// Constructs the . /// - [PublicAPI] - public class ResourceGraphBuilder + public IResourceGraph Build() { - private readonly IJsonApiOptions _options; - private readonly ILogger _logger; - private readonly Dictionary _resourceTypesByClrType = new(); - private readonly TypeLocator _typeLocator = new(); + HashSet resourceTypes = _resourceTypesByClrType.Values.ToHashSet(); - public ResourceGraphBuilder(IJsonApiOptions options, ILoggerFactory loggerFactory) + if (!resourceTypes.Any()) { - ArgumentGuard.NotNull(options, nameof(options)); - ArgumentGuard.NotNull(loggerFactory, nameof(loggerFactory)); - - _options = options; - _logger = loggerFactory.CreateLogger(); + _logger.LogWarning("The resource graph is empty."); } - /// - /// Constructs the . - /// - public IResourceGraph Build() + var resourceGraph = new ResourceGraph(resourceTypes); + + foreach (RelationshipAttribute relationship in resourceTypes.SelectMany(resourceType => resourceType.Relationships)) { - HashSet resourceTypes = _resourceTypesByClrType.Values.ToHashSet(); + relationship.LeftType = resourceGraph.GetResourceType(relationship.LeftClrType!); + ResourceType? rightType = resourceGraph.FindResourceType(relationship.RightClrType!); - if (!resourceTypes.Any()) + if (rightType == null) { - _logger.LogWarning("The resource graph is empty."); + throw new InvalidConfigurationException($"Resource type '{relationship.LeftClrType}' depends on " + + $"'{relationship.RightClrType}', which was not added to the resource graph."); } - var resourceGraph = new ResourceGraph(resourceTypes); + relationship.RightType = rightType; + } - foreach (RelationshipAttribute relationship in resourceTypes.SelectMany(resourceType => resourceType.Relationships)) - { - relationship.LeftType = resourceGraph.GetResourceType(relationship.LeftClrType!); - ResourceType? rightType = resourceGraph.FindResourceType(relationship.RightClrType!); + return resourceGraph; + } - if (rightType == null) - { - throw new InvalidConfigurationException($"Resource type '{relationship.LeftClrType}' depends on " + - $"'{relationship.RightClrType}', which was not added to the resource graph."); - } + public ResourceGraphBuilder Add(DbContext dbContext) + { + ArgumentGuard.NotNull(dbContext, nameof(dbContext)); - relationship.RightType = rightType; + foreach (IEntityType entityType in dbContext.Model.GetEntityTypes()) + { + if (!IsImplicitManyToManyJoinEntity(entityType)) + { + Add(entityType.ClrType); } - - return resourceGraph; } - public ResourceGraphBuilder Add(DbContext dbContext) - { - ArgumentGuard.NotNull(dbContext, nameof(dbContext)); + return this; + } - foreach (IEntityType entityType in dbContext.Model.GetEntityTypes()) - { - if (!IsImplicitManyToManyJoinEntity(entityType)) - { - Add(entityType.ClrType); - } - } + private static bool IsImplicitManyToManyJoinEntity(IEntityType entityType) + { + return entityType.IsPropertyBag && entityType.HasSharedClrType; + } - return this; - } + /// + /// Adds a JSON:API resource. + /// + /// + /// The resource CLR type. + /// + /// + /// The resource identifier CLR type. + /// + /// + /// The name under which the resource is publicly exposed by the API. If nothing is specified, the naming convention is applied on the pluralized CLR + /// type name. + /// + public ResourceGraphBuilder Add(string? publicName = null) + where TResource : class, IIdentifiable + { + return Add(typeof(TResource), typeof(TId), publicName); + } - private static bool IsImplicitManyToManyJoinEntity(IEntityType entityType) - { -#pragma warning disable EF1001 // Internal Entity Framework Core API usage. - return entityType is EntityType { IsImplicitlyCreatedJoinEntityType: true }; -#pragma warning restore EF1001 // Internal Entity Framework Core API usage. - } + /// + /// Adds a JSON:API resource. + /// + /// + /// The resource CLR type. + /// + /// + /// The resource identifier CLR type. + /// + /// + /// The name under which the resource is publicly exposed by the API. If nothing is specified, the naming convention is applied on the pluralized CLR + /// type name. + /// + public ResourceGraphBuilder Add(Type resourceClrType, Type? idClrType = null, string? publicName = null) + { + ArgumentGuard.NotNull(resourceClrType, nameof(resourceClrType)); - /// - /// Adds a JSON:API resource. - /// - /// - /// The resource CLR type. - /// - /// - /// The resource identifier CLR type. - /// - /// - /// The name under which the resource is publicly exposed by the API. If nothing is specified, the naming convention is applied on the pluralized CLR - /// type name. - /// - public ResourceGraphBuilder Add(string? publicName = null) - where TResource : class, IIdentifiable + if (_resourceTypesByClrType.ContainsKey(resourceClrType)) { - return Add(typeof(TResource), typeof(TId), publicName); + return this; } - /// - /// Adds a JSON:API resource. - /// - /// - /// The resource CLR type. - /// - /// - /// The resource identifier CLR type. - /// - /// - /// The name under which the resource is publicly exposed by the API. If nothing is specified, the naming convention is applied on the pluralized CLR - /// type name. - /// - public ResourceGraphBuilder Add(Type resourceClrType, Type? idClrType = null, string? publicName = null) + if (resourceClrType.IsOrImplementsInterface()) { - ArgumentGuard.NotNull(resourceClrType, nameof(resourceClrType)); + string effectivePublicName = publicName ?? FormatResourceName(resourceClrType); + Type? effectiveIdType = idClrType ?? _typeLocator.LookupIdType(resourceClrType); - if (_resourceTypesByClrType.ContainsKey(resourceClrType)) + if (effectiveIdType == null) { - return this; + throw new InvalidConfigurationException($"Resource type '{resourceClrType}' implements 'IIdentifiable', but not 'IIdentifiable'."); } - if (resourceClrType.IsOrImplementsInterface()) - { - string effectivePublicName = publicName ?? FormatResourceName(resourceClrType); - Type? effectiveIdType = idClrType ?? _typeLocator.LookupIdType(resourceClrType); - - if (effectiveIdType == null) - { - throw new InvalidConfigurationException($"Resource type '{resourceClrType}' implements 'IIdentifiable', but not 'IIdentifiable'."); - } - - ResourceType resourceType = CreateResourceType(effectivePublicName, resourceClrType, effectiveIdType); + ResourceType resourceType = CreateResourceType(effectivePublicName, resourceClrType, effectiveIdType); - AssertNoDuplicatePublicName(resourceType, effectivePublicName); + AssertNoDuplicatePublicName(resourceType, effectivePublicName); - _resourceTypesByClrType.Add(resourceClrType, resourceType); - } - else + _resourceTypesByClrType.Add(resourceClrType, resourceType); + } + else + { + if (resourceClrType.GetCustomAttribute() == null) { - _logger.LogWarning($"Skipping: Type '{resourceClrType}' does not implement '{nameof(IIdentifiable)}'."); + _logger.LogWarning( + $"Skipping: Type '{resourceClrType}' does not implement '{nameof(IIdentifiable)}'. Add [NoResource] to suppress this warning."); } - - return this; } - private ResourceType CreateResourceType(string publicName, Type resourceClrType, Type idClrType) - { - IReadOnlyCollection attributes = GetAttributes(resourceClrType); - IReadOnlyCollection relationships = GetRelationships(resourceClrType); - IReadOnlyCollection eagerLoads = GetEagerLoads(resourceClrType); + return this; + } - AssertNoDuplicatePublicName(attributes, relationships); + private ResourceType CreateResourceType(string publicName, Type resourceClrType, Type idClrType) + { + IReadOnlyCollection attributes = GetAttributes(resourceClrType); + IReadOnlyCollection relationships = GetRelationships(resourceClrType); + IReadOnlyCollection eagerLoads = GetEagerLoads(resourceClrType); - var linksAttribute = resourceClrType.GetCustomAttribute(true); + AssertNoDuplicatePublicName(attributes, relationships); - return linksAttribute == null - ? new ResourceType(publicName, resourceClrType, idClrType, attributes, relationships, eagerLoads) - : new ResourceType(publicName, resourceClrType, idClrType, attributes, relationships, eagerLoads, linksAttribute.TopLevelLinks, - linksAttribute.ResourceLinks, linksAttribute.RelationshipLinks); - } + var linksAttribute = resourceClrType.GetCustomAttribute(true); + + return linksAttribute == null + ? new ResourceType(publicName, resourceClrType, idClrType, attributes, relationships, eagerLoads) + : new ResourceType(publicName, resourceClrType, idClrType, attributes, relationships, eagerLoads, linksAttribute.TopLevelLinks, + linksAttribute.ResourceLinks, linksAttribute.RelationshipLinks); + } - private IReadOnlyCollection GetAttributes(Type resourceClrType) + private IReadOnlyCollection GetAttributes(Type resourceClrType) + { + var attributesByName = new Dictionary(); + + foreach (PropertyInfo property in resourceClrType.GetProperties()) { - var attributesByName = new Dictionary(); + var attribute = property.GetCustomAttribute(true); - foreach (PropertyInfo property in resourceClrType.GetProperties()) + if (attribute == null) { - // 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. - // And when using sparse fields, it silently adds 'id' to the set of attributes to retrieve. if (property.Name == nameof(Identifiable.Id)) { - var idAttr = new AttrAttribute - { - PublicName = FormatPropertyName(property), - Property = property, - Capabilities = _options.DefaultAttrCapabilities - }; - - IncludeField(attributesByName, idAttr); - continue; - } + // 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. + // And when using sparse fieldsets, it silently adds 'id' to the set of attributes to retrieve. - var attribute = property.GetCustomAttribute(true); - - if (attribute == null) - { - continue; + attribute = new AttrAttribute(); } - - SetPublicName(attribute, property); - attribute.Property = property; - - if (!attribute.HasExplicitCapabilities) + else { - attribute.Capabilities = _options.DefaultAttrCapabilities; + continue; } - - IncludeField(attributesByName, attribute); - } - - if (attributesByName.Count < 2) - { - _logger.LogWarning($"Type '{resourceClrType}' does not contain any attributes."); } - return attributesByName.Values; - } - - private IReadOnlyCollection GetRelationships(Type resourceClrType) - { - var relationshipsByName = new Dictionary(); - PropertyInfo[] properties = resourceClrType.GetProperties(); + SetPublicName(attribute, property); + attribute.Property = property; - foreach (PropertyInfo property in properties) + if (!attribute.HasExplicitCapabilities) { - var relationship = property.GetCustomAttribute(true); - - if (relationship != null) - { - relationship.Property = property; - SetPublicName(relationship, property); - relationship.LeftClrType = resourceClrType; - relationship.RightClrType = GetRelationshipType(relationship, property); - - IncludeField(relationshipsByName, relationship); - } + attribute.Capabilities = _options.DefaultAttrCapabilities; } - return relationshipsByName.Values; + IncludeField(attributesByName, attribute); } - private void SetPublicName(ResourceFieldAttribute field, PropertyInfo property) + if (attributesByName.Count < 2) { - // ReSharper disable once ConstantNullCoalescingCondition - field.PublicName ??= FormatPropertyName(property); + _logger.LogWarning($"Type '{resourceClrType}' does not contain any attributes."); } - private Type GetRelationshipType(RelationshipAttribute relationship, PropertyInfo property) - { - ArgumentGuard.NotNull(relationship, nameof(relationship)); - ArgumentGuard.NotNull(property, nameof(property)); + return attributesByName.Values; + } - return relationship is HasOneAttribute ? property.PropertyType : property.PropertyType.GetGenericArguments()[0]; - } + private IReadOnlyCollection GetRelationships(Type resourceClrType) + { + var relationshipsByName = new Dictionary(); + PropertyInfo[] properties = resourceClrType.GetProperties(); - private IReadOnlyCollection GetEagerLoads(Type resourceClrType, int recursionDepth = 0) + foreach (PropertyInfo property in properties) { - AssertNoInfiniteRecursion(recursionDepth); + var relationship = property.GetCustomAttribute(true); - var attributes = new List(); - PropertyInfo[] properties = resourceClrType.GetProperties(); - - foreach (PropertyInfo property in properties) + if (relationship != null) { - var eagerLoad = property.GetCustomAttribute(true); + relationship.Property = property; + SetPublicName(relationship, property); + relationship.LeftClrType = resourceClrType; + relationship.RightClrType = GetRelationshipType(relationship, property); - if (eagerLoad == null) - { - continue; - } + IncludeField(relationshipsByName, relationship); + } + } - Type innerType = TypeOrElementType(property.PropertyType); - eagerLoad.Children = GetEagerLoads(innerType, recursionDepth + 1); - eagerLoad.Property = property; + return relationshipsByName.Values; + } - attributes.Add(eagerLoad); - } + private void SetPublicName(ResourceFieldAttribute field, PropertyInfo property) + { + // ReSharper disable once ConstantNullCoalescingCondition + field.PublicName ??= FormatPropertyName(property); + } - return attributes; - } + private Type GetRelationshipType(RelationshipAttribute relationship, PropertyInfo property) + { + ArgumentGuard.NotNull(relationship, nameof(relationship)); + ArgumentGuard.NotNull(property, nameof(property)); - private static void IncludeField(Dictionary fieldsByName, TField field) - where TField : ResourceFieldAttribute - { - if (fieldsByName.TryGetValue(field.PublicName, out var existingField)) - { - throw CreateExceptionForDuplicatePublicName(field.Property.DeclaringType!, existingField, field); - } + return relationship is HasOneAttribute ? property.PropertyType : property.PropertyType.GetGenericArguments()[0]; + } - fieldsByName.Add(field.PublicName, field); - } + private IReadOnlyCollection GetEagerLoads(Type resourceClrType, int recursionDepth = 0) + { + AssertNoInfiniteRecursion(recursionDepth); - private void AssertNoDuplicatePublicName(ResourceType resourceType, string effectivePublicName) + var attributes = new List(); + PropertyInfo[] properties = resourceClrType.GetProperties(); + + foreach (PropertyInfo property in properties) { - var (existingClrType, _) = _resourceTypesByClrType.FirstOrDefault(type => type.Value.PublicName == resourceType.PublicName); + var eagerLoad = property.GetCustomAttribute(true); - if (existingClrType != null) + if (eagerLoad == null) { - throw new InvalidConfigurationException( - $"Resource '{existingClrType}' and '{resourceType.ClrType}' both use public name '{effectivePublicName}'."); + continue; } - } - private void AssertNoDuplicatePublicName(IReadOnlyCollection attributes, IReadOnlyCollection relationships) - { - IEnumerable<(AttrAttribute attribute, RelationshipAttribute relationship)> query = - from attribute in attributes - from relationship in relationships - where attribute.PublicName == relationship.PublicName - select (attribute, relationship); - - (AttrAttribute? duplicateAttribute, RelationshipAttribute? duplicateRelationship) = query.FirstOrDefault(); + Type innerType = TypeOrElementType(property.PropertyType); + eagerLoad.Children = GetEagerLoads(innerType, recursionDepth + 1); + eagerLoad.Property = property; - if (duplicateAttribute != null && duplicateRelationship != null) - { - throw CreateExceptionForDuplicatePublicName(duplicateAttribute.Property.DeclaringType!, duplicateAttribute, duplicateRelationship); - } + attributes.Add(eagerLoad); } - private static InvalidConfigurationException CreateExceptionForDuplicatePublicName(Type containingClrType, ResourceFieldAttribute existingField, - ResourceFieldAttribute field) + return attributes; + } + + private static void IncludeField(Dictionary fieldsByName, TField field) + where TField : ResourceFieldAttribute + { + if (fieldsByName.TryGetValue(field.PublicName, out TField? existingField)) { - return new InvalidConfigurationException( - $"Properties '{containingClrType}.{existingField.Property.Name}' and '{containingClrType}.{field.Property.Name}' both use public name '{field.PublicName}'."); + throw CreateExceptionForDuplicatePublicName(field.Property.DeclaringType!, existingField, field); } - [AssertionMethod] - private static void AssertNoInfiniteRecursion(int recursionDepth) + fieldsByName.Add(field.PublicName, field); + } + + private void AssertNoDuplicatePublicName(ResourceType resourceType, string effectivePublicName) + { + (Type? existingClrType, _) = _resourceTypesByClrType.FirstOrDefault(type => type.Value.PublicName == resourceType.PublicName); + + if (existingClrType != null) { - if (recursionDepth >= 500) - { - throw new InvalidOperationException("Infinite recursion detected in eager-load chain."); - } + throw new InvalidConfigurationException($"Resource '{existingClrType}' and '{resourceType.ClrType}' both use public name '{effectivePublicName}'."); } + } - private Type TypeOrElementType(Type type) - { - Type[] interfaces = type.GetInterfaces() - .Where(@interface => @interface.IsGenericType && @interface.GetGenericTypeDefinition() == typeof(IEnumerable<>)).ToArray(); + private void AssertNoDuplicatePublicName(IReadOnlyCollection attributes, IReadOnlyCollection relationships) + { + IEnumerable<(AttrAttribute attribute, RelationshipAttribute relationship)> query = + from attribute in attributes + from relationship in relationships + where attribute.PublicName == relationship.PublicName + select (attribute, relationship); - return interfaces.Length == 1 ? interfaces.Single().GenericTypeArguments[0] : type; - } + (AttrAttribute? duplicateAttribute, RelationshipAttribute? duplicateRelationship) = query.FirstOrDefault(); - private string FormatResourceName(Type resourceClrType) + if (duplicateAttribute != null && duplicateRelationship != null) { - var formatter = new ResourceNameFormatter(_options.SerializerOptions.PropertyNamingPolicy); - return formatter.FormatResourceName(resourceClrType); + throw CreateExceptionForDuplicatePublicName(duplicateAttribute.Property.DeclaringType!, duplicateAttribute, duplicateRelationship); } + } + + private static InvalidConfigurationException CreateExceptionForDuplicatePublicName(Type containingClrType, ResourceFieldAttribute existingField, + ResourceFieldAttribute field) + { + return new InvalidConfigurationException( + $"Properties '{containingClrType}.{existingField.Property.Name}' and '{containingClrType}.{field.Property.Name}' both use public name '{field.PublicName}'."); + } - private string FormatPropertyName(PropertyInfo resourceProperty) + [AssertionMethod] + private static void AssertNoInfiniteRecursion(int recursionDepth) + { + if (recursionDepth >= 500) { - return _options.SerializerOptions.PropertyNamingPolicy == null - ? resourceProperty.Name - : _options.SerializerOptions.PropertyNamingPolicy.ConvertName(resourceProperty.Name); + throw new InvalidOperationException("Infinite recursion detected in eager-load chain."); } } + + private Type TypeOrElementType(Type type) + { + Type[] interfaces = type.GetInterfaces().Where(@interface => @interface.IsGenericType && @interface.GetGenericTypeDefinition() == typeof(IEnumerable<>)) + .ToArray(); + + return interfaces.Length == 1 ? interfaces.Single().GenericTypeArguments[0] : type; + } + + private string FormatResourceName(Type resourceClrType) + { + var formatter = new ResourceNameFormatter(_options.SerializerOptions.PropertyNamingPolicy); + return formatter.FormatResourceName(resourceClrType); + } + + private string FormatPropertyName(PropertyInfo resourceProperty) + { + return _options.SerializerOptions.PropertyNamingPolicy == null + ? resourceProperty.Name + : _options.SerializerOptions.PropertyNamingPolicy.ConvertName(resourceProperty.Name); + } } diff --git a/src/JsonApiDotNetCore/Configuration/ResourceNameFormatter.cs b/src/JsonApiDotNetCore/Configuration/ResourceNameFormatter.cs index 4ce4b09afd..82a54ff010 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceNameFormatter.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceNameFormatter.cs @@ -1,36 +1,34 @@ -using System; using System.Reflection; using System.Text.Json; using Humanizer; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Configuration -{ - internal sealed class ResourceNameFormatter - { - private readonly JsonNamingPolicy? _namingPolicy; +namespace JsonApiDotNetCore.Configuration; - public ResourceNameFormatter(JsonNamingPolicy? namingPolicy) - { - _namingPolicy = namingPolicy; - } +internal sealed class ResourceNameFormatter +{ + private readonly JsonNamingPolicy? _namingPolicy; - /// - /// Gets the publicly exposed resource name by applying the configured naming convention on the pluralized CLR type name. - /// - public string FormatResourceName(Type resourceClrType) - { - ArgumentGuard.NotNull(resourceClrType, nameof(resourceClrType)); + public ResourceNameFormatter(JsonNamingPolicy? namingPolicy) + { + _namingPolicy = namingPolicy; + } - var resourceAttribute = resourceClrType.GetCustomAttribute(true); + /// + /// Gets the publicly exposed resource name by applying the configured naming convention on the pluralized CLR type name. + /// + public string FormatResourceName(Type resourceClrType) + { + ArgumentGuard.NotNull(resourceClrType, nameof(resourceClrType)); - if (resourceAttribute != null && !string.IsNullOrWhiteSpace(resourceAttribute.PublicName)) - { - return resourceAttribute.PublicName; - } + var resourceAttribute = resourceClrType.GetCustomAttribute(true); - string publicName = resourceClrType.Name.Pluralize(); - return _namingPolicy != null ? _namingPolicy.ConvertName(publicName) : publicName; + if (resourceAttribute != null && !string.IsNullOrWhiteSpace(resourceAttribute.PublicName)) + { + return resourceAttribute.PublicName; } + + string publicName = resourceClrType.Name.Pluralize(); + return _namingPolicy != null ? _namingPolicy.ConvertName(publicName) : publicName; } } diff --git a/src/JsonApiDotNetCore/Configuration/ResourceType.cs b/src/JsonApiDotNetCore/Configuration/ResourceType.cs index 2f71fa5d2b..f305bcb9e6 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceType.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceType.cs @@ -1,222 +1,217 @@ -using System; -using System.Collections.Generic; -using System.Linq; using JetBrains.Annotations; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Configuration +namespace JsonApiDotNetCore.Configuration; + +/// +/// Metadata about the shape of a JSON:API resource in the resource graph. +/// +[PublicAPI] +public sealed class ResourceType { + private readonly Dictionary _fieldsByPublicName = new(); + private readonly Dictionary _fieldsByPropertyName = new(); + /// - /// Metadata about the shape of a JSON:API resource in the resource graph. + /// The publicly exposed resource name. /// - [PublicAPI] - public sealed class ResourceType - { - private readonly Dictionary _fieldsByPublicName = new(); - private readonly Dictionary _fieldsByPropertyName = new(); - - /// - /// The publicly exposed resource name. - /// - public string PublicName { get; } - - /// - /// The CLR type of the resource. - /// - public Type ClrType { get; } - - /// - /// The CLR type of the resource identity. - /// - public Type IdentityClrType { get; } - - /// - /// Exposed resource attributes and relationships. See https://jsonapi.org/format/#document-resource-object-fields. - /// - public IReadOnlyCollection Fields { get; } - - /// - /// Exposed resource attributes. See https://jsonapi.org/format/#document-resource-object-attributes. - /// - public IReadOnlyCollection Attributes { get; } - - /// - /// Exposed resource relationships. See https://jsonapi.org/format/#document-resource-object-relationships. - /// - public IReadOnlyCollection Relationships { get; } - - /// - /// Related entities that are not exposed as resource relationships. - /// - public IReadOnlyCollection EagerLoads { get; } - - /// - /// Configures which links to show in the object for this resource type. Defaults to - /// , which falls back to . - /// - /// - /// In the process of building the resource graph, this value is set based on usage. - /// - public LinkTypes TopLevelLinks { get; } - - /// - /// Configures which links to show in the object for this resource type. Defaults to - /// , which falls back to . - /// - /// - /// In the process of building the resource graph, this value is set based on usage. - /// - public LinkTypes ResourceLinks { get; } - - /// - /// Configures which links to show in the object for all relationships of this resource type. - /// Defaults to , which falls back to . This can be overruled per - /// relationship by setting . - /// - /// - /// In the process of building the resource graph, this value is set based on usage. - /// - public LinkTypes RelationshipLinks { get; } - - public ResourceType(string publicName, Type clrType, Type identityClrType, IReadOnlyCollection? attributes = null, - IReadOnlyCollection? relationships = null, IReadOnlyCollection? eagerLoads = null, - LinkTypes topLevelLinks = LinkTypes.NotConfigured, LinkTypes resourceLinks = LinkTypes.NotConfigured, - LinkTypes relationshipLinks = LinkTypes.NotConfigured) - { - ArgumentGuard.NotNullNorEmpty(publicName, nameof(publicName)); - ArgumentGuard.NotNull(clrType, nameof(clrType)); - ArgumentGuard.NotNull(identityClrType, nameof(identityClrType)); - - PublicName = publicName; - ClrType = clrType; - IdentityClrType = identityClrType; - Attributes = attributes ?? Array.Empty(); - Relationships = relationships ?? Array.Empty(); - EagerLoads = eagerLoads ?? Array.Empty(); - TopLevelLinks = topLevelLinks; - ResourceLinks = resourceLinks; - RelationshipLinks = relationshipLinks; - Fields = Attributes.Cast().Concat(Relationships).ToArray(); - - foreach (ResourceFieldAttribute field in Fields) - { - _fieldsByPublicName.Add(field.PublicName, field); - _fieldsByPropertyName.Add(field.Property.Name, field); - } - } + public string PublicName { get; } - public AttrAttribute GetAttributeByPublicName(string publicName) - { - AttrAttribute? attribute = FindAttributeByPublicName(publicName); - return attribute ?? throw new InvalidOperationException($"Attribute '{publicName}' does not exist on resource type '{PublicName}'."); - } + /// + /// The CLR type of the resource. + /// + public Type ClrType { get; } - public AttrAttribute? FindAttributeByPublicName(string publicName) - { - ArgumentGuard.NotNull(publicName, nameof(publicName)); + /// + /// The CLR type of the resource identity. + /// + public Type IdentityClrType { get; } - return _fieldsByPublicName.TryGetValue(publicName, out ResourceFieldAttribute? field) && field is AttrAttribute attribute ? attribute : null; - } + /// + /// Exposed resource attributes and relationships. See https://jsonapi.org/format/#document-resource-object-fields. + /// + public IReadOnlyCollection Fields { get; } - public AttrAttribute GetAttributeByPropertyName(string propertyName) - { - AttrAttribute? attribute = FindAttributeByPropertyName(propertyName); + /// + /// Exposed resource attributes. See https://jsonapi.org/format/#document-resource-object-attributes. + /// + public IReadOnlyCollection Attributes { get; } - return attribute ?? - throw new InvalidOperationException($"Attribute for property '{propertyName}' does not exist on resource type '{ClrType.Name}'."); - } + /// + /// Exposed resource relationships. See https://jsonapi.org/format/#document-resource-object-relationships. + /// + public IReadOnlyCollection Relationships { get; } - public AttrAttribute? FindAttributeByPropertyName(string propertyName) - { - ArgumentGuard.NotNull(propertyName, nameof(propertyName)); + /// + /// Related entities that are not exposed as resource relationships. + /// + public IReadOnlyCollection EagerLoads { get; } - return _fieldsByPropertyName.TryGetValue(propertyName, out ResourceFieldAttribute? field) && field is AttrAttribute attribute ? attribute : null; - } + /// + /// Configures which links to show in the object for this resource type. Defaults to + /// , which falls back to . + /// + /// + /// In the process of building the resource graph, this value is set based on usage. + /// + public LinkTypes TopLevelLinks { get; } + + /// + /// Configures which links to show in the object for this resource type. Defaults to + /// , which falls back to . + /// + /// + /// In the process of building the resource graph, this value is set based on usage. + /// + public LinkTypes ResourceLinks { get; } - public RelationshipAttribute GetRelationshipByPublicName(string publicName) + /// + /// Configures which links to show in the object for all relationships of this resource type. + /// Defaults to , which falls back to . This can be overruled per + /// relationship by setting . + /// + /// + /// In the process of building the resource graph, this value is set based on usage. + /// + public LinkTypes RelationshipLinks { get; } + + public ResourceType(string publicName, Type clrType, Type identityClrType, IReadOnlyCollection? attributes = null, + IReadOnlyCollection? relationships = null, IReadOnlyCollection? eagerLoads = null, + LinkTypes topLevelLinks = LinkTypes.NotConfigured, LinkTypes resourceLinks = LinkTypes.NotConfigured, + LinkTypes relationshipLinks = LinkTypes.NotConfigured) + { + ArgumentGuard.NotNullNorEmpty(publicName, nameof(publicName)); + ArgumentGuard.NotNull(clrType, nameof(clrType)); + ArgumentGuard.NotNull(identityClrType, nameof(identityClrType)); + + PublicName = publicName; + ClrType = clrType; + IdentityClrType = identityClrType; + Attributes = attributes ?? Array.Empty(); + Relationships = relationships ?? Array.Empty(); + EagerLoads = eagerLoads ?? Array.Empty(); + TopLevelLinks = topLevelLinks; + ResourceLinks = resourceLinks; + RelationshipLinks = relationshipLinks; + Fields = Attributes.Cast().Concat(Relationships).ToArray(); + + foreach (ResourceFieldAttribute field in Fields) { - RelationshipAttribute? relationship = FindRelationshipByPublicName(publicName); - return relationship ?? throw new InvalidOperationException($"Relationship '{publicName}' does not exist on resource type '{PublicName}'."); + _fieldsByPublicName.Add(field.PublicName, field); + _fieldsByPropertyName.Add(field.Property.Name, field); } + } - public RelationshipAttribute? FindRelationshipByPublicName(string publicName) - { - ArgumentGuard.NotNull(publicName, nameof(publicName)); + public AttrAttribute GetAttributeByPublicName(string publicName) + { + AttrAttribute? attribute = FindAttributeByPublicName(publicName); + return attribute ?? throw new InvalidOperationException($"Attribute '{publicName}' does not exist on resource type '{PublicName}'."); + } - return _fieldsByPublicName.TryGetValue(publicName, out ResourceFieldAttribute? field) && field is RelationshipAttribute relationship - ? relationship - : null; - } + public AttrAttribute? FindAttributeByPublicName(string publicName) + { + ArgumentGuard.NotNull(publicName, nameof(publicName)); - public RelationshipAttribute GetRelationshipByPropertyName(string propertyName) - { - RelationshipAttribute? relationship = FindRelationshipByPropertyName(propertyName); + return _fieldsByPublicName.TryGetValue(publicName, out ResourceFieldAttribute? field) && field is AttrAttribute attribute ? attribute : null; + } - return relationship ?? - throw new InvalidOperationException($"Relationship for property '{propertyName}' does not exist on resource type '{ClrType.Name}'."); - } + public AttrAttribute GetAttributeByPropertyName(string propertyName) + { + AttrAttribute? attribute = FindAttributeByPropertyName(propertyName); - public RelationshipAttribute? FindRelationshipByPropertyName(string propertyName) - { - ArgumentGuard.NotNull(propertyName, nameof(propertyName)); + return attribute ?? throw new InvalidOperationException($"Attribute for property '{propertyName}' does not exist on resource type '{ClrType.Name}'."); + } - return _fieldsByPropertyName.TryGetValue(propertyName, out ResourceFieldAttribute? field) && field is RelationshipAttribute relationship - ? relationship - : null; - } + public AttrAttribute? FindAttributeByPropertyName(string propertyName) + { + ArgumentGuard.NotNull(propertyName, nameof(propertyName)); - public override string ToString() - { - return PublicName; - } + return _fieldsByPropertyName.TryGetValue(propertyName, out ResourceFieldAttribute? field) && field is AttrAttribute attribute ? attribute : null; + } - public override bool Equals(object? obj) - { - if (ReferenceEquals(this, obj)) - { - return true; - } + public RelationshipAttribute GetRelationshipByPublicName(string publicName) + { + RelationshipAttribute? relationship = FindRelationshipByPublicName(publicName); + return relationship ?? throw new InvalidOperationException($"Relationship '{publicName}' does not exist on resource type '{PublicName}'."); + } + + public RelationshipAttribute? FindRelationshipByPublicName(string publicName) + { + ArgumentGuard.NotNull(publicName, nameof(publicName)); - if (obj is null || GetType() != obj.GetType()) - { - return false; - } + return _fieldsByPublicName.TryGetValue(publicName, out ResourceFieldAttribute? field) && field is RelationshipAttribute relationship + ? relationship + : null; + } - var other = (ResourceType)obj; + public RelationshipAttribute GetRelationshipByPropertyName(string propertyName) + { + RelationshipAttribute? relationship = FindRelationshipByPropertyName(propertyName); - return PublicName == other.PublicName && ClrType == other.ClrType && IdentityClrType == other.IdentityClrType && - Attributes.SequenceEqual(other.Attributes) && Relationships.SequenceEqual(other.Relationships) && EagerLoads.SequenceEqual(other.EagerLoads) && - TopLevelLinks == other.TopLevelLinks && ResourceLinks == other.ResourceLinks && RelationshipLinks == other.RelationshipLinks; + return relationship ?? + throw new InvalidOperationException($"Relationship for property '{propertyName}' does not exist on resource type '{ClrType.Name}'."); + } + + public RelationshipAttribute? FindRelationshipByPropertyName(string propertyName) + { + ArgumentGuard.NotNull(propertyName, nameof(propertyName)); + + return _fieldsByPropertyName.TryGetValue(propertyName, out ResourceFieldAttribute? field) && field is RelationshipAttribute relationship + ? relationship + : null; + } + + public override string ToString() + { + return PublicName; + } + + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) + { + return true; } - public override int GetHashCode() + if (obj is null || GetType() != obj.GetType()) { - var hashCode = new HashCode(); + return false; + } - hashCode.Add(PublicName); - hashCode.Add(ClrType); - hashCode.Add(IdentityClrType); + var other = (ResourceType)obj; - foreach (AttrAttribute attribute in Attributes) - { - hashCode.Add(attribute); - } + return PublicName == other.PublicName && ClrType == other.ClrType && IdentityClrType == other.IdentityClrType && + Attributes.SequenceEqual(other.Attributes) && Relationships.SequenceEqual(other.Relationships) && EagerLoads.SequenceEqual(other.EagerLoads) && + TopLevelLinks == other.TopLevelLinks && ResourceLinks == other.ResourceLinks && RelationshipLinks == other.RelationshipLinks; + } - foreach (RelationshipAttribute relationship in Relationships) - { - hashCode.Add(relationship); - } + public override int GetHashCode() + { + var hashCode = new HashCode(); - foreach (EagerLoadAttribute eagerLoad in EagerLoads) - { - hashCode.Add(eagerLoad); - } + hashCode.Add(PublicName); + hashCode.Add(ClrType); + hashCode.Add(IdentityClrType); - hashCode.Add(TopLevelLinks); - hashCode.Add(ResourceLinks); - hashCode.Add(RelationshipLinks); + foreach (AttrAttribute attribute in Attributes) + { + hashCode.Add(attribute); + } - return hashCode.ToHashCode(); + foreach (RelationshipAttribute relationship in Relationships) + { + hashCode.Add(relationship); } + + foreach (EagerLoadAttribute eagerLoad in EagerLoads) + { + hashCode.Add(eagerLoad); + } + + hashCode.Add(TopLevelLinks); + hashCode.Add(ResourceLinks); + hashCode.Add(RelationshipLinks); + + return hashCode.ToHashCode(); } } diff --git a/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs b/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs index c69d9305ca..64a15e2eda 100644 --- a/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs +++ b/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs @@ -1,6 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; using JetBrains.Annotations; using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Repositories; @@ -9,132 +6,131 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -namespace JsonApiDotNetCore.Configuration +namespace JsonApiDotNetCore.Configuration; + +[PublicAPI] +public static class ServiceCollectionExtensions { - [PublicAPI] - public static class ServiceCollectionExtensions + private static readonly TypeLocator TypeLocator = new(); + + /// + /// Configures JsonApiDotNetCore by registering resources manually. + /// + public static IServiceCollection AddJsonApi(this IServiceCollection services, Action? options = null, + Action? discovery = null, Action? resources = null, IMvcCoreBuilder? mvcBuilder = null, + ICollection? dbContextTypes = null) { - private static readonly TypeLocator TypeLocator = new(); - - /// - /// Configures JsonApiDotNetCore by registering resources manually. - /// - public static IServiceCollection AddJsonApi(this IServiceCollection services, Action? options = null, - Action? discovery = null, Action? resources = null, IMvcCoreBuilder? mvcBuilder = null, - ICollection? dbContextTypes = null) - { - ArgumentGuard.NotNull(services, nameof(services)); + ArgumentGuard.NotNull(services, nameof(services)); - SetupApplicationBuilder(services, options, discovery, resources, mvcBuilder, dbContextTypes ?? Array.Empty()); + SetupApplicationBuilder(services, options, discovery, resources, mvcBuilder, dbContextTypes ?? Array.Empty()); - return services; - } + return services; + } - /// - /// Configures JsonApiDotNetCore by registering resources from an Entity Framework Core model. - /// - public static IServiceCollection AddJsonApi(this IServiceCollection services, Action? options = null, - Action? discovery = null, Action? resources = null, IMvcCoreBuilder? mvcBuilder = null) - where TDbContext : DbContext - { - return AddJsonApi(services, options, discovery, resources, mvcBuilder, typeof(TDbContext).AsArray()); - } + /// + /// Configures JsonApiDotNetCore by registering resources from an Entity Framework Core model. + /// + public static IServiceCollection AddJsonApi(this IServiceCollection services, Action? options = null, + Action? discovery = null, Action? resources = null, IMvcCoreBuilder? mvcBuilder = null) + where TDbContext : DbContext + { + return AddJsonApi(services, options, discovery, resources, mvcBuilder, typeof(TDbContext).AsArray()); + } - private static void SetupApplicationBuilder(IServiceCollection services, Action? configureOptions, - Action? configureAutoDiscovery, Action? configureResources, IMvcCoreBuilder? mvcBuilder, - ICollection dbContextTypes) - { - using var applicationBuilder = new JsonApiApplicationBuilder(services, mvcBuilder ?? services.AddMvcCore()); - - applicationBuilder.ConfigureJsonApiOptions(configureOptions); - applicationBuilder.ConfigureAutoDiscovery(configureAutoDiscovery); - applicationBuilder.ConfigureResourceGraph(dbContextTypes, configureResources); - applicationBuilder.ConfigureMvc(); - applicationBuilder.DiscoverInjectables(); - applicationBuilder.ConfigureServiceContainer(dbContextTypes); - } + private static void SetupApplicationBuilder(IServiceCollection services, Action? configureOptions, + Action? configureAutoDiscovery, Action? configureResources, IMvcCoreBuilder? mvcBuilder, + ICollection dbContextTypes) + { + using var applicationBuilder = new JsonApiApplicationBuilder(services, mvcBuilder ?? services.AddMvcCore()); + + applicationBuilder.ConfigureJsonApiOptions(configureOptions); + applicationBuilder.ConfigureAutoDiscovery(configureAutoDiscovery); + applicationBuilder.ConfigureResourceGraph(dbContextTypes, configureResources); + applicationBuilder.ConfigureMvc(); + applicationBuilder.DiscoverInjectables(); + applicationBuilder.ConfigureServiceContainer(dbContextTypes); + } - /// - /// Adds IoC container registrations for the various JsonApiDotNetCore resource service interfaces, such as , - /// and the various others. - /// - public static IServiceCollection AddResourceService(this IServiceCollection services) - { - ArgumentGuard.NotNull(services, nameof(services)); + /// + /// Adds IoC container registrations for the various JsonApiDotNetCore resource service interfaces, such as , + /// and the various others. + /// + public static IServiceCollection AddResourceService(this IServiceCollection services) + { + ArgumentGuard.NotNull(services, nameof(services)); - RegisterForConstructedType(services, typeof(TService), ServiceDiscoveryFacade.ServiceInterfaces); + RegisterTypeForUnboundInterfaces(services, typeof(TService), ServiceDiscoveryFacade.ServiceUnboundInterfaces); - return services; - } + return services; + } - /// - /// Adds IoC container registrations for the various JsonApiDotNetCore resource repository interfaces, such as - /// and . - /// - public static IServiceCollection AddResourceRepository(this IServiceCollection services) - { - ArgumentGuard.NotNull(services, nameof(services)); + /// + /// Adds IoC container registrations for the various JsonApiDotNetCore resource repository interfaces, such as + /// and . + /// + public static IServiceCollection AddResourceRepository(this IServiceCollection services) + { + ArgumentGuard.NotNull(services, nameof(services)); - RegisterForConstructedType(services, typeof(TRepository), ServiceDiscoveryFacade.RepositoryInterfaces); + RegisterTypeForUnboundInterfaces(services, typeof(TRepository), ServiceDiscoveryFacade.RepositoryUnboundInterfaces); - return services; - } + return services; + } - /// - /// Adds IoC container registrations for the various JsonApiDotNetCore resource definition interfaces, such as - /// . - /// - public static IServiceCollection AddResourceDefinition(this IServiceCollection services) - { - ArgumentGuard.NotNull(services, nameof(services)); + /// + /// Adds IoC container registrations for the various JsonApiDotNetCore resource definition interfaces, such as + /// . + /// + public static IServiceCollection AddResourceDefinition(this IServiceCollection services) + { + ArgumentGuard.NotNull(services, nameof(services)); - RegisterForConstructedType(services, typeof(TResourceDefinition), ServiceDiscoveryFacade.ResourceDefinitionInterfaces); + RegisterTypeForUnboundInterfaces(services, typeof(TResourceDefinition), ServiceDiscoveryFacade.ResourceDefinitionUnboundInterfaces); - return services; - } + return services; + } - private static void RegisterForConstructedType(IServiceCollection services, Type implementationType, IEnumerable openGenericInterfaces) - { - bool seenCompatibleInterface = false; - ResourceDescriptor? resourceDescriptor = ResolveResourceTypeFromServiceImplementation(implementationType); + private static void RegisterTypeForUnboundInterfaces(IServiceCollection serviceCollection, Type implementationType, IEnumerable unboundInterfaces) + { + bool seenCompatibleInterface = false; + ResourceDescriptor? resourceDescriptor = ResolveResourceTypeFromServiceImplementation(implementationType); - if (resourceDescriptor != null) + if (resourceDescriptor != null) + { + foreach (Type unboundInterface in unboundInterfaces) { - foreach (Type openGenericInterface in openGenericInterfaces) - { - Type constructedType = openGenericInterface.MakeGenericType(resourceDescriptor.ResourceClrType, resourceDescriptor.IdClrType); + Type closedInterface = unboundInterface.MakeGenericType(resourceDescriptor.ResourceClrType, resourceDescriptor.IdClrType); - if (constructedType.IsAssignableFrom(implementationType)) - { - services.AddScoped(constructedType, implementationType); - seenCompatibleInterface = true; - } + if (closedInterface.IsAssignableFrom(implementationType)) + { + serviceCollection.AddScoped(closedInterface, implementationType); + seenCompatibleInterface = true; } } + } - if (!seenCompatibleInterface) - { - throw new InvalidConfigurationException($"{implementationType} does not implement any of the expected JsonApiDotNetCore interfaces."); - } + if (!seenCompatibleInterface) + { + throw new InvalidConfigurationException($"{implementationType} does not implement any of the expected JsonApiDotNetCore interfaces."); } + } - private static ResourceDescriptor? ResolveResourceTypeFromServiceImplementation(Type? serviceType) + private static ResourceDescriptor? ResolveResourceTypeFromServiceImplementation(Type? serviceType) + { + if (serviceType != null) { - if (serviceType != null) + foreach (Type @interface in serviceType.GetInterfaces()) { - foreach (Type @interface in serviceType.GetInterfaces()) - { - Type? firstGenericArgument = @interface.IsGenericType ? @interface.GenericTypeArguments.First() : null; - ResourceDescriptor? resourceDescriptor = TypeLocator.ResolveResourceDescriptor(firstGenericArgument); + Type? firstTypeArgument = @interface.IsGenericType ? @interface.GenericTypeArguments.First() : null; + ResourceDescriptor? resourceDescriptor = TypeLocator.ResolveResourceDescriptor(firstTypeArgument); - if (resourceDescriptor != null) - { - return resourceDescriptor; - } + if (resourceDescriptor != null) + { + return resourceDescriptor; } } - - return null; } + + return null; } } diff --git a/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs b/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs index cdd6a65031..7498391afd 100644 --- a/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs +++ b/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs @@ -1,5 +1,3 @@ -using System; -using System.Collections.Generic; using System.Reflection; using JetBrains.Annotations; using JsonApiDotNetCore.Repositories; @@ -9,165 +7,163 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCore.Configuration +namespace JsonApiDotNetCore.Configuration; + +/// +/// Scans for types like resources, services, repositories and resource definitions in an assembly and registers them to the IoC container. +/// +[PublicAPI] +public sealed class ServiceDiscoveryFacade { - /// - /// Scans for types like resources, services, repositories and resource definitions in an assembly and registers them to the IoC container. - /// - [PublicAPI] - public sealed class ServiceDiscoveryFacade + internal static readonly HashSet ServiceUnboundInterfaces = new() { - internal static readonly HashSet ServiceInterfaces = new() - { - typeof(IResourceService<,>), - typeof(IResourceCommandService<,>), - typeof(IResourceQueryService<,>), - typeof(IGetAllService<,>), - typeof(IGetByIdService<,>), - typeof(IGetSecondaryService<,>), - typeof(IGetRelationshipService<,>), - typeof(ICreateService<,>), - typeof(IAddToRelationshipService<,>), - typeof(IUpdateService<,>), - typeof(ISetRelationshipService<,>), - typeof(IDeleteService<,>), - typeof(IRemoveFromRelationshipService<,>) - }; - - internal static readonly HashSet RepositoryInterfaces = new() - { - typeof(IResourceRepository<,>), - typeof(IResourceWriteRepository<,>), - typeof(IResourceReadRepository<,>) - }; + typeof(IResourceService<,>), + typeof(IResourceCommandService<,>), + typeof(IResourceQueryService<,>), + typeof(IGetAllService<,>), + typeof(IGetByIdService<,>), + typeof(IGetSecondaryService<,>), + typeof(IGetRelationshipService<,>), + typeof(ICreateService<,>), + typeof(IAddToRelationshipService<,>), + typeof(IUpdateService<,>), + typeof(ISetRelationshipService<,>), + typeof(IDeleteService<,>), + typeof(IRemoveFromRelationshipService<,>) + }; + + internal static readonly HashSet RepositoryUnboundInterfaces = new() + { + typeof(IResourceRepository<,>), + typeof(IResourceWriteRepository<,>), + typeof(IResourceReadRepository<,>) + }; - internal static readonly HashSet ResourceDefinitionInterfaces = new() - { - typeof(IResourceDefinition<,>) - }; + internal static readonly HashSet ResourceDefinitionUnboundInterfaces = new() + { + typeof(IResourceDefinition<,>) + }; - private readonly ILogger _logger; - private readonly IServiceCollection _services; - private readonly ResourceGraphBuilder _resourceGraphBuilder; - private readonly ResourceDescriptorAssemblyCache _assemblyCache = new(); - private readonly TypeLocator _typeLocator = new(); + private readonly ILogger _logger; + private readonly IServiceCollection _services; + private readonly ResourceGraphBuilder _resourceGraphBuilder; + private readonly ResourceDescriptorAssemblyCache _assemblyCache = new(); + private readonly TypeLocator _typeLocator = new(); - public ServiceDiscoveryFacade(IServiceCollection services, ResourceGraphBuilder resourceGraphBuilder, ILoggerFactory loggerFactory) - { - ArgumentGuard.NotNull(services, nameof(services)); - ArgumentGuard.NotNull(resourceGraphBuilder, nameof(resourceGraphBuilder)); - ArgumentGuard.NotNull(loggerFactory, nameof(loggerFactory)); + public ServiceDiscoveryFacade(IServiceCollection services, ResourceGraphBuilder resourceGraphBuilder, ILoggerFactory loggerFactory) + { + ArgumentGuard.NotNull(services, nameof(services)); + ArgumentGuard.NotNull(resourceGraphBuilder, nameof(resourceGraphBuilder)); + ArgumentGuard.NotNull(loggerFactory, nameof(loggerFactory)); - _logger = loggerFactory.CreateLogger(); - _services = services; - _resourceGraphBuilder = resourceGraphBuilder; - } + _logger = loggerFactory.CreateLogger(); + _services = services; + _resourceGraphBuilder = resourceGraphBuilder; + } - /// - /// Mark the calling assembly for scanning of resources and injectables. - /// - public ServiceDiscoveryFacade AddCurrentAssembly() - { - return AddAssembly(Assembly.GetCallingAssembly()); - } + /// + /// Mark the calling assembly for scanning of resources and injectables. + /// + public ServiceDiscoveryFacade AddCurrentAssembly() + { + return AddAssembly(Assembly.GetCallingAssembly()); + } - /// - /// Mark the specified assembly for scanning of resources and injectables. - /// - public ServiceDiscoveryFacade AddAssembly(Assembly assembly) - { - ArgumentGuard.NotNull(assembly, nameof(assembly)); + /// + /// Mark the specified assembly for scanning of resources and injectables. + /// + public ServiceDiscoveryFacade AddAssembly(Assembly assembly) + { + ArgumentGuard.NotNull(assembly, nameof(assembly)); - _assemblyCache.RegisterAssembly(assembly); - _logger.LogDebug($"Registering assembly '{assembly.FullName}' for discovery of resources and injectables."); + _assemblyCache.RegisterAssembly(assembly); + _logger.LogDebug($"Registering assembly '{assembly.FullName}' for discovery of resources and injectables."); - return this; - } + return this; + } - internal void DiscoverResources() + internal void DiscoverResources() + { + foreach (ResourceDescriptor resourceDescriptor in _assemblyCache.GetResourceDescriptors()) { - foreach (ResourceDescriptor resourceDescriptor in _assemblyCache.GetResourceDescriptors()) - { - AddResource(resourceDescriptor); - } + AddResource(resourceDescriptor); } + } - internal void DiscoverInjectables() - { - IReadOnlyCollection descriptors = _assemblyCache.GetResourceDescriptors(); - IReadOnlyCollection assemblies = _assemblyCache.GetAssemblies(); - - foreach (Assembly assembly in assemblies) - { - AddDbContextResolvers(assembly); - AddInjectables(descriptors, assembly); - } - } + internal void DiscoverInjectables() + { + IReadOnlyCollection descriptors = _assemblyCache.GetResourceDescriptors(); + IReadOnlyCollection assemblies = _assemblyCache.GetAssemblies(); - private void AddInjectables(IReadOnlyCollection resourceDescriptors, Assembly assembly) + foreach (Assembly assembly in assemblies) { - foreach (ResourceDescriptor resourceDescriptor in resourceDescriptors) - { - AddServices(assembly, resourceDescriptor); - AddRepositories(assembly, resourceDescriptor); - AddResourceDefinitions(assembly, resourceDescriptor); - } + AddDbContextResolvers(assembly); + AddInjectables(descriptors, assembly); } + } - private void AddDbContextResolvers(Assembly assembly) + private void AddInjectables(IReadOnlyCollection resourceDescriptors, Assembly assembly) + { + foreach (ResourceDescriptor resourceDescriptor in resourceDescriptors) { - IEnumerable dbContextTypes = _typeLocator.GetDerivedTypes(assembly, typeof(DbContext)); - - foreach (Type dbContextType in dbContextTypes) - { - Type dbContextResolverType = typeof(DbContextResolver<>).MakeGenericType(dbContextType); - _services.AddScoped(typeof(IDbContextResolver), dbContextResolverType); - } + AddServices(assembly, resourceDescriptor); + AddRepositories(assembly, resourceDescriptor); + AddResourceDefinitions(assembly, resourceDescriptor); } + } - private void AddResource(ResourceDescriptor resourceDescriptor) + private void AddDbContextResolvers(Assembly assembly) + { + IEnumerable dbContextTypes = _typeLocator.GetDerivedTypes(assembly, typeof(DbContext)); + + foreach (Type dbContextType in dbContextTypes) { - _resourceGraphBuilder.Add(resourceDescriptor.ResourceClrType, resourceDescriptor.IdClrType); + Type dbContextResolverClosedType = typeof(DbContextResolver<>).MakeGenericType(dbContextType); + _services.AddScoped(typeof(IDbContextResolver), dbContextResolverClosedType); } + } - private void AddServices(Assembly assembly, ResourceDescriptor resourceDescriptor) + private void AddResource(ResourceDescriptor resourceDescriptor) + { + _resourceGraphBuilder.Add(resourceDescriptor.ResourceClrType, resourceDescriptor.IdClrType); + } + + private void AddServices(Assembly assembly, ResourceDescriptor resourceDescriptor) + { + foreach (Type serviceUnboundInterface in ServiceUnboundInterfaces) { - foreach (Type serviceInterface in ServiceInterfaces) - { - RegisterImplementations(assembly, serviceInterface, resourceDescriptor); - } + RegisterImplementations(assembly, serviceUnboundInterface, resourceDescriptor); } + } - private void AddRepositories(Assembly assembly, ResourceDescriptor resourceDescriptor) + private void AddRepositories(Assembly assembly, ResourceDescriptor resourceDescriptor) + { + foreach (Type repositoryUnboundInterface in RepositoryUnboundInterfaces) { - foreach (Type repositoryInterface in RepositoryInterfaces) - { - RegisterImplementations(assembly, repositoryInterface, resourceDescriptor); - } + RegisterImplementations(assembly, repositoryUnboundInterface, resourceDescriptor); } + } - private void AddResourceDefinitions(Assembly assembly, ResourceDescriptor resourceDescriptor) + private void AddResourceDefinitions(Assembly assembly, ResourceDescriptor resourceDescriptor) + { + foreach (Type resourceDefinitionUnboundInterface in ResourceDefinitionUnboundInterfaces) { - foreach (Type resourceDefinitionInterface in ResourceDefinitionInterfaces) - { - RegisterImplementations(assembly, resourceDefinitionInterface, resourceDescriptor); - } + RegisterImplementations(assembly, resourceDefinitionUnboundInterface, resourceDescriptor); } + } + + private void RegisterImplementations(Assembly assembly, Type interfaceType, ResourceDescriptor resourceDescriptor) + { + Type[] typeArguments = interfaceType.GetTypeInfo().GenericTypeParameters.Length == 2 + ? ArrayFactory.Create(resourceDescriptor.ResourceClrType, resourceDescriptor.IdClrType) + : ArrayFactory.Create(resourceDescriptor.ResourceClrType); + + (Type implementationType, Type serviceInterface)? result = _typeLocator.GetContainerRegistrationFromAssembly(assembly, interfaceType, typeArguments); - private void RegisterImplementations(Assembly assembly, Type interfaceType, ResourceDescriptor resourceDescriptor) + if (result != null) { - Type[] genericArguments = interfaceType.GetTypeInfo().GenericTypeParameters.Length == 2 - ? ArrayFactory.Create(resourceDescriptor.ResourceClrType, resourceDescriptor.IdClrType) - : ArrayFactory.Create(resourceDescriptor.ResourceClrType); - - (Type implementation, Type registrationInterface)? result = - _typeLocator.GetGenericInterfaceImplementation(assembly, interfaceType, genericArguments); - - if (result != null) - { - (Type implementation, Type registrationInterface) = result.Value; - _services.AddScoped(registrationInterface, implementation); - } + (Type implementationType, Type serviceInterface) = result.Value; + _services.AddScoped(serviceInterface, implementationType); } } } diff --git a/src/JsonApiDotNetCore/Configuration/TypeLocator.cs b/src/JsonApiDotNetCore/Configuration/TypeLocator.cs index 9ef4f3590e..2a0369d940 100644 --- a/src/JsonApiDotNetCore/Configuration/TypeLocator.cs +++ b/src/JsonApiDotNetCore/Configuration/TypeLocator.cs @@ -1,164 +1,166 @@ -using System; -using System.Collections.Generic; -using System.Linq; using System.Reflection; using JsonApiDotNetCore.Resources; -namespace JsonApiDotNetCore.Configuration +namespace JsonApiDotNetCore.Configuration; + +/// +/// Used to locate types and facilitate resource auto-discovery. +/// +internal sealed class TypeLocator { + // As a reminder, the following terminology is used for generic types: + // non-generic string + // generic + // unbound Dictionary<,> + // constructed + // open Dictionary + // closed Dictionary + /// - /// Used to locate types and facilitate resource auto-discovery. + /// Attempts to lookup the ID type of the specified resource type. Returns null if it does not implement . /// - internal sealed class TypeLocator + public Type? LookupIdType(Type? resourceClrType) { - /// - /// Attempts to lookup the ID type of the specified resource type. Returns null if it does not implement . - /// - public Type? LookupIdType(Type? resourceClrType) - { - Type? identifiableInterface = resourceClrType?.GetInterfaces().FirstOrDefault(@interface => - @interface.IsGenericType && @interface.GetGenericTypeDefinition() == typeof(IIdentifiable<>)); + Type? identifiableClosedInterface = resourceClrType?.GetInterfaces().FirstOrDefault(@interface => + @interface.IsGenericType && @interface.GetGenericTypeDefinition() == typeof(IIdentifiable<>)); - return identifiableInterface?.GetGenericArguments()[0]; - } + return identifiableClosedInterface?.GetGenericArguments()[0]; + } - /// - /// Attempts to get a descriptor for the specified resource type. - /// - public ResourceDescriptor? ResolveResourceDescriptor(Type? type) + /// + /// Attempts to get a descriptor for the specified resource type. + /// + public ResourceDescriptor? ResolveResourceDescriptor(Type? type) + { + if (type != null && type.IsOrImplementsInterface()) { - if (type != null && type.IsOrImplementsInterface()) - { - Type? idType = LookupIdType(type); + Type? idType = LookupIdType(type); - if (idType != null) - { - return new ResourceDescriptor(type, idType); - } + if (idType != null) + { + return new ResourceDescriptor(type, idType); } - - return null; } - /// - /// Gets all implementations of a generic interface. - /// - /// - /// The assembly to search in. - /// - /// - /// The open generic interface. - /// - /// - /// Generic type parameters to construct the generic interface. - /// - /// - /// ), typeof(Article), typeof(Guid)); - /// ]]> - /// - public (Type implementation, Type registrationInterface)? GetGenericInterfaceImplementation(Assembly assembly, Type openGenericInterface, - params Type[] interfaceGenericTypeArguments) - { - ArgumentGuard.NotNull(assembly, nameof(assembly)); - ArgumentGuard.NotNull(openGenericInterface, nameof(openGenericInterface)); - ArgumentGuard.NotNull(interfaceGenericTypeArguments, nameof(interfaceGenericTypeArguments)); + return null; + } - if (!openGenericInterface.IsInterface || !openGenericInterface.IsGenericType || - openGenericInterface != openGenericInterface.GetGenericTypeDefinition()) - { - throw new ArgumentException($"Specified type '{openGenericInterface.FullName}' is not an open generic interface.", - nameof(openGenericInterface)); - } + /// + /// Gets the implementation type with service interface (to be registered in the IoC container) for the specified unbound generic interface and its type + /// arguments, by scanning for types in the specified assembly that match the signature. + /// + /// + /// The assembly to search for matching types. + /// + /// + /// The unbound generic interface to match against. + /// + /// + /// Generic type arguments to construct . + /// + /// + /// ), typeof(Article), typeof(Guid)); + /// ]]> + /// + public (Type implementationType, Type serviceInterface)? GetContainerRegistrationFromAssembly(Assembly assembly, Type unboundInterface, + params Type[] interfaceTypeArguments) + { + ArgumentGuard.NotNull(assembly, nameof(assembly)); + ArgumentGuard.NotNull(unboundInterface, nameof(unboundInterface)); + ArgumentGuard.NotNull(interfaceTypeArguments, nameof(interfaceTypeArguments)); - if (interfaceGenericTypeArguments.Length != openGenericInterface.GetGenericArguments().Length) - { - throw new ArgumentException( - $"Interface '{openGenericInterface.FullName}' requires {openGenericInterface.GetGenericArguments().Length} type parameters " + - $"instead of {interfaceGenericTypeArguments.Length}.", nameof(interfaceGenericTypeArguments)); - } + if (!unboundInterface.IsInterface || !unboundInterface.IsGenericType || unboundInterface != unboundInterface.GetGenericTypeDefinition()) + { + throw new ArgumentException($"Specified type '{unboundInterface.FullName}' is not an unbound generic interface.", nameof(unboundInterface)); + } - return assembly.GetTypes().Select(type => FindGenericInterfaceImplementationForType(type, openGenericInterface, interfaceGenericTypeArguments)) - .FirstOrDefault(result => result != null); + if (interfaceTypeArguments.Length != unboundInterface.GetGenericArguments().Length) + { + throw new ArgumentException( + $"Interface '{unboundInterface.FullName}' requires {unboundInterface.GetGenericArguments().Length} type arguments " + + $"instead of {interfaceTypeArguments.Length}.", nameof(interfaceTypeArguments)); } - private static (Type implementation, Type registrationInterface)? FindGenericInterfaceImplementationForType(Type nextType, Type openGenericInterface, - Type[] interfaceGenericTypeArguments) + return assembly.GetTypes().Select(type => GetContainerRegistrationFromType(type, unboundInterface, interfaceTypeArguments)) + .FirstOrDefault(result => result != null); + } + + private static (Type implementationType, Type serviceInterface)? GetContainerRegistrationFromType(Type nextType, Type unboundInterface, + Type[] interfaceTypeArguments) + { + if (!nextType.IsNested) { - if (!nextType.IsNested) + foreach (Type nextConstructedInterface in nextType.GetInterfaces().Where(type => type.IsGenericType)) { - foreach (Type nextGenericInterface in nextType.GetInterfaces().Where(type => type.IsGenericType)) + Type nextUnboundInterface = nextConstructedInterface.GetGenericTypeDefinition(); + + if (nextUnboundInterface == unboundInterface) { - Type nextOpenGenericInterface = nextGenericInterface.GetGenericTypeDefinition(); + Type[] nextTypeArguments = nextConstructedInterface.GetGenericArguments(); - if (nextOpenGenericInterface == openGenericInterface) + if (nextTypeArguments.Length == interfaceTypeArguments.Length && nextTypeArguments.SequenceEqual(interfaceTypeArguments)) { - Type[] nextGenericArguments = nextGenericInterface.GetGenericArguments(); - - if (nextGenericArguments.Length == interfaceGenericTypeArguments.Length && - nextGenericArguments.SequenceEqual(interfaceGenericTypeArguments)) - { - return (nextType, nextOpenGenericInterface.MakeGenericType(interfaceGenericTypeArguments)); - } + return (nextType, nextUnboundInterface.MakeGenericType(interfaceTypeArguments)); } } } - - return null; } - /// - /// 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. - /// - /// - /// ), typeof(Article)) - /// ]]> - /// - public IReadOnlyCollection GetDerivedGenericTypes(Assembly assembly, Type openGenericType, params Type[] genericArguments) - { - ArgumentGuard.NotNull(assembly, nameof(assembly)); - ArgumentGuard.NotNull(openGenericType, nameof(openGenericType)); - ArgumentGuard.NotNull(genericArguments, nameof(genericArguments)); + return null; + } - Type genericType = openGenericType.MakeGenericType(genericArguments); - return GetDerivedTypes(assembly, genericType).ToArray(); - } + /// + /// Scans for types in the specified assembly that derive from the specified unbound generic type. + /// + /// + /// The assembly to search for derived types. + /// + /// + /// The unbound generic type to match against. + /// + /// + /// Generic type arguments to construct . + /// + /// + /// ), typeof(Article), typeof(int)) + /// ]]> + /// + public IReadOnlyCollection GetDerivedTypesForUnboundType(Assembly assembly, Type unboundType, params Type[] typeArguments) + { + ArgumentGuard.NotNull(assembly, nameof(assembly)); + ArgumentGuard.NotNull(unboundType, nameof(unboundType)); + ArgumentGuard.NotNull(typeArguments, nameof(typeArguments)); - /// - /// Gets all derivatives of the specified type. - /// - /// - /// The assembly to search. - /// - /// - /// The inherited type. - /// - /// - /// - /// GetDerivedGenericTypes(assembly, typeof(DbContext)) - /// - /// - public IEnumerable GetDerivedTypes(Assembly assembly, Type inheritedType) - { - ArgumentGuard.NotNull(assembly, nameof(assembly)); - ArgumentGuard.NotNull(inheritedType, nameof(inheritedType)); + Type closedType = unboundType.MakeGenericType(typeArguments); + return GetDerivedTypes(assembly, closedType).ToArray(); + } - foreach (Type type in assembly.GetTypes()) + /// + /// Gets all derivatives of the specified type. + /// + /// + /// The assembly to search. + /// + /// + /// The inherited type. + /// + /// + /// + /// GetDerivedTypes(assembly, typeof(DbContext)) + /// + /// + public IEnumerable GetDerivedTypes(Assembly assembly, Type baseType) + { + ArgumentGuard.NotNull(assembly, nameof(assembly)); + ArgumentGuard.NotNull(baseType, nameof(baseType)); + + foreach (Type type in assembly.GetTypes()) + { + if (baseType.IsAssignableFrom(type)) { - if (inheritedType.IsAssignableFrom(type)) - { - yield return type; - } + yield return type; } } } diff --git a/src/JsonApiDotNetCore/Controllers/Annotations/DisableQueryStringAttribute.cs b/src/JsonApiDotNetCore/Controllers/Annotations/DisableQueryStringAttribute.cs index f1d1077687..975472ab28 100644 --- a/src/JsonApiDotNetCore/Controllers/Annotations/DisableQueryStringAttribute.cs +++ b/src/JsonApiDotNetCore/Controllers/Annotations/DisableQueryStringAttribute.cs @@ -1,63 +1,59 @@ -using System; -using System.Collections.Generic; -using System.Linq; using JetBrains.Annotations; using JsonApiDotNetCore.QueryStrings; -namespace JsonApiDotNetCore.Controllers.Annotations +namespace JsonApiDotNetCore.Controllers.Annotations; + +/// +/// Used on an ASP.NET controller class to indicate which query string parameters are blocked. +/// +/// { } +/// ]]> +/// { } +/// ]]> +[PublicAPI] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] +public sealed class DisableQueryStringAttribute : Attribute { + public static readonly DisableQueryStringAttribute Empty = new(JsonApiQueryStringParameters.None); + + public IReadOnlySet ParameterNames { get; } + /// - /// Used on an ASP.NET controller class to indicate which query string parameters are blocked. + /// Disables one or more of the builtin query parameters for a controller. /// - /// { } - /// ]]> - /// { } - /// ]]> - [PublicAPI] - [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] - public sealed class DisableQueryStringAttribute : Attribute + public DisableQueryStringAttribute(JsonApiQueryStringParameters parameters) { - public static readonly DisableQueryStringAttribute Empty = new(JsonApiQueryStringParameters.None); + var parameterNames = new HashSet(); - public IReadOnlySet ParameterNames { get; } - - /// - /// Disables one or more of the builtin query parameters for a controller. - /// - public DisableQueryStringAttribute(JsonApiQueryStringParameters parameters) + foreach (JsonApiQueryStringParameters value in Enum.GetValues()) { - var parameterNames = new HashSet(); - - foreach (JsonApiQueryStringParameters value in Enum.GetValues()) + if (value != JsonApiQueryStringParameters.None && value != JsonApiQueryStringParameters.All && parameters.HasFlag(value)) { - if (value != JsonApiQueryStringParameters.None && value != JsonApiQueryStringParameters.All && parameters.HasFlag(value)) - { - parameterNames.Add(value.ToString()); - } + parameterNames.Add(value.ToString()); } - - 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) - { - ArgumentGuard.NotNullNorEmpty(parameterNames, nameof(parameterNames)); + ParameterNames = parameterNames; + } - ParameterNames = parameterNames.Split(",").ToHashSet(); - } + /// + /// 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) + { + ArgumentGuard.NotNullNorEmpty(parameterNames, nameof(parameterNames)); - public bool ContainsParameter(JsonApiQueryStringParameters parameter) - { - string name = parameter.ToString(); - return ParameterNames.Contains(name); - } + ParameterNames = parameterNames.Split(",").ToHashSet(); + } + + public bool ContainsParameter(JsonApiQueryStringParameters parameter) + { + string name = parameter.ToString(); + return ParameterNames.Contains(name); } } diff --git a/src/JsonApiDotNetCore/Controllers/Annotations/DisableRoutingConventionAttribute.cs b/src/JsonApiDotNetCore/Controllers/Annotations/DisableRoutingConventionAttribute.cs index 93db0d7d0e..34e1132789 100644 --- a/src/JsonApiDotNetCore/Controllers/Annotations/DisableRoutingConventionAttribute.cs +++ b/src/JsonApiDotNetCore/Controllers/Annotations/DisableRoutingConventionAttribute.cs @@ -1,18 +1,16 @@ -using System; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Controllers.Annotations +namespace JsonApiDotNetCore.Controllers.Annotations; + +/// +/// Used on an ASP.NET controller class to indicate that a custom route is used instead of the built-in routing convention. +/// +/// { } +/// ]]> +[PublicAPI] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] +public sealed class DisableRoutingConventionAttribute : Attribute { - /// - /// Used on an ASP.NET controller class to indicate that a custom route is used instead of the built-in routing convention. - /// - /// { } - /// ]]> - [PublicAPI] - [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] - public sealed class DisableRoutingConventionAttribute : Attribute - { - } } diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index 1e37ec66d0..cabe4d49d8 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs @@ -1,7 +1,3 @@ -using System.Collections.Generic; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Middleware; @@ -10,392 +6,391 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCore.Controllers +namespace JsonApiDotNetCore.Controllers; + +/// +/// Implements the foundational ASP.NET 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 _options; + private readonly IResourceGraph _resourceGraph; + private readonly IGetAllService? _getAll; + private readonly IGetByIdService? _getById; + private readonly IGetSecondaryService? _getSecondary; + private readonly IGetRelationshipService? _getRelationship; + private readonly ICreateService? _create; + private readonly IAddToRelationshipService? _addToRelationship; + private readonly IUpdateService? _update; + private readonly ISetRelationshipService? _setRelationship; + private readonly IDeleteService? _delete; + private readonly IRemoveFromRelationshipService? _removeFromRelationship; + private readonly TraceLogWriter> _traceWriter; + /// - /// Implements the foundational ASP.NET controller layer in the JsonApiDotNetCore architecture that delegates to a Resource Service. + /// Creates an instance from a read/write service. /// - /// - /// The resource type. - /// - /// - /// The resource identifier type. - /// - public abstract class BaseJsonApiController : CoreJsonApiController - where TResource : class, IIdentifiable + protected BaseJsonApiController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : this(options, resourceGraph, loggerFactory, resourceService, resourceService) { - private readonly IJsonApiOptions _options; - private readonly IResourceGraph _resourceGraph; - private readonly IGetAllService? _getAll; - private readonly IGetByIdService? _getById; - private readonly IGetSecondaryService? _getSecondary; - private readonly IGetRelationshipService? _getRelationship; - private readonly ICreateService? _create; - private readonly IAddToRelationshipService? _addToRelationship; - private readonly IUpdateService? _update; - private readonly ISetRelationshipService? _setRelationship; - private readonly IDeleteService? _delete; - private readonly IRemoveFromRelationshipService? _removeFromRelationship; - private readonly TraceLogWriter> _traceWriter; - - /// - /// Creates an instance from a read/write service. - /// - protected BaseJsonApiController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, - IResourceService resourceService) - : this(options, resourceGraph, loggerFactory, resourceService, resourceService) - { - } + } - /// - /// Creates an instance from separate services for reading and writing. - /// - protected BaseJsonApiController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, - IResourceQueryService? queryService = null, IResourceCommandService? commandService = null) - : this(options, resourceGraph, loggerFactory, queryService, queryService, queryService, queryService, commandService, commandService, - commandService, commandService, commandService, commandService) - { - } + /// + /// Creates an instance from separate services for reading and writing. + /// + protected BaseJsonApiController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceQueryService? queryService = null, IResourceCommandService? commandService = null) + : this(options, resourceGraph, loggerFactory, queryService, queryService, queryService, queryService, commandService, commandService, commandService, + commandService, commandService, commandService) + { + } + + /// + /// Creates an instance from separate services for the various individual read and write methods. + /// + protected BaseJsonApiController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IGetAllService? getAll = null, IGetByIdService? getById = null, + IGetSecondaryService? getSecondary = null, IGetRelationshipService? getRelationship = null, + ICreateService? create = null, IAddToRelationshipService? addToRelationship = null, + IUpdateService? update = null, ISetRelationshipService? setRelationship = null, + IDeleteService? delete = null, IRemoveFromRelationshipService? removeFromRelationship = null) + { + ArgumentGuard.NotNull(options, nameof(options)); + ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); + ArgumentGuard.NotNull(loggerFactory, nameof(loggerFactory)); + + _options = options; + _resourceGraph = resourceGraph; + _traceWriter = new TraceLogWriter>(loggerFactory); + _getAll = getAll; + _getById = getById; + _getSecondary = getSecondary; + _getRelationship = getRelationship; + _create = create; + _addToRelationship = addToRelationship; + _update = update; + _setRelationship = setRelationship; + _delete = delete; + _removeFromRelationship = removeFromRelationship; + } + + /// + /// Gets a collection of primary resources. Example: + /// + public virtual async Task GetAsync(CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(); - /// - /// Creates an instance from separate services for the various individual read and write methods. - /// - protected BaseJsonApiController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, - IGetAllService? getAll = null, IGetByIdService? getById = null, - IGetSecondaryService? getSecondary = null, IGetRelationshipService? getRelationship = null, - ICreateService? create = null, IAddToRelationshipService? addToRelationship = null, - IUpdateService? update = null, ISetRelationshipService? setRelationship = null, - IDeleteService? delete = null, IRemoveFromRelationshipService? removeFromRelationship = null) + if (_getAll == null) { - ArgumentGuard.NotNull(options, nameof(options)); - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); - ArgumentGuard.NotNull(loggerFactory, nameof(loggerFactory)); - - _options = options; - _resourceGraph = resourceGraph; - _traceWriter = new TraceLogWriter>(loggerFactory); - _getAll = getAll; - _getById = getById; - _getSecondary = getSecondary; - _getRelationship = getRelationship; - _create = create; - _addToRelationship = addToRelationship; - _update = update; - _setRelationship = setRelationship; - _delete = delete; - _removeFromRelationship = removeFromRelationship; + throw new RouteNotAvailableException(HttpMethod.Get, Request.Path); } - /// - /// Gets a collection of primary resources. Example: - /// - public virtual async Task GetAsync(CancellationToken cancellationToken) - { - _traceWriter.LogMethodStart(); + IReadOnlyCollection resources = await _getAll.GetAsync(cancellationToken); - if (_getAll == null) - { - throw new RouteNotAvailableException(HttpMethod.Get, Request.Path); - } + return Ok(resources); + } - IReadOnlyCollection resources = await _getAll.GetAsync(cancellationToken); + /// + /// Gets a single primary resource by ID. Example: + /// + public virtual async Task GetAsync(TId id, CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new + { + id + }); - return Ok(resources); + if (_getById == null) + { + throw new RouteNotAvailableException(HttpMethod.Get, Request.Path); } - /// - /// Gets a single primary resource by ID. Example: - /// - public virtual async Task GetAsync(TId id, CancellationToken cancellationToken) - { - _traceWriter.LogMethodStart(new - { - id - }); + TResource resource = await _getById.GetAsync(id, cancellationToken); - if (_getById == null) - { - throw new RouteNotAvailableException(HttpMethod.Get, Request.Path); - } + return Ok(resource); + } + + /// + /// Gets a secondary resource or collection of secondary resources. Example: Example: + /// + /// + public virtual async Task GetSecondaryAsync(TId id, string relationshipName, CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new + { + id, + relationshipName + }); - TResource resource = await _getById.GetAsync(id, cancellationToken); + ArgumentGuard.NotNullNorEmpty(relationshipName, nameof(relationshipName)); - return Ok(resource); + if (_getSecondary == null) + { + throw new RouteNotAvailableException(HttpMethod.Get, Request.Path); } - /// - /// Gets a secondary resource or collection of secondary resources. Example: Example: - /// - /// - public virtual async Task GetSecondaryAsync(TId id, string relationshipName, CancellationToken cancellationToken) - { - _traceWriter.LogMethodStart(new - { - id, - relationshipName - }); + object? rightValue = await _getSecondary.GetSecondaryAsync(id, relationshipName, cancellationToken); - ArgumentGuard.NotNullNorEmpty(relationshipName, nameof(relationshipName)); + return Ok(rightValue); + } - if (_getSecondary == null) - { - throw new RouteNotAvailableException(HttpMethod.Get, Request.Path); - } + /// + /// Gets a relationship value, which can be a null, a single object or a collection. Example: + /// Example: + /// + /// + public virtual async Task GetRelationshipAsync(TId id, string relationshipName, CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new + { + id, + relationshipName + }); - object? rightValue = await _getSecondary.GetSecondaryAsync(id, relationshipName, cancellationToken); + ArgumentGuard.NotNullNorEmpty(relationshipName, nameof(relationshipName)); - return Ok(rightValue); + if (_getRelationship == null) + { + throw new RouteNotAvailableException(HttpMethod.Get, Request.Path); } - /// - /// Gets a relationship value, which can be a null, a single object or a collection. Example: - /// Example: - /// - /// - public virtual async Task GetRelationshipAsync(TId id, string relationshipName, CancellationToken cancellationToken) - { - _traceWriter.LogMethodStart(new - { - id, - relationshipName - }); + object? rightValue = await _getRelationship.GetRelationshipAsync(id, relationshipName, cancellationToken); - ArgumentGuard.NotNullNorEmpty(relationshipName, nameof(relationshipName)); + return Ok(rightValue); + } - if (_getRelationship == null) - { - throw new RouteNotAvailableException(HttpMethod.Get, Request.Path); - } + /// + /// Creates a new resource with attributes, relationships or both. Example: + /// + public virtual async Task PostAsync([FromBody] TResource resource, CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new + { + resource + }); - object? rightValue = await _getRelationship.GetRelationshipAsync(id, relationshipName, cancellationToken); + ArgumentGuard.NotNull(resource, nameof(resource)); - return Ok(rightValue); + if (_create == null) + { + throw new RouteNotAvailableException(HttpMethod.Post, Request.Path); } - /// - /// Creates a new resource with attributes, relationships or both. Example: - /// - public virtual async Task PostAsync([FromBody] TResource resource, CancellationToken cancellationToken) + if (_options.ValidateModelState && !ModelState.IsValid) { - _traceWriter.LogMethodStart(new - { - resource - }); + throw new InvalidModelStateException(ModelState, typeof(TResource), _options.IncludeExceptionStackTraceInErrors, _resourceGraph); + } - ArgumentGuard.NotNull(resource, nameof(resource)); + TResource? newResource = await _create.CreateAsync(resource, cancellationToken); - if (_create == null) - { - throw new RouteNotAvailableException(HttpMethod.Post, Request.Path); - } + string resourceId = (newResource ?? resource).StringId!; + string locationUrl = $"{HttpContext.Request.Path}/{resourceId}"; - if (_options.ValidateModelState && !ModelState.IsValid) - { - throw new InvalidModelStateException(ModelState, typeof(TResource), _options.IncludeExceptionStackTraceInErrors, _resourceGraph); - } + if (newResource == null) + { + HttpContext.Response.Headers["Location"] = locationUrl; + return NoContent(); + } - TResource? newResource = await _create.CreateAsync(resource, cancellationToken); + return Created(locationUrl, newResource); + } - string resourceId = (newResource ?? resource).StringId!; - string locationUrl = $"{HttpContext.Request.Path}/{resourceId}"; + /// + /// Adds resources to a to-many relationship. Example: + /// + /// + /// Identifies the left side of the relationship. + /// + /// + /// The relationship to add resources to. + /// + /// + /// The set of resources to add to the relationship. + /// + /// + /// Propagates notification that request handling should be canceled. + /// + public virtual async Task PostRelationshipAsync(TId id, string relationshipName, [FromBody] ISet rightResourceIds, + CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new + { + id, + relationshipName, + rightResourceIds + }); - if (newResource == null) - { - HttpContext.Response.Headers["Location"] = locationUrl; - return NoContent(); - } + ArgumentGuard.NotNullNorEmpty(relationshipName, nameof(relationshipName)); + ArgumentGuard.NotNull(rightResourceIds, nameof(rightResourceIds)); - return Created(locationUrl, newResource); + if (_addToRelationship == null) + { + throw new RouteNotAvailableException(HttpMethod.Post, Request.Path); } - /// - /// Adds resources to a to-many relationship. Example: - /// - /// - /// Identifies the left side of the relationship. - /// - /// - /// The relationship to add resources to. - /// - /// - /// The set of resources to add to the relationship. - /// - /// - /// Propagates notification that request handling should be canceled. - /// - public virtual async Task PostRelationshipAsync(TId id, string relationshipName, [FromBody] ISet rightResourceIds, - CancellationToken cancellationToken) - { - _traceWriter.LogMethodStart(new - { - id, - relationshipName, - rightResourceIds - }); + await _addToRelationship.AddToToManyRelationshipAsync(id, relationshipName, rightResourceIds, cancellationToken); - ArgumentGuard.NotNullNorEmpty(relationshipName, nameof(relationshipName)); - ArgumentGuard.NotNull(rightResourceIds, nameof(rightResourceIds)); + return NoContent(); + } - if (_addToRelationship == null) - { - throw new RouteNotAvailableException(HttpMethod.Post, Request.Path); - } + /// + /// Updates the attributes and/or relationships of an existing resource. Only the values of sent attributes are replaced. And only the values of sent + /// relationships are replaced. Example: + /// + public virtual async Task PatchAsync(TId id, [FromBody] TResource resource, CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new + { + id, + resource + }); - await _addToRelationship.AddToToManyRelationshipAsync(id, relationshipName, rightResourceIds, cancellationToken); + ArgumentGuard.NotNull(resource, nameof(resource)); - return NoContent(); + if (_update == null) + { + throw new RouteNotAvailableException(HttpMethod.Patch, Request.Path); } - /// - /// Updates the attributes and/or relationships of an existing resource. Only the values of sent attributes are replaced. And only the values of sent - /// relationships are replaced. Example: - /// - public virtual async Task PatchAsync(TId id, [FromBody] TResource resource, CancellationToken cancellationToken) + if (_options.ValidateModelState && !ModelState.IsValid) { - _traceWriter.LogMethodStart(new - { - id, - resource - }); - - ArgumentGuard.NotNull(resource, nameof(resource)); - - if (_update == null) - { - throw new RouteNotAvailableException(HttpMethod.Patch, Request.Path); - } - - if (_options.ValidateModelState && !ModelState.IsValid) - { - throw new InvalidModelStateException(ModelState, typeof(TResource), _options.IncludeExceptionStackTraceInErrors, _resourceGraph); - } + throw new InvalidModelStateException(ModelState, typeof(TResource), _options.IncludeExceptionStackTraceInErrors, _resourceGraph); + } - TResource? updated = await _update.UpdateAsync(id, resource, cancellationToken); + TResource? updated = await _update.UpdateAsync(id, resource, cancellationToken); - return updated == null ? NoContent() : Ok(updated); - } + return updated == null ? NoContent() : Ok(updated); + } - /// - /// Performs a complete replacement of a relationship on an existing resource. Example: - /// Example: - /// - /// - /// - /// Identifies the left side of the relationship. - /// - /// - /// The relationship for which to perform a complete replacement. - /// - /// - /// The resource or set of resources to assign to the relationship. - /// - /// - /// Propagates notification that request handling should be canceled. - /// - public virtual async Task PatchRelationshipAsync(TId id, string relationshipName, [FromBody] object? rightValue, - CancellationToken cancellationToken) + /// + /// Performs a complete replacement of a relationship on an existing resource. Example: + /// Example: + /// + /// + /// + /// Identifies the left side of the relationship. + /// + /// + /// The relationship for which to perform a complete replacement. + /// + /// + /// The resource or set of resources to assign to the relationship. + /// + /// + /// Propagates notification that request handling should be canceled. + /// + public virtual async Task PatchRelationshipAsync(TId id, string relationshipName, [FromBody] object? rightValue, + CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new { - _traceWriter.LogMethodStart(new - { - id, - relationshipName, - rightValue - }); + id, + relationshipName, + rightValue + }); - ArgumentGuard.NotNullNorEmpty(relationshipName, nameof(relationshipName)); + ArgumentGuard.NotNullNorEmpty(relationshipName, nameof(relationshipName)); - if (_setRelationship == null) - { - throw new RouteNotAvailableException(HttpMethod.Patch, Request.Path); - } + if (_setRelationship == null) + { + throw new RouteNotAvailableException(HttpMethod.Patch, Request.Path); + } - await _setRelationship.SetRelationshipAsync(id, relationshipName, rightValue, cancellationToken); + await _setRelationship.SetRelationshipAsync(id, relationshipName, rightValue, cancellationToken); - return NoContent(); - } + return NoContent(); + } - /// - /// Deletes an existing resource. Example: - /// - public virtual async Task DeleteAsync(TId id, CancellationToken cancellationToken) + /// + /// Deletes an existing resource. Example: + /// + public virtual async Task DeleteAsync(TId id, CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new { - _traceWriter.LogMethodStart(new - { - id - }); + id + }); - if (_delete == null) - { - throw new RouteNotAvailableException(HttpMethod.Delete, Request.Path); - } + if (_delete == null) + { + throw new RouteNotAvailableException(HttpMethod.Delete, Request.Path); + } - await _delete.DeleteAsync(id, cancellationToken); + await _delete.DeleteAsync(id, cancellationToken); - return NoContent(); - } + return NoContent(); + } - /// - /// Removes resources from a to-many relationship. Example: - /// - /// - /// Identifies the left side of the relationship. - /// - /// - /// The relationship to remove resources from. - /// - /// - /// The set of resources to remove from the relationship. - /// - /// - /// Propagates notification that request handling should be canceled. - /// - public virtual async Task DeleteRelationshipAsync(TId id, string relationshipName, [FromBody] ISet rightResourceIds, - CancellationToken cancellationToken) + /// + /// Removes resources from a to-many relationship. Example: + /// + /// + /// Identifies the left side of the relationship. + /// + /// + /// The relationship to remove resources from. + /// + /// + /// The set of resources to remove from the relationship. + /// + /// + /// Propagates notification that request handling should be canceled. + /// + public virtual async Task DeleteRelationshipAsync(TId id, string relationshipName, [FromBody] ISet rightResourceIds, + CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new { - _traceWriter.LogMethodStart(new - { - id, - relationshipName, - rightResourceIds - }); + id, + relationshipName, + rightResourceIds + }); - ArgumentGuard.NotNullNorEmpty(relationshipName, nameof(relationshipName)); - ArgumentGuard.NotNull(rightResourceIds, nameof(rightResourceIds)); + ArgumentGuard.NotNullNorEmpty(relationshipName, nameof(relationshipName)); + ArgumentGuard.NotNull(rightResourceIds, nameof(rightResourceIds)); - if (_removeFromRelationship == null) - { - throw new RouteNotAvailableException(HttpMethod.Delete, Request.Path); - } + if (_removeFromRelationship == null) + { + throw new RouteNotAvailableException(HttpMethod.Delete, Request.Path); + } - await _removeFromRelationship.RemoveFromToManyRelationshipAsync(id, relationshipName, rightResourceIds, cancellationToken); + await _removeFromRelationship.RemoveFromToManyRelationshipAsync(id, relationshipName, rightResourceIds, cancellationToken); - return NoContent(); - } + return NoContent(); } } diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs index 4a63eb5f54..debcfa5c6f 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs @@ -1,8 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; using JetBrains.Annotations; using JsonApiDotNetCore.AtomicOperations; using JsonApiDotNetCore.Configuration; @@ -13,199 +8,197 @@ using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCore.Controllers +namespace JsonApiDotNetCore.Controllers; + +/// +/// Implements the foundational ASP.NET controller layer in the JsonApiDotNetCore architecture for handling atomic:operations requests. See +/// https://jsonapi.org/ext/atomic/ for details. Delegates work to . +/// +[PublicAPI] +public abstract class BaseJsonApiOperationsController : CoreJsonApiController { + private readonly IJsonApiOptions _options; + private readonly IResourceGraph _resourceGraph; + private readonly IOperationsProcessor _processor; + private readonly IJsonApiRequest _request; + private readonly ITargetedFields _targetedFields; + private readonly TraceLogWriter _traceWriter; + + protected BaseJsonApiOperationsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IOperationsProcessor processor, IJsonApiRequest request, ITargetedFields targetedFields) + { + ArgumentGuard.NotNull(options, nameof(options)); + ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); + ArgumentGuard.NotNull(loggerFactory, nameof(loggerFactory)); + ArgumentGuard.NotNull(processor, nameof(processor)); + ArgumentGuard.NotNull(request, nameof(request)); + ArgumentGuard.NotNull(targetedFields, nameof(targetedFields)); + + _options = options; + _resourceGraph = resourceGraph; + _processor = processor; + _request = request; + _targetedFields = targetedFields; + _traceWriter = new TraceLogWriter(loggerFactory); + } + /// - /// Implements the foundational ASP.NET controller layer in the JsonApiDotNetCore architecture for handling atomic:operations requests. See - /// https://jsonapi.org/ext/atomic/ for details. Delegates work to . + /// Atomically processes a list of operations and returns a list of results. All changes are reverted if processing fails. If processing succeeds but + /// none of the operations returns any data, then HTTP 201 is returned instead of 200. /// - [PublicAPI] - public abstract class BaseJsonApiOperationsController : CoreJsonApiController + /// + /// The next example creates a new resource. + /// + /// + /// + /// The next example updates an existing resource. + /// + /// + /// + /// The next example deletes an existing resource. + /// + /// + public virtual async Task PostOperationsAsync([FromBody] IList operations, CancellationToken cancellationToken) { - private readonly IJsonApiOptions _options; - private readonly IResourceGraph _resourceGraph; - private readonly IOperationsProcessor _processor; - private readonly IJsonApiRequest _request; - private readonly ITargetedFields _targetedFields; - private readonly TraceLogWriter _traceWriter; - - protected BaseJsonApiOperationsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, - IOperationsProcessor processor, IJsonApiRequest request, ITargetedFields targetedFields) + _traceWriter.LogMethodStart(new { - ArgumentGuard.NotNull(options, nameof(options)); - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); - ArgumentGuard.NotNull(loggerFactory, nameof(loggerFactory)); - ArgumentGuard.NotNull(processor, nameof(processor)); - ArgumentGuard.NotNull(request, nameof(request)); - ArgumentGuard.NotNull(targetedFields, nameof(targetedFields)); - - _options = options; - _resourceGraph = resourceGraph; - _processor = processor; - _request = request; - _targetedFields = targetedFields; - _traceWriter = new TraceLogWriter(loggerFactory); - } + operations + }); - /// - /// Atomically processes a list of operations and returns a list of results. All changes are reverted if processing fails. If processing succeeds but - /// none of the operations returns any data, then HTTP 201 is returned instead of 200. - /// - /// - /// The next example creates a new resource. - /// - /// - /// - /// The next example updates an existing resource. - /// - /// - /// - /// The next example deletes an existing resource. - /// - /// - public virtual async Task PostOperationsAsync([FromBody] IList operations, CancellationToken cancellationToken) + ArgumentGuard.NotNull(operations, nameof(operations)); + + if (_options.ValidateModelState) { - _traceWriter.LogMethodStart(new - { - operations - }); + ValidateModelState(operations); + } + + IList results = await _processor.ProcessAsync(operations, cancellationToken); + return results.Any(result => result != null) ? Ok(results) : NoContent(); + } - ArgumentGuard.NotNull(operations, nameof(operations)); + protected virtual void ValidateModelState(IList operations) + { + // We must validate the resource inside each operation manually, because they are typed as IIdentifiable. + // Instead of validating IIdentifiable we need to validate the resource runtime-type. + + using IDisposable _ = new RevertRequestStateOnDispose(_request, _targetedFields); + + int operationIndex = 0; + var requestModelState = new List<(string key, ModelStateEntry? entry)>(); + int maxErrorsRemaining = ModelState.MaxAllowedErrors; - if (_options.ValidateModelState) + foreach (OperationContainer operation in operations) + { + if (maxErrorsRemaining < 1) { - ValidateModelState(operations); + break; } - IList results = await _processor.ProcessAsync(operations, cancellationToken); - return results.Any(result => result != null) ? Ok(results) : NoContent(); + maxErrorsRemaining = ValidateOperation(operation, operationIndex, requestModelState, maxErrorsRemaining); + + operationIndex++; } - protected virtual void ValidateModelState(IList operations) + if (requestModelState.Any()) { - // We must validate the resource inside each operation manually, because they are typed as IIdentifiable. - // Instead of validating IIdentifiable we need to validate the resource runtime-type. + Dictionary modelStateDictionary = requestModelState.ToDictionary(tuple => tuple.key, tuple => tuple.entry); - using IDisposable _ = new RevertRequestStateOnDispose(_request, _targetedFields); + throw new InvalidModelStateException(modelStateDictionary, typeof(IList), _options.IncludeExceptionStackTraceInErrors, + _resourceGraph, (collectionType, index) => collectionType == typeof(IList) ? operations[index].Resource.GetType() : null); + } + } - int operationIndex = 0; - var requestModelState = new List<(string key, ModelStateEntry entry)>(); - int maxErrorsRemaining = ModelState.MaxAllowedErrors; + private int ValidateOperation(OperationContainer operation, int operationIndex, List<(string key, ModelStateEntry? entry)> requestModelState, + int maxErrorsRemaining) + { + if (operation.Request.WriteOperation is WriteOperationKind.CreateResource or WriteOperationKind.UpdateResource) + { + _targetedFields.CopyFrom(operation.TargetedFields); + _request.CopyFrom(operation.Request); - foreach (OperationContainer operation in operations) + var validationContext = new ActionContext { - if (maxErrorsRemaining < 1) + ModelState = { - break; + MaxAllowedErrors = maxErrorsRemaining } + }; - maxErrorsRemaining = ValidateOperation(operation, operationIndex, requestModelState, maxErrorsRemaining); + ObjectValidator.Validate(validationContext, null, string.Empty, operation.Resource); - operationIndex++; - } - - if (requestModelState.Any()) - { - Dictionary modelStateDictionary = requestModelState.ToDictionary(tuple => tuple.key, tuple => tuple.entry); - - throw new InvalidModelStateException(modelStateDictionary, typeof(IList), _options.IncludeExceptionStackTraceInErrors, - _resourceGraph, - (collectionType, index) => collectionType == typeof(IList) ? operations[index].Resource.GetType() : null); - } - } - - private int ValidateOperation(OperationContainer operation, int operationIndex, List<(string key, ModelStateEntry entry)> requestModelState, - int maxErrorsRemaining) - { - if (operation.Request.WriteOperation is WriteOperationKind.CreateResource or WriteOperationKind.UpdateResource) + if (!validationContext.ModelState.IsValid) { - _targetedFields.CopyFrom(operation.TargetedFields); - _request.CopyFrom(operation.Request); + int errorsRemaining = maxErrorsRemaining; - var validationContext = new ActionContext + foreach (string key in validationContext.ModelState.Keys) { - ModelState = - { - MaxAllowedErrors = maxErrorsRemaining - } - }; - - ObjectValidator.Validate(validationContext, null, string.Empty, operation.Resource); + ModelStateEntry entry = validationContext.ModelState[key]!; - if (!validationContext.ModelState.IsValid) - { - int errorsRemaining = maxErrorsRemaining; - - foreach (string key in validationContext.ModelState.Keys) + if (entry.ValidationState == ModelValidationState.Invalid) { - ModelStateEntry entry = validationContext.ModelState[key]; + string operationKey = $"[{operationIndex}].{nameof(OperationContainer.Resource)}.{key}"; - if (entry.ValidationState == ModelValidationState.Invalid) + if (entry.Errors.Count > 0 && entry.Errors[0].Exception is TooManyModelErrorsException) { - string operationKey = $"[{operationIndex}].{nameof(OperationContainer.Resource)}.{key}"; - - if (entry.Errors.Count > 0 && entry.Errors[0].Exception is TooManyModelErrorsException) - { - requestModelState.Insert(0, (operationKey, entry)); - } - else - { - requestModelState.Add((operationKey, entry)); - } - - errorsRemaining -= entry.Errors.Count; + requestModelState.Insert(0, (operationKey, entry)); + } + else + { + requestModelState.Add((operationKey, entry)); } - } - return errorsRemaining; + errorsRemaining -= entry.Errors.Count; + } } - } - return maxErrorsRemaining; + return errorsRemaining; + } } + + return maxErrorsRemaining; } } diff --git a/src/JsonApiDotNetCore/Controllers/CoreJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/CoreJsonApiController.cs index 7bb96adedf..561a443d14 100644 --- a/src/JsonApiDotNetCore/Controllers/CoreJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/CoreJsonApiController.cs @@ -1,39 +1,36 @@ -using System.Collections.Generic; -using System.Linq; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.AspNetCore.Mvc; -namespace JsonApiDotNetCore.Controllers +namespace JsonApiDotNetCore.Controllers; + +/// +/// Provides helper methods to raise JSON:API compliant errors from controller actions. +/// +public abstract class CoreJsonApiController : ControllerBase { - /// - /// Provides helper methods to raise JSON:API compliant errors from controller actions. - /// - public abstract class CoreJsonApiController : ControllerBase + protected IActionResult Error(ErrorObject error) { - protected IActionResult Error(ErrorObject error) - { - ArgumentGuard.NotNull(error, nameof(error)); + ArgumentGuard.NotNull(error, nameof(error)); - return new ObjectResult(error) - { - StatusCode = (int)error.StatusCode - }; - } - - protected IActionResult Error(IEnumerable errors) + return new ObjectResult(error) { - IReadOnlyList? errorList = ToErrorList(errors); - ArgumentGuard.NotNullNorEmpty(errorList, nameof(errors)); + StatusCode = (int)error.StatusCode + }; + } - return new ObjectResult(errorList) - { - StatusCode = (int)ErrorObject.GetResponseStatusCode(errorList) - }; - } + protected IActionResult Error(IEnumerable errors) + { + IReadOnlyList? errorList = ToErrorList(errors); + ArgumentGuard.NotNullNorEmpty(errorList, nameof(errors)); - private static IReadOnlyList? ToErrorList(IEnumerable? errors) + return new ObjectResult(errorList) { - return errors?.ToArray(); - } + StatusCode = (int)ErrorObject.GetResponseStatusCode(errorList) + }; + } + + private static IReadOnlyList? ToErrorList(IEnumerable? errors) + { + return errors?.ToArray(); } } diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs index bbfa52af89..290487cb76 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs @@ -3,29 +3,28 @@ using JsonApiDotNetCore.Services; using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCore.Controllers +namespace JsonApiDotNetCore.Controllers; + +/// +/// The base class to derive resource-specific write-only controllers from. Returns HTTP 405 on read-only endpoints. 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 : JsonApiController + where TResource : class, IIdentifiable { /// - /// The base class to derive resource-specific write-only controllers from. Returns HTTP 405 on read-only endpoints. If you want to provide routing - /// templates yourself, you should derive from BaseJsonApiController directly. + /// Creates an instance from a write-only service. /// - /// - /// The resource type. - /// - /// - /// The resource identifier type. - /// - public abstract class JsonApiCommandController : JsonApiController - where TResource : class, IIdentifiable + protected JsonApiCommandController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceCommandService commandService) + : base(options, resourceGraph, loggerFactory, null, null, null, null, commandService, commandService, commandService, commandService, commandService, + commandService) { - /// - /// Creates an instance from a write-only service. - /// - protected JsonApiCommandController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, - IResourceCommandService commandService) - : base(options, resourceGraph, loggerFactory, null, null, null, null, commandService, commandService, commandService, commandService, - commandService, commandService) - { - } } } diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs index 6654dc534c..091bbee47b 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs @@ -1,121 +1,117 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Services; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCore.Controllers +namespace JsonApiDotNetCore.Controllers; + +/// +/// 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 abstract class JsonApiController : BaseJsonApiController + where TResource : 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 abstract class JsonApiController : BaseJsonApiController - where TResource : class, IIdentifiable + /// + protected JsonApiController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { - /// - protected JsonApiController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, - IResourceService resourceService) - : base(options, resourceGraph, loggerFactory, resourceService) - { - } + } - /// - protected JsonApiController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, - IGetAllService? getAll = null, IGetByIdService? getById = null, - IGetSecondaryService? getSecondary = null, IGetRelationshipService? getRelationship = null, - ICreateService? create = null, IAddToRelationshipService? addToRelationship = null, - IUpdateService? update = null, ISetRelationshipService? setRelationship = null, - IDeleteService? delete = null, IRemoveFromRelationshipService? removeFromRelationship = null) - : base(options, resourceGraph, loggerFactory, getAll, getById, getSecondary, getRelationship, create, addToRelationship, update, setRelationship, - delete, removeFromRelationship) - { - } + /// + protected JsonApiController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IGetAllService? getAll = null, IGetByIdService? getById = null, + IGetSecondaryService? getSecondary = null, IGetRelationshipService? getRelationship = null, + ICreateService? create = null, IAddToRelationshipService? addToRelationship = null, + IUpdateService? update = null, ISetRelationshipService? setRelationship = null, + IDeleteService? delete = null, IRemoveFromRelationshipService? removeFromRelationship = null) + : base(options, resourceGraph, loggerFactory, getAll, getById, getSecondary, getRelationship, create, addToRelationship, update, setRelationship, + delete, removeFromRelationship) + { + } - /// - [HttpGet] - [HttpHead] - public override async Task GetAsync(CancellationToken cancellationToken) - { - return await base.GetAsync(cancellationToken); - } + /// + [HttpGet] + [HttpHead] + public override async Task GetAsync(CancellationToken cancellationToken) + { + return await base.GetAsync(cancellationToken); + } - /// - [HttpGet("{id}")] - [HttpHead("{id}")] - public override async Task GetAsync(TId id, CancellationToken cancellationToken) - { - return await base.GetAsync(id, cancellationToken); - } + /// + [HttpGet("{id}")] + [HttpHead("{id}")] + public override async Task GetAsync(TId id, CancellationToken cancellationToken) + { + return await base.GetAsync(id, cancellationToken); + } - /// - [HttpGet("{id}/{relationshipName}")] - [HttpHead("{id}/{relationshipName}")] - public override async Task GetSecondaryAsync(TId id, string relationshipName, CancellationToken cancellationToken) - { - return await base.GetSecondaryAsync(id, relationshipName, cancellationToken); - } + /// + [HttpGet("{id}/{relationshipName}")] + [HttpHead("{id}/{relationshipName}")] + public override async Task GetSecondaryAsync(TId id, string relationshipName, CancellationToken cancellationToken) + { + return await base.GetSecondaryAsync(id, relationshipName, cancellationToken); + } - /// - [HttpGet("{id}/relationships/{relationshipName}")] - [HttpHead("{id}/relationships/{relationshipName}")] - public override async Task GetRelationshipAsync(TId id, string relationshipName, CancellationToken cancellationToken) - { - return await base.GetRelationshipAsync(id, relationshipName, cancellationToken); - } + /// + [HttpGet("{id}/relationships/{relationshipName}")] + [HttpHead("{id}/relationships/{relationshipName}")] + public override async Task GetRelationshipAsync(TId id, string relationshipName, CancellationToken cancellationToken) + { + return await base.GetRelationshipAsync(id, relationshipName, cancellationToken); + } - /// - [HttpPost] - public override async Task PostAsync([FromBody] TResource resource, CancellationToken cancellationToken) - { - return await base.PostAsync(resource, cancellationToken); - } + /// + [HttpPost] + public override async Task PostAsync([FromBody] TResource resource, CancellationToken cancellationToken) + { + return await base.PostAsync(resource, cancellationToken); + } - /// - [HttpPost("{id}/relationships/{relationshipName}")] - public override async Task PostRelationshipAsync(TId id, string relationshipName, [FromBody] ISet rightResourceIds, - CancellationToken cancellationToken) - { - return await base.PostRelationshipAsync(id, relationshipName, rightResourceIds, cancellationToken); - } + /// + [HttpPost("{id}/relationships/{relationshipName}")] + public override async Task PostRelationshipAsync(TId id, string relationshipName, [FromBody] ISet rightResourceIds, + CancellationToken cancellationToken) + { + return await base.PostRelationshipAsync(id, relationshipName, rightResourceIds, cancellationToken); + } - /// - [HttpPatch("{id}")] - public override async Task PatchAsync(TId id, [FromBody] TResource resource, CancellationToken cancellationToken) - { - return await base.PatchAsync(id, resource, cancellationToken); - } + /// + [HttpPatch("{id}")] + public override async Task PatchAsync(TId id, [FromBody] TResource resource, CancellationToken cancellationToken) + { + return await base.PatchAsync(id, resource, cancellationToken); + } - /// - [HttpPatch("{id}/relationships/{relationshipName}")] - public override async Task PatchRelationshipAsync(TId id, string relationshipName, [FromBody] object? rightValue, - CancellationToken cancellationToken) - { - return await base.PatchRelationshipAsync(id, relationshipName, rightValue, cancellationToken); - } + /// + [HttpPatch("{id}/relationships/{relationshipName}")] + public override async Task PatchRelationshipAsync(TId id, string relationshipName, [FromBody] object? rightValue, + CancellationToken cancellationToken) + { + return await base.PatchRelationshipAsync(id, relationshipName, rightValue, cancellationToken); + } - /// - [HttpDelete("{id}")] - public override async Task DeleteAsync(TId id, CancellationToken cancellationToken) - { - return await base.DeleteAsync(id, cancellationToken); - } + /// + [HttpDelete("{id}")] + public override async Task DeleteAsync(TId id, CancellationToken cancellationToken) + { + return await base.DeleteAsync(id, cancellationToken); + } - /// - [HttpDelete("{id}/relationships/{relationshipName}")] - public override async Task DeleteRelationshipAsync(TId id, string relationshipName, [FromBody] ISet rightResourceIds, - CancellationToken cancellationToken) - { - return await base.DeleteRelationshipAsync(id, relationshipName, rightResourceIds, cancellationToken); - } + /// + [HttpDelete("{id}/relationships/{relationshipName}")] + public override async Task DeleteRelationshipAsync(TId id, string relationshipName, [FromBody] ISet rightResourceIds, + CancellationToken cancellationToken) + { + return await base.DeleteRelationshipAsync(id, relationshipName, rightResourceIds, cancellationToken); } } diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiEndpoints.cs b/src/JsonApiDotNetCore/Controllers/JsonApiEndpoints.cs index 5fa0c129d8..92ba933fe3 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiEndpoints.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiEndpoints.cs @@ -1,31 +1,29 @@ -using System; using JetBrains.Annotations; // ReSharper disable CheckNamespace #pragma warning disable AV1505 // Namespace should match with assembly name -namespace JsonApiDotNetCore.Controllers +namespace JsonApiDotNetCore.Controllers; + +// IMPORTANT: An internal copy of this type exists in the SourceGenerators project. Keep these in sync when making changes. +[PublicAPI] +[Flags] +public enum JsonApiEndpoints { - // IMPORTANT: An internal copy of this type exists in the SourceGenerators project. Keep these in sync when making changes. - [PublicAPI] - [Flags] - public enum JsonApiEndpoints - { - None = 0, - GetCollection = 1, - GetSingle = 1 << 1, - GetSecondary = 1 << 2, - GetRelationship = 1 << 3, - Post = 1 << 4, - PostRelationship = 1 << 5, - Patch = 1 << 6, - PatchRelationship = 1 << 7, - Delete = 1 << 8, - DeleteRelationship = 1 << 9, + None = 0, + GetCollection = 1, + GetSingle = 1 << 1, + GetSecondary = 1 << 2, + GetRelationship = 1 << 3, + Post = 1 << 4, + PostRelationship = 1 << 5, + Patch = 1 << 6, + PatchRelationship = 1 << 7, + Delete = 1 << 8, + DeleteRelationship = 1 << 9, - Query = GetCollection | GetSingle | GetSecondary | GetRelationship, - Command = Post | PostRelationship | Patch | PatchRelationship | Delete | DeleteRelationship, + Query = GetCollection | GetSingle | GetSecondary | GetRelationship, + Command = Post | PostRelationship | Patch | PatchRelationship | Delete | DeleteRelationship, - All = Query | Command - } + All = Query | Command } diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiOperationsController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiOperationsController.cs index 3f034c08d8..452a5eac09 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiOperationsController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiOperationsController.cs @@ -1,6 +1,3 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using JsonApiDotNetCore.AtomicOperations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; @@ -8,25 +5,24 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCore.Controllers +namespace JsonApiDotNetCore.Controllers; + +/// +/// The base class to derive atomic:operations 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 BaseJsonApiOperationsController directly. +/// +public abstract class JsonApiOperationsController : BaseJsonApiOperationsController { - /// - /// The base class to derive atomic:operations 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 BaseJsonApiOperationsController directly. - /// - public abstract class JsonApiOperationsController : BaseJsonApiOperationsController + protected JsonApiOperationsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IOperationsProcessor processor, + IJsonApiRequest request, ITargetedFields targetedFields) + : base(options, resourceGraph, loggerFactory, processor, request, targetedFields) { - protected JsonApiOperationsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, - IOperationsProcessor processor, IJsonApiRequest request, ITargetedFields targetedFields) - : base(options, resourceGraph, loggerFactory, processor, request, targetedFields) - { - } + } - /// - [HttpPost] - public override async Task PostOperationsAsync([FromBody] IList operations, CancellationToken cancellationToken) - { - return await base.PostOperationsAsync(operations, cancellationToken); - } + /// + [HttpPost] + public override async Task PostOperationsAsync([FromBody] IList operations, CancellationToken cancellationToken) + { + return await base.PostOperationsAsync(operations, cancellationToken); } } diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiQueryController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiQueryController.cs index d56eab8d60..fa101c2118 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiQueryController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiQueryController.cs @@ -3,28 +3,27 @@ using JsonApiDotNetCore.Services; using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCore.Controllers +namespace JsonApiDotNetCore.Controllers; + +/// +/// The base class to derive resource-specific read-only controllers from. Returns HTTP 405 on write-only endpoints. 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 : JsonApiController + where TResource : class, IIdentifiable { /// - /// The base class to derive resource-specific read-only controllers from. Returns HTTP 405 on write-only endpoints. If you want to provide routing - /// templates yourself, you should derive from BaseJsonApiController directly. + /// Creates an instance from a read-only service. /// - /// - /// The resource type. - /// - /// - /// The resource identifier type. - /// - public abstract class JsonApiQueryController : JsonApiController - where TResource : class, IIdentifiable + protected JsonApiQueryController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceQueryService queryService) + : base(options, resourceGraph, loggerFactory, queryService, queryService, queryService, queryService) { - /// - /// Creates an instance from a read-only service. - /// - protected JsonApiQueryController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, - IResourceQueryService queryService) - : base(options, resourceGraph, loggerFactory, queryService, queryService, queryService, queryService) - { - } } } diff --git a/src/JsonApiDotNetCore/Diagnostics/AspNetCodeTimerSession.cs b/src/JsonApiDotNetCore/Diagnostics/AspNetCodeTimerSession.cs index 997580b00e..bef29dba78 100644 --- a/src/JsonApiDotNetCore/Diagnostics/AspNetCodeTimerSession.cs +++ b/src/JsonApiDotNetCore/Diagnostics/AspNetCodeTimerSession.cs @@ -1,83 +1,81 @@ -using System; using JetBrains.Annotations; using Microsoft.AspNetCore.Http; -namespace JsonApiDotNetCore.Diagnostics +namespace JsonApiDotNetCore.Diagnostics; + +/// +/// Code timing session management intended for use in ASP.NET Web Applications. Uses to isolate concurrent requests. +/// Can be used with async/wait, but it cannot distinguish between concurrently running threads within a single HTTP request, so you'll need to pass an +/// instance through the entire call chain in that case. +/// +[PublicAPI] +public sealed class AspNetCodeTimerSession : ICodeTimerSession { - /// - /// Code timing session management intended for use in ASP.NET Web Applications. Uses to isolate concurrent requests. - /// Can be used with async/wait, but it cannot distinguish between concurrently running threads within a single HTTP request, so you'll need to pass an - /// instance through the entire call chain in that case. - /// - [PublicAPI] - public sealed class AspNetCodeTimerSession : ICodeTimerSession - { - private const string HttpContextItemKey = "CascadingCodeTimer:Session"; + private const string HttpContextItemKey = "CascadingCodeTimer:Session"; - private readonly HttpContext? _httpContext; - private readonly IHttpContextAccessor? _httpContextAccessor; + private readonly HttpContext? _httpContext; + private readonly IHttpContextAccessor? _httpContextAccessor; - public ICodeTimer CodeTimer + public ICodeTimer CodeTimer + { + get { - get - { - HttpContext httpContext = GetHttpContext(); - var codeTimer = (ICodeTimer?)httpContext.Items[HttpContextItemKey]; - - if (codeTimer == null) - { - codeTimer = new CascadingCodeTimer(); - httpContext.Items[HttpContextItemKey] = codeTimer; - } + HttpContext httpContext = GetHttpContext(); + var codeTimer = (ICodeTimer?)httpContext.Items[HttpContextItemKey]; - return codeTimer; + if (codeTimer == null) + { + codeTimer = new CascadingCodeTimer(); + httpContext.Items[HttpContextItemKey] = codeTimer; } - } - - public event EventHandler? Disposed; - - public AspNetCodeTimerSession(IHttpContextAccessor httpContextAccessor) - { - ArgumentGuard.NotNull(httpContextAccessor, nameof(httpContextAccessor)); - _httpContextAccessor = httpContextAccessor; + return codeTimer; } + } - public AspNetCodeTimerSession(HttpContext httpContext) - { - ArgumentGuard.NotNull(httpContext, nameof(httpContext)); + public event EventHandler? Disposed; - _httpContext = httpContext; - } + public AspNetCodeTimerSession(IHttpContextAccessor httpContextAccessor) + { + ArgumentGuard.NotNull(httpContextAccessor, nameof(httpContextAccessor)); - public void Dispose() - { - HttpContext? httpContext = TryGetHttpContext(); - var codeTimer = (ICodeTimer?)httpContext?.Items[HttpContextItemKey]; + _httpContextAccessor = httpContextAccessor; + } - if (codeTimer != null) - { - codeTimer.Dispose(); - httpContext!.Items[HttpContextItemKey] = null; - } + public AspNetCodeTimerSession(HttpContext httpContext) + { + ArgumentGuard.NotNull(httpContext, nameof(httpContext)); - OnDisposed(); - } + _httpContext = httpContext; + } - private void OnDisposed() - { - Disposed?.Invoke(this, EventArgs.Empty); - } + public void Dispose() + { + HttpContext? httpContext = TryGetHttpContext(); + var codeTimer = (ICodeTimer?)httpContext?.Items[HttpContextItemKey]; - private HttpContext GetHttpContext() + if (codeTimer != null) { - HttpContext? httpContext = TryGetHttpContext(); - return httpContext ?? throw new InvalidOperationException("An active HTTP request is required."); + codeTimer.Dispose(); + httpContext!.Items[HttpContextItemKey] = null; } - private HttpContext? TryGetHttpContext() - { - return _httpContext ?? _httpContextAccessor?.HttpContext; - } + OnDisposed(); + } + + private void OnDisposed() + { + Disposed?.Invoke(this, EventArgs.Empty); + } + + private HttpContext GetHttpContext() + { + HttpContext? httpContext = TryGetHttpContext(); + return httpContext ?? throw new InvalidOperationException("An active HTTP request is required."); + } + + private HttpContext? TryGetHttpContext() + { + return _httpContext ?? _httpContextAccessor?.HttpContext; } } diff --git a/src/JsonApiDotNetCore/Diagnostics/CascadingCodeTimer.cs b/src/JsonApiDotNetCore/Diagnostics/CascadingCodeTimer.cs index 3b8d5ced72..fde552ec8f 100644 --- a/src/JsonApiDotNetCore/Diagnostics/CascadingCodeTimer.cs +++ b/src/JsonApiDotNetCore/Diagnostics/CascadingCodeTimer.cs @@ -1,281 +1,277 @@ -using System; -using System.Collections.Generic; using System.Diagnostics; using System.Globalization; -using System.Linq; using System.Runtime.InteropServices; using System.Text; -namespace JsonApiDotNetCore.Diagnostics +namespace JsonApiDotNetCore.Diagnostics; + +/// +/// Records execution times for nested code blocks. +/// +internal sealed class CascadingCodeTimer : ICodeTimer { - /// - /// Records execution times for nested code blocks. - /// - internal sealed class CascadingCodeTimer : ICodeTimer - { - private readonly Stopwatch _stopwatch = new(); - private readonly Stack _activeScopeStack = new(); - private readonly List _completedScopes = new(); + private readonly Stopwatch _stopwatch = new(); + private readonly Stack _activeScopeStack = new(); + private readonly List _completedScopes = new(); - static CascadingCodeTimer() + static CascadingCodeTimer() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - // Be default, measurements using Stopwatch can differ 25%-30% on the same function on the same computer. - // The steps below ensure to get an accuracy of 0.1%-0.2%. With this accuracy, algorithms can be tested and compared. - // https://www.codeproject.com/Articles/61964/Performance-Tests-Precise-Run-Time-Measurements-wi + // Be default, measurements using Stopwatch can differ 25%-30% on the same function on the same computer. + // The steps below ensure to get an accuracy of 0.1%-0.2%. With this accuracy, algorithms can be tested and compared. + // https://www.codeproject.com/Articles/61964/Performance-Tests-Precise-Run-Time-Measurements-wi - // The most important thing is to prevent switching between CPU cores or processors. Switching dismisses the cache, etc. and has a huge performance impact on the test. - Process.GetCurrentProcess().ProcessorAffinity = new IntPtr(2); + // The most important thing is to prevent switching between CPU cores or processors. Switching dismisses the cache, etc. and has a huge performance impact on the test. + Process.GetCurrentProcess().ProcessorAffinity = new IntPtr(2); - // To get the CPU core more exclusively, we must prevent that other processes can use this CPU core. We set our process priority to achieve this. - // Note we should NOT set the thread priority, because async/await usage makes the code jump between pooled threads (depending on Synchronization Context). - Process.GetCurrentProcess().PriorityClass = ProcessPriorityClass.High; - } + // To get the CPU core more exclusively, we must prevent that other processes can use this CPU core. We set our process priority to achieve this. + // Note we should NOT set the thread priority, because async/await usage makes the code jump between pooled threads (depending on Synchronization Context). + Process.GetCurrentProcess().PriorityClass = ProcessPriorityClass.High; } + } - /// - public IDisposable Measure(string name, bool excludeInRelativeCost = false) - { - MeasureScope childScope = CreateChildScope(name, excludeInRelativeCost); - _activeScopeStack.Push(childScope); + /// + public IDisposable Measure(string name, bool excludeInRelativeCost = false) + { + MeasureScope childScope = CreateChildScope(name, excludeInRelativeCost); + _activeScopeStack.Push(childScope); - return childScope; - } + return childScope; + } - private MeasureScope CreateChildScope(string name, bool excludeInRelativeCost) + private MeasureScope CreateChildScope(string name, bool excludeInRelativeCost) + { + if (_activeScopeStack.TryPeek(out MeasureScope? topScope)) { - if (_activeScopeStack.TryPeek(out MeasureScope? topScope)) - { - return topScope.SpawnChild(this, name, excludeInRelativeCost); - } + return topScope.SpawnChild(this, name, excludeInRelativeCost); + } + + return new MeasureScope(this, name, excludeInRelativeCost); + } - return new MeasureScope(this, name, excludeInRelativeCost); + private void Close(MeasureScope scope) + { + if (!_activeScopeStack.TryPeek(out MeasureScope? topScope) || topScope != scope) + { + throw new InvalidOperationException($"Scope '{scope.Name}' cannot be disposed at this time, because it is not the currently active scope."); } - private void Close(MeasureScope scope) + _activeScopeStack.Pop(); + + if (!_activeScopeStack.Any()) { - if (!_activeScopeStack.TryPeek(out MeasureScope? topScope) || topScope != scope) - { - throw new InvalidOperationException($"Scope '{scope.Name}' cannot be disposed at this time, because it is not the currently active scope."); - } + _completedScopes.Add(scope); + } + } - _activeScopeStack.Pop(); + /// + public string GetResults() + { + int paddingLength = GetPaddingLength(); - if (!_activeScopeStack.Any()) - { - _completedScopes.Add(scope); - } + var builder = new StringBuilder(); + WriteResult(builder, paddingLength); + + return builder.ToString(); + } + + private int GetPaddingLength() + { + int maxLength = 0; + + foreach (MeasureScope scope in _completedScopes) + { + int nextLength = scope.GetPaddingLength(); + maxLength = Math.Max(maxLength, nextLength); } - /// - public string GetResults() + if (_activeScopeStack.Any()) { - int paddingLength = GetPaddingLength(); + MeasureScope scope = _activeScopeStack.Peek(); + int nextLength = scope.GetPaddingLength(); + maxLength = Math.Max(maxLength, nextLength); + } + + return maxLength + 3; + } - var builder = new StringBuilder(); - WriteResult(builder, paddingLength); + private void WriteResult(StringBuilder builder, int paddingLength) + { + foreach (MeasureScope scope in _completedScopes) + { + scope.WriteResult(builder, 0, paddingLength); + } - return builder.ToString(); + if (_activeScopeStack.Any()) + { + MeasureScope scope = _activeScopeStack.Peek(); + scope.WriteResult(builder, 0, paddingLength); } + } - private int GetPaddingLength() + public void Dispose() + { + if (_stopwatch.IsRunning) { - int maxLength = 0; + _stopwatch.Stop(); + } - foreach (MeasureScope scope in _completedScopes) - { - int nextLength = scope.GetPaddingLength(); - maxLength = Math.Max(maxLength, nextLength); - } + _completedScopes.Clear(); + _activeScopeStack.Clear(); + } - if (_activeScopeStack.Any()) - { - MeasureScope scope = _activeScopeStack.Peek(); - int nextLength = scope.GetPaddingLength(); - maxLength = Math.Max(maxLength, nextLength); - } + private sealed class MeasureScope : IDisposable + { + private readonly CascadingCodeTimer _owner; + private readonly IList _children = new List(); + private readonly bool _excludeInRelativeCost; + private readonly TimeSpan _startedAt; + private TimeSpan? _stoppedAt; - return maxLength + 3; - } + public string Name { get; } - private void WriteResult(StringBuilder builder, int paddingLength) + public MeasureScope(CascadingCodeTimer owner, string name, bool excludeInRelativeCost) { - foreach (MeasureScope scope in _completedScopes) - { - scope.WriteResult(builder, 0, paddingLength); - } + _owner = owner; + _excludeInRelativeCost = excludeInRelativeCost; + Name = name; - if (_activeScopeStack.Any()) - { - MeasureScope scope = _activeScopeStack.Peek(); - scope.WriteResult(builder, 0, paddingLength); - } + EnsureRunning(); + _startedAt = owner._stopwatch.Elapsed; } - public void Dispose() + private void EnsureRunning() { - if (_stopwatch.IsRunning) + if (!_owner._stopwatch.IsRunning) { - _stopwatch.Stop(); + _owner._stopwatch.Start(); } - - _completedScopes.Clear(); - _activeScopeStack.Clear(); } - private sealed class MeasureScope : IDisposable + public MeasureScope SpawnChild(CascadingCodeTimer owner, string name, bool excludeInRelativeCost) { - private readonly CascadingCodeTimer _owner; - private readonly IList _children = new List(); - private readonly bool _excludeInRelativeCost; - private readonly TimeSpan _startedAt; - private TimeSpan? _stoppedAt; - - public string Name { get; } - - public MeasureScope(CascadingCodeTimer owner, string name, bool excludeInRelativeCost) - { - _owner = owner; - _excludeInRelativeCost = excludeInRelativeCost; - Name = name; + var childScope = new MeasureScope(owner, name, excludeInRelativeCost); + _children.Add(childScope); + return childScope; + } - EnsureRunning(); - _startedAt = owner._stopwatch.Elapsed; - } + public int GetPaddingLength() + { + return GetPaddingLength(0); + } - private void EnsureRunning() - { - if (!_owner._stopwatch.IsRunning) - { - _owner._stopwatch.Start(); - } - } + private int GetPaddingLength(int indent) + { + int selfLength = indent * 2 + Name.Length; + int maxChildrenLength = 0; - public MeasureScope SpawnChild(CascadingCodeTimer owner, string name, bool excludeInRelativeCost) + foreach (MeasureScope child in _children) { - var childScope = new MeasureScope(owner, name, excludeInRelativeCost); - _children.Add(childScope); - return childScope; + int nextLength = child.GetPaddingLength(indent + 1); + maxChildrenLength = Math.Max(nextLength, maxChildrenLength); } - public int GetPaddingLength() - { - return GetPaddingLength(0); - } + return Math.Max(selfLength, maxChildrenLength); + } - private int GetPaddingLength(int indent) - { - int selfLength = indent * 2 + Name.Length; - int maxChildrenLength = 0; + private TimeSpan GetElapsedInSelf() + { + return GetElapsedInTotal() - GetElapsedInChildren(); + } - foreach (MeasureScope child in _children) - { - int nextLength = child.GetPaddingLength(indent + 1); - maxChildrenLength = Math.Max(nextLength, maxChildrenLength); - } + private TimeSpan GetElapsedInTotal() + { + TimeSpan stoppedAt = _stoppedAt ?? _owner._stopwatch.Elapsed; + return stoppedAt - _startedAt; + } - return Math.Max(selfLength, maxChildrenLength); - } + private TimeSpan GetElapsedInChildren() + { + TimeSpan elapsedInChildren = TimeSpan.Zero; - private TimeSpan GetElapsedInSelf() + foreach (MeasureScope childScope in _children) { - return GetElapsedInTotal() - GetElapsedInChildren(); + elapsedInChildren += childScope.GetElapsedInTotal(); } - private TimeSpan GetElapsedInTotal() - { - TimeSpan stoppedAt = _stoppedAt ?? _owner._stopwatch.Elapsed; - return stoppedAt - _startedAt; - } + return elapsedInChildren; + } - private TimeSpan GetElapsedInChildren() - { - TimeSpan elapsedInChildren = TimeSpan.Zero; + private TimeSpan GetSkippedInTotal() + { + TimeSpan skippedInSelf = _excludeInRelativeCost ? GetElapsedInSelf() : TimeSpan.Zero; + TimeSpan skippedInChildren = GetSkippedInChildren(); - foreach (MeasureScope childScope in _children) - { - elapsedInChildren += childScope.GetElapsedInTotal(); - } + return skippedInSelf + skippedInChildren; + } - return elapsedInChildren; - } + private TimeSpan GetSkippedInChildren() + { + TimeSpan skippedInChildren = TimeSpan.Zero; - private TimeSpan GetSkippedInTotal() + foreach (MeasureScope childScope in _children) { - TimeSpan skippedInSelf = _excludeInRelativeCost ? GetElapsedInSelf() : TimeSpan.Zero; - TimeSpan skippedInChildren = GetSkippedInChildren(); - - return skippedInSelf + skippedInChildren; + skippedInChildren += childScope.GetSkippedInTotal(); } - private TimeSpan GetSkippedInChildren() - { - TimeSpan skippedInChildren = TimeSpan.Zero; + return skippedInChildren; + } - foreach (MeasureScope childScope in _children) - { - skippedInChildren += childScope.GetSkippedInTotal(); - } + public void WriteResult(StringBuilder builder, int indent, int paddingLength) + { + TimeSpan timeElapsedGlobal = GetElapsedInTotal() - GetSkippedInTotal(); + WriteResult(builder, indent, timeElapsedGlobal, paddingLength); + } - return skippedInChildren; - } + private void WriteResult(StringBuilder builder, int indent, TimeSpan timeElapsedGlobal, int paddingLength) + { + TimeSpan timeElapsedInSelf = GetElapsedInSelf(); + double scaleElapsedInSelf = timeElapsedGlobal != TimeSpan.Zero ? timeElapsedInSelf / timeElapsedGlobal : 0; - public void WriteResult(StringBuilder builder, int indent, int paddingLength) - { - TimeSpan timeElapsedGlobal = GetElapsedInTotal() - GetSkippedInTotal(); - WriteResult(builder, indent, timeElapsedGlobal, paddingLength); - } + WriteIndent(builder, indent); + builder.Append(Name); + WritePadding(builder, indent, paddingLength); + builder.AppendFormat(CultureInfo.InvariantCulture, "{0,19:G}", timeElapsedInSelf); - private void WriteResult(StringBuilder builder, int indent, TimeSpan timeElapsedGlobal, int paddingLength) + if (!_excludeInRelativeCost) { - TimeSpan timeElapsedInSelf = GetElapsedInSelf(); - double scaleElapsedInSelf = timeElapsedGlobal != TimeSpan.Zero ? timeElapsedInSelf / timeElapsedGlobal : 0; - - WriteIndent(builder, indent); - builder.Append(Name); - WritePadding(builder, indent, paddingLength); - builder.AppendFormat(CultureInfo.InvariantCulture, "{0,19:G}", timeElapsedInSelf); - - if (!_excludeInRelativeCost) - { - builder.Append(" ... "); - builder.AppendFormat(CultureInfo.InvariantCulture, "{0,7:#0.00%}", scaleElapsedInSelf); - } - - if (_stoppedAt == null) - { - builder.Append(" (active)"); - } - - builder.AppendLine(); - - foreach (MeasureScope child in _children) - { - child.WriteResult(builder, indent + 1, timeElapsedGlobal, paddingLength); - } + builder.Append(" ... "); + builder.AppendFormat(CultureInfo.InvariantCulture, "{0,7:#0.00%}", scaleElapsedInSelf); } - private static void WriteIndent(StringBuilder builder, int indent) + if (_stoppedAt == null) { - builder.Append(new string(' ', indent * 2)); + builder.Append(" (active)"); } - private void WritePadding(StringBuilder builder, int indent, int paddingLength) + builder.AppendLine(); + + foreach (MeasureScope child in _children) { - string padding = new('.', paddingLength - Name.Length - indent * 2); - builder.Append(' '); - builder.Append(padding); - builder.Append(' '); + child.WriteResult(builder, indent + 1, timeElapsedGlobal, paddingLength); } + } - public void Dispose() + private static void WriteIndent(StringBuilder builder, int indent) + { + builder.Append(new string(' ', indent * 2)); + } + + private void WritePadding(StringBuilder builder, int indent, int paddingLength) + { + string padding = new('.', paddingLength - Name.Length - indent * 2); + builder.Append(' '); + builder.Append(padding); + builder.Append(' '); + } + + public void Dispose() + { + if (_stoppedAt == null) { - if (_stoppedAt == null) - { - _stoppedAt = _owner._stopwatch.Elapsed; - _owner.Close(this); - } + _stoppedAt = _owner._stopwatch.Elapsed; + _owner.Close(this); } } } diff --git a/src/JsonApiDotNetCore/Diagnostics/CodeTimingSessionManager.cs b/src/JsonApiDotNetCore/Diagnostics/CodeTimingSessionManager.cs index 2d6b8eaae9..d858aa6f4b 100644 --- a/src/JsonApiDotNetCore/Diagnostics/CodeTimingSessionManager.cs +++ b/src/JsonApiDotNetCore/Diagnostics/CodeTimingSessionManager.cs @@ -1,95 +1,92 @@ -using System; -using System.Linq; using System.Reflection; #pragma warning disable AV1008 // Class should not be static -namespace JsonApiDotNetCore.Diagnostics +namespace JsonApiDotNetCore.Diagnostics; + +/// +/// Provides access to the "current" measurement, which removes the need to pass along a instance through the entire +/// call chain. +/// +public static class CodeTimingSessionManager { - /// - /// Provides access to the "current" measurement, which removes the need to pass along a instance through the entire - /// call chain. - /// - public static class CodeTimingSessionManager - { - public static readonly bool IsEnabled; - private static ICodeTimerSession? _session; + public static readonly bool IsEnabled; + private static ICodeTimerSession? _session; - public static ICodeTimer Current + public static ICodeTimer Current + { + get { - get + if (!IsEnabled) { - if (!IsEnabled) - { - return DisabledCodeTimer.Instance; - } + return DisabledCodeTimer.Instance; + } - AssertHasActiveSession(); + AssertHasActiveSession(); - return _session!.CodeTimer; - } + return _session!.CodeTimer; } + } - static CodeTimingSessionManager() - { + static CodeTimingSessionManager() + { #if DEBUG - IsEnabled = !IsRunningInTest() && !IsRunningInBenchmark(); + IsEnabled = !IsRunningInTest() && !IsRunningInBenchmark(); #else - IsEnabled = false; + IsEnabled = false; #endif - } + } - // ReSharper disable once UnusedMember.Local - private static bool IsRunningInTest() - { - const string testAssemblyName = "xunit.core"; + // ReSharper disable once UnusedMember.Local + private static bool IsRunningInTest() + { + const string testAssemblyName = "xunit.core"; - return AppDomain.CurrentDomain.GetAssemblies().Any(assembly => - assembly.FullName != null && assembly.FullName.StartsWith(testAssemblyName, StringComparison.Ordinal)); - } + return AppDomain.CurrentDomain.GetAssemblies().Any(assembly => + assembly.FullName != null && assembly.FullName.StartsWith(testAssemblyName, StringComparison.Ordinal)); + } - // ReSharper disable once UnusedMember.Local - private static bool IsRunningInBenchmark() - { - return Assembly.GetEntryAssembly()?.GetName().Name == "Benchmarks"; - } + // ReSharper disable once UnusedMember.Local + private static bool IsRunningInBenchmark() + { + return Assembly.GetEntryAssembly()?.GetName().Name == "Benchmarks"; + } - private static void AssertHasActiveSession() + private static void AssertHasActiveSession() + { + if (_session == null) { - if (_session == null) - { - throw new InvalidOperationException($"Call {nameof(Capture)} before accessing the current session."); - } + throw new InvalidOperationException($"Call {nameof(Capture)} before accessing the current session."); } + } - public static void Capture(ICodeTimerSession session) - { - ArgumentGuard.NotNull(session, nameof(session)); + public static void Capture(ICodeTimerSession session) + { + ArgumentGuard.NotNull(session, nameof(session)); - AssertNoActiveSession(); + AssertNoActiveSession(); - if (IsEnabled) - { - session.Disposed += SessionOnDisposed; - _session = session; - } + if (IsEnabled) + { + session.Disposed += SessionOnDisposed; + _session = session; } + } - private static void AssertNoActiveSession() + private static void AssertNoActiveSession() + { + if (_session != null) { - if (_session != null) - { - throw new InvalidOperationException("Sessions cannot be nested. Dispose the current session first."); - } + throw new InvalidOperationException("Sessions cannot be nested. Dispose the current session first."); } + } - private static void SessionOnDisposed(object? sender, EventArgs args) + private static void SessionOnDisposed(object? sender, EventArgs args) + { + if (_session != null) { - if (_session != null) - { - _session.Disposed -= SessionOnDisposed; - _session = null; - } + _session.Disposed -= SessionOnDisposed; + _session = null; } } } diff --git a/src/JsonApiDotNetCore/Diagnostics/DefaultCodeTimerSession.cs b/src/JsonApiDotNetCore/Diagnostics/DefaultCodeTimerSession.cs index d35d08cd1f..a1662095cb 100644 --- a/src/JsonApiDotNetCore/Diagnostics/DefaultCodeTimerSession.cs +++ b/src/JsonApiDotNetCore/Diagnostics/DefaultCodeTimerSession.cs @@ -1,52 +1,48 @@ -using System; -using System.Threading; +namespace JsonApiDotNetCore.Diagnostics; -namespace JsonApiDotNetCore.Diagnostics +/// +/// General code timing session management. Can be used with async/wait, but it cannot distinguish between concurrently running threads, so you'll need +/// to pass an instance through the entire call chain in that case. +/// +public sealed class DefaultCodeTimerSession : ICodeTimerSession { - /// - /// General code timing session management. Can be used with async/wait, but it cannot distinguish between concurrently running threads, so you'll need - /// to pass an instance through the entire call chain in that case. - /// - public sealed class DefaultCodeTimerSession : ICodeTimerSession - { - private readonly AsyncLocal _codeTimerInContext = new(); + private readonly AsyncLocal _codeTimerInContext = new(); - public ICodeTimer CodeTimer + public ICodeTimer CodeTimer + { + get { - get - { - AssertNotDisposed(); + AssertNotDisposed(); - return _codeTimerInContext.Value!; - } + return _codeTimerInContext.Value!; } + } - public event EventHandler? Disposed; + public event EventHandler? Disposed; - public DefaultCodeTimerSession() - { - _codeTimerInContext.Value = new CascadingCodeTimer(); - } + public DefaultCodeTimerSession() + { + _codeTimerInContext.Value = new CascadingCodeTimer(); + } - private void AssertNotDisposed() + private void AssertNotDisposed() + { + if (_codeTimerInContext.Value == null) { - if (_codeTimerInContext.Value == null) - { - throw new ObjectDisposedException(nameof(DefaultCodeTimerSession)); - } + throw new ObjectDisposedException(nameof(DefaultCodeTimerSession)); } + } - public void Dispose() - { - _codeTimerInContext.Value?.Dispose(); - _codeTimerInContext.Value = null; + public void Dispose() + { + _codeTimerInContext.Value?.Dispose(); + _codeTimerInContext.Value = null; - OnDisposed(); - } + OnDisposed(); + } - private void OnDisposed() - { - Disposed?.Invoke(this, EventArgs.Empty); - } + private void OnDisposed() + { + Disposed?.Invoke(this, EventArgs.Empty); } } diff --git a/src/JsonApiDotNetCore/Diagnostics/DisabledCodeTimer.cs b/src/JsonApiDotNetCore/Diagnostics/DisabledCodeTimer.cs index 1739ed3e81..928017b7e0 100644 --- a/src/JsonApiDotNetCore/Diagnostics/DisabledCodeTimer.cs +++ b/src/JsonApiDotNetCore/Diagnostics/DisabledCodeTimer.cs @@ -1,30 +1,27 @@ -using System; +namespace JsonApiDotNetCore.Diagnostics; -namespace JsonApiDotNetCore.Diagnostics +/// +/// Doesn't record anything. Intended for Release builds and to not break existing tests. +/// +internal sealed class DisabledCodeTimer : ICodeTimer { - /// - /// Doesn't record anything. Intended for Release builds and to not break existing tests. - /// - internal sealed class DisabledCodeTimer : ICodeTimer - { - public static readonly DisabledCodeTimer Instance = new(); + public static readonly DisabledCodeTimer Instance = new(); - private DisabledCodeTimer() - { - } + private DisabledCodeTimer() + { + } - public IDisposable Measure(string name, bool excludeInRelativeCost = false) - { - return this; - } + public IDisposable Measure(string name, bool excludeInRelativeCost = false) + { + return this; + } - public string GetResults() - { - return string.Empty; - } + public string GetResults() + { + return string.Empty; + } - public void Dispose() - { - } + public void Dispose() + { } } diff --git a/src/JsonApiDotNetCore/Diagnostics/ICodeTimer.cs b/src/JsonApiDotNetCore/Diagnostics/ICodeTimer.cs index 9e8abfe9e1..23ff4ac605 100644 --- a/src/JsonApiDotNetCore/Diagnostics/ICodeTimer.cs +++ b/src/JsonApiDotNetCore/Diagnostics/ICodeTimer.cs @@ -1,27 +1,24 @@ -using System; +namespace JsonApiDotNetCore.Diagnostics; -namespace JsonApiDotNetCore.Diagnostics +/// +/// Records execution times for code blocks. +/// +public interface ICodeTimer : IDisposable { /// - /// Records execution times for code blocks. + /// Starts recording the duration of a code block. Wrap this call in a using statement, so the recording stops when the return value goes out of + /// scope. /// - public interface ICodeTimer : IDisposable - { - /// - /// Starts recording the duration of a code block. Wrap this call in a using statement, so the recording stops when the return value goes out of - /// scope. - /// - /// - /// Description of what is being recorded. - /// - /// - /// When set, indicates to exclude this measurement in calculated percentages. false by default. - /// - IDisposable Measure(string name, bool excludeInRelativeCost = false); + /// + /// Description of what is being recorded. + /// + /// + /// When set, indicates to exclude this measurement in calculated percentages. false by default. + /// + IDisposable Measure(string name, bool excludeInRelativeCost = false); - /// - /// Returns intermediate or final results. - /// - string GetResults(); - } + /// + /// Returns intermediate or final results. + /// + string GetResults(); } diff --git a/src/JsonApiDotNetCore/Diagnostics/ICodeTimerSession.cs b/src/JsonApiDotNetCore/Diagnostics/ICodeTimerSession.cs index 5da473c38d..7e631a778c 100644 --- a/src/JsonApiDotNetCore/Diagnostics/ICodeTimerSession.cs +++ b/src/JsonApiDotNetCore/Diagnostics/ICodeTimerSession.cs @@ -1,14 +1,11 @@ -using System; +namespace JsonApiDotNetCore.Diagnostics; -namespace JsonApiDotNetCore.Diagnostics +/// +/// Removes the need to pass along a instance through the entire call chain when using code timing. +/// +public interface ICodeTimerSession : IDisposable { - /// - /// Removes the need to pass along a instance through the entire call chain when using code timing. - /// - public interface ICodeTimerSession : IDisposable - { - ICodeTimer CodeTimer { get; } + ICodeTimer CodeTimer { get; } - event EventHandler Disposed; - } + event EventHandler Disposed; } diff --git a/src/JsonApiDotNetCore/Diagnostics/MeasurementSettings.cs b/src/JsonApiDotNetCore/Diagnostics/MeasurementSettings.cs index 4fd42b2e30..0a92f9d570 100644 --- a/src/JsonApiDotNetCore/Diagnostics/MeasurementSettings.cs +++ b/src/JsonApiDotNetCore/Diagnostics/MeasurementSettings.cs @@ -1,10 +1,9 @@ #pragma warning disable AV1008 // Class should not be static -namespace JsonApiDotNetCore.Diagnostics +namespace JsonApiDotNetCore.Diagnostics; + +internal static class MeasurementSettings { - internal static class MeasurementSettings - { - public static readonly bool ExcludeDatabaseInPercentages = bool.Parse(bool.TrueString); - public static readonly bool ExcludeJsonSerializationInPercentages = bool.Parse(bool.FalseString); - } + public static readonly bool ExcludeDatabaseInPercentages = bool.Parse(bool.TrueString); + public static readonly bool ExcludeJsonSerializationInPercentages = bool.Parse(bool.FalseString); } diff --git a/src/JsonApiDotNetCore/Errors/CannotClearRequiredRelationshipException.cs b/src/JsonApiDotNetCore/Errors/CannotClearRequiredRelationshipException.cs index 4a7c6b5e66..b73b256d25 100644 --- a/src/JsonApiDotNetCore/Errors/CannotClearRequiredRelationshipException.cs +++ b/src/JsonApiDotNetCore/Errors/CannotClearRequiredRelationshipException.cs @@ -2,22 +2,21 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Errors +namespace JsonApiDotNetCore.Errors; + +/// +/// The error that is thrown when a required relationship is cleared. +/// +[PublicAPI] +public sealed class CannotClearRequiredRelationshipException : JsonApiException { - /// - /// The error that is thrown when a required relationship is cleared. - /// - [PublicAPI] - public sealed class CannotClearRequiredRelationshipException : JsonApiException - { - public CannotClearRequiredRelationshipException(string relationshipName, string resourceId, string resourceType) - : base(new ErrorObject(HttpStatusCode.BadRequest) - { - Title = "Failed to clear a required relationship.", - Detail = $"The relationship '{relationshipName}' on resource type '{resourceType}' " + - $"with ID '{resourceId}' cannot be cleared because it is a required relationship." - }) + public CannotClearRequiredRelationshipException(string relationshipName, string resourceId, string resourceType) + : base(new ErrorObject(HttpStatusCode.BadRequest) { - } + Title = "Failed to clear a required relationship.", + Detail = $"The relationship '{relationshipName}' on resource type '{resourceType}' " + + $"with ID '{resourceId}' cannot be cleared because it is a required relationship." + }) + { } } diff --git a/src/JsonApiDotNetCore/Errors/DuplicateLocalIdValueException.cs b/src/JsonApiDotNetCore/Errors/DuplicateLocalIdValueException.cs index 2926ee43b0..7b714d2b9d 100644 --- a/src/JsonApiDotNetCore/Errors/DuplicateLocalIdValueException.cs +++ b/src/JsonApiDotNetCore/Errors/DuplicateLocalIdValueException.cs @@ -2,21 +2,20 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Errors +namespace JsonApiDotNetCore.Errors; + +/// +/// The error that is thrown when assigning a local ID that was already assigned in an earlier operation. +/// +[PublicAPI] +public sealed class DuplicateLocalIdValueException : JsonApiException { - /// - /// The error that is thrown when assigning a local ID that was already assigned in an earlier operation. - /// - [PublicAPI] - public sealed class DuplicateLocalIdValueException : JsonApiException - { - public DuplicateLocalIdValueException(string localId) - : base(new ErrorObject(HttpStatusCode.BadRequest) - { - Title = "Another local ID with the same name is already defined at this point.", - Detail = $"Another local ID with name '{localId}' is already defined at this point." - }) + public DuplicateLocalIdValueException(string localId) + : base(new ErrorObject(HttpStatusCode.BadRequest) { - } + Title = "Another local ID with the same name is already defined at this point.", + Detail = $"Another local ID with name '{localId}' is already defined at this point." + }) + { } } diff --git a/src/JsonApiDotNetCore/Errors/FailedOperationException.cs b/src/JsonApiDotNetCore/Errors/FailedOperationException.cs index 4ae21ef469..e94b5a7c1b 100644 --- a/src/JsonApiDotNetCore/Errors/FailedOperationException.cs +++ b/src/JsonApiDotNetCore/Errors/FailedOperationException.cs @@ -1,27 +1,25 @@ -using System; using System.Net; using JetBrains.Annotations; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Errors +namespace JsonApiDotNetCore.Errors; + +/// +/// The error that is thrown when an operation in an atomic:operations request failed to be processed for unknown reasons. +/// +[PublicAPI] +public sealed class FailedOperationException : JsonApiException { - /// - /// The error that is thrown when an operation in an atomic:operations request failed to be processed for unknown reasons. - /// - [PublicAPI] - public sealed class FailedOperationException : JsonApiException - { - public FailedOperationException(int operationIndex, Exception innerException) - : base(new ErrorObject(HttpStatusCode.InternalServerError) - { - Title = "An unhandled error occurred while processing an operation in this request.", - Detail = innerException.Message, - Source = new ErrorSource - { - Pointer = $"/atomic:operations[{operationIndex}]" - } - }, innerException) + public FailedOperationException(int operationIndex, Exception innerException) + : base(new ErrorObject(HttpStatusCode.InternalServerError) { - } + Title = "An unhandled error occurred while processing an operation in this request.", + Detail = innerException.Message, + Source = new ErrorSource + { + Pointer = $"/atomic:operations[{operationIndex}]" + } + }, innerException) + { } } diff --git a/src/JsonApiDotNetCore/Errors/IncompatibleLocalIdTypeException.cs b/src/JsonApiDotNetCore/Errors/IncompatibleLocalIdTypeException.cs index 9b2e46357c..f3f7b33bb5 100644 --- a/src/JsonApiDotNetCore/Errors/IncompatibleLocalIdTypeException.cs +++ b/src/JsonApiDotNetCore/Errors/IncompatibleLocalIdTypeException.cs @@ -2,21 +2,20 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Errors +namespace JsonApiDotNetCore.Errors; + +/// +/// The error that is thrown when referencing a local ID that was assigned to a different resource type. +/// +[PublicAPI] +public sealed class IncompatibleLocalIdTypeException : JsonApiException { - /// - /// The error that is thrown when referencing a local ID that was assigned to a different resource type. - /// - [PublicAPI] - public sealed class IncompatibleLocalIdTypeException : JsonApiException - { - public IncompatibleLocalIdTypeException(string localId, string declaredType, string currentType) - : base(new ErrorObject(HttpStatusCode.BadRequest) - { - Title = "Incompatible type in Local ID usage.", - Detail = $"Local ID '{localId}' belongs to resource type '{declaredType}' instead of '{currentType}'." - }) + public IncompatibleLocalIdTypeException(string localId, string declaredType, string currentType) + : base(new ErrorObject(HttpStatusCode.BadRequest) { - } + Title = "Incompatible type in Local ID usage.", + Detail = $"Local ID '{localId}' belongs to resource type '{declaredType}' instead of '{currentType}'." + }) + { } } diff --git a/src/JsonApiDotNetCore/Errors/InvalidConfigurationException.cs b/src/JsonApiDotNetCore/Errors/InvalidConfigurationException.cs index 75a2275f15..e25fa5244c 100644 --- a/src/JsonApiDotNetCore/Errors/InvalidConfigurationException.cs +++ b/src/JsonApiDotNetCore/Errors/InvalidConfigurationException.cs @@ -1,17 +1,15 @@ -using System; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Errors +namespace JsonApiDotNetCore.Errors; + +/// +/// The error that is thrown when configured usage of this library is invalid. +/// +[PublicAPI] +public sealed class InvalidConfigurationException : Exception { - /// - /// The error that is thrown when configured usage of this library is invalid. - /// - [PublicAPI] - public sealed class InvalidConfigurationException : Exception + public InvalidConfigurationException(string message, Exception? innerException = null) + : base(message, innerException) { - public InvalidConfigurationException(string message, Exception? innerException = null) - : base(message, innerException) - { - } } } diff --git a/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs b/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs index b9924d92d5..eede4fed25 100644 --- a/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs +++ b/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; using System.Diagnostics; -using System.Linq; using System.Net; using System.Reflection; using System.Text.Json.Serialization; @@ -12,371 +9,367 @@ using JsonApiDotNetCore.Serialization.Objects; using Microsoft.AspNetCore.Mvc.ModelBinding; -namespace JsonApiDotNetCore.Errors +namespace JsonApiDotNetCore.Errors; + +/// +/// The error that is thrown when ASP.NET ModelState validation fails. +/// +[PublicAPI] +public sealed class InvalidModelStateException : JsonApiException { - /// - /// The error that is thrown when ASP.NET ModelState validation fails. - /// - [PublicAPI] - public sealed class InvalidModelStateException : JsonApiException + public InvalidModelStateException(IReadOnlyDictionary modelState, Type modelType, bool includeExceptionStackTraceInErrors, + IResourceGraph resourceGraph, Func? getCollectionElementTypeCallback = null) + : base(FromModelStateDictionary(modelState, modelType, resourceGraph, includeExceptionStackTraceInErrors, getCollectionElementTypeCallback)) { - public InvalidModelStateException(IReadOnlyDictionary modelState, Type modelType, bool includeExceptionStackTraceInErrors, - IResourceGraph resourceGraph, Func? getCollectionElementTypeCallback = null) - : base(FromModelStateDictionary(modelState, modelType, resourceGraph, includeExceptionStackTraceInErrors, getCollectionElementTypeCallback)) - { - } + } + + private static IEnumerable FromModelStateDictionary(IReadOnlyDictionary modelState, Type modelType, + IResourceGraph resourceGraph, bool includeExceptionStackTraceInErrors, Func? getCollectionElementTypeCallback) + { + ArgumentGuard.NotNull(modelState, nameof(modelState)); + ArgumentGuard.NotNull(modelType, nameof(modelType)); + ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); - private static IEnumerable FromModelStateDictionary(IReadOnlyDictionary modelState, Type modelType, - IResourceGraph resourceGraph, bool includeExceptionStackTraceInErrors, Func? getCollectionElementTypeCallback) + List errorObjects = new(); + + foreach ((ModelStateEntry entry, string? sourcePointer) in ResolveSourcePointers(modelState, modelType, resourceGraph, + getCollectionElementTypeCallback)) { - ArgumentGuard.NotNull(modelState, nameof(modelState)); - ArgumentGuard.NotNull(modelType, nameof(modelType)); - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); + AppendToErrorObjects(entry, errorObjects, sourcePointer, includeExceptionStackTraceInErrors); + } - List errorObjects = new(); + return errorObjects; + } - foreach ((ModelStateEntry entry, string? sourcePointer) in ResolveSourcePointers(modelState, modelType, resourceGraph, - getCollectionElementTypeCallback)) - { - AppendToErrorObjects(entry, errorObjects, sourcePointer, includeExceptionStackTraceInErrors); - } + private static IEnumerable<(ModelStateEntry entry, string? sourcePointer)> ResolveSourcePointers(IReadOnlyDictionary modelState, + Type modelType, IResourceGraph resourceGraph, Func? getCollectionElementTypeCallback) + { + foreach (string key in modelState.Keys) + { + var rootSegment = ModelStateKeySegment.Create(modelType, key, getCollectionElementTypeCallback); + string? sourcePointer = ResolveSourcePointer(rootSegment, resourceGraph); - return errorObjects; + yield return (modelState[key]!, sourcePointer); } + } - private static IEnumerable<(ModelStateEntry entry, string? sourcePointer)> ResolveSourcePointers( - IReadOnlyDictionary modelState, Type modelType, IResourceGraph resourceGraph, - Func? getCollectionElementTypeCallback) + private static string? ResolveSourcePointer(ModelStateKeySegment segment, IResourceGraph resourceGraph) + { + if (segment is ArrayIndexerSegment indexerSegment) { - foreach (string key in modelState.Keys) - { - var rootSegment = ModelStateKeySegment.Create(modelType, key, getCollectionElementTypeCallback); - string? sourcePointer = ResolveSourcePointer(rootSegment, resourceGraph); - - yield return (modelState[key], sourcePointer); - } + return ResolveSourcePointerInArrayIndexer(indexerSegment, resourceGraph); } - private static string? ResolveSourcePointer(ModelStateKeySegment segment, IResourceGraph resourceGraph) + if (segment is PropertySegment propertySegment) { - if (segment is ArrayIndexerSegment indexerSegment) + if (segment.IsInComplexType) { - return ResolveSourcePointerInArrayIndexer(indexerSegment, resourceGraph); + return ResolveSourcePointerInComplexType(propertySegment, resourceGraph); } - if (segment is PropertySegment propertySegment) + if (propertySegment.PropertyName == nameof(OperationContainer.Resource) && propertySegment.Parent != null && + propertySegment.Parent.ModelType == typeof(IList)) { - if (segment.IsInComplexType) - { - return ResolveSourcePointerInComplexType(propertySegment, resourceGraph); - } + // Special case: Stepping over OperationContainer.Resource property. - if (propertySegment.PropertyName == nameof(OperationContainer.Resource) && propertySegment.Parent != null && - propertySegment.Parent.ModelType == typeof(IList)) + if (segment.GetNextSegment(propertySegment.ModelType, false, $"{segment.SourcePointer}/data") is not PropertySegment nextPropertySegment) { - // Special case: Stepping over OperationContainer.Resource property. - - if (segment.GetNextSegment(propertySegment.ModelType, false, $"{segment.SourcePointer}/data") is not PropertySegment nextPropertySegment) - { - return null; - } - - propertySegment = nextPropertySegment; + return null; } - return ResolveSourcePointerInResourceField(propertySegment, resourceGraph); + propertySegment = nextPropertySegment; } - return segment.SourcePointer; + return ResolveSourcePointerInResourceField(propertySegment, resourceGraph); } - private static string? ResolveSourcePointerInArrayIndexer(ArrayIndexerSegment segment, IResourceGraph resourceGraph) - { - string sourcePointer = $"{segment.SourcePointer ?? "/atomic:operations"}[{segment.ArrayIndex}]"; - Type elementType = segment.GetCollectionElementType(); + return segment.SourcePointer; + } - ModelStateKeySegment? nextSegment = segment.GetNextSegment(elementType, segment.IsInComplexType, sourcePointer); - return nextSegment != null ? ResolveSourcePointer(nextSegment, resourceGraph) : sourcePointer; - } + private static string? ResolveSourcePointerInArrayIndexer(ArrayIndexerSegment segment, IResourceGraph resourceGraph) + { + string sourcePointer = $"{segment.SourcePointer ?? "/atomic:operations"}[{segment.ArrayIndex}]"; + Type elementType = segment.GetCollectionElementType(); - private static string? ResolveSourcePointerInComplexType(PropertySegment segment, IResourceGraph resourceGraph) + ModelStateKeySegment? nextSegment = segment.GetNextSegment(elementType, segment.IsInComplexType, sourcePointer); + return nextSegment != null ? ResolveSourcePointer(nextSegment, resourceGraph) : sourcePointer; + } + + private static string? ResolveSourcePointerInComplexType(PropertySegment segment, IResourceGraph resourceGraph) + { + PropertyInfo? property = segment.ModelType.GetProperty(segment.PropertyName); + + if (property == null) { - PropertyInfo? property = segment.ModelType.GetProperty(segment.PropertyName); + return null; + } - if (property == null) - { - return null; - } + string publicName = PropertySegment.GetPublicNameForProperty(property); + string? sourcePointer = segment.SourcePointer != null ? $"{segment.SourcePointer}/{publicName}" : null; - string publicName = PropertySegment.GetPublicNameForProperty(property); - string? sourcePointer = segment.SourcePointer != null ? $"{segment.SourcePointer}/{publicName}" : null; + ModelStateKeySegment? nextSegment = segment.GetNextSegment(property.PropertyType, true, sourcePointer); + return nextSegment != null ? ResolveSourcePointer(nextSegment, resourceGraph) : sourcePointer; + } - ModelStateKeySegment? nextSegment = segment.GetNextSegment(property.PropertyType, true, sourcePointer); - return nextSegment != null ? ResolveSourcePointer(nextSegment, resourceGraph) : sourcePointer; - } + private static string? ResolveSourcePointerInResourceField(PropertySegment segment, IResourceGraph resourceGraph) + { + ResourceType? resourceType = resourceGraph.FindResourceType(segment.ModelType); - private static string? ResolveSourcePointerInResourceField(PropertySegment segment, IResourceGraph resourceGraph) + if (resourceType != null) { - ResourceType? resourceType = resourceGraph.FindResourceType(segment.ModelType); + AttrAttribute? attribute = resourceType.FindAttributeByPropertyName(segment.PropertyName); - if (resourceType != null) + if (attribute != null) { - AttrAttribute? attribute = resourceType.FindAttributeByPropertyName(segment.PropertyName); - - if (attribute != null) - { - return ResolveSourcePointerInAttribute(segment, attribute, resourceGraph); - } + return ResolveSourcePointerInAttribute(segment, attribute, resourceGraph); + } - RelationshipAttribute? relationship = resourceType.FindRelationshipByPropertyName(segment.PropertyName); + RelationshipAttribute? relationship = resourceType.FindRelationshipByPropertyName(segment.PropertyName); - if (relationship != null) - { - return ResolveSourcePointerInRelationship(segment, relationship, resourceGraph); - } + if (relationship != null) + { + return ResolveSourcePointerInRelationship(segment, relationship, resourceGraph); } - - return null; } - private static string? ResolveSourcePointerInAttribute(PropertySegment segment, AttrAttribute attribute, IResourceGraph resourceGraph) - { - string sourcePointer = attribute.Property.Name == nameof(Identifiable.Id) - ? $"{segment.SourcePointer ?? "/data"}/{attribute.PublicName}" - : $"{segment.SourcePointer ?? "/data"}/attributes/{attribute.PublicName}"; + return null; + } - ModelStateKeySegment? nextSegment = segment.GetNextSegment(attribute.Property.PropertyType, true, sourcePointer); - return nextSegment != null ? ResolveSourcePointer(nextSegment, resourceGraph) : sourcePointer; - } + private static string? ResolveSourcePointerInAttribute(PropertySegment segment, AttrAttribute attribute, IResourceGraph resourceGraph) + { + string sourcePointer = attribute.Property.Name == nameof(Identifiable.Id) + ? $"{segment.SourcePointer ?? "/data"}/{attribute.PublicName}" + : $"{segment.SourcePointer ?? "/data"}/attributes/{attribute.PublicName}"; - private static string? ResolveSourcePointerInRelationship(PropertySegment segment, RelationshipAttribute relationship, IResourceGraph resourceGraph) - { - string sourcePointer = $"{segment.SourcePointer ?? "/data"}/relationships/{relationship.PublicName}/data"; + ModelStateKeySegment? nextSegment = segment.GetNextSegment(attribute.Property.PropertyType, true, sourcePointer); + return nextSegment != null ? ResolveSourcePointer(nextSegment, resourceGraph) : sourcePointer; + } - ModelStateKeySegment? nextSegment = segment.GetNextSegment(relationship.RightType.ClrType, false, sourcePointer); - return nextSegment != null ? ResolveSourcePointer(nextSegment, resourceGraph) : sourcePointer; - } + private static string? ResolveSourcePointerInRelationship(PropertySegment segment, RelationshipAttribute relationship, IResourceGraph resourceGraph) + { + string sourcePointer = $"{segment.SourcePointer ?? "/data"}/relationships/{relationship.PublicName}/data"; + + ModelStateKeySegment? nextSegment = segment.GetNextSegment(relationship.RightType.ClrType, false, sourcePointer); + return nextSegment != null ? ResolveSourcePointer(nextSegment, resourceGraph) : sourcePointer; + } - private static void AppendToErrorObjects(ModelStateEntry entry, List errorObjects, string? sourcePointer, - bool includeExceptionStackTraceInErrors) + private static void AppendToErrorObjects(ModelStateEntry entry, List errorObjects, string? sourcePointer, + bool includeExceptionStackTraceInErrors) + { + foreach (ModelError error in entry.Errors) { - foreach (ModelError error in entry.Errors) + if (error.Exception is JsonApiException jsonApiException) { - if (error.Exception is JsonApiException jsonApiException) - { - errorObjects.AddRange(jsonApiException.Errors); - } - else - { - ErrorObject errorObject = FromModelError(error, sourcePointer, includeExceptionStackTraceInErrors); - errorObjects.Add(errorObject); - } + errorObjects.AddRange(jsonApiException.Errors); + } + else + { + ErrorObject errorObject = FromModelError(error, sourcePointer, includeExceptionStackTraceInErrors); + errorObjects.Add(errorObject); } } + } - private static ErrorObject FromModelError(ModelError modelError, string? sourcePointer, bool includeExceptionStackTraceInErrors) + private static ErrorObject FromModelError(ModelError modelError, string? sourcePointer, bool includeExceptionStackTraceInErrors) + { + var error = new ErrorObject(HttpStatusCode.UnprocessableEntity) { - var error = new ErrorObject(HttpStatusCode.UnprocessableEntity) - { - Title = "Input validation failed.", - Detail = modelError.Exception is TooManyModelErrorsException tooManyException ? tooManyException.Message : modelError.ErrorMessage, - Source = sourcePointer == null - ? null - : new ErrorSource - { - Pointer = sourcePointer - } - }; - - if (includeExceptionStackTraceInErrors && modelError.Exception != null) - { - Exception exception = modelError.Exception.Demystify(); - string[] stackTraceLines = exception.ToString().Split(Environment.NewLine); - - if (stackTraceLines.Any()) + Title = "Input validation failed.", + Detail = modelError.Exception is TooManyModelErrorsException tooManyException ? tooManyException.Message : modelError.ErrorMessage, + Source = sourcePointer == null + ? null + : new ErrorSource { - error.Meta ??= new Dictionary(); - error.Meta["StackTrace"] = stackTraceLines; + Pointer = sourcePointer } - } + }; - return error; - } - - /// - /// Base type that represents a segment in a ModelState key. - /// - private abstract class ModelStateKeySegment + if (includeExceptionStackTraceInErrors && modelError.Exception != null) { - private const char Dot = '.'; - private const char BracketOpen = '['; - private const char BracketClose = ']'; - private static readonly char[] KeySegmentStartTokens = ArrayFactory.Create(Dot, BracketOpen); + Exception exception = modelError.Exception.Demystify(); + string[] stackTraceLines = exception.ToString().Split(Environment.NewLine); - // The right part of the full key, which nested segments are produced from. - private readonly string _nextKey; + if (stackTraceLines.Any()) + { + error.Meta ??= new Dictionary(); + error.Meta["StackTrace"] = stackTraceLines; + } + } - // Enables to resolve the runtime-type of a collection element, such as the resource type in an atomic:operation. - protected Func? GetCollectionElementTypeCallback { get; } + return error; + } - // In case of a property, its declaring type. In case of an indexer, the collection type or collection element type (in case the parent is a relationship). - public Type ModelType { get; } + /// + /// Base type that represents a segment in a ModelState key. + /// + private abstract class ModelStateKeySegment + { + private const char Dot = '.'; + private const char BracketOpen = '['; + private const char BracketClose = ']'; + private static readonly char[] KeySegmentStartTokens = ArrayFactory.Create(Dot, BracketOpen); - // Indicates we're in a complex object, so to determine public name, inspect [JsonPropertyName] instead of [Attr], [HasOne] etc. - public bool IsInComplexType { get; } + // The right part of the full key, which nested segments are produced from. + private readonly string _nextKey; - // The source pointer we've built up, so far. This is null whenever input is not recognized. - public string? SourcePointer { get; } + // Enables to resolve the runtime-type of a collection element, such as the resource type in an atomic:operation. + protected Func? GetCollectionElementTypeCallback { get; } - public ModelStateKeySegment? Parent { get; } + // In case of a property, its declaring type. In case of an indexer, the collection type or collection element type (in case the parent is a relationship). + public Type ModelType { get; } - protected ModelStateKeySegment(Type modelType, bool isInComplexType, string nextKey, string? sourcePointer, ModelStateKeySegment? parent, - Func? getCollectionElementTypeCallback) - { - ArgumentGuard.NotNull(modelType, nameof(modelType)); - ArgumentGuard.NotNull(nextKey, nameof(nextKey)); - - ModelType = modelType; - IsInComplexType = isInComplexType; - _nextKey = nextKey; - SourcePointer = sourcePointer; - Parent = parent; - GetCollectionElementTypeCallback = getCollectionElementTypeCallback; - } + // Indicates we're in a complex object, so to determine public name, inspect [JsonPropertyName] instead of [Attr], [HasOne] etc. + public bool IsInComplexType { get; } - public ModelStateKeySegment? GetNextSegment(Type modelType, bool isInComplexType, string? sourcePointer) - { - ArgumentGuard.NotNull(modelType, nameof(modelType)); + // The source pointer we've built up, so far. This is null whenever input is not recognized. + public string? SourcePointer { get; } - return _nextKey == string.Empty - ? null - : CreateSegment(modelType, _nextKey, isInComplexType, this, sourcePointer, GetCollectionElementTypeCallback); - } + public ModelStateKeySegment? Parent { get; } - public static ModelStateKeySegment Create(Type modelType, string key, Func? getCollectionElementTypeCallback) - { - ArgumentGuard.NotNull(modelType, nameof(modelType)); - ArgumentGuard.NotNull(key, nameof(key)); + protected ModelStateKeySegment(Type modelType, bool isInComplexType, string nextKey, string? sourcePointer, ModelStateKeySegment? parent, + Func? getCollectionElementTypeCallback) + { + ArgumentGuard.NotNull(modelType, nameof(modelType)); + ArgumentGuard.NotNull(nextKey, nameof(nextKey)); + + ModelType = modelType; + IsInComplexType = isInComplexType; + _nextKey = nextKey; + SourcePointer = sourcePointer; + Parent = parent; + GetCollectionElementTypeCallback = getCollectionElementTypeCallback; + } - return CreateSegment(modelType, key, false, null, null, getCollectionElementTypeCallback); - } + public ModelStateKeySegment? GetNextSegment(Type modelType, bool isInComplexType, string? sourcePointer) + { + ArgumentGuard.NotNull(modelType, nameof(modelType)); - private static ModelStateKeySegment CreateSegment(Type modelType, string key, bool isInComplexType, ModelStateKeySegment? parent, - string? sourcePointer, Func? getCollectionElementTypeCallback) - { - string? segmentValue = null; - string? nextKey = null; + return _nextKey == string.Empty ? null : CreateSegment(modelType, _nextKey, isInComplexType, this, sourcePointer, GetCollectionElementTypeCallback); + } - int segmentEndIndex = key.IndexOfAny(KeySegmentStartTokens); + public static ModelStateKeySegment Create(Type modelType, string key, Func? getCollectionElementTypeCallback) + { + ArgumentGuard.NotNull(modelType, nameof(modelType)); + ArgumentGuard.NotNull(key, nameof(key)); - if (segmentEndIndex == 0 && key[0] == BracketOpen) - { - int bracketCloseIndex = key.IndexOf(BracketClose); + return CreateSegment(modelType, key, false, null, null, getCollectionElementTypeCallback); + } - if (bracketCloseIndex != -1) - { - segmentValue = key[1.. bracketCloseIndex]; + private static ModelStateKeySegment CreateSegment(Type modelType, string key, bool isInComplexType, ModelStateKeySegment? parent, string? sourcePointer, + Func? getCollectionElementTypeCallback) + { + string? segmentValue = null; + string? nextKey = null; - int nextKeyStartIndex = key.Length > bracketCloseIndex + 1 && key[bracketCloseIndex + 1] == Dot - ? bracketCloseIndex + 2 - : bracketCloseIndex + 1; + int segmentEndIndex = key.IndexOfAny(KeySegmentStartTokens); - nextKey = key[nextKeyStartIndex..]; + if (segmentEndIndex == 0 && key[0] == BracketOpen) + { + int bracketCloseIndex = key.IndexOf(BracketClose); - if (int.TryParse(segmentValue, out int indexValue)) - { - return new ArrayIndexerSegment(indexValue, modelType, isInComplexType, nextKey, sourcePointer, parent, - getCollectionElementTypeCallback); - } + if (bracketCloseIndex != -1) + { + segmentValue = key[1.. bracketCloseIndex]; - // If the value between brackets is not numeric, consider it an unspeakable property. For example: - // "Foo[Bar]" instead of "Foo.Bar". Its unclear when this happens, but ASP.NET source contains tests for such keys. - } - } + int nextKeyStartIndex = key.Length > bracketCloseIndex + 1 && key[bracketCloseIndex + 1] == Dot + ? bracketCloseIndex + 2 + : bracketCloseIndex + 1; - if (segmentValue == null) - { - segmentValue = segmentEndIndex == -1 ? key : key[..segmentEndIndex]; + nextKey = key[nextKeyStartIndex..]; - nextKey = segmentEndIndex != -1 && key.Length > segmentEndIndex && key[segmentEndIndex] == Dot - ? key[(segmentEndIndex + 1)..] - : key[segmentValue.Length..]; - } + if (int.TryParse(segmentValue, out int indexValue)) + { + return new ArrayIndexerSegment(indexValue, modelType, isInComplexType, nextKey, sourcePointer, parent, + getCollectionElementTypeCallback); + } - // Workaround for a quirk in ModelState validation. Some controller action methods have an 'id' parameter before the [FromBody] parameter. - // When a validation error occurs on top-level 'Id' in the request body, its key contains 'id' instead of 'Id' (the error message is correct, though). - // We compensate for that case here, so that we'll find 'Id' in the resource graph when building the source pointer. - if (segmentValue == "id") - { - segmentValue = "Id"; + // If the value between brackets is not numeric, consider it an unspeakable property. For example: + // "Foo[Bar]" instead of "Foo.Bar". Its unclear when this happens, but ASP.NET source contains tests for such keys. } - - return new PropertySegment(segmentValue, modelType, isInComplexType, nextKey!, sourcePointer, parent, getCollectionElementTypeCallback); } - } - - /// - /// Represents an array indexer in a ModelState key, such as "1" in "Customer.Orders[1].Amount". - /// - private sealed class ArrayIndexerSegment : ModelStateKeySegment - { - private static readonly CollectionConverter CollectionConverter = new(); - - public int ArrayIndex { get; } - public ArrayIndexerSegment(int arrayIndex, Type modelType, bool isInComplexType, string nextKey, string? sourcePointer, - ModelStateKeySegment? parent, Func? getCollectionElementTypeCallback) - : base(modelType, isInComplexType, nextKey, sourcePointer, parent, getCollectionElementTypeCallback) + if (segmentValue == null) { - ArrayIndex = arrayIndex; + segmentValue = segmentEndIndex == -1 ? key : key[..segmentEndIndex]; + + nextKey = segmentEndIndex != -1 && key.Length > segmentEndIndex && key[segmentEndIndex] == Dot + ? key[(segmentEndIndex + 1)..] + : key[segmentValue.Length..]; } - public Type GetCollectionElementType() + // Workaround for a quirk in ModelState validation. Some controller action methods have an 'id' parameter before the [FromBody] parameter. + // When a validation error occurs on top-level 'Id' in the request body, its key contains 'id' instead of 'Id' (the error message is correct, though). + // We compensate for that case here, so that we'll find 'Id' in the resource graph when building the source pointer. + if (segmentValue == "id") { - Type? type = GetCollectionElementTypeCallback?.Invoke(ModelType, ArrayIndex); - return type ?? GetDeclaredCollectionElementType(); + segmentValue = "Id"; } - private Type GetDeclaredCollectionElementType() - { - if (ModelType != typeof(string)) - { - Type? elementType = CollectionConverter.FindCollectionElementType(ModelType); + return new PropertySegment(segmentValue, modelType, isInComplexType, nextKey!, sourcePointer, parent, getCollectionElementTypeCallback); + } + } - if (elementType != null) - { - return elementType; - } - } + /// + /// Represents an array indexer in a ModelState key, such as "1" in "Customer.Orders[1].Amount". + /// + private sealed class ArrayIndexerSegment : ModelStateKeySegment + { + private static readonly CollectionConverter CollectionConverter = new(); - // In case of a to-many relationship, the ModelType already contains the element type. - return ModelType; - } + public int ArrayIndex { get; } + + public ArrayIndexerSegment(int arrayIndex, Type modelType, bool isInComplexType, string nextKey, string? sourcePointer, ModelStateKeySegment? parent, + Func? getCollectionElementTypeCallback) + : base(modelType, isInComplexType, nextKey, sourcePointer, parent, getCollectionElementTypeCallback) + { + ArrayIndex = arrayIndex; } - /// - /// Represents a property in a ModelState key, such as "Orders" in "Customer.Orders[1].Amount". - /// - private sealed class PropertySegment : ModelStateKeySegment + public Type GetCollectionElementType() { - public string PropertyName { get; } + Type? type = GetCollectionElementTypeCallback?.Invoke(ModelType, ArrayIndex); + return type ?? GetDeclaredCollectionElementType(); + } - public PropertySegment(string propertyName, Type modelType, bool isInComplexType, string nextKey, string? sourcePointer, - ModelStateKeySegment? parent, Func? getCollectionElementTypeCallback) - : base(modelType, isInComplexType, nextKey, sourcePointer, parent, getCollectionElementTypeCallback) + private Type GetDeclaredCollectionElementType() + { + if (ModelType != typeof(string)) { - ArgumentGuard.NotNull(propertyName, nameof(propertyName)); + Type? elementType = CollectionConverter.FindCollectionElementType(ModelType); - PropertyName = propertyName; + if (elementType != null) + { + return elementType; + } } - public static string GetPublicNameForProperty(PropertyInfo property) - { - ArgumentGuard.NotNull(property, nameof(property)); + // In case of a to-many relationship, the ModelType already contains the element type. + return ModelType; + } + } - var jsonNameAttribute = property.GetCustomAttribute(true); - return jsonNameAttribute?.Name ?? property.Name; - } + /// + /// Represents a property in a ModelState key, such as "Orders" in "Customer.Orders[1].Amount". + /// + private sealed class PropertySegment : ModelStateKeySegment + { + public string PropertyName { get; } + + public PropertySegment(string propertyName, Type modelType, bool isInComplexType, string nextKey, string? sourcePointer, ModelStateKeySegment? parent, + Func? getCollectionElementTypeCallback) + : base(modelType, isInComplexType, nextKey, sourcePointer, parent, getCollectionElementTypeCallback) + { + ArgumentGuard.NotNull(propertyName, nameof(propertyName)); + + PropertyName = propertyName; + } + + public static string GetPublicNameForProperty(PropertyInfo property) + { + ArgumentGuard.NotNull(property, nameof(property)); + + var jsonNameAttribute = property.GetCustomAttribute(true); + return jsonNameAttribute?.Name ?? property.Name; } } } diff --git a/src/JsonApiDotNetCore/Errors/InvalidQueryException.cs b/src/JsonApiDotNetCore/Errors/InvalidQueryException.cs index 22c8738002..f4332c1af1 100644 --- a/src/JsonApiDotNetCore/Errors/InvalidQueryException.cs +++ b/src/JsonApiDotNetCore/Errors/InvalidQueryException.cs @@ -1,24 +1,22 @@ -using System; using System.Net; using JetBrains.Annotations; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Errors +namespace JsonApiDotNetCore.Errors; + +/// +/// The error that is thrown when translating a to Entity Framework Core fails. +/// +[PublicAPI] +public sealed class InvalidQueryException : JsonApiException { - /// - /// The error that is thrown when translating a to Entity Framework Core fails. - /// - [PublicAPI] - public sealed class InvalidQueryException : JsonApiException - { - public InvalidQueryException(string reason, Exception? innerException) - : base(new ErrorObject(HttpStatusCode.BadRequest) - { - Title = reason, - Detail = innerException?.Message - }, innerException) + public InvalidQueryException(string reason, Exception? innerException) + : base(new ErrorObject(HttpStatusCode.BadRequest) { - } + Title = reason, + Detail = innerException?.Message + }, innerException) + { } } diff --git a/src/JsonApiDotNetCore/Errors/InvalidQueryStringParameterException.cs b/src/JsonApiDotNetCore/Errors/InvalidQueryStringParameterException.cs index e85c7798f5..485cf3685b 100644 --- a/src/JsonApiDotNetCore/Errors/InvalidQueryStringParameterException.cs +++ b/src/JsonApiDotNetCore/Errors/InvalidQueryStringParameterException.cs @@ -1,30 +1,28 @@ -using System; using System.Net; using JetBrains.Annotations; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Errors +namespace JsonApiDotNetCore.Errors; + +/// +/// The error that is thrown when processing the request fails due to an error in the request query string. +/// +[PublicAPI] +public sealed class InvalidQueryStringParameterException : JsonApiException { - /// - /// The error that is thrown when processing the request fails due to an error in the request query string. - /// - [PublicAPI] - public sealed class InvalidQueryStringParameterException : JsonApiException - { - public string ParameterName { get; } + public string ParameterName { get; } - public InvalidQueryStringParameterException(string parameterName, string genericMessage, string specificMessage, Exception? innerException = null) - : base(new ErrorObject(HttpStatusCode.BadRequest) - { - Title = genericMessage, - Detail = specificMessage, - Source = new ErrorSource - { - Parameter = parameterName - } - }, innerException) + public InvalidQueryStringParameterException(string parameterName, string genericMessage, string specificMessage, Exception? innerException = null) + : base(new ErrorObject(HttpStatusCode.BadRequest) { - ParameterName = parameterName; - } + Title = genericMessage, + Detail = specificMessage, + Source = new ErrorSource + { + Parameter = parameterName + } + }, innerException) + { + ParameterName = parameterName; } } diff --git a/src/JsonApiDotNetCore/Errors/InvalidRequestBodyException.cs b/src/JsonApiDotNetCore/Errors/InvalidRequestBodyException.cs index 18929ed3d0..d8d752ece4 100644 --- a/src/JsonApiDotNetCore/Errors/InvalidRequestBodyException.cs +++ b/src/JsonApiDotNetCore/Errors/InvalidRequestBodyException.cs @@ -1,37 +1,34 @@ -using System; -using System.Collections.Generic; using System.Net; using JetBrains.Annotations; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Errors +namespace JsonApiDotNetCore.Errors; + +/// +/// The error that is thrown when deserializing the request body fails. +/// +[PublicAPI] +public sealed class InvalidRequestBodyException : JsonApiException { - /// - /// The error that is thrown when deserializing the request body fails. - /// - [PublicAPI] - public sealed class InvalidRequestBodyException : JsonApiException - { - public InvalidRequestBodyException(string? requestBody, string? genericMessage, string? specificMessage, string? sourcePointer, - HttpStatusCode? alternativeStatusCode = null, Exception? innerException = null) - : base(new ErrorObject(alternativeStatusCode ?? HttpStatusCode.UnprocessableEntity) - { - Title = genericMessage != null ? $"Failed to deserialize request body: {genericMessage}" : "Failed to deserialize request body.", - Detail = specificMessage, - Source = sourcePointer == null - ? null - : new ErrorSource - { - Pointer = sourcePointer - }, - Meta = string.IsNullOrEmpty(requestBody) - ? null - : new Dictionary - { - ["RequestBody"] = requestBody - } - }, innerException) + public InvalidRequestBodyException(string? requestBody, string? genericMessage, string? specificMessage, string? sourcePointer, + HttpStatusCode? alternativeStatusCode = null, Exception? innerException = null) + : base(new ErrorObject(alternativeStatusCode ?? HttpStatusCode.UnprocessableEntity) { - } + Title = genericMessage != null ? $"Failed to deserialize request body: {genericMessage}" : "Failed to deserialize request body.", + Detail = specificMessage, + Source = sourcePointer == null + ? null + : new ErrorSource + { + Pointer = sourcePointer + }, + Meta = string.IsNullOrEmpty(requestBody) + ? null + : new Dictionary + { + ["RequestBody"] = requestBody + } + }, innerException) + { } } diff --git a/src/JsonApiDotNetCore/Errors/JsonApiException.cs b/src/JsonApiDotNetCore/Errors/JsonApiException.cs index ea68e67144..6bb62177dc 100644 --- a/src/JsonApiDotNetCore/Errors/JsonApiException.cs +++ b/src/JsonApiDotNetCore/Errors/JsonApiException.cs @@ -1,54 +1,50 @@ -using System; -using System.Collections.Generic; -using System.Linq; using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Serialization; using JetBrains.Annotations; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Errors +namespace JsonApiDotNetCore.Errors; + +/// +/// The base class for an that represents one or more JSON:API error objects in an unsuccessful response. +/// +[PublicAPI] +public class JsonApiException : Exception { - /// - /// The base class for an that represents one or more JSON:API error objects in an unsuccessful response. - /// - [PublicAPI] - public class JsonApiException : Exception + private static readonly JsonSerializerOptions SerializerOptions = new() + { + WriteIndented = true, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + public IReadOnlyList Errors { get; } + + public JsonApiException(ErrorObject error, Exception? innerException = null) + : base(null, innerException) + { + ArgumentGuard.NotNull(error, nameof(error)); + + Errors = error.AsArray(); + } + + public JsonApiException(IEnumerable errors, Exception? innerException = null) + : base(null, innerException) + { + IReadOnlyList? errorList = ToErrorList(errors); + ArgumentGuard.NotNullNorEmpty(errorList, nameof(errors)); + + Errors = errorList; + } + + private static IReadOnlyList? ToErrorList(IEnumerable? errors) + { + return errors?.ToList(); + } + + public string GetSummary() { - private static readonly JsonSerializerOptions SerializerOptions = new() - { - WriteIndented = true, - Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - }; - - public IReadOnlyList Errors { get; } - - public JsonApiException(ErrorObject error, Exception? innerException = null) - : base(null, innerException) - { - ArgumentGuard.NotNull(error, nameof(error)); - - Errors = error.AsArray(); - } - - public JsonApiException(IEnumerable errors, Exception? innerException = null) - : base(null, innerException) - { - IReadOnlyList? errorList = ToErrorList(errors); - ArgumentGuard.NotNullNorEmpty(errorList, nameof(errors)); - - Errors = errorList; - } - - private static IReadOnlyList? ToErrorList(IEnumerable? errors) - { - return errors?.ToList(); - } - - public string GetSummary() - { - return $"{nameof(JsonApiException)}: Errors = {JsonSerializer.Serialize(Errors, SerializerOptions)}"; - } + return $"{nameof(JsonApiException)}: Errors = {JsonSerializer.Serialize(Errors, SerializerOptions)}"; } } diff --git a/src/JsonApiDotNetCore/Errors/LocalIdSingleOperationException.cs b/src/JsonApiDotNetCore/Errors/LocalIdSingleOperationException.cs index 35e6a5f40d..d99eb6dfbb 100644 --- a/src/JsonApiDotNetCore/Errors/LocalIdSingleOperationException.cs +++ b/src/JsonApiDotNetCore/Errors/LocalIdSingleOperationException.cs @@ -2,21 +2,20 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Errors +namespace JsonApiDotNetCore.Errors; + +/// +/// The error that is thrown when assigning and referencing a local ID within the same operation. +/// +[PublicAPI] +public sealed class LocalIdSingleOperationException : JsonApiException { - /// - /// The error that is thrown when assigning and referencing a local ID within the same operation. - /// - [PublicAPI] - public sealed class LocalIdSingleOperationException : JsonApiException - { - public LocalIdSingleOperationException(string localId) - : base(new ErrorObject(HttpStatusCode.BadRequest) - { - Title = "Local ID cannot be both defined and used within the same operation.", - Detail = $"Local ID '{localId}' cannot be both defined and used within the same operation." - }) + public LocalIdSingleOperationException(string localId) + : base(new ErrorObject(HttpStatusCode.BadRequest) { - } + Title = "Local ID cannot be both defined and used within the same operation.", + Detail = $"Local ID '{localId}' cannot be both defined and used within the same operation." + }) + { } } diff --git a/src/JsonApiDotNetCore/Errors/MissingResourceInRelationship.cs b/src/JsonApiDotNetCore/Errors/MissingResourceInRelationship.cs index 7afe5b04cc..e80181898d 100644 --- a/src/JsonApiDotNetCore/Errors/MissingResourceInRelationship.cs +++ b/src/JsonApiDotNetCore/Errors/MissingResourceInRelationship.cs @@ -1,23 +1,22 @@ using JetBrains.Annotations; -namespace JsonApiDotNetCore.Errors +namespace JsonApiDotNetCore.Errors; + +[PublicAPI] +public sealed class MissingResourceInRelationship { - [PublicAPI] - public sealed class MissingResourceInRelationship - { - public string RelationshipName { get; } - public string ResourceType { get; } - public string ResourceId { get; } + public string RelationshipName { get; } + public string ResourceType { get; } + public string ResourceId { get; } - public MissingResourceInRelationship(string relationshipName, string resourceType, string resourceId) - { - ArgumentGuard.NotNullNorEmpty(relationshipName, nameof(relationshipName)); - ArgumentGuard.NotNullNorEmpty(resourceType, nameof(resourceType)); - ArgumentGuard.NotNullNorEmpty(resourceId, nameof(resourceId)); + public MissingResourceInRelationship(string relationshipName, string resourceType, string resourceId) + { + ArgumentGuard.NotNullNorEmpty(relationshipName, nameof(relationshipName)); + ArgumentGuard.NotNullNorEmpty(resourceType, nameof(resourceType)); + ArgumentGuard.NotNullNorEmpty(resourceId, nameof(resourceId)); - RelationshipName = relationshipName; - ResourceType = resourceType; - ResourceId = resourceId; - } + RelationshipName = relationshipName; + ResourceType = resourceType; + ResourceId = resourceId; } } diff --git a/src/JsonApiDotNetCore/Errors/MissingTransactionSupportException.cs b/src/JsonApiDotNetCore/Errors/MissingTransactionSupportException.cs index 81e13baeda..f67dd0d243 100644 --- a/src/JsonApiDotNetCore/Errors/MissingTransactionSupportException.cs +++ b/src/JsonApiDotNetCore/Errors/MissingTransactionSupportException.cs @@ -2,21 +2,20 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Errors +namespace JsonApiDotNetCore.Errors; + +/// +/// The error that is thrown when accessing a repository that does not support transactions during an atomic:operations request. +/// +[PublicAPI] +public sealed class MissingTransactionSupportException : JsonApiException { - /// - /// The error that is thrown when accessing a repository that does not support transactions during an atomic:operations request. - /// - [PublicAPI] - public sealed class MissingTransactionSupportException : JsonApiException - { - public MissingTransactionSupportException(string resourceType) - : base(new ErrorObject(HttpStatusCode.UnprocessableEntity) - { - Title = "Unsupported resource type in atomic:operations request.", - Detail = $"Operations on resources of type '{resourceType}' cannot be used because transaction support is unavailable." - }) + public MissingTransactionSupportException(string resourceType) + : base(new ErrorObject(HttpStatusCode.UnprocessableEntity) { - } + Title = "Unsupported resource type in atomic:operations request.", + Detail = $"Operations on resources of type '{resourceType}' cannot be used because transaction support is unavailable." + }) + { } } diff --git a/src/JsonApiDotNetCore/Errors/NonParticipatingTransactionException.cs b/src/JsonApiDotNetCore/Errors/NonParticipatingTransactionException.cs index 909f4a047d..09e116c4c1 100644 --- a/src/JsonApiDotNetCore/Errors/NonParticipatingTransactionException.cs +++ b/src/JsonApiDotNetCore/Errors/NonParticipatingTransactionException.cs @@ -2,21 +2,20 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Errors +namespace JsonApiDotNetCore.Errors; + +/// +/// The error that is thrown when a repository does not participate in the overarching transaction during an atomic:operations request. +/// +[PublicAPI] +public sealed class NonParticipatingTransactionException : JsonApiException { - /// - /// The error that is thrown when a repository does not participate in the overarching transaction during an atomic:operations request. - /// - [PublicAPI] - public sealed class NonParticipatingTransactionException : JsonApiException - { - public NonParticipatingTransactionException() - : base(new ErrorObject(HttpStatusCode.UnprocessableEntity) - { - Title = "Unsupported combination of resource types in atomic:operations request.", - Detail = "All operations need to participate in a single shared transaction, which is not the case for this request." - }) + public NonParticipatingTransactionException() + : base(new ErrorObject(HttpStatusCode.UnprocessableEntity) { - } + Title = "Unsupported combination of resource types in atomic:operations request.", + Detail = "All operations need to participate in a single shared transaction, which is not the case for this request." + }) + { } } diff --git a/src/JsonApiDotNetCore/Errors/RelationshipNotFoundException.cs b/src/JsonApiDotNetCore/Errors/RelationshipNotFoundException.cs index 07b5d0e25a..5c4d59e479 100644 --- a/src/JsonApiDotNetCore/Errors/RelationshipNotFoundException.cs +++ b/src/JsonApiDotNetCore/Errors/RelationshipNotFoundException.cs @@ -2,21 +2,20 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Errors +namespace JsonApiDotNetCore.Errors; + +/// +/// The error that is thrown when a relationship does not exist. +/// +[PublicAPI] +public sealed class RelationshipNotFoundException : JsonApiException { - /// - /// The error that is thrown when a relationship does not exist. - /// - [PublicAPI] - public sealed class RelationshipNotFoundException : JsonApiException - { - public RelationshipNotFoundException(string relationshipName, string resourceType) - : base(new ErrorObject(HttpStatusCode.NotFound) - { - Title = "The requested relationship does not exist.", - Detail = $"Resource of type '{resourceType}' does not contain a relationship named '{relationshipName}'." - }) + public RelationshipNotFoundException(string relationshipName, string resourceType) + : base(new ErrorObject(HttpStatusCode.NotFound) { - } + Title = "The requested relationship does not exist.", + Detail = $"Resource of type '{resourceType}' does not contain a relationship named '{relationshipName}'." + }) + { } } diff --git a/src/JsonApiDotNetCore/Errors/ResourceAlreadyExistsException.cs b/src/JsonApiDotNetCore/Errors/ResourceAlreadyExistsException.cs index 384289576d..eb4a444d42 100644 --- a/src/JsonApiDotNetCore/Errors/ResourceAlreadyExistsException.cs +++ b/src/JsonApiDotNetCore/Errors/ResourceAlreadyExistsException.cs @@ -2,21 +2,20 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Errors +namespace JsonApiDotNetCore.Errors; + +/// +/// The error that is thrown when creating a resource with an ID that already exists. +/// +[PublicAPI] +public sealed class ResourceAlreadyExistsException : JsonApiException { - /// - /// The error that is thrown when creating a resource with an ID that already exists. - /// - [PublicAPI] - public sealed class ResourceAlreadyExistsException : JsonApiException - { - public ResourceAlreadyExistsException(string resourceId, string resourceType) - : base(new ErrorObject(HttpStatusCode.Conflict) - { - Title = "Another resource with the specified ID already exists.", - Detail = $"Another resource of type '{resourceType}' with ID '{resourceId}' already exists." - }) + public ResourceAlreadyExistsException(string resourceId, string resourceType) + : base(new ErrorObject(HttpStatusCode.Conflict) { - } + Title = "Another resource with the specified ID already exists.", + Detail = $"Another resource of type '{resourceType}' with ID '{resourceId}' already exists." + }) + { } } diff --git a/src/JsonApiDotNetCore/Errors/ResourceNotFoundException.cs b/src/JsonApiDotNetCore/Errors/ResourceNotFoundException.cs index 50a05b70ff..28b60851c3 100644 --- a/src/JsonApiDotNetCore/Errors/ResourceNotFoundException.cs +++ b/src/JsonApiDotNetCore/Errors/ResourceNotFoundException.cs @@ -2,21 +2,20 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Errors +namespace JsonApiDotNetCore.Errors; + +/// +/// The error that is thrown when a resource does not exist. +/// +[PublicAPI] +public sealed class ResourceNotFoundException : JsonApiException { - /// - /// The error that is thrown when a resource does not exist. - /// - [PublicAPI] - public sealed class ResourceNotFoundException : JsonApiException - { - public ResourceNotFoundException(string resourceId, string resourceType) - : base(new ErrorObject(HttpStatusCode.NotFound) - { - Title = "The requested resource does not exist.", - Detail = $"Resource of type '{resourceType}' with ID '{resourceId}' does not exist." - }) + public ResourceNotFoundException(string resourceId, string resourceType) + : base(new ErrorObject(HttpStatusCode.NotFound) { - } + Title = "The requested resource does not exist.", + Detail = $"Resource of type '{resourceType}' with ID '{resourceId}' does not exist." + }) + { } } diff --git a/src/JsonApiDotNetCore/Errors/ResourcesInRelationshipsNotFoundException.cs b/src/JsonApiDotNetCore/Errors/ResourcesInRelationshipsNotFoundException.cs index 550bb290df..f40f76188b 100644 --- a/src/JsonApiDotNetCore/Errors/ResourcesInRelationshipsNotFoundException.cs +++ b/src/JsonApiDotNetCore/Errors/ResourcesInRelationshipsNotFoundException.cs @@ -1,30 +1,27 @@ -using System.Collections.Generic; -using System.Linq; using System.Net; using JetBrains.Annotations; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Errors +namespace JsonApiDotNetCore.Errors; + +/// +/// The error that is thrown when referencing one or more non-existing resources in one or more relationships. +/// +[PublicAPI] +public sealed class ResourcesInRelationshipsNotFoundException : JsonApiException { - /// - /// The error that is thrown when referencing one or more non-existing resources in one or more relationships. - /// - [PublicAPI] - public sealed class ResourcesInRelationshipsNotFoundException : JsonApiException + public ResourcesInRelationshipsNotFoundException(IEnumerable missingResources) + : base(missingResources.Select(CreateError)) { - public ResourcesInRelationshipsNotFoundException(IEnumerable missingResources) - : base(missingResources.Select(CreateError)) - { - } + } - private static ErrorObject CreateError(MissingResourceInRelationship missingResourceInRelationship) + private static ErrorObject CreateError(MissingResourceInRelationship missingResourceInRelationship) + { + return new ErrorObject(HttpStatusCode.NotFound) { - return new ErrorObject(HttpStatusCode.NotFound) - { - Title = "A related resource does not exist.", - Detail = $"Related resource of type '{missingResourceInRelationship.ResourceType}' with ID '{missingResourceInRelationship.ResourceId}' " + - $"in relationship '{missingResourceInRelationship.RelationshipName}' does not exist." - }; - } + Title = "A related resource does not exist.", + Detail = $"Related resource of type '{missingResourceInRelationship.ResourceType}' with ID '{missingResourceInRelationship.ResourceId}' " + + $"in relationship '{missingResourceInRelationship.RelationshipName}' does not exist." + }; } } diff --git a/src/JsonApiDotNetCore/Errors/RouteNotAvailableException.cs b/src/JsonApiDotNetCore/Errors/RouteNotAvailableException.cs index ed0797efba..80a368379b 100644 --- a/src/JsonApiDotNetCore/Errors/RouteNotAvailableException.cs +++ b/src/JsonApiDotNetCore/Errors/RouteNotAvailableException.cs @@ -1,26 +1,24 @@ using System.Net; -using System.Net.Http; using JetBrains.Annotations; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Errors +namespace JsonApiDotNetCore.Errors; + +/// +/// The error that is thrown when a request is received for an HTTP route that is not exposed. +/// +[PublicAPI] +public sealed class RouteNotAvailableException : JsonApiException { - /// - /// The error that is thrown when a request is received for an HTTP route that is not exposed. - /// - [PublicAPI] - public sealed class RouteNotAvailableException : JsonApiException - { - public HttpMethod Method { get; } + public HttpMethod Method { get; } - public RouteNotAvailableException(HttpMethod method, string route) - : base(new ErrorObject(HttpStatusCode.Forbidden) - { - Title = "The requested endpoint is not accessible.", - Detail = $"Endpoint '{route}' is not accessible for {method} requests." - }) + public RouteNotAvailableException(HttpMethod method, string route) + : base(new ErrorObject(HttpStatusCode.Forbidden) { - Method = method; - } + Title = "The requested endpoint is not accessible.", + Detail = $"Endpoint '{route}' is not accessible for {method} requests." + }) + { + Method = method; } } diff --git a/src/JsonApiDotNetCore/Errors/UnknownLocalIdValueException.cs b/src/JsonApiDotNetCore/Errors/UnknownLocalIdValueException.cs index 6882853eab..b5eeef052e 100644 --- a/src/JsonApiDotNetCore/Errors/UnknownLocalIdValueException.cs +++ b/src/JsonApiDotNetCore/Errors/UnknownLocalIdValueException.cs @@ -2,21 +2,20 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Errors +namespace JsonApiDotNetCore.Errors; + +/// +/// The error that is thrown when referencing a local ID that hasn't been assigned. +/// +[PublicAPI] +public sealed class UnknownLocalIdValueException : JsonApiException { - /// - /// The error that is thrown when referencing a local ID that hasn't been assigned. - /// - [PublicAPI] - public sealed class UnknownLocalIdValueException : JsonApiException - { - public UnknownLocalIdValueException(string localId) - : base(new ErrorObject(HttpStatusCode.BadRequest) - { - Title = "Server-generated value for local ID is not available at this point.", - Detail = $"Server-generated value for local ID '{localId}' is not available at this point." - }) + public UnknownLocalIdValueException(string localId) + : base(new ErrorObject(HttpStatusCode.BadRequest) { - } + Title = "Server-generated value for local ID is not available at this point.", + Detail = $"Server-generated value for local ID '{localId}' is not available at this point." + }) + { } } diff --git a/src/JsonApiDotNetCore/Errors/UnsuccessfulActionResultException.cs b/src/JsonApiDotNetCore/Errors/UnsuccessfulActionResultException.cs index 34f3faafc4..c5c55e4b70 100644 --- a/src/JsonApiDotNetCore/Errors/UnsuccessfulActionResultException.cs +++ b/src/JsonApiDotNetCore/Errors/UnsuccessfulActionResultException.cs @@ -3,51 +3,50 @@ using JsonApiDotNetCore.Serialization.Objects; using Microsoft.AspNetCore.Mvc; -namespace JsonApiDotNetCore.Errors -{ - /// - /// The error that is thrown when an with non-success status is returned from a controller method. - /// - [PublicAPI] - public sealed class UnsuccessfulActionResultException : JsonApiException - { - public UnsuccessfulActionResultException(HttpStatusCode status) - : base(new ErrorObject(status) - { - Title = status.ToString() - }) - { - } +namespace JsonApiDotNetCore.Errors; - public UnsuccessfulActionResultException(ProblemDetails problemDetails) - : base(ToError(problemDetails)) +/// +/// The error that is thrown when an with non-success status is returned from a controller method. +/// +[PublicAPI] +public sealed class UnsuccessfulActionResultException : JsonApiException +{ + public UnsuccessfulActionResultException(HttpStatusCode status) + : base(new ErrorObject(status) { - } + Title = status.ToString() + }) + { + } - private static ErrorObject ToError(ProblemDetails problemDetails) - { - ArgumentGuard.NotNull(problemDetails, nameof(problemDetails)); + public UnsuccessfulActionResultException(ProblemDetails problemDetails) + : base(ToError(problemDetails)) + { + } - HttpStatusCode status = problemDetails.Status != null ? (HttpStatusCode)problemDetails.Status.Value : HttpStatusCode.InternalServerError; + private static ErrorObject ToError(ProblemDetails problemDetails) + { + ArgumentGuard.NotNull(problemDetails, nameof(problemDetails)); - var error = new ErrorObject(status) - { - Title = problemDetails.Title, - Detail = problemDetails.Detail - }; + HttpStatusCode status = problemDetails.Status != null ? (HttpStatusCode)problemDetails.Status.Value : HttpStatusCode.InternalServerError; - if (!string.IsNullOrWhiteSpace(problemDetails.Instance)) - { - error.Id = problemDetails.Instance; - } + var error = new ErrorObject(status) + { + Title = problemDetails.Title, + Detail = problemDetails.Detail + }; - if (!string.IsNullOrWhiteSpace(problemDetails.Type)) - { - error.Links ??= new ErrorLinks(); - error.Links.About = problemDetails.Type; - } + if (!string.IsNullOrWhiteSpace(problemDetails.Instance)) + { + error.Id = problemDetails.Instance; + } - return error; + if (!string.IsNullOrWhiteSpace(problemDetails.Type)) + { + error.Links ??= new ErrorLinks(); + error.Links.About = problemDetails.Type; } + + return error; } } diff --git a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj index f69d39d64a..6842b86545 100644 --- a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj +++ b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj @@ -1,12 +1,12 @@ - $(JsonApiDotNetCoreVersionPrefix) - $(NetCoreAppVersion) + $(TargetFrameworkName) true true + $(JsonApiDotNetCoreVersionPrefix) jsonapidotnetcore;jsonapi;json:api;dotnet;asp.net;rest;web-api A framework for building JSON:API compliant REST APIs using ASP.NET and Entity Framework Core. Includes support for Atomic Operations. The ultimate goal of this library is to eliminate as much boilerplate as possible by offering out-of-the-box features such as sorting, filtering and pagination. You just need to focus on defining the resources and implementing your custom business logic. This library has been designed around dependency injection making extensibility incredibly easy. json-api-dotnet @@ -40,7 +40,7 @@ - + diff --git a/src/JsonApiDotNetCore/Middleware/AsyncConvertEmptyActionResultFilter.cs b/src/JsonApiDotNetCore/Middleware/AsyncConvertEmptyActionResultFilter.cs index efe5a54f00..cf51a82733 100644 --- a/src/JsonApiDotNetCore/Middleware/AsyncConvertEmptyActionResultFilter.cs +++ b/src/JsonApiDotNetCore/Middleware/AsyncConvertEmptyActionResultFilter.cs @@ -1,34 +1,32 @@ -using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.Infrastructure; -namespace JsonApiDotNetCore.Middleware +namespace JsonApiDotNetCore.Middleware; + +/// +public sealed class AsyncConvertEmptyActionResultFilter : IAsyncConvertEmptyActionResultFilter { /// - public sealed class AsyncConvertEmptyActionResultFilter : IAsyncConvertEmptyActionResultFilter + public async Task OnResultExecutionAsync(ResultExecutingContext context, ResultExecutionDelegate next) { - /// - public async Task OnResultExecutionAsync(ResultExecutingContext context, ResultExecutionDelegate next) - { - ArgumentGuard.NotNull(context, nameof(context)); - ArgumentGuard.NotNull(next, nameof(next)); + ArgumentGuard.NotNull(context, nameof(context)); + ArgumentGuard.NotNull(next, nameof(next)); - if (context.HttpContext.IsJsonApiRequest()) + if (context.HttpContext.IsJsonApiRequest()) + { + if (context.Result is not ObjectResult objectResult || objectResult.Value == null) { - if (context.Result is not ObjectResult objectResult || objectResult.Value == null) + if (context.Result is IStatusCodeActionResult statusCodeResult) { - if (context.Result is IStatusCodeActionResult statusCodeResult) + context.Result = new ObjectResult(null) { - context.Result = new ObjectResult(null) - { - StatusCode = statusCodeResult.StatusCode - }; - } + StatusCode = statusCodeResult.StatusCode + }; } } - - await next(); } + + await next(); } } diff --git a/src/JsonApiDotNetCore/Middleware/AsyncJsonApiExceptionFilter.cs b/src/JsonApiDotNetCore/Middleware/AsyncJsonApiExceptionFilter.cs index d82cbddeed..e87fc98389 100644 --- a/src/JsonApiDotNetCore/Middleware/AsyncJsonApiExceptionFilter.cs +++ b/src/JsonApiDotNetCore/Middleware/AsyncJsonApiExceptionFilter.cs @@ -1,41 +1,38 @@ -using System.Collections.Generic; -using System.Threading.Tasks; using JetBrains.Annotations; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; -namespace JsonApiDotNetCore.Middleware +namespace JsonApiDotNetCore.Middleware; + +/// +[PublicAPI] +public sealed class AsyncJsonApiExceptionFilter : IAsyncJsonApiExceptionFilter { - /// - [PublicAPI] - public sealed class AsyncJsonApiExceptionFilter : IAsyncJsonApiExceptionFilter + private readonly IExceptionHandler _exceptionHandler; + + public AsyncJsonApiExceptionFilter(IExceptionHandler exceptionHandler) { - private readonly IExceptionHandler _exceptionHandler; + ArgumentGuard.NotNull(exceptionHandler, nameof(exceptionHandler)); - public AsyncJsonApiExceptionFilter(IExceptionHandler exceptionHandler) - { - ArgumentGuard.NotNull(exceptionHandler, nameof(exceptionHandler)); + _exceptionHandler = exceptionHandler; + } - _exceptionHandler = exceptionHandler; - } + /// + public Task OnExceptionAsync(ExceptionContext context) + { + ArgumentGuard.NotNull(context, nameof(context)); - /// - public Task OnExceptionAsync(ExceptionContext context) + if (context.HttpContext.IsJsonApiRequest()) { - ArgumentGuard.NotNull(context, nameof(context)); + IReadOnlyList errors = _exceptionHandler.HandleException(context.Exception); - if (context.HttpContext.IsJsonApiRequest()) + context.Result = new ObjectResult(errors) { - IReadOnlyList errors = _exceptionHandler.HandleException(context.Exception); - - context.Result = new ObjectResult(errors) - { - StatusCode = (int)ErrorObject.GetResponseStatusCode(errors) - }; - } - - return Task.CompletedTask; + StatusCode = (int)ErrorObject.GetResponseStatusCode(errors) + }; } + + return Task.CompletedTask; } } diff --git a/src/JsonApiDotNetCore/Middleware/AsyncQueryStringActionFilter.cs b/src/JsonApiDotNetCore/Middleware/AsyncQueryStringActionFilter.cs index 85ce5046af..6f31c28d2a 100644 --- a/src/JsonApiDotNetCore/Middleware/AsyncQueryStringActionFilter.cs +++ b/src/JsonApiDotNetCore/Middleware/AsyncQueryStringActionFilter.cs @@ -1,36 +1,34 @@ using System.Reflection; -using System.Threading.Tasks; using JsonApiDotNetCore.Controllers.Annotations; using JsonApiDotNetCore.QueryStrings; using Microsoft.AspNetCore.Mvc.Filters; -namespace JsonApiDotNetCore.Middleware +namespace JsonApiDotNetCore.Middleware; + +/// +public sealed class AsyncQueryStringActionFilter : IAsyncQueryStringActionFilter { - /// - public sealed class AsyncQueryStringActionFilter : IAsyncQueryStringActionFilter + private readonly IQueryStringReader _queryStringReader; + + public AsyncQueryStringActionFilter(IQueryStringReader queryStringReader) { - private readonly IQueryStringReader _queryStringReader; + ArgumentGuard.NotNull(queryStringReader, nameof(queryStringReader)); - public AsyncQueryStringActionFilter(IQueryStringReader queryStringReader) - { - ArgumentGuard.NotNull(queryStringReader, nameof(queryStringReader)); + _queryStringReader = queryStringReader; + } - _queryStringReader = queryStringReader; - } + /// + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + ArgumentGuard.NotNull(context, nameof(context)); + ArgumentGuard.NotNull(next, nameof(next)); - /// - public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + if (context.HttpContext.IsJsonApiRequest()) { - ArgumentGuard.NotNull(context, nameof(context)); - ArgumentGuard.NotNull(next, nameof(next)); - - if (context.HttpContext.IsJsonApiRequest()) - { - var disableQueryStringAttribute = context.Controller.GetType().GetCustomAttribute(true); - _queryStringReader.ReadAll(disableQueryStringAttribute); - } - - await next(); + var disableQueryStringAttribute = context.Controller.GetType().GetCustomAttribute(true); + _queryStringReader.ReadAll(disableQueryStringAttribute); } + + await next(); } } diff --git a/src/JsonApiDotNetCore/Middleware/EndpointKind.cs b/src/JsonApiDotNetCore/Middleware/EndpointKind.cs index 6123aa09f9..b97d7735f3 100644 --- a/src/JsonApiDotNetCore/Middleware/EndpointKind.cs +++ b/src/JsonApiDotNetCore/Middleware/EndpointKind.cs @@ -1,25 +1,24 @@ -namespace JsonApiDotNetCore.Middleware +namespace JsonApiDotNetCore.Middleware; + +public enum EndpointKind { - public enum EndpointKind - { - /// - /// A top-level resource request, for example: "/blogs" or "/blogs/123" - /// - Primary, + /// + /// 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 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, + /// + /// A relationship request, for example: "/blogs/123/relationships/author" or "/author/123/relationships/articles" + /// + Relationship, - /// - /// A request to an atomic:operations endpoint. - /// - AtomicOperations - } + /// + /// A request to an atomic:operations endpoint. + /// + AtomicOperations } diff --git a/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs b/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs index 5a0aab7787..ea1d67743d 100644 --- a/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs +++ b/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; using System.Diagnostics; -using System.Linq; using System.Net; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; @@ -9,100 +6,99 @@ using JsonApiDotNetCore.Serialization.Objects; using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCore.Middleware +namespace JsonApiDotNetCore.Middleware; + +/// +[PublicAPI] +public class ExceptionHandler : IExceptionHandler { - /// - [PublicAPI] - public class ExceptionHandler : IExceptionHandler + private readonly IJsonApiOptions _options; + private readonly ILogger _logger; + + public ExceptionHandler(ILoggerFactory loggerFactory, IJsonApiOptions options) { - private readonly IJsonApiOptions _options; - private readonly ILogger _logger; + ArgumentGuard.NotNull(loggerFactory, nameof(loggerFactory)); + ArgumentGuard.NotNull(options, nameof(options)); - public ExceptionHandler(ILoggerFactory loggerFactory, IJsonApiOptions options) - { - ArgumentGuard.NotNull(loggerFactory, nameof(loggerFactory)); - ArgumentGuard.NotNull(options, nameof(options)); + _options = options; + _logger = loggerFactory.CreateLogger(); + } - _options = options; - _logger = loggerFactory.CreateLogger(); - } + public IReadOnlyList HandleException(Exception exception) + { + ArgumentGuard.NotNull(exception, nameof(exception)); - public IReadOnlyList HandleException(Exception exception) - { - ArgumentGuard.NotNull(exception, nameof(exception)); + Exception demystified = exception.Demystify(); - Exception demystified = exception.Demystify(); + LogException(demystified); - LogException(demystified); + return CreateErrorResponse(demystified); + } - return CreateErrorResponse(demystified); - } + private void LogException(Exception exception) + { + LogLevel level = GetLogLevel(exception); + string message = GetLogMessage(exception); - private void LogException(Exception exception) - { - LogLevel level = GetLogLevel(exception); - string message = GetLogMessage(exception); + _logger.Log(level, exception, message); + } - _logger.Log(level, exception, message); - } + protected virtual LogLevel GetLogLevel(Exception exception) + { + ArgumentGuard.NotNull(exception, nameof(exception)); - protected virtual LogLevel GetLogLevel(Exception exception) + if (exception is OperationCanceledException) { - ArgumentGuard.NotNull(exception, nameof(exception)); + return LogLevel.None; + } - if (exception is OperationCanceledException) - { - return LogLevel.None; - } + if (exception is JsonApiException and not FailedOperationException) + { + return LogLevel.Information; + } - if (exception is JsonApiException and not FailedOperationException) - { - return LogLevel.Information; - } + return LogLevel.Error; + } - return LogLevel.Error; - } + protected virtual string GetLogMessage(Exception exception) + { + ArgumentGuard.NotNull(exception, nameof(exception)); - protected virtual string GetLogMessage(Exception exception) - { - ArgumentGuard.NotNull(exception, nameof(exception)); + return exception is JsonApiException jsonApiException ? jsonApiException.GetSummary() : exception.Message; + } - return exception is JsonApiException jsonApiException ? jsonApiException.GetSummary() : exception.Message; - } + protected virtual IReadOnlyList CreateErrorResponse(Exception exception) + { + ArgumentGuard.NotNull(exception, nameof(exception)); - protected virtual IReadOnlyList CreateErrorResponse(Exception exception) - { - ArgumentGuard.NotNull(exception, nameof(exception)); - - IReadOnlyList errors = exception is JsonApiException jsonApiException ? jsonApiException.Errors : - exception is OperationCanceledException ? new ErrorObject((HttpStatusCode)499) - { - Title = "Request execution was canceled." - }.AsArray() : new ErrorObject(HttpStatusCode.InternalServerError) - { - Title = "An unhandled error occurred while processing this request.", - Detail = exception.Message - }.AsArray(); - - if (_options.IncludeExceptionStackTraceInErrors && exception is not InvalidModelStateException) + IReadOnlyList errors = exception is JsonApiException jsonApiException ? jsonApiException.Errors : + exception is OperationCanceledException ? new ErrorObject((HttpStatusCode)499) { - IncludeStackTraces(exception, errors); - } + Title = "Request execution was canceled." + }.AsArray() : new ErrorObject(HttpStatusCode.InternalServerError) + { + Title = "An unhandled error occurred while processing this request.", + Detail = exception.Message + }.AsArray(); - return errors; + if (_options.IncludeExceptionStackTraceInErrors && exception is not InvalidModelStateException) + { + IncludeStackTraces(exception, errors); } - private void IncludeStackTraces(Exception exception, IReadOnlyList errors) - { - string[] stackTraceLines = exception.ToString().Split(Environment.NewLine); + return errors; + } + + private void IncludeStackTraces(Exception exception, IReadOnlyList errors) + { + string[] stackTraceLines = exception.ToString().Split(Environment.NewLine); - if (stackTraceLines.Any()) + if (stackTraceLines.Any()) + { + foreach (ErrorObject error in errors) { - foreach (ErrorObject error in errors) - { - error.Meta ??= new Dictionary(); - error.Meta["StackTrace"] = stackTraceLines; - } + error.Meta ??= new Dictionary(); + error.Meta["StackTrace"] = stackTraceLines; } } } diff --git a/src/JsonApiDotNetCore/Middleware/FixedQueryFeature.cs b/src/JsonApiDotNetCore/Middleware/FixedQueryFeature.cs deleted file mode 100644 index 36105b5e88..0000000000 --- a/src/JsonApiDotNetCore/Middleware/FixedQueryFeature.cs +++ /dev/null @@ -1,90 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Features; -using Microsoft.Extensions.Primitives; - -namespace JsonApiDotNetCore.Middleware -{ - /// - /// Replacement implementation for the ASP.NET built-in , to workaround bug https://github.com/dotnet/aspnetcore/issues/33394. - /// This is identical to the built-in version, except it calls . - /// - internal sealed class FixedQueryFeature : IQueryFeature - { - // Lambda hoisted to static readonly field to improve inlining https://github.com/dotnet/roslyn/issues/13624 - private static readonly Func NullRequestFeature = _ => null; - - private FeatureReferences _features; - - private string? _original; - private IQueryCollection? _parsedValues; - - private IHttpRequestFeature HttpRequestFeature => _features.Fetch(ref _features.Cache, NullRequestFeature)!; - - /// - [AllowNull] - public IQueryCollection Query - { - get - { - if (IsFeatureCollectionNull()) - { - return _parsedValues ??= QueryCollection.Empty; - } - - string current = HttpRequestFeature.QueryString; - - if (_parsedValues == null || !string.Equals(_original, current, StringComparison.Ordinal)) - { - _original = current; - - Dictionary? result = FixedQueryHelpers.ParseNullableQuery(current); - - _parsedValues = result == null ? QueryCollection.Empty : new QueryCollection(result); - } - - return _parsedValues; - } - set - { - _parsedValues = value; - - if (!IsFeatureCollectionNull()) - { - if (value == null) - { - _original = string.Empty; - HttpRequestFeature.QueryString = string.Empty; - } - else - { - _original = QueryString.Create(_parsedValues!).ToString(); - HttpRequestFeature.QueryString = _original; - } - } - } - } - - /// - /// Initializes a new instance of . - /// - /// - /// The to initialize. - /// - public FixedQueryFeature(IFeatureCollection features) - { - ArgumentGuard.NotNull(features, nameof(features)); - - _features.Initalize(features); - } - - private bool IsFeatureCollectionNull() - { - // ReSharper disable once ConditionIsAlwaysTrueOrFalse - // Justification: This code was copied from the ASP.NET sources. A struct instance can be created without calling one of its constructors. - return _features.Collection == null; - } - } -} diff --git a/src/JsonApiDotNetCore/Middleware/FixedQueryHelpers.cs b/src/JsonApiDotNetCore/Middleware/FixedQueryHelpers.cs deleted file mode 100644 index 7c42d0aeea..0000000000 --- a/src/JsonApiDotNetCore/Middleware/FixedQueryHelpers.cs +++ /dev/null @@ -1,102 +0,0 @@ -using System; -using System.Collections.Generic; -using Microsoft.AspNetCore.WebUtilities; -using Microsoft.Extensions.Primitives; - -#pragma warning disable AV1008 // Class should not be static -#pragma warning disable AV1708 // Type name contains term that should be avoided -#pragma warning disable AV1130 // Return type in method signature should be a collection interface instead of a concrete type -#pragma warning disable AV1532 // Loop statement contains nested loop - -namespace JsonApiDotNetCore.Middleware -{ - /// - /// Replacement implementation for the ASP.NET built-in , to workaround bug https://github.com/dotnet/aspnetcore/issues/33394. - /// This is identical to the built-in version, except it properly un-escapes query string keys without a value. - /// - internal static class FixedQueryHelpers - { - /// - /// Parse a query string into its component key and value parts. - /// - /// - /// The raw query string value, with or without the leading '?'. - /// - /// - /// A collection of parsed keys and values, null if there are no entries. - /// - public static Dictionary? ParseNullableQuery(string queryString) - { - var accumulator = new KeyValueAccumulator(); - - if (string.IsNullOrEmpty(queryString) || queryString == "?") - { - return null; - } - - int scanIndex = 0; - - if (queryString[0] == '?') - { - scanIndex = 1; - } - - int textLength = queryString.Length; - int equalIndex = queryString.IndexOf('='); - - if (equalIndex == -1) - { - equalIndex = textLength; - } - - while (scanIndex < textLength) - { - int delimiterIndex = queryString.IndexOf('&', scanIndex); - - if (delimiterIndex == -1) - { - delimiterIndex = textLength; - } - - if (equalIndex < delimiterIndex) - { - while (scanIndex != equalIndex && char.IsWhiteSpace(queryString[scanIndex])) - { - ++scanIndex; - } - - string name = queryString.Substring(scanIndex, equalIndex - scanIndex); - string value = queryString.Substring(equalIndex + 1, delimiterIndex - equalIndex - 1); - accumulator.Append(Uri.UnescapeDataString(name.Replace('+', ' ')), Uri.UnescapeDataString(value.Replace('+', ' '))); - equalIndex = queryString.IndexOf('=', delimiterIndex); - - if (equalIndex == -1) - { - equalIndex = textLength; - } - } - else - { - if (delimiterIndex > scanIndex) - { - // original code: - // accumulator.Append(queryString.Substring(scanIndex, delimiterIndex - scanIndex), string.Empty); - - // replacement: - string name = queryString.Substring(scanIndex, delimiterIndex - scanIndex); - accumulator.Append(Uri.UnescapeDataString(name.Replace('+', ' ')), string.Empty); - } - } - - scanIndex = delimiterIndex + 1; - } - - if (!accumulator.HasValues) - { - return null; - } - - return accumulator.GetResults(); - } - } -} diff --git a/src/JsonApiDotNetCore/Middleware/HeaderConstants.cs b/src/JsonApiDotNetCore/Middleware/HeaderConstants.cs index c860204c52..43a5989d59 100644 --- a/src/JsonApiDotNetCore/Middleware/HeaderConstants.cs +++ b/src/JsonApiDotNetCore/Middleware/HeaderConstants.cs @@ -2,12 +2,11 @@ #pragma warning disable AV1008 // Class should not be static -namespace JsonApiDotNetCore.Middleware +namespace JsonApiDotNetCore.Middleware; + +[PublicAPI] +public static class HeaderConstants { - [PublicAPI] - public static class HeaderConstants - { - public const string MediaType = "application/vnd.api+json"; - public const string AtomicOperationsMediaType = MediaType + "; ext=\"https://jsonapi.org/ext/atomic\""; - } + public const string MediaType = "application/vnd.api+json"; + public const string AtomicOperationsMediaType = MediaType + "; ext=\"https://jsonapi.org/ext/atomic\""; } diff --git a/src/JsonApiDotNetCore/Middleware/HttpContextExtensions.cs b/src/JsonApiDotNetCore/Middleware/HttpContextExtensions.cs index a3d137fefd..b6785e8198 100644 --- a/src/JsonApiDotNetCore/Middleware/HttpContextExtensions.cs +++ b/src/JsonApiDotNetCore/Middleware/HttpContextExtensions.cs @@ -1,29 +1,28 @@ using JetBrains.Annotations; using Microsoft.AspNetCore.Http; -namespace JsonApiDotNetCore.Middleware +namespace JsonApiDotNetCore.Middleware; + +[PublicAPI] +public static class HttpContextExtensions { - [PublicAPI] - public static class HttpContextExtensions - { - private const string IsJsonApiRequestKey = "JsonApiDotNetCore_IsJsonApiRequest"; + private const string IsJsonApiRequestKey = "JsonApiDotNetCore_IsJsonApiRequest"; - /// - /// Indicates whether the currently executing HTTP request is being handled by JsonApiDotNetCore. - /// - public static bool IsJsonApiRequest(this HttpContext httpContext) - { - ArgumentGuard.NotNull(httpContext, nameof(httpContext)); + /// + /// Indicates whether the currently executing HTTP request is being handled by JsonApiDotNetCore. + /// + public static bool IsJsonApiRequest(this HttpContext httpContext) + { + ArgumentGuard.NotNull(httpContext, nameof(httpContext)); - string? value = httpContext.Items[IsJsonApiRequestKey] as string; - return value == bool.TrueString; - } + string? value = httpContext.Items[IsJsonApiRequestKey] as string; + return value == bool.TrueString; + } - internal static void RegisterJsonApiRequest(this HttpContext httpContext) - { - ArgumentGuard.NotNull(httpContext, nameof(httpContext)); + internal static void RegisterJsonApiRequest(this HttpContext httpContext) + { + ArgumentGuard.NotNull(httpContext, nameof(httpContext)); - httpContext.Items[IsJsonApiRequestKey] = bool.TrueString; - } + httpContext.Items[IsJsonApiRequestKey] = bool.TrueString; } } diff --git a/src/JsonApiDotNetCore/Middleware/IAsyncConvertEmptyActionResultFilter.cs b/src/JsonApiDotNetCore/Middleware/IAsyncConvertEmptyActionResultFilter.cs index 8e0f8b394e..87676657e5 100644 --- a/src/JsonApiDotNetCore/Middleware/IAsyncConvertEmptyActionResultFilter.cs +++ b/src/JsonApiDotNetCore/Middleware/IAsyncConvertEmptyActionResultFilter.cs @@ -1,20 +1,19 @@ using JetBrains.Annotations; using Microsoft.AspNetCore.Mvc.Filters; -namespace JsonApiDotNetCore.Middleware +namespace JsonApiDotNetCore.Middleware; + +/// +/// Converts action result without parameters into action result with null parameter. +/// +/// return NotFound(null) +/// ]]> +/// +/// This ensures our formatter is invoked, where we'll build a JSON:API compliant response. For details, see: +/// https://github.com/dotnet/aspnetcore/issues/16969 +/// +[PublicAPI] +public interface IAsyncConvertEmptyActionResultFilter : IAsyncAlwaysRunResultFilter { - /// - /// Converts action result without parameters into action result with null parameter. - /// - /// return NotFound(null) - /// ]]> - /// - /// This ensures our formatter is invoked, where we'll build a JSON:API compliant response. For details, see: - /// https://github.com/dotnet/aspnetcore/issues/16969 - /// - [PublicAPI] - public interface IAsyncConvertEmptyActionResultFilter : IAsyncAlwaysRunResultFilter - { - } } diff --git a/src/JsonApiDotNetCore/Middleware/IAsyncJsonApiExceptionFilter.cs b/src/JsonApiDotNetCore/Middleware/IAsyncJsonApiExceptionFilter.cs index 7ae6981c77..fb0cbb9b17 100644 --- a/src/JsonApiDotNetCore/Middleware/IAsyncJsonApiExceptionFilter.cs +++ b/src/JsonApiDotNetCore/Middleware/IAsyncJsonApiExceptionFilter.cs @@ -1,13 +1,12 @@ using JetBrains.Annotations; using Microsoft.AspNetCore.Mvc.Filters; -namespace JsonApiDotNetCore.Middleware +namespace JsonApiDotNetCore.Middleware; + +/// +/// Application-wide exception filter that invokes for JSON:API requests. +/// +[PublicAPI] +public interface IAsyncJsonApiExceptionFilter : IAsyncExceptionFilter { - /// - /// Application-wide exception filter that invokes for JSON:API requests. - /// - [PublicAPI] - public interface IAsyncJsonApiExceptionFilter : IAsyncExceptionFilter - { - } } diff --git a/src/JsonApiDotNetCore/Middleware/IAsyncQueryStringActionFilter.cs b/src/JsonApiDotNetCore/Middleware/IAsyncQueryStringActionFilter.cs index 4ad18fcf35..0c9cbfbb29 100644 --- a/src/JsonApiDotNetCore/Middleware/IAsyncQueryStringActionFilter.cs +++ b/src/JsonApiDotNetCore/Middleware/IAsyncQueryStringActionFilter.cs @@ -1,13 +1,12 @@ using JetBrains.Annotations; using Microsoft.AspNetCore.Mvc.Filters; -namespace JsonApiDotNetCore.Middleware +namespace JsonApiDotNetCore.Middleware; + +/// +/// Application-wide entry point for processing JSON:API request query strings. +/// +[PublicAPI] +public interface IAsyncQueryStringActionFilter : IAsyncActionFilter { - /// - /// Application-wide entry point for processing JSON:API request query strings. - /// - [PublicAPI] - public interface IAsyncQueryStringActionFilter : IAsyncActionFilter - { - } } diff --git a/src/JsonApiDotNetCore/Middleware/IControllerResourceMapping.cs b/src/JsonApiDotNetCore/Middleware/IControllerResourceMapping.cs index c15f3037bd..dc47971808 100644 --- a/src/JsonApiDotNetCore/Middleware/IControllerResourceMapping.cs +++ b/src/JsonApiDotNetCore/Middleware/IControllerResourceMapping.cs @@ -1,21 +1,19 @@ -using System; using JsonApiDotNetCore.Configuration; -namespace JsonApiDotNetCore.Middleware +namespace JsonApiDotNetCore.Middleware; + +/// +/// Registry of which resource type is associated with which controller. +/// +public interface IControllerResourceMapping { /// - /// Registry of which resource type is associated with which controller. + /// Gets the associated resource type for the provided controller type. /// - public interface IControllerResourceMapping - { - /// - /// Gets the associated resource type for the provided controller type. - /// - ResourceType? GetResourceTypeForController(Type? controllerType); + ResourceType? GetResourceTypeForController(Type? controllerType); - /// - /// Gets the associated controller name for the provided resource type. - /// - string? GetControllerNameForResourceType(ResourceType? resourceType); - } + /// + /// Gets the associated controller name for the provided resource type. + /// + string? GetControllerNameForResourceType(ResourceType? resourceType); } diff --git a/src/JsonApiDotNetCore/Middleware/IExceptionHandler.cs b/src/JsonApiDotNetCore/Middleware/IExceptionHandler.cs index a962d8cfdd..3b3f5307be 100644 --- a/src/JsonApiDotNetCore/Middleware/IExceptionHandler.cs +++ b/src/JsonApiDotNetCore/Middleware/IExceptionHandler.cs @@ -1,14 +1,11 @@ -using System; -using System.Collections.Generic; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Middleware +namespace JsonApiDotNetCore.Middleware; + +/// +/// Central place to handle all exceptions, such as log them and translate into error response. +/// +public interface IExceptionHandler { - /// - /// Central place to handle all exceptions, such as log them and translate into error response. - /// - public interface IExceptionHandler - { - IReadOnlyList HandleException(Exception exception); - } + IReadOnlyList HandleException(Exception exception); } diff --git a/src/JsonApiDotNetCore/Middleware/IJsonApiInputFormatter.cs b/src/JsonApiDotNetCore/Middleware/IJsonApiInputFormatter.cs index 268c0a2697..cb5fe76167 100644 --- a/src/JsonApiDotNetCore/Middleware/IJsonApiInputFormatter.cs +++ b/src/JsonApiDotNetCore/Middleware/IJsonApiInputFormatter.cs @@ -1,13 +1,12 @@ using JetBrains.Annotations; using Microsoft.AspNetCore.Mvc.Formatters; -namespace JsonApiDotNetCore.Middleware +namespace JsonApiDotNetCore.Middleware; + +/// +/// Application-wide entry point for reading JSON:API request bodies. +/// +[PublicAPI] +public interface IJsonApiInputFormatter : IInputFormatter { - /// - /// Application-wide entry point for reading JSON:API request bodies. - /// - [PublicAPI] - public interface IJsonApiInputFormatter : IInputFormatter - { - } } diff --git a/src/JsonApiDotNetCore/Middleware/IJsonApiOutputFormatter.cs b/src/JsonApiDotNetCore/Middleware/IJsonApiOutputFormatter.cs index 725accb03f..bc7213ebed 100644 --- a/src/JsonApiDotNetCore/Middleware/IJsonApiOutputFormatter.cs +++ b/src/JsonApiDotNetCore/Middleware/IJsonApiOutputFormatter.cs @@ -1,13 +1,12 @@ using JetBrains.Annotations; using Microsoft.AspNetCore.Mvc.Formatters; -namespace JsonApiDotNetCore.Middleware +namespace JsonApiDotNetCore.Middleware; + +/// +/// Application-wide entry point for writing JSON:API response bodies. +/// +[PublicAPI] +public interface IJsonApiOutputFormatter : IOutputFormatter { - /// - /// Application-wide entry point for writing JSON:API response bodies. - /// - [PublicAPI] - public interface IJsonApiOutputFormatter : IOutputFormatter - { - } } diff --git a/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs b/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs index c1d353851d..1d66bf517f 100644 --- a/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs +++ b/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs @@ -1,68 +1,67 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Middleware +namespace JsonApiDotNetCore.Middleware; + +/// +/// Metadata associated with the JSON:API request that is currently being processed. +/// +public interface IJsonApiRequest { /// - /// Metadata associated with the JSON:API request that is currently being processed. + /// Routing information, based on the path of the request URL. /// - public interface IJsonApiRequest - { - /// - /// Routing information, based on the path of the request URL. - /// - public EndpointKind Kind { get; } + public EndpointKind Kind { get; } - /// - /// The ID of the primary resource for this request. This would be null in "/blogs", "123" in "/blogs/123" or "/blogs/123/author". This is - /// null before and after processing operations in an atomic:operations request. - /// - string? PrimaryId { get; } + /// + /// The ID of the primary resource for this request. This would be null in "/blogs", "123" in "/blogs/123" or "/blogs/123/author". This is + /// null before and after processing operations in an atomic:operations request. + /// + string? PrimaryId { get; } - /// - /// The primary resource type for this request. This would be "blogs" in "/blogs", "/blogs/123" or "/blogs/123/author". This is null before and - /// after processing operations in an atomic:operations request. - /// - ResourceType? PrimaryResourceType { get; } + /// + /// The primary resource type for this request. This would be "blogs" in "/blogs", "/blogs/123" or "/blogs/123/author". This is null before and + /// after processing operations in an atomic:operations request. + /// + ResourceType? PrimaryResourceType { get; } - /// - /// The secondary resource type 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". This is null before and after processing operations in an atomic:operations - /// request. - /// - ResourceType? SecondaryResourceType { get; } + /// + /// The secondary resource type 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". This is null before and after processing operations in an atomic:operations + /// request. + /// + ResourceType? SecondaryResourceType { get; } - /// - /// The relationship for this request. This would be null in "/blogs", "/blogs/123" and "/blogs/123/unknownResource" or "author" in - /// "/blogs/123/author" and "/blogs/123/relationships/author". This is null before and after processing operations in an atomic:operations - /// request. - /// - RelationshipAttribute? Relationship { get; } + /// + /// The relationship for this request. This would be null in "/blogs", "/blogs/123" and "/blogs/123/unknownResource" or "author" in + /// "/blogs/123/author" and "/blogs/123/relationships/author". This is null before and after processing operations in an atomic:operations + /// request. + /// + RelationshipAttribute? Relationship { get; } - /// - /// Indicates whether this request targets a single resource or a collection of resources. - /// - bool IsCollection { 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 (resources and relationships), as opposed to applying changes. - /// - bool IsReadOnly { get; } + /// + /// Indicates whether this request targets only fetching of data (resources and relationships), as opposed to applying changes. + /// + bool IsReadOnly { get; } - /// - /// In case of a non-readonly request, this indicates the kind of write operation currently being processed. This is null when processing a - /// read-only operation, and before and after processing operations in an atomic:operations request. - /// - WriteOperationKind? WriteOperation { get; } + /// + /// In case of a non-readonly request, this indicates the kind of write operation currently being processed. This is null when processing a + /// read-only operation, and before and after processing operations in an atomic:operations request. + /// + WriteOperationKind? WriteOperation { get; } - /// - /// In case of an atomic:operations request, identifies the overarching transaction. - /// - string? TransactionId { get; } + /// + /// In case of an atomic:operations request, identifies the overarching transaction. + /// + string? TransactionId { get; } - /// - /// Performs a shallow copy. - /// - void CopyFrom(IJsonApiRequest other); - } + /// + /// Performs a shallow copy. + /// + void CopyFrom(IJsonApiRequest other); } diff --git a/src/JsonApiDotNetCore/Middleware/IJsonApiRoutingConvention.cs b/src/JsonApiDotNetCore/Middleware/IJsonApiRoutingConvention.cs index d5db22efa6..86b68a9b03 100644 --- a/src/JsonApiDotNetCore/Middleware/IJsonApiRoutingConvention.cs +++ b/src/JsonApiDotNetCore/Middleware/IJsonApiRoutingConvention.cs @@ -1,13 +1,12 @@ using JetBrains.Annotations; using Microsoft.AspNetCore.Mvc.ApplicationModels; -namespace JsonApiDotNetCore.Middleware +namespace JsonApiDotNetCore.Middleware; + +/// +/// Service for specifying which routing convention to use. This can be overridden to customize the relation between controllers and mapped routes. +/// +[PublicAPI] +public interface IJsonApiRoutingConvention : IApplicationModelConvention, IControllerResourceMapping { - /// - /// Service for specifying which routing convention to use. This can be overridden to customize the relation between controllers and mapped routes. - /// - [PublicAPI] - public interface IJsonApiRoutingConvention : IApplicationModelConvention, IControllerResourceMapping - { - } } diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiInputFormatter.cs b/src/JsonApiDotNetCore/Middleware/JsonApiInputFormatter.cs index 46153e6502..077f0573f0 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiInputFormatter.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiInputFormatter.cs @@ -1,31 +1,29 @@ -using System.Threading.Tasks; using JsonApiDotNetCore.Serialization.Request; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.Extensions.DependencyInjection; -namespace JsonApiDotNetCore.Middleware +namespace JsonApiDotNetCore.Middleware; + +/// +public sealed class JsonApiInputFormatter : IJsonApiInputFormatter { /// - public sealed class JsonApiInputFormatter : IJsonApiInputFormatter + public bool CanRead(InputFormatterContext context) { - /// - public bool CanRead(InputFormatterContext context) - { - ArgumentGuard.NotNull(context, nameof(context)); + ArgumentGuard.NotNull(context, nameof(context)); - return context.HttpContext.IsJsonApiRequest(); - } + return context.HttpContext.IsJsonApiRequest(); + } - /// - public async Task ReadAsync(InputFormatterContext context) - { - ArgumentGuard.NotNull(context, nameof(context)); + /// + public async Task ReadAsync(InputFormatterContext context) + { + ArgumentGuard.NotNull(context, nameof(context)); - var reader = context.HttpContext.RequestServices.GetRequiredService(); + var reader = context.HttpContext.RequestServices.GetRequiredService(); - object? model = await reader.ReadAsync(context.HttpContext.Request); + object? model = await reader.ReadAsync(context.HttpContext.Request); - return model == null ? await InputFormatterResult.NoValueAsync() : await InputFormatterResult.SuccessAsync(model); - } + return model == null ? await InputFormatterResult.NoValueAsync() : await InputFormatterResult.SuccessAsync(model); } } diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs b/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs index 934839e56e..94507750da 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs @@ -1,9 +1,5 @@ -using System; -using System.Linq; using System.Net; -using System.Net.Http; using System.Text.Json; -using System.Threading.Tasks; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Diagnostics; @@ -11,293 +7,282 @@ using JsonApiDotNetCore.Serialization.Objects; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Extensions; -using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Mvc.Controllers; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Logging; using Microsoft.Net.Http.Headers; -namespace JsonApiDotNetCore.Middleware +namespace JsonApiDotNetCore.Middleware; + +/// +/// Intercepts HTTP requests to populate injected instance for JSON:API requests. +/// +[PublicAPI] +public sealed class JsonApiMiddleware { - /// - /// Intercepts HTTP requests to populate injected instance for JSON:API requests. - /// - [PublicAPI] - public sealed class JsonApiMiddleware - { - private static readonly MediaTypeHeaderValue MediaType = MediaTypeHeaderValue.Parse(HeaderConstants.MediaType); - private static readonly MediaTypeHeaderValue AtomicOperationsMediaType = MediaTypeHeaderValue.Parse(HeaderConstants.AtomicOperationsMediaType); + private static readonly MediaTypeHeaderValue MediaType = MediaTypeHeaderValue.Parse(HeaderConstants.MediaType); + private static readonly MediaTypeHeaderValue AtomicOperationsMediaType = MediaTypeHeaderValue.Parse(HeaderConstants.AtomicOperationsMediaType); - private readonly RequestDelegate _next; + private readonly RequestDelegate _next; - public JsonApiMiddleware(RequestDelegate next, IHttpContextAccessor httpContextAccessor) - { - _next = next; + public JsonApiMiddleware(RequestDelegate next, IHttpContextAccessor httpContextAccessor) + { + _next = next; - var session = new AspNetCodeTimerSession(httpContextAccessor); - CodeTimingSessionManager.Capture(session); - } + var session = new AspNetCodeTimerSession(httpContextAccessor); + CodeTimingSessionManager.Capture(session); + } - public async Task InvokeAsync(HttpContext httpContext, IControllerResourceMapping controllerResourceMapping, IJsonApiOptions options, - IJsonApiRequest request, ILogger logger) + public async Task InvokeAsync(HttpContext httpContext, IControllerResourceMapping controllerResourceMapping, IJsonApiOptions options, + IJsonApiRequest request, ILogger logger) + { + ArgumentGuard.NotNull(httpContext, nameof(httpContext)); + ArgumentGuard.NotNull(controllerResourceMapping, nameof(controllerResourceMapping)); + ArgumentGuard.NotNull(options, nameof(options)); + ArgumentGuard.NotNull(request, nameof(request)); + ArgumentGuard.NotNull(logger, nameof(logger)); + + using (CodeTimingSessionManager.Current.Measure("JSON:API middleware")) { - ArgumentGuard.NotNull(httpContext, nameof(httpContext)); - ArgumentGuard.NotNull(controllerResourceMapping, nameof(controllerResourceMapping)); - ArgumentGuard.NotNull(options, nameof(options)); - ArgumentGuard.NotNull(request, nameof(request)); - ArgumentGuard.NotNull(logger, nameof(logger)); + if (!await ValidateIfMatchHeaderAsync(httpContext, options.SerializerWriteOptions)) + { + return; + } - using (CodeTimingSessionManager.Current.Measure("JSON:API middleware")) + RouteValueDictionary routeValues = httpContext.GetRouteData().Values; + ResourceType? primaryResourceType = CreatePrimaryResourceType(httpContext, controllerResourceMapping); + + if (primaryResourceType != null) { - if (!await ValidateIfMatchHeaderAsync(httpContext, options.SerializerWriteOptions)) + if (!await ValidateContentTypeHeaderAsync(HeaderConstants.MediaType, httpContext, options.SerializerWriteOptions) || + !await ValidateAcceptHeaderAsync(MediaType, httpContext, options.SerializerWriteOptions)) { return; } - RouteValueDictionary routeValues = httpContext.GetRouteData().Values; - ResourceType? primaryResourceType = CreatePrimaryResourceType(httpContext, controllerResourceMapping); - - if (primaryResourceType != null) - { - if (!await ValidateContentTypeHeaderAsync(HeaderConstants.MediaType, httpContext, options.SerializerWriteOptions) || - !await ValidateAcceptHeaderAsync(MediaType, httpContext, options.SerializerWriteOptions)) - { - return; - } + SetupResourceRequest((JsonApiRequest)request, primaryResourceType, routeValues, httpContext.Request); - SetupResourceRequest((JsonApiRequest)request, primaryResourceType, routeValues, httpContext.Request); - - httpContext.RegisterJsonApiRequest(); - } - else if (IsRouteForOperations(routeValues)) + httpContext.RegisterJsonApiRequest(); + } + else if (IsRouteForOperations(routeValues)) + { + if (!await ValidateContentTypeHeaderAsync(HeaderConstants.AtomicOperationsMediaType, httpContext, options.SerializerWriteOptions) || + !await ValidateAcceptHeaderAsync(AtomicOperationsMediaType, httpContext, options.SerializerWriteOptions)) { - if (!await ValidateContentTypeHeaderAsync(HeaderConstants.AtomicOperationsMediaType, httpContext, options.SerializerWriteOptions) || - !await ValidateAcceptHeaderAsync(AtomicOperationsMediaType, httpContext, options.SerializerWriteOptions)) - { - return; - } - - SetupOperationsRequest((JsonApiRequest)request, options, httpContext.Request); - - httpContext.RegisterJsonApiRequest(); + return; } - // Workaround for bug https://github.com/dotnet/aspnetcore/issues/33394 (fixed in .NET 6) - // Note that integration tests do not cover this, because the query string is short-circuited through WebApplicationFactory. - // To manually test, execute a GET request such as http://localhost:14140/api/v1/todoItems?include=owner&fields[people]= - // and observe it does not fail with 400 "Unknown query string parameter". - httpContext.Features.Set(new FixedQueryFeature(httpContext.Features)); + SetupOperationsRequest((JsonApiRequest)request, options, httpContext.Request); - using (CodeTimingSessionManager.Current.Measure("Subsequent middleware")) - { - await _next(httpContext); - } + httpContext.RegisterJsonApiRequest(); } - if (CodeTimingSessionManager.IsEnabled) + using (CodeTimingSessionManager.Current.Measure("Subsequent middleware")) { - string timingResults = CodeTimingSessionManager.Current.GetResults(); - string url = httpContext.Request.GetDisplayUrl(); - logger.LogInformation($"Measurement results for {httpContext.Request.Method} {url}:{Environment.NewLine}{timingResults}"); + await _next(httpContext); } } - private async Task ValidateIfMatchHeaderAsync(HttpContext httpContext, JsonSerializerOptions serializerOptions) + if (CodeTimingSessionManager.IsEnabled) + { + string timingResults = CodeTimingSessionManager.Current.GetResults(); + string url = httpContext.Request.GetDisplayUrl(); + logger.LogInformation($"Measurement results for {httpContext.Request.Method} {url}:{Environment.NewLine}{timingResults}"); + } + } + + private async Task ValidateIfMatchHeaderAsync(HttpContext httpContext, JsonSerializerOptions serializerOptions) + { + if (httpContext.Request.Headers.ContainsKey(HeaderNames.IfMatch)) { - if (httpContext.Request.Headers.ContainsKey(HeaderNames.IfMatch)) + await FlushResponseAsync(httpContext.Response, serializerOptions, new ErrorObject(HttpStatusCode.PreconditionFailed) { - await FlushResponseAsync(httpContext.Response, serializerOptions, new ErrorObject(HttpStatusCode.PreconditionFailed) + Title = "Detection of mid-air edit collisions using ETags is not supported.", + Source = new ErrorSource { - Title = "Detection of mid-air edit collisions using ETags is not supported.", - Source = new ErrorSource - { - Header = "If-Match" - } - }); - - return false; - } + Header = "If-Match" + } + }); - return true; + return false; } - private static ResourceType? CreatePrimaryResourceType(HttpContext httpContext, IControllerResourceMapping controllerResourceMapping) - { - Endpoint? endpoint = httpContext.GetEndpoint(); - var controllerActionDescriptor = endpoint?.Metadata.GetMetadata(); + return true; + } - return controllerActionDescriptor != null - ? controllerResourceMapping.GetResourceTypeForController(controllerActionDescriptor.ControllerTypeInfo) - : null; - } + private static ResourceType? CreatePrimaryResourceType(HttpContext httpContext, IControllerResourceMapping controllerResourceMapping) + { + Endpoint? endpoint = httpContext.GetEndpoint(); + var controllerActionDescriptor = endpoint?.Metadata.GetMetadata(); - private static async Task ValidateContentTypeHeaderAsync(string allowedContentType, HttpContext httpContext, - JsonSerializerOptions serializerOptions) - { - string? contentType = httpContext.Request.ContentType; + return controllerActionDescriptor != null + ? controllerResourceMapping.GetResourceTypeForController(controllerActionDescriptor.ControllerTypeInfo) + : null; + } + + private static async Task ValidateContentTypeHeaderAsync(string allowedContentType, HttpContext httpContext, JsonSerializerOptions serializerOptions) + { + string? contentType = httpContext.Request.ContentType; - // ReSharper disable once ConditionIsAlwaysTrueOrFalse - // Justification: Workaround for https://github.com/dotnet/aspnetcore/issues/32097 (fixed in .NET 6) - if (contentType != null && contentType != allowedContentType) + if (contentType != null && contentType != allowedContentType) + { + await FlushResponseAsync(httpContext.Response, serializerOptions, new ErrorObject(HttpStatusCode.UnsupportedMediaType) { - await FlushResponseAsync(httpContext.Response, serializerOptions, new ErrorObject(HttpStatusCode.UnsupportedMediaType) + Title = "The specified Content-Type header value is not supported.", + Detail = $"Please specify '{allowedContentType}' instead of '{contentType}' for the Content-Type header value.", + Source = new ErrorSource { - Title = "The specified Content-Type header value is not supported.", - Detail = $"Please specify '{allowedContentType}' instead of '{contentType}' for the Content-Type header value.", - Source = new ErrorSource - { - Header = "Content-Type" - } - }); - - return false; - } + Header = "Content-Type" + } + }); - return true; + return false; } - private static async Task ValidateAcceptHeaderAsync(MediaTypeHeaderValue allowedMediaTypeValue, HttpContext httpContext, - JsonSerializerOptions serializerOptions) - { - string[] acceptHeaders = httpContext.Request.Headers.GetCommaSeparatedValues("Accept"); + return true; + } - if (!acceptHeaders.Any()) - { - return true; - } + private static async Task ValidateAcceptHeaderAsync(MediaTypeHeaderValue allowedMediaTypeValue, HttpContext httpContext, + JsonSerializerOptions serializerOptions) + { + string[] acceptHeaders = httpContext.Request.Headers.GetCommaSeparatedValues("Accept"); + + if (!acceptHeaders.Any()) + { + return true; + } - bool seenCompatibleMediaType = false; + bool seenCompatibleMediaType = false; - foreach (string acceptHeader in acceptHeaders) + foreach (string acceptHeader in acceptHeaders) + { + if (MediaTypeHeaderValue.TryParse(acceptHeader, out MediaTypeHeaderValue? headerValue)) { - if (MediaTypeHeaderValue.TryParse(acceptHeader, out MediaTypeHeaderValue? headerValue)) + headerValue.Quality = null; + + if (headerValue.MediaType == "*/*" || headerValue.MediaType == "application/*") { - headerValue.Quality = null; - - if (headerValue.MediaType == "*/*" || headerValue.MediaType == "application/*") - { - seenCompatibleMediaType = true; - break; - } - - if (allowedMediaTypeValue.Equals(headerValue)) - { - seenCompatibleMediaType = true; - break; - } + seenCompatibleMediaType = true; + break; } - } - if (!seenCompatibleMediaType) - { - await FlushResponseAsync(httpContext.Response, serializerOptions, new ErrorObject(HttpStatusCode.NotAcceptable) + if (allowedMediaTypeValue.Equals(headerValue)) { - Title = "The specified Accept header value does not contain any supported media types.", - Detail = $"Please include '{allowedMediaTypeValue}' in the Accept header values.", - Source = new ErrorSource - { - Header = "Accept" - } - }); - - return false; + seenCompatibleMediaType = true; + break; + } } - - return true; } - private static async Task FlushResponseAsync(HttpResponse httpResponse, JsonSerializerOptions serializerOptions, ErrorObject error) + if (!seenCompatibleMediaType) { - httpResponse.ContentType = HeaderConstants.MediaType; - httpResponse.StatusCode = (int)error.StatusCode; - - var errorDocument = new Document + await FlushResponseAsync(httpContext.Response, serializerOptions, new ErrorObject(HttpStatusCode.NotAcceptable) { - Errors = error.AsList() - }; + Title = "The specified Accept header value does not contain any supported media types.", + Detail = $"Please include '{allowedMediaTypeValue}' in the Accept header values.", + Source = new ErrorSource + { + Header = "Accept" + } + }); - await JsonSerializer.SerializeAsync(httpResponse.Body, errorDocument, serializerOptions); - await httpResponse.Body.FlushAsync(); + return false; } - private static void SetupResourceRequest(JsonApiRequest request, ResourceType primaryResourceType, RouteValueDictionary routeValues, - HttpRequest httpRequest) - { - request.IsReadOnly = httpRequest.Method == HttpMethod.Get.Method || httpRequest.Method == HttpMethod.Head.Method; - request.PrimaryResourceType = primaryResourceType; - request.PrimaryId = GetPrimaryRequestId(routeValues); + return true; + } - string? relationshipName = GetRelationshipNameForSecondaryRequest(routeValues); + private static async Task FlushResponseAsync(HttpResponse httpResponse, JsonSerializerOptions serializerOptions, ErrorObject error) + { + httpResponse.ContentType = HeaderConstants.MediaType; + httpResponse.StatusCode = (int)error.StatusCode; - if (relationshipName != null) - { - request.Kind = IsRouteForRelationship(routeValues) ? EndpointKind.Relationship : EndpointKind.Secondary; + var errorDocument = new Document + { + Errors = error.AsList() + }; - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true + await JsonSerializer.SerializeAsync(httpResponse.Body, errorDocument, serializerOptions); + await httpResponse.Body.FlushAsync(); + } - request.WriteOperation = - httpRequest.Method == HttpMethod.Post.Method ? WriteOperationKind.AddToRelationship : - httpRequest.Method == HttpMethod.Patch.Method ? WriteOperationKind.SetRelationship : - httpRequest.Method == HttpMethod.Delete.Method ? WriteOperationKind.RemoveFromRelationship : null; + private static void SetupResourceRequest(JsonApiRequest request, ResourceType primaryResourceType, RouteValueDictionary routeValues, + HttpRequest httpRequest) + { + request.IsReadOnly = httpRequest.Method == HttpMethod.Get.Method || httpRequest.Method == HttpMethod.Head.Method; + request.PrimaryResourceType = primaryResourceType; + request.PrimaryId = GetPrimaryRequestId(routeValues); - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore + string? relationshipName = GetRelationshipNameForSecondaryRequest(routeValues); - RelationshipAttribute? requestRelationship = primaryResourceType.FindRelationshipByPublicName(relationshipName); + if (relationshipName != null) + { + request.Kind = IsRouteForRelationship(routeValues) ? EndpointKind.Relationship : EndpointKind.Secondary; - if (requestRelationship != null) - { - request.Relationship = requestRelationship; - request.SecondaryResourceType = requestRelationship.RightType; - } - } - else - { - request.Kind = EndpointKind.Primary; + // @formatter:wrap_chained_method_calls chop_always + // @formatter:keep_existing_linebreaks true - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true + request.WriteOperation = + httpRequest.Method == HttpMethod.Post.Method ? WriteOperationKind.AddToRelationship : + httpRequest.Method == HttpMethod.Patch.Method ? WriteOperationKind.SetRelationship : + httpRequest.Method == HttpMethod.Delete.Method ? WriteOperationKind.RemoveFromRelationship : null; - request.WriteOperation = - httpRequest.Method == HttpMethod.Post.Method ? WriteOperationKind.CreateResource : - httpRequest.Method == HttpMethod.Patch.Method ? WriteOperationKind.UpdateResource : - httpRequest.Method == HttpMethod.Delete.Method ? WriteOperationKind.DeleteResource : null; + // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_chained_method_calls restore - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore - } + RelationshipAttribute? requestRelationship = primaryResourceType.FindRelationshipByPublicName(relationshipName); - bool isGetAll = request.PrimaryId == null && request.IsReadOnly; - request.IsCollection = isGetAll || request.Relationship is HasManyAttribute; + if (requestRelationship != null) + { + request.Relationship = requestRelationship; + request.SecondaryResourceType = requestRelationship.RightType; + } } - - private static string? GetPrimaryRequestId(RouteValueDictionary routeValues) + else { - return routeValues.TryGetValue("id", out object? id) ? (string?)id : null; - } + request.Kind = EndpointKind.Primary; - private static string? GetRelationshipNameForSecondaryRequest(RouteValueDictionary routeValues) - { - return routeValues.TryGetValue("relationshipName", out object? routeValue) ? (string?)routeValue : null; - } + // @formatter:wrap_chained_method_calls chop_always + // @formatter:keep_existing_linebreaks true - private static bool IsRouteForRelationship(RouteValueDictionary routeValues) - { - string actionName = (string)routeValues["action"]!; - return actionName.EndsWith("Relationship", StringComparison.Ordinal); - } + request.WriteOperation = + httpRequest.Method == HttpMethod.Post.Method ? WriteOperationKind.CreateResource : + httpRequest.Method == HttpMethod.Patch.Method ? WriteOperationKind.UpdateResource : + httpRequest.Method == HttpMethod.Delete.Method ? WriteOperationKind.DeleteResource : null; - private static bool IsRouteForOperations(RouteValueDictionary routeValues) - { - string actionName = (string)routeValues["action"]!; - return actionName == "PostOperations"; + // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_chained_method_calls restore } - private static void SetupOperationsRequest(JsonApiRequest request, IJsonApiOptions options, HttpRequest httpRequest) - { - request.IsReadOnly = false; - request.Kind = EndpointKind.AtomicOperations; - } + bool isGetAll = request.PrimaryId == null && request.IsReadOnly; + request.IsCollection = isGetAll || request.Relationship is HasManyAttribute; + } + + private static string? GetPrimaryRequestId(RouteValueDictionary routeValues) + { + return routeValues.TryGetValue("id", out object? id) ? (string?)id : null; + } + + private static string? GetRelationshipNameForSecondaryRequest(RouteValueDictionary routeValues) + { + return routeValues.TryGetValue("relationshipName", out object? routeValue) ? (string?)routeValue : null; + } + + private static bool IsRouteForRelationship(RouteValueDictionary routeValues) + { + string actionName = (string)routeValues["action"]!; + return actionName.EndsWith("Relationship", StringComparison.Ordinal); + } + + private static bool IsRouteForOperations(RouteValueDictionary routeValues) + { + string actionName = (string)routeValues["action"]!; + return actionName == "PostOperations"; + } + + private static void SetupOperationsRequest(JsonApiRequest request, IJsonApiOptions options, HttpRequest httpRequest) + { + request.IsReadOnly = false; + request.Kind = EndpointKind.AtomicOperations; } } diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiOutputFormatter.cs b/src/JsonApiDotNetCore/Middleware/JsonApiOutputFormatter.cs index 93d531dc58..c32bb9d9f9 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiOutputFormatter.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiOutputFormatter.cs @@ -1,28 +1,26 @@ -using System.Threading.Tasks; using JsonApiDotNetCore.Serialization.Response; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.Extensions.DependencyInjection; -namespace JsonApiDotNetCore.Middleware +namespace JsonApiDotNetCore.Middleware; + +/// +public sealed class JsonApiOutputFormatter : IJsonApiOutputFormatter { /// - public sealed class JsonApiOutputFormatter : IJsonApiOutputFormatter + public bool CanWriteResult(OutputFormatterCanWriteContext context) { - /// - public bool CanWriteResult(OutputFormatterCanWriteContext context) - { - ArgumentGuard.NotNull(context, nameof(context)); + ArgumentGuard.NotNull(context, nameof(context)); - return context.HttpContext.IsJsonApiRequest(); - } + return context.HttpContext.IsJsonApiRequest(); + } - /// - public async Task WriteAsync(OutputFormatterWriteContext context) - { - ArgumentGuard.NotNull(context, nameof(context)); + /// + public async Task WriteAsync(OutputFormatterWriteContext context) + { + ArgumentGuard.NotNull(context, nameof(context)); - var writer = context.HttpContext.RequestServices.GetRequiredService(); - await writer.WriteAsync(context.Object, context.HttpContext); - } + var writer = context.HttpContext.RequestServices.GetRequiredService(); + await writer.WriteAsync(context.Object, context.HttpContext); } } diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs b/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs index 7801d059d3..a28c01fcd6 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs @@ -2,53 +2,52 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Middleware +namespace JsonApiDotNetCore.Middleware; + +/// +[PublicAPI] +public sealed class JsonApiRequest : IJsonApiRequest { /// - [PublicAPI] - public sealed class JsonApiRequest : IJsonApiRequest - { - /// - public EndpointKind Kind { get; set; } + public EndpointKind Kind { get; set; } - /// - public string? PrimaryId { get; set; } - - /// - public ResourceType? PrimaryResourceType { get; set; } + /// + public string? PrimaryId { get; set; } - /// - public ResourceType? SecondaryResourceType { get; set; } + /// + public ResourceType? PrimaryResourceType { get; set; } - /// - public RelationshipAttribute? Relationship { get; set; } + /// + public ResourceType? SecondaryResourceType { get; set; } - /// - public bool IsCollection { get; set; } + /// + public RelationshipAttribute? Relationship { get; set; } - /// - public bool IsReadOnly { get; set; } + /// + public bool IsCollection { get; set; } - /// - public WriteOperationKind? WriteOperation { get; set; } + /// + public bool IsReadOnly { get; set; } - /// - public string? TransactionId { get; set; } + /// + public WriteOperationKind? WriteOperation { get; set; } - /// - public void CopyFrom(IJsonApiRequest other) - { - ArgumentGuard.NotNull(other, nameof(other)); + /// + public string? TransactionId { get; set; } - Kind = other.Kind; - PrimaryId = other.PrimaryId; - PrimaryResourceType = other.PrimaryResourceType; - SecondaryResourceType = other.SecondaryResourceType; - Relationship = other.Relationship; - IsCollection = other.IsCollection; - IsReadOnly = other.IsReadOnly; - WriteOperation = other.WriteOperation; - TransactionId = other.TransactionId; - } + /// + public void CopyFrom(IJsonApiRequest other) + { + ArgumentGuard.NotNull(other, nameof(other)); + + Kind = other.Kind; + PrimaryId = other.PrimaryId; + PrimaryResourceType = other.PrimaryResourceType; + SecondaryResourceType = other.SecondaryResourceType; + Relationship = other.Relationship; + IsCollection = other.IsCollection; + IsReadOnly = other.IsReadOnly; + WriteOperation = other.WriteOperation; + TransactionId = other.TransactionId; } } diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs b/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs index bd56c41123..fe95d93446 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs @@ -1,6 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; using System.Reflection; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; @@ -11,185 +8,181 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ApplicationModels; -namespace JsonApiDotNetCore.Middleware +namespace JsonApiDotNetCore.Middleware; + +/// +/// The default routing convention registers the name of the resource as the route using the serializer naming convention. The default for this is 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. +/// +/// { } // => /someResources/relationship/relatedResource +/// +/// public class RandomNameController : JsonApiController { } // => /someResources/relationship/relatedResource +/// +/// // when using kebab-case naming convention: +/// public class SomeResourceController : JsonApiController { } // => /some-resources/relationship/related-resource +/// +/// public class SomeVeryCustomController : CoreJsonApiController { } // => /someVeryCustoms/relationship/relatedResource +/// ]]> +[PublicAPI] +public sealed class JsonApiRoutingConvention : IJsonApiRoutingConvention { - /// - /// The default routing convention registers the name of the resource as the route using the serializer naming convention. The default for this is 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. - /// - /// { } // => /someResources/relationship/relatedResource - /// - /// public class RandomNameController : JsonApiController { } // => /someResources/relationship/relatedResource - /// - /// // when using kebab-case naming convention: - /// public class SomeResourceController : JsonApiController { } // => /some-resources/relationship/related-resource - /// - /// public class SomeVeryCustomController : CoreJsonApiController { } // => /someVeryCustoms/relationship/relatedResource - /// ]]> - [PublicAPI] - public sealed class JsonApiRoutingConvention : IJsonApiRoutingConvention + private readonly IJsonApiOptions _options; + private readonly IResourceGraph _resourceGraph; + private readonly Dictionary _registeredControllerNameByTemplate = new(); + private readonly Dictionary _resourceTypePerControllerTypeMap = new(); + private readonly Dictionary _controllerPerResourceTypeMap = new(); + + public JsonApiRoutingConvention(IJsonApiOptions options, IResourceGraph resourceGraph) { - private readonly IJsonApiOptions _options; - private readonly IResourceGraph _resourceGraph; - private readonly Dictionary _registeredControllerNameByTemplate = new(); - private readonly Dictionary _resourceTypePerControllerTypeMap = new(); - private readonly Dictionary _controllerPerResourceTypeMap = new(); + ArgumentGuard.NotNull(options, nameof(options)); + ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); - public JsonApiRoutingConvention(IJsonApiOptions options, IResourceGraph resourceGraph) - { - ArgumentGuard.NotNull(options, nameof(options)); - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); + _options = options; + _resourceGraph = resourceGraph; + } - _options = options; - _resourceGraph = resourceGraph; - } + /// + public ResourceType? GetResourceTypeForController(Type? controllerType) + { + return controllerType != null && _resourceTypePerControllerTypeMap.TryGetValue(controllerType, out ResourceType? resourceType) ? resourceType : null; + } - /// - public ResourceType? GetResourceTypeForController(Type? controllerType) - { - return controllerType != null && _resourceTypePerControllerTypeMap.TryGetValue(controllerType, out ResourceType? resourceType) - ? resourceType - : null; - } + /// + public string? GetControllerNameForResourceType(ResourceType? resourceType) + { + return resourceType != null && _controllerPerResourceTypeMap.TryGetValue(resourceType, out ControllerModel? controllerModel) + ? controllerModel.ControllerName + : null; + } - /// - public string? GetControllerNameForResourceType(ResourceType? resourceType) - { - return resourceType != null && _controllerPerResourceTypeMap.TryGetValue(resourceType, out ControllerModel? controllerModel) - ? controllerModel.ControllerName - : null; - } + /// + public void Apply(ApplicationModel application) + { + ArgumentGuard.NotNull(application, nameof(application)); - /// - public void Apply(ApplicationModel application) + foreach (ControllerModel controller in application.Controllers) { - ArgumentGuard.NotNull(application, nameof(application)); + bool isOperationsController = IsOperationsController(controller.ControllerType); - foreach (ControllerModel controller in application.Controllers) + if (!isOperationsController) { - bool isOperationsController = IsOperationsController(controller.ControllerType); + Type? resourceClrType = ExtractResourceClrTypeFromController(controller.ControllerType); - if (!isOperationsController) + if (resourceClrType != null) { - Type? resourceClrType = ExtractResourceClrTypeFromController(controller.ControllerType); + ResourceType? resourceType = _resourceGraph.FindResourceType(resourceClrType); - if (resourceClrType != null) + if (resourceType != null) { - ResourceType? resourceType = _resourceGraph.FindResourceType(resourceClrType); - - if (resourceType != null) - { - if (_controllerPerResourceTypeMap.ContainsKey(resourceType)) - { - throw new InvalidConfigurationException($"Multiple controllers found for resource type '{resourceType}'."); - } - - _resourceTypePerControllerTypeMap.Add(controller.ControllerType, resourceType); - _controllerPerResourceTypeMap.Add(resourceType, controller); - } - else + if (_controllerPerResourceTypeMap.ContainsKey(resourceType)) { - throw new InvalidConfigurationException($"Controller '{controller.ControllerType}' depends on " + - $"resource type '{resourceClrType}', which does not exist in the resource graph."); + throw new InvalidConfigurationException($"Multiple controllers found for resource type '{resourceType}'."); } + + _resourceTypePerControllerTypeMap.Add(controller.ControllerType, resourceType); + _controllerPerResourceTypeMap.Add(resourceType, controller); + } + else + { + throw new InvalidConfigurationException($"Controller '{controller.ControllerType}' depends on " + + $"resource type '{resourceClrType}', which does not exist in the resource graph."); } } + } - if (!IsRoutingConventionEnabled(controller)) - { - continue; - } + if (!IsRoutingConventionEnabled(controller)) + { + continue; + } - string template = TemplateFromResource(controller) ?? TemplateFromController(controller); + string template = TemplateFromResource(controller) ?? TemplateFromController(controller); - if (_registeredControllerNameByTemplate.ContainsKey(template)) - { - throw new InvalidConfigurationException( - $"Cannot register '{controller.ControllerType.FullName}' for template '{template}' because '{_registeredControllerNameByTemplate[template]}' was already registered for this template."); - } + if (_registeredControllerNameByTemplate.ContainsKey(template)) + { + throw new InvalidConfigurationException( + $"Cannot register '{controller.ControllerType.FullName}' for template '{template}' because '{_registeredControllerNameByTemplate[template]}' was already registered for this template."); + } - _registeredControllerNameByTemplate.Add(template, controller.ControllerType.FullName!); + _registeredControllerNameByTemplate.Add(template, controller.ControllerType.FullName!); - controller.Selectors[0].AttributeRouteModel = new AttributeRouteModel - { - Template = template - }; - } + controller.Selectors[0].AttributeRouteModel = new AttributeRouteModel + { + Template = template + }; } + } - private bool IsRoutingConventionEnabled(ControllerModel controller) + private bool IsRoutingConventionEnabled(ControllerModel controller) + { + return controller.ControllerType.IsSubclassOf(typeof(CoreJsonApiController)) && + controller.ControllerType.GetCustomAttribute(true) == null; + } + + /// + /// Derives a template from the resource type, and checks if this template was already registered. + /// + private string? TemplateFromResource(ControllerModel model) + { + if (_resourceTypePerControllerTypeMap.TryGetValue(model.ControllerType, out ResourceType? resourceType)) { - return controller.ControllerType.IsSubclassOf(typeof(CoreJsonApiController)) && - controller.ControllerType.GetCustomAttribute(true) == null; + return $"{_options.Namespace}/{resourceType.PublicName}"; } - /// - /// Derives a template from the resource type, and checks if this template was already registered. - /// - private string? TemplateFromResource(ControllerModel model) - { - if (_resourceTypePerControllerTypeMap.TryGetValue(model.ControllerType, out ResourceType? resourceType)) - { - return $"{_options.Namespace}/{resourceType.PublicName}"; - } + return null; + } - return null; - } + /// + /// Derives a template from the controller name, and checks if this template was already registered. + /// + private string TemplateFromController(ControllerModel model) + { + string controllerName = _options.SerializerOptions.PropertyNamingPolicy == null + ? model.ControllerName + : _options.SerializerOptions.PropertyNamingPolicy.ConvertName(model.ControllerName); - /// - /// Derives a template from the controller name, and checks if this template was already registered. - /// - private string TemplateFromController(ControllerModel model) - { - string controllerName = _options.SerializerOptions.PropertyNamingPolicy == null - ? model.ControllerName - : _options.SerializerOptions.PropertyNamingPolicy.ConvertName(model.ControllerName); + return $"{_options.Namespace}/{controllerName}"; + } - return $"{_options.Namespace}/{controllerName}"; - } + /// + /// Determines the resource type associated to a controller by inspecting generic type arguments in its inheritance tree. + /// + private Type? ExtractResourceClrTypeFromController(Type controllerType) + { + Type aspNetControllerType = typeof(ControllerBase); + Type coreControllerType = typeof(CoreJsonApiController); + Type baseControllerUnboundType = typeof(BaseJsonApiController<,>); + Type? currentType = controllerType; - /// - /// Determines the resource associated to a controller by inspecting generic arguments in its inheritance tree. - /// - private Type? ExtractResourceClrTypeFromController(Type type) + while (!currentType.IsGenericType || currentType.GetGenericTypeDefinition() != baseControllerUnboundType) { - Type aspNetControllerType = typeof(ControllerBase); - Type coreControllerType = typeof(CoreJsonApiController); - Type baseControllerType = typeof(BaseJsonApiController<,>); - Type? currentType = type; + Type? nextBaseType = currentType.BaseType; - while (!currentType.IsGenericType || currentType.GetGenericTypeDefinition() != baseControllerType) + if ((nextBaseType == aspNetControllerType || nextBaseType == coreControllerType) && currentType.IsGenericType) { - Type? nextBaseType = currentType.BaseType; + Type? resourceClrType = currentType.GetGenericArguments().FirstOrDefault(typeArgument => typeArgument.IsOrImplementsInterface()); - if ((nextBaseType == aspNetControllerType || nextBaseType == coreControllerType) && currentType.IsGenericType) + if (resourceClrType != null) { - Type? resourceClrType = currentType.GetGenericArguments() - .FirstOrDefault(typeArgument => typeArgument.IsOrImplementsInterface()); - - if (resourceClrType != null) - { - return resourceClrType; - } + return resourceClrType; } + } - currentType = nextBaseType; + currentType = nextBaseType; - if (currentType == null) - { - break; - } + if (currentType == null) + { + break; } - - return currentType?.GetGenericArguments().First(); } - private static bool IsOperationsController(Type type) - { - Type baseControllerType = typeof(BaseJsonApiOperationsController); - return baseControllerType.IsAssignableFrom(type); - } + return currentType?.GetGenericArguments().First(); + } + + private static bool IsOperationsController(Type type) + { + Type baseControllerType = typeof(BaseJsonApiOperationsController); + return baseControllerType.IsAssignableFrom(type); } } diff --git a/src/JsonApiDotNetCore/Middleware/TraceLogWriter.cs b/src/JsonApiDotNetCore/Middleware/TraceLogWriter.cs index 03e38d827d..e00bbd50a8 100644 --- a/src/JsonApiDotNetCore/Middleware/TraceLogWriter.cs +++ b/src/JsonApiDotNetCore/Middleware/TraceLogWriter.cs @@ -1,4 +1,3 @@ -using System; using System.Reflection; using System.Runtime.CompilerServices; using System.Text; @@ -7,149 +6,148 @@ using System.Text.Json.Serialization; using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCore.Middleware +namespace JsonApiDotNetCore.Middleware; + +internal abstract class TraceLogWriter { - internal abstract class TraceLogWriter + protected static readonly JsonSerializerOptions SerializerOptions = new() { - protected static readonly JsonSerializerOptions SerializerOptions = new() - { - WriteIndented = true, - Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, - ReferenceHandler = ReferenceHandler.Preserve - }; - } + WriteIndented = true, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + ReferenceHandler = ReferenceHandler.Preserve + }; +} - internal sealed class TraceLogWriter : TraceLogWriter - { - private readonly ILogger _logger; +internal sealed class TraceLogWriter : TraceLogWriter +{ + private readonly ILogger _logger; - private bool IsEnabled => _logger.IsEnabled(LogLevel.Trace); + private bool IsEnabled => _logger.IsEnabled(LogLevel.Trace); - public TraceLogWriter(ILoggerFactory loggerFactory) - { - _logger = loggerFactory.CreateLogger(typeof(T)); - } + public TraceLogWriter(ILoggerFactory loggerFactory) + { + _logger = loggerFactory.CreateLogger(typeof(T)); + } - public void LogMethodStart(object? parameters = null, [CallerMemberName] string memberName = "") + public void LogMethodStart(object? parameters = null, [CallerMemberName] string memberName = "") + { + if (IsEnabled) { - if (IsEnabled) - { - string message = FormatMessage(memberName, parameters); - WriteMessageToLog(message); - } + string message = FormatMessage(memberName, parameters); + WriteMessageToLog(message); } + } - public void LogMessage(Func messageFactory) + public void LogMessage(Func messageFactory) + { + if (IsEnabled) { - if (IsEnabled) - { - string message = messageFactory(); - WriteMessageToLog(message); - } + string message = messageFactory(); + WriteMessageToLog(message); } + } - private static string FormatMessage(string memberName, object? parameters) - { - var builder = new StringBuilder(); + 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(')'); + builder.Append("Entering "); + builder.Append(memberName); + builder.Append('('); + WriteProperties(builder, parameters); + builder.Append(')'); - return builder.ToString(); - } + return builder.ToString(); + } - private static void WriteProperties(StringBuilder builder, object? propertyContainer) + private static void WriteProperties(StringBuilder builder, object? propertyContainer) + { + if (propertyContainer != null) { - if (propertyContainer != null) - { - bool isFirstMember = true; + bool isFirstMember = true; - foreach (PropertyInfo property in propertyContainer.GetType().GetProperties()) + foreach (PropertyInfo property in propertyContainer.GetType().GetProperties()) + { + if (isFirstMember) { - if (isFirstMember) - { - isFirstMember = false; - } - else - { - builder.Append(", "); - } - - WriteProperty(builder, property, propertyContainer); + 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(": "); + private static void WriteProperty(StringBuilder builder, PropertyInfo property, object instance) + { + builder.Append(property.Name); + builder.Append(": "); - object? value = property.GetValue(instance); + object? 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); - } + if (value == null) + { + builder.Append("null"); } - - private static void WriteObject(StringBuilder builder, object value) + else if (value is string stringValue) { - if (HasToStringOverload(value.GetType())) - { - builder.Append(value); - } - else - { - string text = SerializeObject(value); - builder.Append(text); - } + builder.Append('"'); + builder.Append(stringValue); + builder.Append('"'); } - - private static bool HasToStringOverload(Type? type) + else { - if (type != null) - { - MethodInfo? toStringMethod = type.GetMethod("ToString", Array.Empty()); - - if (toStringMethod != null && toStringMethod.DeclaringType != typeof(object)) - { - return true; - } - } + WriteObject(builder, value); + } + } - return false; + private static void WriteObject(StringBuilder builder, object value) + { + if (HasToStringOverload(value.GetType())) + { + builder.Append(value); } + else + { + string text = SerializeObject(value); + builder.Append(text); + } + } - private static string SerializeObject(object value) + private static bool HasToStringOverload(Type? type) + { + if (type != null) { - try - { - return JsonSerializer.Serialize(value, SerializerOptions); - } - catch (JsonException) + MethodInfo? toStringMethod = type.GetMethod("ToString", Array.Empty()); + + if (toStringMethod != null && toStringMethod.DeclaringType != typeof(object)) { - // Never crash as a result of logging, this is best-effort only. - return "object"; + return true; } } - private void WriteMessageToLog(string message) + return false; + } + + private static string SerializeObject(object value) + { + try + { + return JsonSerializer.Serialize(value, SerializerOptions); + } + catch (JsonException) { - _logger.LogTrace(message); + // 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/Middleware/WriteOperationKind.cs b/src/JsonApiDotNetCore/Middleware/WriteOperationKind.cs index aeec6c1cab..b5c553d533 100644 --- a/src/JsonApiDotNetCore/Middleware/WriteOperationKind.cs +++ b/src/JsonApiDotNetCore/Middleware/WriteOperationKind.cs @@ -1,40 +1,39 @@ -namespace JsonApiDotNetCore.Middleware +namespace JsonApiDotNetCore.Middleware; + +/// +/// Lists the functional write operations, originating from a POST/PATCH/DELETE request against a single resource/relationship or a POST request against +/// a list of operations. +/// +public enum WriteOperationKind { /// - /// Lists the functional write operations, originating from a POST/PATCH/DELETE request against a single resource/relationship or a POST request against - /// a list of operations. + /// Create a new resource with attributes, relationships or both. /// - public enum WriteOperationKind - { - /// - /// Create a new resource with attributes, relationships or both. - /// - CreateResource, + CreateResource, - /// - /// Update the attributes and/or relationships of an existing resource. Only the values of sent attributes are replaced. And only the values of sent - /// relationships are replaced. - /// - UpdateResource, + /// + /// Update the attributes and/or relationships of an existing resource. Only the values of sent attributes are replaced. And only the values of sent + /// relationships are replaced. + /// + UpdateResource, - /// - /// Delete an existing resource. - /// - DeleteResource, + /// + /// Delete an existing resource. + /// + DeleteResource, - /// - /// Perform a complete replacement of a relationship on an existing resource. - /// - SetRelationship, + /// + /// Perform a complete replacement of a relationship on an existing resource. + /// + SetRelationship, - /// - /// Add resources to a to-many relationship. - /// - AddToRelationship, + /// + /// Add resources to a to-many relationship. + /// + AddToRelationship, - /// - /// Remove resources from a to-many relationship. - /// - RemoveFromRelationship - } + /// + /// Remove resources from a to-many relationship. + /// + RemoveFromRelationship } diff --git a/src/JsonApiDotNetCore/ObjectExtensions.cs b/src/JsonApiDotNetCore/ObjectExtensions.cs index e0f4ce6af7..bd42993803 100644 --- a/src/JsonApiDotNetCore/ObjectExtensions.cs +++ b/src/JsonApiDotNetCore/ObjectExtensions.cs @@ -1,38 +1,35 @@ -using System.Collections.Generic; - #pragma warning disable AV1130 // Return type in method signature should be a collection interface instead of a concrete type -namespace JsonApiDotNetCore +namespace JsonApiDotNetCore; + +internal static class ObjectExtensions { - internal static class ObjectExtensions + public static IEnumerable AsEnumerable(this T element) { - public static IEnumerable AsEnumerable(this T element) - { - yield return element; - } + yield return element; + } - public static T[] AsArray(this T element) + public static T[] AsArray(this T element) + { + return new[] { - return new[] - { - element - }; - } + element + }; + } - public static List AsList(this T element) + public static List AsList(this T element) + { + return new List { - return new List - { - element - }; - } + element + }; + } - public static HashSet AsHashSet(this T element) + public static HashSet AsHashSet(this T element) + { + return new HashSet { - return new HashSet - { - element - }; - } + element + }; } } diff --git a/src/JsonApiDotNetCore/Queries/ExpressionInScope.cs b/src/JsonApiDotNetCore/Queries/ExpressionInScope.cs index 5b73deee0a..37d40f127b 100644 --- a/src/JsonApiDotNetCore/Queries/ExpressionInScope.cs +++ b/src/JsonApiDotNetCore/Queries/ExpressionInScope.cs @@ -2,28 +2,27 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries.Expressions; -namespace JsonApiDotNetCore.Queries +namespace JsonApiDotNetCore.Queries; + +/// +/// Represents an expression coming from query string. The scope determines at which depth in the to apply its expression. +/// +[PublicAPI] +public class ExpressionInScope { - /// - /// Represents an expression coming from query string. The scope determines at which depth in the to apply its expression. - /// - [PublicAPI] - public class ExpressionInScope - { - public ResourceFieldChainExpression? Scope { get; } - public QueryExpression Expression { get; } + public ResourceFieldChainExpression? Scope { get; } + public QueryExpression Expression { get; } - public ExpressionInScope(ResourceFieldChainExpression? scope, QueryExpression expression) - { - ArgumentGuard.NotNull(expression, nameof(expression)); + public ExpressionInScope(ResourceFieldChainExpression? scope, QueryExpression expression) + { + ArgumentGuard.NotNull(expression, nameof(expression)); - Scope = scope; - Expression = expression; - } + Scope = scope; + Expression = expression; + } - public override string ToString() - { - return $"{Scope} => {Expression}"; - } + public override string ToString() + { + return $"{Scope} => {Expression}"; } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/AnyExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/AnyExpression.cs index 0682cc64a0..be8a22abee 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/AnyExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/AnyExpression.cs @@ -1,82 +1,79 @@ -using System; using System.Collections.Immutable; -using System.Linq; using System.Text; using JetBrains.Annotations; using JsonApiDotNetCore.Queries.Internal.Parsing; -namespace JsonApiDotNetCore.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions; + +/// +/// Represents the "any" filter function, resulting from text such as: any(name,'Jack','Joe') +/// +[PublicAPI] +public class AnyExpression : FilterExpression { - /// - /// Represents the "any" filter function, resulting from text such as: any(name,'Jack','Joe') - /// - [PublicAPI] - public class AnyExpression : FilterExpression + public ResourceFieldChainExpression TargetAttribute { get; } + public IImmutableSet Constants { get; } + + public AnyExpression(ResourceFieldChainExpression targetAttribute, IImmutableSet constants) { - public ResourceFieldChainExpression TargetAttribute { get; } - public IImmutableSet Constants { get; } + ArgumentGuard.NotNull(targetAttribute, nameof(targetAttribute)); + ArgumentGuard.NotNull(constants, nameof(constants)); - public AnyExpression(ResourceFieldChainExpression targetAttribute, IImmutableSet constants) + if (constants.Count < 2) { - ArgumentGuard.NotNull(targetAttribute, nameof(targetAttribute)); - ArgumentGuard.NotNull(constants, nameof(constants)); + throw new ArgumentException("At least two constants are required.", nameof(constants)); + } - if (constants.Count < 2) - { - throw new ArgumentException("At least two constants are required.", nameof(constants)); - } + TargetAttribute = targetAttribute; + Constants = constants; + } - TargetAttribute = targetAttribute; - Constants = constants; - } + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitAny(this, argument); + } - public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) - { - return visitor.VisitAny(this, argument); - } + public override string ToString() + { + var builder = new StringBuilder(); - 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()).OrderBy(value => value))); + builder.Append(')'); - builder.Append(Keywords.Any); - builder.Append('('); - builder.Append(TargetAttribute); - builder.Append(','); - builder.Append(string.Join(",", Constants.Select(constant => constant.ToString()).OrderBy(value => value))); - builder.Append(')'); + return builder.ToString(); + } - return builder.ToString(); + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) + { + return true; } - public override bool Equals(object? obj) + if (obj is null || GetType() != obj.GetType()) { - if (ReferenceEquals(this, obj)) - { - return true; - } + return false; + } - if (obj is null || GetType() != obj.GetType()) - { - return false; - } + var other = (AnyExpression)obj; - var other = (AnyExpression)obj; + return TargetAttribute.Equals(other.TargetAttribute) && Constants.SetEquals(other.Constants); + } - return TargetAttribute.Equals(other.TargetAttribute) && Constants.SetEquals(other.Constants); - } + public override int GetHashCode() + { + var hashCode = new HashCode(); + hashCode.Add(TargetAttribute); - public override int GetHashCode() + foreach (LiteralConstantExpression constant in Constants) { - var hashCode = new HashCode(); - hashCode.Add(TargetAttribute); - - foreach (LiteralConstantExpression constant in Constants) - { - hashCode.Add(constant); - } - - return hashCode.ToHashCode(); + hashCode.Add(constant); } + + return hashCode.ToHashCode(); } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/ComparisonExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/ComparisonExpression.cs index 77d0281063..4c858bd743 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/ComparisonExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/ComparisonExpression.cs @@ -1,59 +1,57 @@ -using System; using Humanizer; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions; + +/// +/// Represents a comparison filter function, resulting from text such as: equals(name,'Joe') +/// +[PublicAPI] +public class ComparisonExpression : FilterExpression { - /// - /// Represents a comparison filter function, resulting from text such as: equals(name,'Joe') - /// - [PublicAPI] - public class ComparisonExpression : FilterExpression + public ComparisonOperator Operator { get; } + public QueryExpression Left { get; } + public QueryExpression Right { get; } + + public ComparisonExpression(ComparisonOperator @operator, QueryExpression left, QueryExpression right) { - public ComparisonOperator Operator { get; } - public QueryExpression Left { get; } - public QueryExpression Right { get; } + ArgumentGuard.NotNull(left, nameof(left)); + ArgumentGuard.NotNull(right, nameof(right)); - public ComparisonExpression(ComparisonOperator @operator, QueryExpression left, QueryExpression right) - { - ArgumentGuard.NotNull(left, nameof(left)); - ArgumentGuard.NotNull(right, nameof(right)); + Operator = @operator; + Left = left; + Right = right; + } - Operator = @operator; - Left = left; - Right = right; - } + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitComparison(this, argument); + } - public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) - { - return visitor.VisitComparison(this, argument); - } + public override string ToString() + { + return $"{Operator.ToString().Camelize()}({Left},{Right})"; + } - public override string ToString() + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) { - return $"{Operator.ToString().Camelize()}({Left},{Right})"; + return true; } - public override bool Equals(object? obj) + if (obj is null || GetType() != obj.GetType()) { - if (ReferenceEquals(this, obj)) - { - return true; - } - - if (obj is null || GetType() != obj.GetType()) - { - return false; - } + return false; + } - var other = (ComparisonExpression)obj; + var other = (ComparisonExpression)obj; - return Operator == other.Operator && Left.Equals(other.Left) && Right.Equals(other.Right); - } + return Operator == other.Operator && Left.Equals(other.Left) && Right.Equals(other.Right); + } - public override int GetHashCode() - { - return HashCode.Combine(Operator, Left, Right); - } + public override int GetHashCode() + { + return HashCode.Combine(Operator, Left, Right); } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/ComparisonOperator.cs b/src/JsonApiDotNetCore/Queries/Expressions/ComparisonOperator.cs index fbe12f1b0c..ca06d02254 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/ComparisonOperator.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/ComparisonOperator.cs @@ -1,11 +1,10 @@ -namespace JsonApiDotNetCore.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions; + +public enum ComparisonOperator { - public enum ComparisonOperator - { - Equals, - GreaterThan, - GreaterOrEqual, - LessThan, - LessOrEqual - } + Equals, + GreaterThan, + GreaterOrEqual, + LessThan, + LessOrEqual } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/CountExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/CountExpression.cs index e60867c63d..1bab96eaab 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/CountExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/CountExpression.cs @@ -1,53 +1,52 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Queries.Internal.Parsing; -namespace JsonApiDotNetCore.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions; + +/// +/// Represents the "count" function, resulting from text such as: count(articles) +/// +[PublicAPI] +public class CountExpression : FunctionExpression { - /// - /// Represents the "count" function, resulting from text such as: count(articles) - /// - [PublicAPI] - public class CountExpression : FunctionExpression + public ResourceFieldChainExpression TargetCollection { get; } + + public CountExpression(ResourceFieldChainExpression targetCollection) { - public ResourceFieldChainExpression TargetCollection { get; } + ArgumentGuard.NotNull(targetCollection, nameof(targetCollection)); - public CountExpression(ResourceFieldChainExpression targetCollection) - { - ArgumentGuard.NotNull(targetCollection, nameof(targetCollection)); + TargetCollection = targetCollection; + } - TargetCollection = targetCollection; - } + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitCount(this, argument); + } - public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) - { - return visitor.VisitCount(this, argument); - } + public override string ToString() + { + return $"{Keywords.Count}({TargetCollection})"; + } - public override string ToString() + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) { - return $"{Keywords.Count}({TargetCollection})"; + return true; } - public override bool Equals(object? obj) + if (obj is null || GetType() != obj.GetType()) { - if (ReferenceEquals(this, obj)) - { - return true; - } - - if (obj is null || GetType() != obj.GetType()) - { - return false; - } + return false; + } - var other = (CountExpression)obj; + var other = (CountExpression)obj; - return TargetCollection.Equals(other.TargetCollection); - } + return TargetCollection.Equals(other.TargetCollection); + } - public override int GetHashCode() - { - return TargetCollection.GetHashCode(); - } + public override int GetHashCode() + { + return TargetCollection.GetHashCode(); } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/FilterExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/FilterExpression.cs index 1ed0ed1de0..513fbf9ac8 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/FilterExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/FilterExpression.cs @@ -1,9 +1,8 @@ -namespace JsonApiDotNetCore.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions; + +/// +/// Represents the base type for filter functions that return a boolean value. +/// +public abstract class FilterExpression : FunctionExpression { - /// - /// Represents the base type for filter functions that return a boolean value. - /// - public abstract class FilterExpression : FunctionExpression - { - } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/FunctionExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/FunctionExpression.cs index 6d7990a16a..2e0b76b255 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/FunctionExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/FunctionExpression.cs @@ -1,9 +1,8 @@ -namespace JsonApiDotNetCore.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions; + +/// +/// Represents the base type for functions that return a value. +/// +public abstract class FunctionExpression : QueryExpression { - /// - /// Represents the base type for functions that return a value. - /// - public abstract class FunctionExpression : QueryExpression - { - } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/HasExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/HasExpression.cs index 7e38acd447..d1376a3091 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/HasExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/HasExpression.cs @@ -1,70 +1,68 @@ -using System; using System.Text; using JetBrains.Annotations; using JsonApiDotNetCore.Queries.Internal.Parsing; -namespace JsonApiDotNetCore.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions; + +/// +/// Represents the "has" filter function, resulting from text such as: has(articles) or has(articles,equals(isHidden,'false')) +/// +[PublicAPI] +public class HasExpression : FilterExpression { - /// - /// Represents the "has" filter function, resulting from text such as: has(articles) or has(articles,equals(isHidden,'false')) - /// - [PublicAPI] - public class HasExpression : FilterExpression + public ResourceFieldChainExpression TargetCollection { get; } + public FilterExpression? Filter { get; } + + public HasExpression(ResourceFieldChainExpression targetCollection, FilterExpression? filter) { - public ResourceFieldChainExpression TargetCollection { get; } - public FilterExpression? Filter { get; } + ArgumentGuard.NotNull(targetCollection, nameof(targetCollection)); - public HasExpression(ResourceFieldChainExpression targetCollection, FilterExpression? filter) - { - ArgumentGuard.NotNull(targetCollection, nameof(targetCollection)); + TargetCollection = targetCollection; + Filter = filter; + } - TargetCollection = targetCollection; - Filter = filter; - } + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitHas(this, argument); + } - public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) - { - return visitor.VisitHas(this, argument); - } + public override string ToString() + { + var builder = new StringBuilder(); + builder.Append(Keywords.Has); + builder.Append('('); + builder.Append(TargetCollection); - public override string ToString() + if (Filter != null) { - var builder = new StringBuilder(); - builder.Append(Keywords.Has); - builder.Append('('); - builder.Append(TargetCollection); + builder.Append(','); + builder.Append(Filter); + } - if (Filter != null) - { - builder.Append(','); - builder.Append(Filter); - } + builder.Append(')'); - builder.Append(')'); + return builder.ToString(); + } - return builder.ToString(); + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) + { + return true; } - public override bool Equals(object? obj) + if (obj is null || GetType() != obj.GetType()) { - if (ReferenceEquals(this, obj)) - { - return true; - } - - if (obj is null || GetType() != obj.GetType()) - { - return false; - } + return false; + } - var other = (HasExpression)obj; + var other = (HasExpression)obj; - return TargetCollection.Equals(other.TargetCollection) && Equals(Filter, other.Filter); - } + return TargetCollection.Equals(other.TargetCollection) && Equals(Filter, other.Filter); + } - public override int GetHashCode() - { - return HashCode.Combine(TargetCollection, Filter); - } + public override int GetHashCode() + { + return HashCode.Combine(TargetCollection, Filter); } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/IdentifierExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/IdentifierExpression.cs index 974c4892de..133af83ad4 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/IdentifierExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/IdentifierExpression.cs @@ -1,9 +1,8 @@ -namespace JsonApiDotNetCore.Queries.Expressions +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 { - /// - /// 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 index 19bc92e5d6..edf44e1f14 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/IncludeChainConverter.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/IncludeChainConverter.cs @@ -1,169 +1,165 @@ -using System; -using System.Collections.Generic; using System.Collections.Immutable; -using System.Linq; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions; + +/// +/// Converts includes between tree and chain formats. Exists for backwards compatibility, subject to be removed in the future. +/// +internal sealed class IncludeChainConverter { /// - /// Converts includes between tree and chain formats. Exists for backwards compatibility, subject to be removed in the future. + /// Converts a tree of inclusions into a set of relationship chains. /// - internal sealed class IncludeChainConverter + /// + /// Input tree: Output chains: + /// Blog, + /// Article -> Revisions -> Author + /// ]]> + /// + public IReadOnlyCollection GetRelationshipChains(IncludeExpression include) { - /// - /// Converts a tree of inclusions into a set of relationship chains. - /// - /// - /// Input tree: Output chains: - /// Blog, - /// Article -> Revisions -> Author - /// ]]> - /// - public IReadOnlyCollection GetRelationshipChains(IncludeExpression include) + ArgumentGuard.NotNull(include, nameof(include)); + + if (!include.Elements.Any()) { - ArgumentGuard.NotNull(include, nameof(include)); + return Array.Empty(); + } - if (!include.Elements.Any()) - { - return Array.Empty(); - } + var converter = new IncludeToChainsConverter(); + converter.Visit(include, null); - var converter = new IncludeToChainsConverter(); - converter.Visit(include, null); + return converter.Chains; + } - return converter.Chains; - } + /// + /// Converts a set of relationship chains into a tree of inclusions. + /// + /// + /// Input chains: Blog, + /// Article -> Revisions -> Author + /// ]]> Output tree: + /// + /// + public IncludeExpression FromRelationshipChains(IEnumerable chains) + { + ArgumentGuard.NotNull(chains, nameof(chains)); - /// - /// Converts a set of relationship chains into a tree of inclusions. - /// - /// - /// Input chains: Blog, - /// Article -> Revisions -> Author - /// ]]> Output tree: - /// - /// - public IncludeExpression FromRelationshipChains(IEnumerable chains) - { - ArgumentGuard.NotNull(chains, nameof(chains)); + IImmutableSet elements = ConvertChainsToElements(chains); + return elements.Any() ? new IncludeExpression(elements) : IncludeExpression.Empty; + } - IImmutableSet elements = ConvertChainsToElements(chains); - return elements.Any() ? new IncludeExpression(elements) : IncludeExpression.Empty; - } + private static IImmutableSet ConvertChainsToElements(IEnumerable chains) + { + var rootNode = new MutableIncludeNode(null!); - private static IImmutableSet ConvertChainsToElements(IEnumerable chains) + foreach (ResourceFieldChainExpression chain in chains) { - var rootNode = new MutableIncludeNode(null!); + ConvertChainToElement(chain, rootNode); + } + + return rootNode.Children.Values.Select(child => child.ToExpression()).ToImmutableHashSet(); + } - foreach (ResourceFieldChainExpression chain in chains) + private static void ConvertChainToElement(ResourceFieldChainExpression chain, MutableIncludeNode rootNode) + { + MutableIncludeNode currentNode = rootNode; + + foreach (RelationshipAttribute relationship in chain.Fields.OfType()) + { + if (!currentNode.Children.ContainsKey(relationship)) { - ConvertChainToElement(chain, rootNode); + currentNode.Children[relationship] = new MutableIncludeNode(relationship); } - return rootNode.Children.Values.Select(child => child.ToExpression()).ToImmutableHashSet(); + currentNode = currentNode.Children[relationship]; } + } - private static void ConvertChainToElement(ResourceFieldChainExpression chain, MutableIncludeNode rootNode) - { - MutableIncludeNode currentNode = rootNode; + private sealed class IncludeToChainsConverter : QueryExpressionVisitor + { + private readonly Stack _parentRelationshipStack = new(); - foreach (RelationshipAttribute relationship in chain.Fields.OfType()) - { - if (!currentNode.Children.ContainsKey(relationship)) - { - currentNode.Children[relationship] = new MutableIncludeNode(relationship); - } + public List Chains { get; } = new(); - currentNode = currentNode.Children[relationship]; + public override object? VisitInclude(IncludeExpression expression, object? argument) + { + foreach (IncludeElementExpression element in expression.Elements) + { + Visit(element, null); } + + return null; } - private sealed class IncludeToChainsConverter : QueryExpressionVisitor + public override object? VisitIncludeElement(IncludeElementExpression expression, object? argument) { - private readonly Stack _parentRelationshipStack = new(); - - public List Chains { get; } = new(); - - public override object? VisitInclude(IncludeExpression expression, object? argument) + if (!expression.Children.Any()) { - foreach (IncludeElementExpression element in expression.Elements) - { - Visit(element, null); - } - - return null; + FlushChain(expression); } - - public override object? VisitIncludeElement(IncludeElementExpression expression, object? argument) + else { - if (!expression.Children.Any()) - { - FlushChain(expression); - } - else - { - _parentRelationshipStack.Push(expression.Relationship); - - foreach (IncludeElementExpression child in expression.Children) - { - Visit(child, null); - } + _parentRelationshipStack.Push(expression.Relationship); - _parentRelationshipStack.Pop(); + foreach (IncludeElementExpression child in expression.Children) + { + Visit(child, null); } - return null; + _parentRelationshipStack.Pop(); } - private void FlushChain(IncludeElementExpression expression) - { - ImmutableArray.Builder chainBuilder = - ImmutableArray.CreateBuilder(_parentRelationshipStack.Count + 1); + return null; + } - chainBuilder.AddRange(_parentRelationshipStack.Reverse()); - chainBuilder.Add(expression.Relationship); + private void FlushChain(IncludeElementExpression expression) + { + ImmutableArray.Builder chainBuilder = + ImmutableArray.CreateBuilder(_parentRelationshipStack.Count + 1); - Chains.Add(new ResourceFieldChainExpression(chainBuilder.ToImmutable())); - } + chainBuilder.AddRange(_parentRelationshipStack.Reverse()); + chainBuilder.Add(expression.Relationship); + + Chains.Add(new ResourceFieldChainExpression(chainBuilder.ToImmutable())); } + } - private sealed class MutableIncludeNode - { - private readonly RelationshipAttribute _relationship; + private sealed class MutableIncludeNode + { + private readonly RelationshipAttribute _relationship; - public IDictionary Children { get; } = new Dictionary(); + public IDictionary Children { get; } = new Dictionary(); - public MutableIncludeNode(RelationshipAttribute relationship) - { - _relationship = relationship; - } + public MutableIncludeNode(RelationshipAttribute relationship) + { + _relationship = relationship; + } - public IncludeElementExpression ToExpression() - { - IImmutableSet elementChildren = Children.Values.Select(child => child.ToExpression()).ToImmutableHashSet(); - return new IncludeElementExpression(_relationship, elementChildren); - } + public IncludeElementExpression ToExpression() + { + IImmutableSet elementChildren = Children.Values.Select(child => child.ToExpression()).ToImmutableHashSet(); + return new IncludeElementExpression(_relationship, elementChildren); } } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/IncludeElementExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/IncludeElementExpression.cs index 8cc148376b..cd95ef61a3 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/IncludeElementExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/IncludeElementExpression.cs @@ -1,83 +1,80 @@ -using System; using System.Collections.Immutable; -using System.Linq; using System.Text; using JetBrains.Annotations; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions; + +/// +/// Represents an element in . +/// +[PublicAPI] +public class IncludeElementExpression : QueryExpression { - /// - /// Represents an element in . - /// - [PublicAPI] - public class IncludeElementExpression : QueryExpression + public RelationshipAttribute Relationship { get; } + public IImmutableSet Children { get; } + + public IncludeElementExpression(RelationshipAttribute relationship) + : this(relationship, ImmutableHashSet.Empty) { - public RelationshipAttribute Relationship { get; } - public IImmutableSet Children { get; } + } - public IncludeElementExpression(RelationshipAttribute relationship) - : this(relationship, ImmutableHashSet.Empty) - { - } + public IncludeElementExpression(RelationshipAttribute relationship, IImmutableSet children) + { + ArgumentGuard.NotNull(relationship, nameof(relationship)); + ArgumentGuard.NotNull(children, nameof(children)); - public IncludeElementExpression(RelationshipAttribute relationship, IImmutableSet children) - { - ArgumentGuard.NotNull(relationship, nameof(relationship)); - ArgumentGuard.NotNull(children, nameof(children)); + Relationship = relationship; + Children = children; + } - Relationship = relationship; - Children = children; - } + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitIncludeElement(this, argument); + } - public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) - { - return visitor.VisitIncludeElement(this, argument); - } + public override string ToString() + { + var builder = new StringBuilder(); + builder.Append(Relationship); - public override string ToString() + if (Children.Any()) { - var builder = new StringBuilder(); - builder.Append(Relationship); + builder.Append('{'); + builder.Append(string.Join(",", Children.Select(child => child.ToString()).OrderBy(name => name))); + builder.Append('}'); + } - if (Children.Any()) - { - builder.Append('{'); - builder.Append(string.Join(",", Children.Select(child => child.ToString()).OrderBy(name => name))); - builder.Append('}'); - } + return builder.ToString(); + } - return builder.ToString(); + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) + { + return true; } - public override bool Equals(object? obj) + if (obj is null || GetType() != obj.GetType()) { - if (ReferenceEquals(this, obj)) - { - return true; - } + return false; + } - if (obj is null || GetType() != obj.GetType()) - { - return false; - } + var other = (IncludeElementExpression)obj; - var other = (IncludeElementExpression)obj; + return Relationship.Equals(other.Relationship) && Children.SetEquals(other.Children); + } - return Relationship.Equals(other.Relationship) && Children.SetEquals(other.Children); - } + public override int GetHashCode() + { + var hashCode = new HashCode(); + hashCode.Add(Relationship); - public override int GetHashCode() + foreach (IncludeElementExpression child in Children) { - var hashCode = new HashCode(); - hashCode.Add(Relationship); - - foreach (IncludeElementExpression child in Children) - { - hashCode.Add(child); - } - - return hashCode.ToHashCode(); + hashCode.Add(child); } + + return hashCode.ToHashCode(); } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/IncludeExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/IncludeExpression.cs index 3c5cbeb333..4597570ba3 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/IncludeExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/IncludeExpression.cs @@ -1,73 +1,69 @@ -using System; -using System.Collections.Generic; using System.Collections.Immutable; -using System.Linq; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions; + +/// +/// Represents an inclusion tree, resulting from text such as: owner,articles.revisions +/// +[PublicAPI] +public class IncludeExpression : QueryExpression { - /// - /// Represents an inclusion tree, resulting from text such as: owner,articles.revisions - /// - [PublicAPI] - public class IncludeExpression : QueryExpression - { - private static readonly IncludeChainConverter IncludeChainConverter = new(); + private static readonly IncludeChainConverter IncludeChainConverter = new(); - public static readonly IncludeExpression Empty = new(); + public static readonly IncludeExpression Empty = new(); - public IImmutableSet Elements { get; } + public IImmutableSet Elements { get; } - public IncludeExpression(IImmutableSet elements) - { - ArgumentGuard.NotNullNorEmpty(elements, nameof(elements)); + public IncludeExpression(IImmutableSet elements) + { + ArgumentGuard.NotNullNorEmpty(elements, nameof(elements)); - Elements = elements; - } + Elements = elements; + } - private IncludeExpression() - { - Elements = ImmutableHashSet.Empty; - } + private IncludeExpression() + { + Elements = ImmutableHashSet.Empty; + } - public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) - { - return visitor.VisitInclude(this, argument); - } + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitInclude(this, argument); + } + + public override string ToString() + { + IReadOnlyCollection chains = IncludeChainConverter.GetRelationshipChains(this); + return string.Join(",", chains.Select(child => child.ToString()).OrderBy(name => name)); + } - public override string ToString() + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) { - IReadOnlyCollection chains = IncludeChainConverter.GetRelationshipChains(this); - return string.Join(",", chains.Select(child => child.ToString()).OrderBy(name => name)); + return true; } - public override bool Equals(object? obj) + if (obj is null || GetType() != obj.GetType()) { - if (ReferenceEquals(this, obj)) - { - return true; - } + return false; + } - if (obj is null || GetType() != obj.GetType()) - { - return false; - } + var other = (IncludeExpression)obj; - var other = (IncludeExpression)obj; + return Elements.SetEquals(other.Elements); + } - return Elements.SetEquals(other.Elements); - } + public override int GetHashCode() + { + var hashCode = new HashCode(); - public override int GetHashCode() + foreach (IncludeElementExpression element in Elements) { - var hashCode = new HashCode(); - - foreach (IncludeElementExpression element in Elements) - { - hashCode.Add(element); - } - - return hashCode.ToHashCode(); + hashCode.Add(element); } + + return hashCode.ToHashCode(); } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/LiteralConstantExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/LiteralConstantExpression.cs index 962914d83d..bc5b4790ac 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/LiteralConstantExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/LiteralConstantExpression.cs @@ -1,53 +1,52 @@ using JetBrains.Annotations; -namespace JsonApiDotNetCore.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions; + +/// +/// Represents a non-null constant value, resulting from text such as: equals(firstName,'Jack') +/// +[PublicAPI] +public class LiteralConstantExpression : IdentifierExpression { - /// - /// Represents a non-null constant value, resulting from text such as: equals(firstName,'Jack') - /// - [PublicAPI] - public class LiteralConstantExpression : IdentifierExpression + public string Value { get; } + + public LiteralConstantExpression(string text) { - public string Value { get; } + ArgumentGuard.NotNull(text, nameof(text)); - public LiteralConstantExpression(string text) - { - ArgumentGuard.NotNull(text, nameof(text)); + Value = text; + } - Value = text; - } + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitLiteralConstant(this, argument); + } - public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) - { - return visitor.VisitLiteralConstant(this, argument); - } + public override string ToString() + { + string value = Value.Replace("\'", "\'\'"); + return $"'{value}'"; + } - public override string ToString() + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) { - string value = Value.Replace("\'", "\'\'"); - return $"'{value}'"; + return true; } - public override bool Equals(object? obj) + if (obj is null || GetType() != obj.GetType()) { - if (ReferenceEquals(this, obj)) - { - return true; - } - - if (obj is null || GetType() != obj.GetType()) - { - return false; - } + return false; + } - var other = (LiteralConstantExpression)obj; + var other = (LiteralConstantExpression)obj; - return Value == other.Value; - } + return Value == other.Value; + } - public override int GetHashCode() - { - return Value.GetHashCode(); - } + public override int GetHashCode() + { + return Value.GetHashCode(); } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/LogicalExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/LogicalExpression.cs index 6ab16b885f..0308c04de2 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/LogicalExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/LogicalExpression.cs @@ -1,93 +1,90 @@ -using System; using System.Collections.Immutable; -using System.Linq; using System.Text; using Humanizer; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions; + +/// +/// Represents a logical filter function, resulting from text such as: and(equals(title,'Work'),has(articles)) +/// +[PublicAPI] +public class LogicalExpression : FilterExpression { - /// - /// Represents a logical filter function, resulting from text such as: and(equals(title,'Work'),has(articles)) - /// - [PublicAPI] - public class LogicalExpression : FilterExpression + public LogicalOperator Operator { get; } + public IImmutableList Terms { get; } + + public LogicalExpression(LogicalOperator @operator, params FilterExpression[] terms) + : this(@operator, terms.ToImmutableArray()) { - public LogicalOperator Operator { get; } - public IImmutableList Terms { get; } + } - public LogicalExpression(LogicalOperator @operator, params FilterExpression[] terms) - : this(@operator, terms.ToImmutableArray()) - { - } + public LogicalExpression(LogicalOperator @operator, IImmutableList terms) + { + ArgumentGuard.NotNull(terms, nameof(terms)); - public LogicalExpression(LogicalOperator @operator, IImmutableList terms) + if (terms.Count < 2) { - ArgumentGuard.NotNull(terms, nameof(terms)); + throw new ArgumentException("At least two terms are required.", nameof(terms)); + } - if (terms.Count < 2) - { - throw new ArgumentException("At least two terms are required.", nameof(terms)); - } + Operator = @operator; + Terms = terms; + } - Operator = @operator; - Terms = terms; - } + public static FilterExpression? Compose(LogicalOperator @operator, params FilterExpression?[] filters) + { + ArgumentGuard.NotNull(filters, nameof(filters)); - public static FilterExpression? Compose(LogicalOperator @operator, params FilterExpression?[] filters) - { - ArgumentGuard.NotNull(filters, nameof(filters)); + ImmutableArray terms = filters.WhereNotNull().ToImmutableArray(); - ImmutableArray terms = filters.WhereNotNull().ToImmutableArray(); + return terms.Length > 1 ? new LogicalExpression(@operator, terms) : terms.FirstOrDefault(); + } - return terms.Length > 1 ? new LogicalExpression(@operator, terms) : terms.FirstOrDefault(); - } + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitLogical(this, argument); + } - public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) - { - return visitor.VisitLogical(this, argument); - } + public override string ToString() + { + var builder = new StringBuilder(); - 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(')'); - builder.Append(Operator.ToString().Camelize()); - builder.Append('('); - builder.Append(string.Join(",", Terms.Select(term => term.ToString()))); - builder.Append(')'); + return builder.ToString(); + } - return builder.ToString(); + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) + { + return true; } - public override bool Equals(object? obj) + if (obj is null || GetType() != obj.GetType()) { - if (ReferenceEquals(this, obj)) - { - return true; - } + return false; + } - if (obj is null || GetType() != obj.GetType()) - { - return false; - } + var other = (LogicalExpression)obj; - var other = (LogicalExpression)obj; + return Operator == other.Operator && Terms.SequenceEqual(other.Terms); + } - return Operator == other.Operator && Terms.SequenceEqual(other.Terms); - } + public override int GetHashCode() + { + var hashCode = new HashCode(); + hashCode.Add(Operator); - public override int GetHashCode() + foreach (QueryExpression term in Terms) { - var hashCode = new HashCode(); - hashCode.Add(Operator); - - foreach (QueryExpression term in Terms) - { - hashCode.Add(term); - } - - return hashCode.ToHashCode(); + hashCode.Add(term); } + + return hashCode.ToHashCode(); } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/LogicalOperator.cs b/src/JsonApiDotNetCore/Queries/Expressions/LogicalOperator.cs index 3514820f86..c6c838c47e 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/LogicalOperator.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/LogicalOperator.cs @@ -1,8 +1,7 @@ -namespace JsonApiDotNetCore.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions; + +public enum LogicalOperator { - public enum LogicalOperator - { - And, - Or - } + And, + Or } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/MatchTextExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/MatchTextExpression.cs index 2f82548e1d..f528790fd3 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/MatchTextExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/MatchTextExpression.cs @@ -1,67 +1,65 @@ -using System; using System.Text; using Humanizer; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions; + +/// +/// Represents a text-matching filter function, resulting from text such as: startsWith(name,'A') +/// +[PublicAPI] +public class MatchTextExpression : FilterExpression { - /// - /// Represents a text-matching filter function, resulting from text such as: startsWith(name,'A') - /// - [PublicAPI] - public class MatchTextExpression : FilterExpression + public ResourceFieldChainExpression TargetAttribute { get; } + public LiteralConstantExpression TextValue { get; } + public TextMatchKind MatchKind { get; } + + public MatchTextExpression(ResourceFieldChainExpression targetAttribute, LiteralConstantExpression textValue, TextMatchKind matchKind) { - public ResourceFieldChainExpression TargetAttribute { get; } - public LiteralConstantExpression TextValue { get; } - public TextMatchKind MatchKind { get; } + ArgumentGuard.NotNull(targetAttribute, nameof(targetAttribute)); + ArgumentGuard.NotNull(textValue, nameof(textValue)); - public MatchTextExpression(ResourceFieldChainExpression targetAttribute, LiteralConstantExpression textValue, TextMatchKind matchKind) - { - ArgumentGuard.NotNull(targetAttribute, nameof(targetAttribute)); - ArgumentGuard.NotNull(textValue, nameof(textValue)); + TargetAttribute = targetAttribute; + TextValue = textValue; + MatchKind = matchKind; + } - TargetAttribute = targetAttribute; - TextValue = textValue; - MatchKind = matchKind; - } + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitMatchText(this, argument); + } - public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) - { - return visitor.VisitMatchText(this, argument); - } + public override string ToString() + { + var builder = new StringBuilder(); - public override string ToString() - { - var builder = new StringBuilder(); + builder.Append(MatchKind.ToString().Camelize()); + builder.Append('('); + builder.Append(string.Join(",", TargetAttribute, TextValue)); + builder.Append(')'); - builder.Append(MatchKind.ToString().Camelize()); - builder.Append('('); - builder.Append(string.Join(",", TargetAttribute, TextValue)); - builder.Append(')'); + return builder.ToString(); + } - return builder.ToString(); + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) + { + return true; } - public override bool Equals(object? obj) + if (obj is null || GetType() != obj.GetType()) { - if (ReferenceEquals(this, obj)) - { - return true; - } - - if (obj is null || GetType() != obj.GetType()) - { - return false; - } + return false; + } - var other = (MatchTextExpression)obj; + var other = (MatchTextExpression)obj; - return TargetAttribute.Equals(other.TargetAttribute) && TextValue.Equals(other.TextValue) && MatchKind == other.MatchKind; - } + return TargetAttribute.Equals(other.TargetAttribute) && TextValue.Equals(other.TextValue) && MatchKind == other.MatchKind; + } - public override int GetHashCode() - { - return HashCode.Combine(TargetAttribute, TextValue, MatchKind); - } + public override int GetHashCode() + { + return HashCode.Combine(TargetAttribute, TextValue, MatchKind); } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/NotExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/NotExpression.cs index f7a34aa212..c6d13f9876 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/NotExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/NotExpression.cs @@ -1,53 +1,52 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Queries.Internal.Parsing; -namespace JsonApiDotNetCore.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions; + +/// +/// Represents the "not" filter function, resulting from text such as: not(equals(title,'Work')) +/// +[PublicAPI] +public class NotExpression : FilterExpression { - /// - /// Represents the "not" filter function, resulting from text such as: not(equals(title,'Work')) - /// - [PublicAPI] - public class NotExpression : FilterExpression + public FilterExpression Child { get; } + + public NotExpression(FilterExpression child) { - public FilterExpression Child { get; } + ArgumentGuard.NotNull(child, nameof(child)); - public NotExpression(FilterExpression child) - { - ArgumentGuard.NotNull(child, nameof(child)); + Child = child; + } - Child = child; - } + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitNot(this, argument); + } - public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) - { - return visitor.VisitNot(this, argument); - } + public override string ToString() + { + return $"{Keywords.Not}({Child})"; + } - public override string ToString() + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) { - return $"{Keywords.Not}({Child})"; + return true; } - public override bool Equals(object? obj) + if (obj is null || GetType() != obj.GetType()) { - if (ReferenceEquals(this, obj)) - { - return true; - } - - if (obj is null || GetType() != obj.GetType()) - { - return false; - } + return false; + } - var other = (NotExpression)obj; + var other = (NotExpression)obj; - return Child.Equals(other.Child); - } + return Child.Equals(other.Child); + } - public override int GetHashCode() - { - return Child.GetHashCode(); - } + public override int GetHashCode() + { + return Child.GetHashCode(); } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/NullConstantExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/NullConstantExpression.cs index 172a900884..bd9d3cf268 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/NullConstantExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/NullConstantExpression.cs @@ -1,49 +1,47 @@ -using System; using JetBrains.Annotations; using JsonApiDotNetCore.Queries.Internal.Parsing; -namespace JsonApiDotNetCore.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions; + +/// +/// Represents the constant null, resulting from text such as: equals(lastName,null) +/// +[PublicAPI] +public class NullConstantExpression : IdentifierExpression { - /// - /// Represents the constant null, resulting from text such as: equals(lastName,null) - /// - [PublicAPI] - public class NullConstantExpression : IdentifierExpression - { - public static readonly NullConstantExpression Instance = new(); + public static readonly NullConstantExpression Instance = new(); - private NullConstantExpression() - { - } + private NullConstantExpression() + { + } - public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) - { - return visitor.VisitNullConstant(this, argument); - } + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitNullConstant(this, argument); + } - public override string ToString() - { - return Keywords.Null; - } + public override string ToString() + { + return Keywords.Null; + } - public override bool Equals(object? obj) + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) { - if (ReferenceEquals(this, obj)) - { - return true; - } - - if (obj is null || GetType() != obj.GetType()) - { - return false; - } - return true; } - public override int GetHashCode() + if (obj is null || GetType() != obj.GetType()) { - return new HashCode().ToHashCode(); + return false; } + + return true; + } + + public override int GetHashCode() + { + return new HashCode().ToHashCode(); } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/PaginationElementQueryStringValueExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/PaginationElementQueryStringValueExpression.cs index 69bb675bdc..d60282bcb0 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/PaginationElementQueryStringValueExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/PaginationElementQueryStringValueExpression.cs @@ -1,53 +1,51 @@ -using System; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions; + +/// +/// Represents an element in . +/// +[PublicAPI] +public class PaginationElementQueryStringValueExpression : QueryExpression { - /// - /// Represents an element in . - /// - [PublicAPI] - public class PaginationElementQueryStringValueExpression : QueryExpression + public ResourceFieldChainExpression? Scope { get; } + public int Value { get; } + + public PaginationElementQueryStringValueExpression(ResourceFieldChainExpression? scope, int value) { - public ResourceFieldChainExpression? Scope { get; } - public int Value { get; } + Scope = scope; + Value = value; + } - 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 TResult Accept(QueryExpressionVisitor visitor, TArgument argument) - { - return visitor.PaginationElementQueryStringValue(this, argument); - } + public override string ToString() + { + return Scope == null ? Value.ToString() : $"{Scope}: {Value}"; + } - public override string ToString() + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) { - return Scope == null ? Value.ToString() : $"{Scope}: {Value}"; + return true; } - public override bool Equals(object? obj) + if (obj is null || GetType() != obj.GetType()) { - if (ReferenceEquals(this, obj)) - { - return true; - } - - if (obj is null || GetType() != obj.GetType()) - { - return false; - } + return false; + } - var other = (PaginationElementQueryStringValueExpression)obj; + var other = (PaginationElementQueryStringValueExpression)obj; - return Equals(Scope, other.Scope) && Value == other.Value; - } + return Equals(Scope, other.Scope) && Value == other.Value; + } - public override int GetHashCode() - { - return HashCode.Combine(Scope, Value); - } + public override int GetHashCode() + { + return HashCode.Combine(Scope, Value); } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/PaginationExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/PaginationExpression.cs index f15e714c2e..4b88ca55da 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/PaginationExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/PaginationExpression.cs @@ -1,56 +1,54 @@ -using System; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; -namespace JsonApiDotNetCore.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions; + +/// +/// Represents a pagination, produced from . +/// +[PublicAPI] +public class PaginationExpression : QueryExpression { - /// - /// Represents a pagination, produced from . - /// - [PublicAPI] - public class PaginationExpression : QueryExpression + public PageNumber PageNumber { get; } + public PageSize? PageSize { get; } + + public PaginationExpression(PageNumber pageNumber, PageSize? pageSize) { - public PageNumber PageNumber { get; } - public PageSize? PageSize { get; } + ArgumentGuard.NotNull(pageNumber, nameof(pageNumber)); - public PaginationExpression(PageNumber pageNumber, PageSize? pageSize) - { - ArgumentGuard.NotNull(pageNumber, nameof(pageNumber)); + PageNumber = pageNumber; + PageSize = pageSize; + } - PageNumber = pageNumber; - PageSize = pageSize; - } + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitPagination(this, argument); + } - 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)"; + } - public override string ToString() + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) { - return PageSize != null ? $"Page number: {PageNumber}, size: {PageSize}" : "(none)"; + return true; } - public override bool Equals(object? obj) + if (obj is null || GetType() != obj.GetType()) { - if (ReferenceEquals(this, obj)) - { - return true; - } - - if (obj is null || GetType() != obj.GetType()) - { - return false; - } + return false; + } - var other = (PaginationExpression)obj; + var other = (PaginationExpression)obj; - return PageNumber.Equals(other.PageNumber) && Equals(PageSize, other.PageSize); - } + return PageNumber.Equals(other.PageNumber) && Equals(PageSize, other.PageSize); + } - public override int GetHashCode() - { - return HashCode.Combine(PageNumber, PageSize); - } + public override int GetHashCode() + { + return HashCode.Combine(PageNumber, PageSize); } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/PaginationQueryStringValueExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/PaginationQueryStringValueExpression.cs index 3afce49b3b..68eb353b5c 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/PaginationQueryStringValueExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/PaginationQueryStringValueExpression.cs @@ -1,62 +1,59 @@ -using System; using System.Collections.Immutable; -using System.Linq; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions; + +/// +/// Represents pagination in a query string, resulting from text such as: 1,articles:2 +/// +[PublicAPI] +public class PaginationQueryStringValueExpression : QueryExpression { - /// - /// Represents pagination in a query string, resulting from text such as: 1,articles:2 - /// - [PublicAPI] - public class PaginationQueryStringValueExpression : QueryExpression + public IImmutableList Elements { get; } + + public PaginationQueryStringValueExpression(IImmutableList elements) { - public IImmutableList Elements { get; } + ArgumentGuard.NotNullNorEmpty(elements, nameof(elements)); - public PaginationQueryStringValueExpression(IImmutableList elements) - { - ArgumentGuard.NotNullNorEmpty(elements, nameof(elements)); + Elements = elements; + } - Elements = elements; - } + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.PaginationQueryStringValue(this, argument); + } - 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())); + } - public override string ToString() + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) { - return string.Join(",", Elements.Select(constant => constant.ToString())); + return true; } - public override bool Equals(object? obj) + if (obj is null || GetType() != obj.GetType()) { - if (ReferenceEquals(this, obj)) - { - return true; - } + return false; + } - if (obj is null || GetType() != obj.GetType()) - { - return false; - } + var other = (PaginationQueryStringValueExpression)obj; - var other = (PaginationQueryStringValueExpression)obj; + return Elements.SequenceEqual(other.Elements); + } - return Elements.SequenceEqual(other.Elements); - } + public override int GetHashCode() + { + var hashCode = new HashCode(); - public override int GetHashCode() + foreach (PaginationElementQueryStringValueExpression element in Elements) { - var hashCode = new HashCode(); - - foreach (PaginationElementQueryStringValueExpression element in Elements) - { - hashCode.Add(element); - } - - return hashCode.ToHashCode(); + hashCode.Add(element); } + + return hashCode.ToHashCode(); } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpression.cs index 44d5311b19..e442e6968d 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpression.cs @@ -1,13 +1,12 @@ using System.Linq.Expressions; -namespace JsonApiDotNetCore.Queries.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 { - /// - /// 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); - } + public abstract TResult Accept(QueryExpressionVisitor visitor, TArgument argument); } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs index 8ba23c826d..cd5937cd80 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs @@ -2,292 +2,291 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; -namespace JsonApiDotNetCore.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions; + +/// +/// Building block for rewriting trees. It walks through nested expressions and updates parent on changes. +/// +[PublicAPI] +public class QueryExpressionRewriter : QueryExpressionVisitor { - /// - /// Building block for rewriting trees. It walks through nested expressions and updates parent on changes. - /// - [PublicAPI] - public class QueryExpressionRewriter : QueryExpressionVisitor + public override QueryExpression? Visit(QueryExpression expression, TArgument argument) { - public override QueryExpression? Visit(QueryExpression expression, TArgument argument) - { - return expression.Accept(this, argument); - } + return expression.Accept(this, argument); + } + + public override QueryExpression DefaultVisit(QueryExpression expression, TArgument argument) + { + return expression; + } + + public override QueryExpression? VisitComparison(ComparisonExpression expression, TArgument argument) + { + QueryExpression? newLeft = Visit(expression.Left, argument); + QueryExpression? newRight = Visit(expression.Right, argument); - public override QueryExpression DefaultVisit(QueryExpression expression, TArgument argument) + if (newLeft != null && newRight != null) { - return expression; + var newExpression = new ComparisonExpression(expression.Operator, newLeft, newRight); + return newExpression.Equals(expression) ? expression : newExpression; } - public override QueryExpression? VisitComparison(ComparisonExpression expression, TArgument argument) - { - QueryExpression? newLeft = Visit(expression.Left, argument); - QueryExpression? newRight = Visit(expression.Right, argument); + return null; + } - if (newLeft != null && newRight != null) - { - var newExpression = new ComparisonExpression(expression.Operator, newLeft, newRight); - return newExpression.Equals(expression) ? expression : newExpression; - } + public override QueryExpression? VisitResourceFieldChain(ResourceFieldChainExpression expression, TArgument argument) + { + return expression; + } - return null; - } + public override QueryExpression VisitLiteralConstant(LiteralConstantExpression expression, TArgument argument) + { + return expression; + } - public override QueryExpression? VisitResourceFieldChain(ResourceFieldChainExpression expression, TArgument argument) + public override QueryExpression VisitNullConstant(NullConstantExpression expression, TArgument argument) + { + return expression; + } + + public override QueryExpression? VisitLogical(LogicalExpression expression, TArgument argument) + { + IImmutableList newTerms = VisitList(expression.Terms, argument); + + if (newTerms.Count == 1) { - return expression; + return newTerms[0]; } - public override QueryExpression VisitLiteralConstant(LiteralConstantExpression expression, TArgument argument) + if (newTerms.Count != 0) { - return expression; + var newExpression = new LogicalExpression(expression.Operator, newTerms); + return newExpression.Equals(expression) ? expression : newExpression; } - public override QueryExpression VisitNullConstant(NullConstantExpression expression, TArgument argument) + return null; + } + + public override QueryExpression? VisitNot(NotExpression expression, TArgument argument) + { + if (Visit(expression.Child, argument) is FilterExpression newChild) { - return expression; + var newExpression = new NotExpression(newChild); + return newExpression.Equals(expression) ? expression : newExpression; } - public override QueryExpression? VisitLogical(LogicalExpression expression, TArgument argument) + return null; + } + + public override QueryExpression? VisitHas(HasExpression expression, TArgument argument) + { + if (Visit(expression.TargetCollection, argument) is ResourceFieldChainExpression newTargetCollection) { - IImmutableList newTerms = VisitList(expression.Terms, argument); + FilterExpression? newFilter = expression.Filter != null ? Visit(expression.Filter, argument) as FilterExpression : null; - if (newTerms.Count == 1) - { - return newTerms[0]; - } + var newExpression = new HasExpression(newTargetCollection, newFilter); + return newExpression.Equals(expression) ? expression : newExpression; + } - if (newTerms.Count != 0) - { - var newExpression = new LogicalExpression(expression.Operator, newTerms); - return newExpression.Equals(expression) ? expression : newExpression; - } + return null; + } - return null; - } + public override QueryExpression? VisitSortElement(SortElementExpression expression, TArgument argument) + { + SortElementExpression? newExpression = null; - public override QueryExpression? VisitNot(NotExpression expression, TArgument argument) + if (expression.Count != null) { - if (Visit(expression.Child, argument) is FilterExpression newChild) + if (Visit(expression.Count, argument) is CountExpression newCount) { - var newExpression = new NotExpression(newChild); - return newExpression.Equals(expression) ? expression : newExpression; + newExpression = new SortElementExpression(newCount, expression.IsAscending); } - - return null; } - - public override QueryExpression? VisitHas(HasExpression expression, TArgument argument) + else if (expression.TargetAttribute != null) { - if (Visit(expression.TargetCollection, argument) is ResourceFieldChainExpression newTargetCollection) + if (Visit(expression.TargetAttribute, argument) is ResourceFieldChainExpression newTargetAttribute) { - FilterExpression? newFilter = expression.Filter != null ? Visit(expression.Filter, argument) as FilterExpression : null; - - var newExpression = new HasExpression(newTargetCollection, newFilter); - return newExpression.Equals(expression) ? expression : newExpression; + newExpression = new SortElementExpression(newTargetAttribute, expression.IsAscending); } - - return null; } - public override QueryExpression? VisitSortElement(SortElementExpression expression, TArgument argument) + if (newExpression != null) { - SortElementExpression? newExpression = null; - - if (expression.Count != null) - { - if (Visit(expression.Count, argument) is CountExpression newCount) - { - newExpression = new SortElementExpression(newCount, expression.IsAscending); - } - } - else if (expression.TargetAttribute != null) - { - if (Visit(expression.TargetAttribute, argument) is ResourceFieldChainExpression newTargetAttribute) - { - newExpression = new SortElementExpression(newTargetAttribute, expression.IsAscending); - } - } + return newExpression.Equals(expression) ? expression : newExpression; + } - if (newExpression != null) - { - return newExpression.Equals(expression) ? expression : newExpression; - } + return null; + } - return null; - } + public override QueryExpression? VisitSort(SortExpression expression, TArgument argument) + { + IImmutableList newElements = VisitList(expression.Elements, argument); - public override QueryExpression? VisitSort(SortExpression expression, TArgument argument) + if (newElements.Count != 0) { - IImmutableList newElements = VisitList(expression.Elements, argument); + var newExpression = new SortExpression(newElements); + return newExpression.Equals(expression) ? expression : newExpression; + } - if (newElements.Count != 0) - { - var newExpression = new SortExpression(newElements); - return newExpression.Equals(expression) ? expression : newExpression; - } + return null; + } - return null; - } + public override QueryExpression VisitPagination(PaginationExpression expression, TArgument argument) + { + return expression; + } - public override QueryExpression VisitPagination(PaginationExpression expression, TArgument argument) + public override QueryExpression? VisitCount(CountExpression expression, TArgument argument) + { + if (Visit(expression.TargetCollection, argument) is ResourceFieldChainExpression newTargetCollection) { - return expression; + var newExpression = new CountExpression(newTargetCollection); + return newExpression.Equals(expression) ? expression : newExpression; } - public override QueryExpression? VisitCount(CountExpression expression, TArgument argument) - { - if (Visit(expression.TargetCollection, argument) is ResourceFieldChainExpression newTargetCollection) - { - var newExpression = new CountExpression(newTargetCollection); - return newExpression.Equals(expression) ? expression : newExpression; - } + return null; + } - return null; - } + public override QueryExpression? VisitMatchText(MatchTextExpression expression, TArgument argument) + { + var newTargetAttribute = Visit(expression.TargetAttribute, argument) as ResourceFieldChainExpression; + var newTextValue = Visit(expression.TextValue, argument) as LiteralConstantExpression; - public override QueryExpression? VisitMatchText(MatchTextExpression expression, TArgument argument) + if (newTargetAttribute != null && newTextValue != null) { - var newTargetAttribute = Visit(expression.TargetAttribute, argument) as ResourceFieldChainExpression; - var newTextValue = Visit(expression.TextValue, argument) as LiteralConstantExpression; - - if (newTargetAttribute != null && newTextValue != null) - { - var newExpression = new MatchTextExpression(newTargetAttribute, newTextValue, expression.MatchKind); - return newExpression.Equals(expression) ? expression : newExpression; - } - - return null; + var newExpression = new MatchTextExpression(newTargetAttribute, newTextValue, expression.MatchKind); + return newExpression.Equals(expression) ? expression : newExpression; } - public override QueryExpression? VisitAny(AnyExpression expression, TArgument argument) - { - var newTargetAttribute = Visit(expression.TargetAttribute, argument) as ResourceFieldChainExpression; - IImmutableSet newConstants = VisitSet(expression.Constants, argument); + return null; + } - if (newTargetAttribute != null) - { - var newExpression = new AnyExpression(newTargetAttribute, newConstants); - return newExpression.Equals(expression) ? expression : newExpression; - } + public override QueryExpression? VisitAny(AnyExpression expression, TArgument argument) + { + var newTargetAttribute = Visit(expression.TargetAttribute, argument) as ResourceFieldChainExpression; + IImmutableSet newConstants = VisitSet(expression.Constants, argument); - return null; + if (newTargetAttribute != null) + { + var newExpression = new AnyExpression(newTargetAttribute, newConstants); + return newExpression.Equals(expression) ? expression : newExpression; } - public override QueryExpression? VisitSparseFieldTable(SparseFieldTableExpression expression, TArgument argument) - { - ImmutableDictionary.Builder newTable = - ImmutableDictionary.CreateBuilder(); + return null; + } - foreach ((ResourceType resourceType, SparseFieldSetExpression sparseFieldSet) in expression.Table) - { - if (Visit(sparseFieldSet, argument) is SparseFieldSetExpression newSparseFieldSet) - { - newTable[resourceType] = newSparseFieldSet; - } - } + public override QueryExpression? VisitSparseFieldTable(SparseFieldTableExpression expression, TArgument argument) + { + ImmutableDictionary.Builder newTable = + ImmutableDictionary.CreateBuilder(); - if (newTable.Count > 0) + foreach ((ResourceType resourceType, SparseFieldSetExpression sparseFieldSet) in expression.Table) + { + if (Visit(sparseFieldSet, argument) is SparseFieldSetExpression newSparseFieldSet) { - var newExpression = new SparseFieldTableExpression(newTable.ToImmutable()); - return newExpression.Equals(expression) ? expression : newExpression; + newTable[resourceType] = newSparseFieldSet; } - - return null; } - public override QueryExpression VisitSparseFieldSet(SparseFieldSetExpression expression, TArgument argument) + if (newTable.Count > 0) { - return expression; + var newExpression = new SparseFieldTableExpression(newTable.ToImmutable()); + return newExpression.Equals(expression) ? expression : newExpression; } - public override QueryExpression? VisitQueryStringParameterScope(QueryStringParameterScopeExpression expression, TArgument argument) - { - var newParameterName = Visit(expression.ParameterName, argument) as LiteralConstantExpression; - ResourceFieldChainExpression? newScope = expression.Scope != null ? Visit(expression.Scope, argument) as ResourceFieldChainExpression : null; + return null; + } - if (newParameterName != null) - { - var newExpression = new QueryStringParameterScopeExpression(newParameterName, newScope); - return newExpression.Equals(expression) ? expression : newExpression; - } + public override QueryExpression VisitSparseFieldSet(SparseFieldSetExpression expression, TArgument argument) + { + return expression; + } - return null; - } + public override QueryExpression? VisitQueryStringParameterScope(QueryStringParameterScopeExpression expression, TArgument argument) + { + var newParameterName = Visit(expression.ParameterName, argument) as LiteralConstantExpression; + ResourceFieldChainExpression? newScope = expression.Scope != null ? Visit(expression.Scope, argument) as ResourceFieldChainExpression : null; - public override QueryExpression PaginationQueryStringValue(PaginationQueryStringValueExpression expression, TArgument argument) + if (newParameterName != null) { - IImmutableList newElements = VisitList(expression.Elements, argument); - - var newExpression = new PaginationQueryStringValueExpression(newElements); + var newExpression = new QueryStringParameterScopeExpression(newParameterName, newScope); return newExpression.Equals(expression) ? expression : newExpression; } - public override QueryExpression PaginationElementQueryStringValue(PaginationElementQueryStringValueExpression expression, TArgument argument) - { - ResourceFieldChainExpression? newScope = expression.Scope != null ? Visit(expression.Scope, argument) as ResourceFieldChainExpression : null; - - var newExpression = new PaginationElementQueryStringValueExpression(newScope, expression.Value); - return newExpression.Equals(expression) ? expression : newExpression; - } + return null; + } - public override QueryExpression VisitInclude(IncludeExpression expression, TArgument argument) - { - IImmutableSet newElements = VisitSet(expression.Elements, argument); + public override QueryExpression PaginationQueryStringValue(PaginationQueryStringValueExpression expression, TArgument argument) + { + IImmutableList newElements = VisitList(expression.Elements, argument); - if (newElements.Count == 0) - { - return IncludeExpression.Empty; - } + var newExpression = new PaginationQueryStringValueExpression(newElements); + return newExpression.Equals(expression) ? expression : newExpression; + } - var newExpression = new IncludeExpression(newElements); - return newExpression.Equals(expression) ? expression : newExpression; - } + public override QueryExpression PaginationElementQueryStringValue(PaginationElementQueryStringValueExpression expression, TArgument argument) + { + ResourceFieldChainExpression? newScope = expression.Scope != null ? Visit(expression.Scope, argument) as ResourceFieldChainExpression : null; - public override QueryExpression VisitIncludeElement(IncludeElementExpression expression, TArgument argument) - { - IImmutableSet newElements = VisitSet(expression.Children, argument); + var newExpression = new PaginationElementQueryStringValueExpression(newScope, expression.Value); + return newExpression.Equals(expression) ? expression : newExpression; + } - var newExpression = new IncludeElementExpression(expression.Relationship, newElements); - return newExpression.Equals(expression) ? expression : newExpression; - } + public override QueryExpression VisitInclude(IncludeExpression expression, TArgument argument) + { + IImmutableSet newElements = VisitSet(expression.Elements, argument); - public override QueryExpression VisitQueryableHandler(QueryableHandlerExpression expression, TArgument argument) + if (newElements.Count == 0) { - return expression; + return IncludeExpression.Empty; } - protected virtual IImmutableList VisitList(IImmutableList elements, TArgument argument) - where TExpression : QueryExpression - { - ImmutableArray.Builder arrayBuilder = ImmutableArray.CreateBuilder(elements.Count); + var newExpression = new IncludeExpression(newElements); + return newExpression.Equals(expression) ? expression : newExpression; + } - foreach (TExpression element in elements) + public override QueryExpression VisitIncludeElement(IncludeElementExpression expression, TArgument argument) + { + IImmutableSet newElements = VisitSet(expression.Children, argument); + + var newExpression = new IncludeElementExpression(expression.Relationship, newElements); + return newExpression.Equals(expression) ? expression : newExpression; + } + + public override QueryExpression VisitQueryableHandler(QueryableHandlerExpression expression, TArgument argument) + { + return expression; + } + + protected virtual IImmutableList VisitList(IImmutableList elements, TArgument argument) + where TExpression : QueryExpression + { + ImmutableArray.Builder arrayBuilder = ImmutableArray.CreateBuilder(elements.Count); + + foreach (TExpression element in elements) + { + if (Visit(element, argument) is TExpression newElement) { - if (Visit(element, argument) is TExpression newElement) - { - arrayBuilder.Add(newElement); - } + arrayBuilder.Add(newElement); } - - return arrayBuilder.ToImmutable(); } - protected virtual IImmutableSet VisitSet(IImmutableSet elements, TArgument argument) - where TExpression : QueryExpression - { - ImmutableHashSet.Builder setBuilder = ImmutableHashSet.CreateBuilder(); + return arrayBuilder.ToImmutable(); + } + + protected virtual IImmutableSet VisitSet(IImmutableSet elements, TArgument argument) + where TExpression : QueryExpression + { + ImmutableHashSet.Builder setBuilder = ImmutableHashSet.CreateBuilder(); - foreach (TExpression element in elements) + foreach (TExpression element in elements) + { + if (Visit(element, argument) is TExpression newElement) { - if (Visit(element, argument) is TExpression newElement) - { - setBuilder.Add(newElement); - } + setBuilder.Add(newElement); } - - return setBuilder.ToImmutable(); } + + return setBuilder.ToImmutable(); } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs index dc5963a573..7c893ba81c 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs @@ -1,126 +1,125 @@ using JetBrains.Annotations; -namespace JsonApiDotNetCore.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions; + +/// +/// Implements the visitor design pattern that enables traversing a tree. +/// +[PublicAPI] +public abstract class QueryExpressionVisitor { - /// - /// Implements the visitor design pattern that enables traversing a tree. - /// - [PublicAPI] - 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 VisitHas(HasExpression 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 VisitAny(AnyExpression expression, TArgument argument) - { - return DefaultVisit(expression, argument); - } - - public virtual TResult VisitSparseFieldTable(SparseFieldTableExpression 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); - } + 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 VisitHas(HasExpression 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 VisitAny(AnyExpression expression, TArgument argument) + { + return DefaultVisit(expression, argument); + } + + public virtual TResult VisitSparseFieldTable(SparseFieldTableExpression 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 index dcd59928d4..db0e887c09 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/QueryStringParameterScopeExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryStringParameterScopeExpression.cs @@ -1,55 +1,53 @@ -using System; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions; + +/// +/// Represents the scope of a query string parameter, resulting from text such as: ?filter[articles]=... +/// +[PublicAPI] +public class QueryStringParameterScopeExpression : QueryExpression { - /// - /// Represents the scope of a query string parameter, resulting from text such as: ?filter[articles]=... - /// - [PublicAPI] - public class QueryStringParameterScopeExpression : QueryExpression + public LiteralConstantExpression ParameterName { get; } + public ResourceFieldChainExpression? Scope { get; } + + public QueryStringParameterScopeExpression(LiteralConstantExpression parameterName, ResourceFieldChainExpression? scope) { - public LiteralConstantExpression ParameterName { get; } - public ResourceFieldChainExpression? Scope { get; } + ArgumentGuard.NotNull(parameterName, nameof(parameterName)); - public QueryStringParameterScopeExpression(LiteralConstantExpression parameterName, ResourceFieldChainExpression? scope) - { - ArgumentGuard.NotNull(parameterName, nameof(parameterName)); + ParameterName = parameterName; + Scope = scope; + } - ParameterName = parameterName; - Scope = scope; - } + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitQueryStringParameterScope(this, argument); + } - public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) - { - return visitor.VisitQueryStringParameterScope(this, argument); - } + public override string ToString() + { + return Scope == null ? ParameterName.ToString() : $"{ParameterName}: {Scope}"; + } - public override string ToString() + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) { - return Scope == null ? ParameterName.ToString() : $"{ParameterName}: {Scope}"; + return true; } - public override bool Equals(object? obj) + if (obj is null || GetType() != obj.GetType()) { - if (ReferenceEquals(this, obj)) - { - return true; - } - - if (obj is null || GetType() != obj.GetType()) - { - return false; - } + return false; + } - var other = (QueryStringParameterScopeExpression)obj; + var other = (QueryStringParameterScopeExpression)obj; - return ParameterName.Equals(other.ParameterName) && Equals(Scope, other.Scope); - } + return ParameterName.Equals(other.ParameterName) && Equals(Scope, other.Scope); + } - public override int GetHashCode() - { - return HashCode.Combine(ParameterName, Scope); - } + public override int GetHashCode() + { + return HashCode.Combine(ParameterName, Scope); } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/QueryableHandlerExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryableHandlerExpression.cs index 6e57c55172..bffd8ae0ce 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/QueryableHandlerExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryableHandlerExpression.cs @@ -1,65 +1,62 @@ -using System; -using System.Linq; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using Microsoft.Extensions.Primitives; -namespace JsonApiDotNetCore.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions; + +/// +/// Holds an expression, used for custom query string handlers from s. +/// +[PublicAPI] +public class QueryableHandlerExpression : QueryExpression { - /// - /// Holds an expression, used for custom query string handlers from s. - /// - [PublicAPI] - public class QueryableHandlerExpression : QueryExpression + private readonly object _queryableHandler; + private readonly StringValues _parameterValue; + + public QueryableHandlerExpression(object queryableHandler, StringValues parameterValue) { - private readonly object _queryableHandler; - private readonly StringValues _parameterValue; + ArgumentGuard.NotNull(queryableHandler, nameof(queryableHandler)); - public QueryableHandlerExpression(object queryableHandler, StringValues parameterValue) - { - ArgumentGuard.NotNull(queryableHandler, nameof(queryableHandler)); + _queryableHandler = queryableHandler; + _parameterValue = parameterValue; + } - _queryableHandler = queryableHandler; - _parameterValue = parameterValue; - } + public IQueryable Apply(IQueryable query) + where TResource : class, IIdentifiable + { + var handler = (Func, StringValues, IQueryable>)_queryableHandler; + return handler(query, _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 TResult Accept(QueryExpressionVisitor visitor, TArgument argument) - { - return visitor.VisitQueryableHandler(this, argument); - } + public override string ToString() + { + return $"handler('{_parameterValue}')"; + } - public override string ToString() + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) { - return $"handler('{_parameterValue}')"; + return true; } - public override bool Equals(object? obj) + if (obj is null || GetType() != obj.GetType()) { - if (ReferenceEquals(this, obj)) - { - return true; - } - - if (obj is null || GetType() != obj.GetType()) - { - return false; - } + return false; + } - var other = (QueryableHandlerExpression)obj; + var other = (QueryableHandlerExpression)obj; - return _queryableHandler == other._queryableHandler && _parameterValue.Equals(other._parameterValue); - } + return _queryableHandler == other._queryableHandler && _parameterValue.Equals(other._parameterValue); + } - public override int GetHashCode() - { - return HashCode.Combine(_queryableHandler, _parameterValue); - } + public override int GetHashCode() + { + return HashCode.Combine(_queryableHandler, _parameterValue); } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/ResourceFieldChainExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/ResourceFieldChainExpression.cs index 59b78d9e55..7f19c55ba0 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/ResourceFieldChainExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/ResourceFieldChainExpression.cs @@ -1,70 +1,67 @@ -using System; using System.Collections.Immutable; -using System.Linq; using JetBrains.Annotations; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions; + +/// +/// Represents a chain of fields (relationships and attributes), resulting from text such as: articles.revisions.author +/// +[PublicAPI] +public class ResourceFieldChainExpression : IdentifierExpression { - /// - /// Represents a chain of fields (relationships and attributes), resulting from text such as: articles.revisions.author - /// - [PublicAPI] - public class ResourceFieldChainExpression : IdentifierExpression + public IImmutableList Fields { get; } + + public ResourceFieldChainExpression(ResourceFieldAttribute field) { - public IImmutableList Fields { get; } + ArgumentGuard.NotNull(field, nameof(field)); - public ResourceFieldChainExpression(ResourceFieldAttribute field) - { - ArgumentGuard.NotNull(field, nameof(field)); + Fields = ImmutableArray.Create(field); + } - Fields = ImmutableArray.Create(field); - } + public ResourceFieldChainExpression(IImmutableList fields) + { + ArgumentGuard.NotNullNorEmpty(fields, nameof(fields)); - public ResourceFieldChainExpression(IImmutableList fields) - { - ArgumentGuard.NotNullNorEmpty(fields, nameof(fields)); + Fields = fields; + } - Fields = fields; - } + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitResourceFieldChain(this, argument); + } - 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 override string ToString() + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) { - return string.Join(".", Fields.Select(field => field.PublicName)); + return true; } - public override bool Equals(object? obj) + if (obj is null || GetType() != obj.GetType()) { - if (ReferenceEquals(this, obj)) - { - return true; - } + return false; + } - if (obj is null || GetType() != obj.GetType()) - { - return false; - } + var other = (ResourceFieldChainExpression)obj; - var other = (ResourceFieldChainExpression)obj; + return Fields.SequenceEqual(other.Fields); + } - return Fields.SequenceEqual(other.Fields); - } + public override int GetHashCode() + { + var hashCode = new HashCode(); - public override int GetHashCode() + foreach (ResourceFieldAttribute field in Fields) { - var hashCode = new HashCode(); - - foreach (ResourceFieldAttribute field in Fields) - { - hashCode.Add(field); - } - - return hashCode.ToHashCode(); + hashCode.Add(field); } + + return hashCode.ToHashCode(); } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SortElementExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/SortElementExpression.cs index d2f342db16..9de73655ad 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/SortElementExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SortElementExpression.cs @@ -1,81 +1,79 @@ -using System; using System.Text; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions; + +/// +/// Represents an element in . +/// +[PublicAPI] +public class SortElementExpression : QueryExpression { - /// - /// Represents an element in . - /// - [PublicAPI] - public class SortElementExpression : QueryExpression + public ResourceFieldChainExpression? TargetAttribute { get; } + public CountExpression? Count { get; } + public bool IsAscending { get; } + + public SortElementExpression(ResourceFieldChainExpression targetAttribute, bool isAscending) { - public ResourceFieldChainExpression? TargetAttribute { get; } - public CountExpression? Count { get; } - public bool IsAscending { get; } + ArgumentGuard.NotNull(targetAttribute, nameof(targetAttribute)); - public SortElementExpression(ResourceFieldChainExpression targetAttribute, bool isAscending) - { - ArgumentGuard.NotNull(targetAttribute, nameof(targetAttribute)); + TargetAttribute = targetAttribute; + IsAscending = isAscending; + } - TargetAttribute = targetAttribute; - IsAscending = isAscending; - } + public SortElementExpression(CountExpression count, bool isAscending) + { + ArgumentGuard.NotNull(count, nameof(count)); - public SortElementExpression(CountExpression count, bool isAscending) - { - ArgumentGuard.NotNull(count, nameof(count)); + Count = count; + IsAscending = isAscending; + } - Count = count; - IsAscending = isAscending; - } + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitSortElement(this, argument); + } + + public override string ToString() + { + var builder = new StringBuilder(); - public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + if (!IsAscending) { - return visitor.VisitSortElement(this, argument); + builder.Append('-'); } - public override string ToString() + if (TargetAttribute != null) { - var builder = new StringBuilder(); - - if (!IsAscending) - { - builder.Append('-'); - } - - if (TargetAttribute != null) - { - builder.Append(TargetAttribute); - } - else if (Count != null) - { - builder.Append(Count); - } - - return builder.ToString(); + builder.Append(TargetAttribute); } - - public override bool Equals(object? obj) + else if (Count != null) { - if (ReferenceEquals(this, obj)) - { - return true; - } - - if (obj is null || GetType() != obj.GetType()) - { - return false; - } + builder.Append(Count); + } - var other = (SortElementExpression)obj; + return builder.ToString(); + } - return Equals(TargetAttribute, other.TargetAttribute) && Equals(Count, other.Count) && IsAscending == other.IsAscending; + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) + { + return true; } - public override int GetHashCode() + if (obj is null || GetType() != obj.GetType()) { - return HashCode.Combine(TargetAttribute, Count, IsAscending); + return false; } + + var other = (SortElementExpression)obj; + + return Equals(TargetAttribute, other.TargetAttribute) && Equals(Count, other.Count) && IsAscending == other.IsAscending; + } + + public override int GetHashCode() + { + return HashCode.Combine(TargetAttribute, Count, IsAscending); } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SortExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/SortExpression.cs index c8a375d7cc..b61b34eb91 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/SortExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SortExpression.cs @@ -1,62 +1,59 @@ -using System; using System.Collections.Immutable; -using System.Linq; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions; + +/// +/// Represents a sorting, resulting from text such as: lastName,-lastModifiedAt +/// +[PublicAPI] +public class SortExpression : QueryExpression { - /// - /// Represents a sorting, resulting from text such as: lastName,-lastModifiedAt - /// - [PublicAPI] - public class SortExpression : QueryExpression + public IImmutableList Elements { get; } + + public SortExpression(IImmutableList elements) { - public IImmutableList Elements { get; } + ArgumentGuard.NotNullNorEmpty(elements, nameof(elements)); - public SortExpression(IImmutableList elements) - { - ArgumentGuard.NotNullNorEmpty(elements, nameof(elements)); + Elements = elements; + } - Elements = elements; - } + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitSort(this, argument); + } - 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())); + } - public override string ToString() + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) { - return string.Join(",", Elements.Select(child => child.ToString())); + return true; } - public override bool Equals(object? obj) + if (obj is null || GetType() != obj.GetType()) { - if (ReferenceEquals(this, obj)) - { - return true; - } + return false; + } - if (obj is null || GetType() != obj.GetType()) - { - return false; - } + var other = (SortExpression)obj; - var other = (SortExpression)obj; + return Elements.SequenceEqual(other.Elements); + } - return Elements.SequenceEqual(other.Elements); - } + public override int GetHashCode() + { + var hashCode = new HashCode(); - public override int GetHashCode() + foreach (SortElementExpression element in Elements) { - var hashCode = new HashCode(); - - foreach (SortElementExpression element in Elements) - { - hashCode.Add(element); - } - - return hashCode.ToHashCode(); + hashCode.Add(element); } + + return hashCode.ToHashCode(); } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs index 6bb4611375..c0532070e5 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs @@ -1,63 +1,60 @@ -using System; using System.Collections.Immutable; -using System.Linq; using JetBrains.Annotations; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions; + +/// +/// Represents a sparse fieldset, resulting from text such as: firstName,lastName,articles +/// +[PublicAPI] +public class SparseFieldSetExpression : QueryExpression { - /// - /// Represents a sparse fieldset, resulting from text such as: firstName,lastName,articles - /// - [PublicAPI] - public class SparseFieldSetExpression : QueryExpression + public IImmutableSet Fields { get; } + + public SparseFieldSetExpression(IImmutableSet fields) { - public IImmutableSet Fields { get; } + ArgumentGuard.NotNullNorEmpty(fields, nameof(fields)); - public SparseFieldSetExpression(IImmutableSet fields) - { - ArgumentGuard.NotNullNorEmpty(fields, nameof(fields)); + Fields = fields; + } - Fields = fields; - } + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitSparseFieldSet(this, argument); + } - public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) - { - return visitor.VisitSparseFieldSet(this, argument); - } + public override string ToString() + { + return string.Join(",", Fields.Select(child => child.PublicName).OrderBy(name => name)); + } - public override string ToString() + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) { - return string.Join(",", Fields.Select(child => child.PublicName).OrderBy(name => name)); + return true; } - public override bool Equals(object? obj) + if (obj is null || GetType() != obj.GetType()) { - if (ReferenceEquals(this, obj)) - { - return true; - } + return false; + } - if (obj is null || GetType() != obj.GetType()) - { - return false; - } + var other = (SparseFieldSetExpression)obj; - var other = (SparseFieldSetExpression)obj; + return Fields.SetEquals(other.Fields); + } - return Fields.SetEquals(other.Fields); - } + public override int GetHashCode() + { + var hashCode = new HashCode(); - public override int GetHashCode() + foreach (ResourceFieldAttribute field in Fields) { - var hashCode = new HashCode(); - - foreach (ResourceFieldAttribute field in Fields) - { - hashCode.Add(field); - } - - return hashCode.ToHashCode(); + hashCode.Add(field); } + + return hashCode.ToHashCode(); } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpressionExtensions.cs b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpressionExtensions.cs index a9d037165a..f0e434cccd 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpressionExtensions.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpressionExtensions.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Immutable; using System.Linq.Expressions; using JetBrains.Annotations; @@ -6,70 +5,69 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions; + +[PublicAPI] +public static class SparseFieldSetExpressionExtensions { - [PublicAPI] - public static class SparseFieldSetExpressionExtensions + public static SparseFieldSetExpression? Including(this SparseFieldSetExpression? sparseFieldSet, + Expression> fieldSelector, IResourceGraph resourceGraph) + where TResource : class, IIdentifiable { - public static SparseFieldSetExpression? Including(this SparseFieldSetExpression? sparseFieldSet, - Expression> fieldSelector, IResourceGraph resourceGraph) - where TResource : class, IIdentifiable - { - ArgumentGuard.NotNull(fieldSelector, nameof(fieldSelector)); - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); - - SparseFieldSetExpression? newSparseFieldSet = sparseFieldSet; + ArgumentGuard.NotNull(fieldSelector, nameof(fieldSelector)); + ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); - foreach (ResourceFieldAttribute field in resourceGraph.GetFields(fieldSelector)) - { - newSparseFieldSet = IncludeField(newSparseFieldSet, field); - } + SparseFieldSetExpression? newSparseFieldSet = sparseFieldSet; - return newSparseFieldSet; + foreach (ResourceFieldAttribute field in resourceGraph.GetFields(fieldSelector)) + { + newSparseFieldSet = IncludeField(newSparseFieldSet, field); } - private static SparseFieldSetExpression? IncludeField(SparseFieldSetExpression? sparseFieldSet, ResourceFieldAttribute fieldToInclude) - { - if (sparseFieldSet == null || sparseFieldSet.Fields.Contains(fieldToInclude)) - { - return sparseFieldSet; - } + return newSparseFieldSet; + } - IImmutableSet newSparseFieldSet = sparseFieldSet.Fields.Add(fieldToInclude); - return new SparseFieldSetExpression(newSparseFieldSet); + private static SparseFieldSetExpression? IncludeField(SparseFieldSetExpression? sparseFieldSet, ResourceFieldAttribute fieldToInclude) + { + if (sparseFieldSet == null || sparseFieldSet.Fields.Contains(fieldToInclude)) + { + return sparseFieldSet; } - public static SparseFieldSetExpression? Excluding(this SparseFieldSetExpression? sparseFieldSet, - Expression> fieldSelector, IResourceGraph resourceGraph) - where TResource : class, IIdentifiable - { - ArgumentGuard.NotNull(fieldSelector, nameof(fieldSelector)); - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); + IImmutableSet newSparseFieldSet = sparseFieldSet.Fields.Add(fieldToInclude); + return new SparseFieldSetExpression(newSparseFieldSet); + } - SparseFieldSetExpression? newSparseFieldSet = sparseFieldSet; + public static SparseFieldSetExpression? Excluding(this SparseFieldSetExpression? sparseFieldSet, + Expression> fieldSelector, IResourceGraph resourceGraph) + where TResource : class, IIdentifiable + { + ArgumentGuard.NotNull(fieldSelector, nameof(fieldSelector)); + ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); - foreach (ResourceFieldAttribute field in resourceGraph.GetFields(fieldSelector)) - { - newSparseFieldSet = ExcludeField(newSparseFieldSet, field); - } + SparseFieldSetExpression? newSparseFieldSet = sparseFieldSet; - return newSparseFieldSet; + foreach (ResourceFieldAttribute field in resourceGraph.GetFields(fieldSelector)) + { + newSparseFieldSet = ExcludeField(newSparseFieldSet, field); } - private static SparseFieldSetExpression? ExcludeField(SparseFieldSetExpression? sparseFieldSet, ResourceFieldAttribute fieldToExclude) - { - // Design tradeoff: When the sparse fieldset is empty, it means all fields will be selected. - // Adding an exclusion in that case is a no-op, which results in still retrieving the excluded field from data store. - // But later, when serializing the response, the sparse fieldset is first populated with all fields, - // so then the exclusion will actually be applied and the excluded field is not returned to the client. + return newSparseFieldSet; + } - if (sparseFieldSet == null || !sparseFieldSet.Fields.Contains(fieldToExclude)) - { - return sparseFieldSet; - } + private static SparseFieldSetExpression? ExcludeField(SparseFieldSetExpression? sparseFieldSet, ResourceFieldAttribute fieldToExclude) + { + // Design tradeoff: When the sparse fieldset is empty, it means all fields will be selected. + // Adding an exclusion in that case is a no-op, which results in still retrieving the excluded field from data store. + // But later, when serializing the response, the sparse fieldset is first populated with all fields, + // so then the exclusion will actually be applied and the excluded field is not returned to the client. - IImmutableSet newSparseFieldSet = sparseFieldSet.Fields.Remove(fieldToExclude); - return new SparseFieldSetExpression(newSparseFieldSet); + if (sparseFieldSet == null || !sparseFieldSet.Fields.Contains(fieldToExclude)) + { + return sparseFieldSet; } + + IImmutableSet newSparseFieldSet = sparseFieldSet.Fields.Remove(fieldToExclude); + return new SparseFieldSetExpression(newSparseFieldSet); } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldTableExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldTableExpression.cs index 8543823199..8ec77f12fc 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldTableExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldTableExpression.cs @@ -1,79 +1,77 @@ -using System; using System.Collections.Immutable; using System.Text; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; -namespace JsonApiDotNetCore.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions; + +/// +/// Represents a lookup table of sparse fieldsets per resource type. +/// +[PublicAPI] +public class SparseFieldTableExpression : QueryExpression { - /// - /// Represents a lookup table of sparse fieldsets per resource type. - /// - [PublicAPI] - public class SparseFieldTableExpression : QueryExpression + public IImmutableDictionary Table { get; } + + public SparseFieldTableExpression(IImmutableDictionary table) { - public IImmutableDictionary Table { get; } + ArgumentGuard.NotNullNorEmpty(table, nameof(table), "entries"); - public SparseFieldTableExpression(IImmutableDictionary table) - { - ArgumentGuard.NotNullNorEmpty(table, nameof(table), "entries"); + Table = table; + } - Table = table; - } + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitSparseFieldTable(this, argument); + } - public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) - { - return visitor.VisitSparseFieldTable(this, argument); - } + public override string ToString() + { + var builder = new StringBuilder(); - public override string ToString() + foreach ((ResourceType resourceType, SparseFieldSetExpression fields) in Table) { - var builder = new StringBuilder(); - - foreach ((ResourceType resourceType, SparseFieldSetExpression fields) in Table) + if (builder.Length > 0) { - if (builder.Length > 0) - { - builder.Append(','); - } - - builder.Append(resourceType.PublicName); - builder.Append('('); - builder.Append(fields); - builder.Append(')'); + builder.Append(','); } - return builder.ToString(); + builder.Append(resourceType.PublicName); + builder.Append('('); + builder.Append(fields); + builder.Append(')'); } - public override bool Equals(object? obj) - { - if (ReferenceEquals(this, obj)) - { - return true; - } - - if (obj is null || GetType() != obj.GetType()) - { - return false; - } - - var other = (SparseFieldTableExpression)obj; + return builder.ToString(); + } - return Table.DictionaryEqual(other.Table); + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) + { + return true; } - public override int GetHashCode() + if (obj is null || GetType() != obj.GetType()) { - var hashCode = new HashCode(); + return false; + } - foreach ((ResourceType resourceType, SparseFieldSetExpression sparseFieldSet) in Table) - { - hashCode.Add(resourceType); - hashCode.Add(sparseFieldSet); - } + var other = (SparseFieldTableExpression)obj; + + return Table.DictionaryEqual(other.Table); + } - return hashCode.ToHashCode(); + public override int GetHashCode() + { + var hashCode = new HashCode(); + + foreach ((ResourceType resourceType, SparseFieldSetExpression sparseFieldSet) in Table) + { + hashCode.Add(resourceType); + hashCode.Add(sparseFieldSet); } + + return hashCode.ToHashCode(); } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/TextMatchKind.cs b/src/JsonApiDotNetCore/Queries/Expressions/TextMatchKind.cs index e51436b252..e9b28c4b88 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/TextMatchKind.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/TextMatchKind.cs @@ -1,9 +1,8 @@ -namespace JsonApiDotNetCore.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions; + +public enum TextMatchKind { - public enum TextMatchKind - { - Contains, - StartsWith, - EndsWith - } + Contains, + StartsWith, + EndsWith } diff --git a/src/JsonApiDotNetCore/Queries/IPaginationContext.cs b/src/JsonApiDotNetCore/Queries/IPaginationContext.cs index da769a3ade..44578e5277 100644 --- a/src/JsonApiDotNetCore/Queries/IPaginationContext.cs +++ b/src/JsonApiDotNetCore/Queries/IPaginationContext.cs @@ -1,38 +1,37 @@ using JsonApiDotNetCore.Configuration; -namespace JsonApiDotNetCore.Queries +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 { /// - /// Tracks values used for pagination, which is a combined effort from options, query string parsing and fetching the total number of rows. + /// The value 1, unless specified from query string. Never null. Cannot be higher than options.MaximumPageNumber. /// - public interface IPaginationContext - { - /// - /// The value 1, unless specified from query string. Never null. Cannot be higher than options.MaximumPageNumber. - /// - PageNumber PageNumber { get; set; } + 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 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; } - /// - /// Indicates whether the number of resources on the current page equals the page size. When true, a subsequent page might exist (assuming - /// is unknown). - /// - bool IsPageFull { get; set; } + /// + /// Indicates whether the number of resources on the current page equals the page size. When true, a subsequent page might exist (assuming + /// is unknown). + /// + bool IsPageFull { get; set; } - /// - /// The total number of resources. null when is set to false. - /// - int? TotalResourceCount { 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; } - } + /// + /// 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 index c9a82a7b36..451297a509 100644 --- a/src/JsonApiDotNetCore/Queries/IQueryConstraintProvider.cs +++ b/src/JsonApiDotNetCore/Queries/IQueryConstraintProvider.cs @@ -1,15 +1,12 @@ -using System.Collections.Generic; +namespace JsonApiDotNetCore.Queries; -namespace JsonApiDotNetCore.Queries +/// +/// Provides constraints (such as filters, sorting, pagination, sparse fieldsets and inclusions) to be applied on a data set. +/// +public interface IQueryConstraintProvider { /// - /// Provides constraints (such as filters, sorting, pagination, sparse fieldsets and inclusions) to be applied on a data set. + /// Returns a set of scoped expressions. /// - public interface IQueryConstraintProvider - { - /// - /// Returns a set of scoped expressions. - /// - public IReadOnlyCollection GetConstraints(); - } + public IReadOnlyCollection GetConstraints(); } diff --git a/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs index b81c9bacd4..a9d99c3b13 100644 --- a/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs @@ -1,66 +1,64 @@ -using System.Collections.Generic; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Queries +namespace JsonApiDotNetCore.Queries; + +/// +/// Takes scoped expressions from s and transforms them. +/// +public interface IQueryLayerComposer { /// - /// Takes scoped expressions from s and transforms them. + /// Builds a filter from constraints, used to determine total resource count on a primary collection endpoint. /// - public interface IQueryLayerComposer - { - /// - /// Builds a filter from constraints, used to determine total resource count on a primary collection endpoint. - /// - FilterExpression? GetPrimaryFilterFromConstraints(ResourceType primaryResourceType); + FilterExpression? GetPrimaryFilterFromConstraints(ResourceType primaryResourceType); - /// - /// Builds a filter from constraints, used to determine total resource count on a secondary collection endpoint. - /// - FilterExpression? GetSecondaryFilterFromConstraints(TId primaryId, HasManyAttribute hasManyRelationship); + /// + /// Builds a filter from constraints, used to determine total resource count on a secondary collection endpoint. + /// + FilterExpression? GetSecondaryFilterFromConstraints(TId primaryId, HasManyAttribute hasManyRelationship); - /// - /// Collects constraints and builds a out of them, used to retrieve the actual resources. - /// - QueryLayer ComposeFromConstraints(ResourceType requestResourceType); + /// + /// Collects constraints and builds a out of them, used to retrieve the actual resources. + /// + QueryLayer ComposeFromConstraints(ResourceType requestResourceType); - /// - /// Collects constraints and builds a out of them, used to retrieve one resource. - /// - QueryLayer ComposeForGetById(TId id, ResourceType primaryResourceType, TopFieldSelection fieldSelection); + /// + /// Collects constraints and builds a out of them, used to retrieve one resource. + /// + QueryLayer ComposeForGetById(TId id, ResourceType primaryResourceType, TopFieldSelection fieldSelection); - /// - /// Collects constraints and builds the secondary layer for a relationship endpoint. - /// - QueryLayer ComposeSecondaryLayerForRelationship(ResourceType secondaryResourceType); + /// + /// Collects constraints and builds the secondary layer for a relationship endpoint. + /// + QueryLayer ComposeSecondaryLayerForRelationship(ResourceType secondaryResourceType); - /// - /// Wraps a layer for a secondary endpoint into a primary layer, rewriting top-level includes. - /// - QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer, ResourceType primaryResourceType, TId primaryId, - RelationshipAttribute relationship); + /// + /// Wraps a layer for a secondary endpoint into a primary layer, rewriting top-level includes. + /// + QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer, ResourceType primaryResourceType, TId primaryId, + RelationshipAttribute relationship); - /// - /// Builds a query that retrieves the primary resource, including all of its attributes and all targeted relationships, during a create/update/delete - /// request. - /// - QueryLayer ComposeForUpdate(TId id, ResourceType primaryResourceType); + /// + /// Builds a query that retrieves the primary resource, including all of its attributes and all targeted relationships, during a create/update/delete + /// request. + /// + QueryLayer ComposeForUpdate(TId id, ResourceType primaryResourceType); - /// - /// Builds a query for each targeted relationship with a filter to match on its right resource IDs. - /// - IEnumerable<(QueryLayer, RelationshipAttribute)> ComposeForGetTargetedSecondaryResourceIds(IIdentifiable primaryResource); + /// + /// Builds a query for each targeted relationship with a filter to match on its right resource IDs. + /// + IEnumerable<(QueryLayer, RelationshipAttribute)> ComposeForGetTargetedSecondaryResourceIds(IIdentifiable primaryResource); - /// - /// Builds a query for the specified relationship with a filter to match on its right resource IDs. - /// - QueryLayer ComposeForGetRelationshipRightIds(RelationshipAttribute relationship, ICollection rightResourceIds); + /// + /// Builds a query for the specified relationship with a filter to match on its right resource IDs. + /// + QueryLayer ComposeForGetRelationshipRightIds(RelationshipAttribute relationship, ICollection rightResourceIds); - /// - /// Builds a query for a to-many relationship with a filter to match on its left and right resource IDs. - /// - QueryLayer ComposeForHasMany(HasManyAttribute hasManyRelationship, TId leftId, ICollection rightResourceIds); - } + /// + /// Builds a query for a to-many relationship with a filter to match on its left and right resource IDs. + /// + QueryLayer ComposeForHasMany(HasManyAttribute hasManyRelationship, TId leftId, ICollection rightResourceIds); } diff --git a/src/JsonApiDotNetCore/Queries/Internal/EvaluatedIncludeCache.cs b/src/JsonApiDotNetCore/Queries/Internal/EvaluatedIncludeCache.cs index 44baafc65f..509baf73ee 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/EvaluatedIncludeCache.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/EvaluatedIncludeCache.cs @@ -1,24 +1,23 @@ using JsonApiDotNetCore.Queries.Expressions; -namespace JsonApiDotNetCore.Queries.Internal +namespace JsonApiDotNetCore.Queries.Internal; + +/// +internal sealed class EvaluatedIncludeCache : IEvaluatedIncludeCache { + private IncludeExpression? _include; + /// - internal sealed class EvaluatedIncludeCache : IEvaluatedIncludeCache + public void Set(IncludeExpression include) { - private IncludeExpression? _include; - - /// - public void Set(IncludeExpression include) - { - ArgumentGuard.NotNull(include, nameof(include)); + ArgumentGuard.NotNull(include, nameof(include)); - _include = include; - } + _include = include; + } - /// - public IncludeExpression? Get() - { - return _include; - } + /// + public IncludeExpression? Get() + { + return _include; } } diff --git a/src/JsonApiDotNetCore/Queries/Internal/IEvaluatedIncludeCache.cs b/src/JsonApiDotNetCore/Queries/Internal/IEvaluatedIncludeCache.cs index 618caeb286..93b85c090e 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/IEvaluatedIncludeCache.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/IEvaluatedIncludeCache.cs @@ -1,23 +1,22 @@ using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources; -namespace JsonApiDotNetCore.Queries.Internal +namespace JsonApiDotNetCore.Queries.Internal; + +/// +/// Provides in-memory storage for the evaluated inclusion tree within a request. This tree is produced from query string and resource definition +/// callbacks. The cache enables the serialization layer to take changes from into +/// account. +/// +public interface IEvaluatedIncludeCache { /// - /// Provides in-memory storage for the evaluated inclusion tree within a request. This tree is produced from query string and resource definition - /// callbacks. The cache enables the serialization layer to take changes from into - /// account. + /// Stores the evaluated inclusion tree for later usage. /// - public interface IEvaluatedIncludeCache - { - /// - /// Stores the evaluated inclusion tree for later usage. - /// - void Set(IncludeExpression include); + void Set(IncludeExpression include); - /// - /// Gets the evaluated inclusion tree that was stored earlier. - /// - IncludeExpression? Get(); - } + /// + /// Gets the evaluated inclusion tree that was stored earlier. + /// + IncludeExpression? Get(); } diff --git a/src/JsonApiDotNetCore/Queries/Internal/ISparseFieldSetCache.cs b/src/JsonApiDotNetCore/Queries/Internal/ISparseFieldSetCache.cs index cb3ab1f2d3..32a4724637 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/ISparseFieldSetCache.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/ISparseFieldSetCache.cs @@ -3,38 +3,37 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Queries.Internal +namespace JsonApiDotNetCore.Queries.Internal; + +/// +/// Takes sparse fieldsets from s and invokes +/// on them. +/// +/// +/// This cache ensures that for each request (or operation per request), the resource definition callback is executed only twice per resource type. The +/// first invocation is used to obtain the fields to retrieve from the underlying data store, while the second invocation is used to determine which +/// fields to write to the response body. +/// +public interface ISparseFieldSetCache { /// - /// Takes sparse fieldsets from s and invokes - /// on them. + /// Gets the set of sparse fields to retrieve from the underlying data store. Returns an empty set to retrieve all fields. /// - /// - /// This cache ensures that for each request (or operation per request), the resource definition callback is executed only twice per resource type. The - /// first invocation is used to obtain the fields to retrieve from the underlying data store, while the second invocation is used to determine which - /// fields to write to the response body. - /// - public interface ISparseFieldSetCache - { - /// - /// Gets the set of sparse fields to retrieve from the underlying data store. Returns an empty set to retrieve all fields. - /// - IImmutableSet GetSparseFieldSetForQuery(ResourceType resourceType); + IImmutableSet GetSparseFieldSetForQuery(ResourceType resourceType); - /// - /// Gets the set of attributes to retrieve from the underlying data store for relationship endpoints. This always returns 'id', along with any additional - /// attributes from resource definition callback. - /// - IImmutableSet GetIdAttributeSetForRelationshipQuery(ResourceType resourceType); + /// + /// Gets the set of attributes to retrieve from the underlying data store for relationship endpoints. This always returns 'id', along with any additional + /// attributes from resource definition callback. + /// + IImmutableSet GetIdAttributeSetForRelationshipQuery(ResourceType resourceType); - /// - /// Gets the evaluated set of sparse fields to serialize into the response body. - /// - IImmutableSet GetSparseFieldSetForSerializer(ResourceType resourceType); + /// + /// Gets the evaluated set of sparse fields to serialize into the response body. + /// + IImmutableSet GetSparseFieldSetForSerializer(ResourceType resourceType); - /// - /// Resets the cached results from resource definition callbacks. - /// - void Reset(); - } + /// + /// Resets the cached results from resource definition callbacks. + /// + void Reset(); } diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/FieldChainRequirements.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/FieldChainRequirements.cs index 5864c18d3e..58ab6f0830 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/FieldChainRequirements.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/FieldChainRequirements.cs @@ -1,34 +1,32 @@ -using System; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Queries.Internal.Parsing +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 { /// - /// 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. + /// Indicates a single , optionally preceded by a chain of s. /// - [Flags] - public enum FieldChainRequirements - { - /// - /// Indicates a single , optionally preceded by a chain of s. - /// - EndsInAttribute = 1, + EndsInAttribute = 1, - /// - /// Indicates a single , optionally preceded by a chain of s. - /// - EndsInToOne = 2, + /// + /// 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 a single , optionally preceded by a chain of s. + /// + EndsInToMany = 4, - /// - /// Indicates one or a chain of s. - /// - IsRelationship = EndsInToOne | EndsInToMany - } + /// + /// 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 index de2f246503..c2b66f9063 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Immutable; using System.Reflection; using Humanizer; @@ -8,354 +7,353 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Queries.Internal.Parsing +namespace JsonApiDotNetCore.Queries.Internal.Parsing; + +[PublicAPI] +public class FilterParser : QueryExpressionParser { - [PublicAPI] - public class FilterParser : QueryExpressionParser - { - private readonly IResourceFactory _resourceFactory; - private readonly Action? _validateSingleFieldCallback; - private ResourceType? _resourceTypeInScope; + private readonly IResourceFactory _resourceFactory; + private readonly Action? _validateSingleFieldCallback; + private ResourceType? _resourceTypeInScope; - public FilterParser(IResourceFactory resourceFactory, Action? validateSingleFieldCallback = null) - { - ArgumentGuard.NotNull(resourceFactory, nameof(resourceFactory)); + public FilterParser(IResourceFactory resourceFactory, Action? validateSingleFieldCallback = null) + { + ArgumentGuard.NotNull(resourceFactory, nameof(resourceFactory)); - _resourceFactory = resourceFactory; - _validateSingleFieldCallback = validateSingleFieldCallback; - } + _resourceFactory = resourceFactory; + _validateSingleFieldCallback = validateSingleFieldCallback; + } - public FilterExpression Parse(string source, ResourceType resourceTypeInScope) - { - ArgumentGuard.NotNull(resourceTypeInScope, nameof(resourceTypeInScope)); + public FilterExpression Parse(string source, ResourceType resourceTypeInScope) + { + ArgumentGuard.NotNull(resourceTypeInScope, nameof(resourceTypeInScope)); - _resourceTypeInScope = resourceTypeInScope; + _resourceTypeInScope = resourceTypeInScope; - Tokenize(source); + Tokenize(source); - FilterExpression expression = ParseFilter(); + FilterExpression expression = ParseFilter(); - AssertTokenStackIsEmpty(); + AssertTokenStackIsEmpty(); - return expression; - } + return expression; + } - protected FilterExpression ParseFilter() + protected FilterExpression ParseFilter() + { + if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Text) { - if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Text) + switch (nextToken.Value) { - 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: { - 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(); - } + 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); + throw new QueryParseException("Filter function expected."); + } - FilterExpression child = ParseFilter(); + protected NotExpression ParseNot() + { + EatText(Keywords.Not); + EatSingleCharacterToken(TokenKind.OpenParen); - EatSingleCharacterToken(TokenKind.CloseParen); + FilterExpression child = ParseFilter(); - return new NotExpression(child); - } + EatSingleCharacterToken(TokenKind.CloseParen); - protected LogicalExpression ParseLogical(string operatorName) - { - EatText(operatorName); - EatSingleCharacterToken(TokenKind.OpenParen); + return new NotExpression(child); + } - ImmutableArray.Builder termsBuilder = ImmutableArray.CreateBuilder(); + protected LogicalExpression ParseLogical(string operatorName) + { + EatText(operatorName); + EatSingleCharacterToken(TokenKind.OpenParen); - FilterExpression term = ParseFilter(); - termsBuilder.Add(term); + ImmutableArray.Builder termsBuilder = ImmutableArray.CreateBuilder(); + FilterExpression term = ParseFilter(); + termsBuilder.Add(term); + + EatSingleCharacterToken(TokenKind.Comma); + + term = ParseFilter(); + termsBuilder.Add(term); + + while (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Comma) + { EatSingleCharacterToken(TokenKind.Comma); term = ParseFilter(); termsBuilder.Add(term); + } - while (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Comma) - { - EatSingleCharacterToken(TokenKind.Comma); + EatSingleCharacterToken(TokenKind.CloseParen); - term = ParseFilter(); - termsBuilder.Add(term); - } + var logicalOperator = Enum.Parse(operatorName.Pascalize()); + return new LogicalExpression(logicalOperator, termsBuilder.ToImmutable()); + } - EatSingleCharacterToken(TokenKind.CloseParen); + protected ComparisonExpression ParseComparison(string operatorName) + { + var comparisonOperator = Enum.Parse(operatorName.Pascalize()); - var logicalOperator = Enum.Parse(operatorName.Pascalize()); - return new LogicalExpression(logicalOperator, termsBuilder.ToImmutable()); - } + EatText(operatorName); + EatSingleCharacterToken(TokenKind.OpenParen); - protected ComparisonExpression ParseComparison(string operatorName) - { - var comparisonOperator = Enum.Parse(operatorName.Pascalize()); + // Allow equality comparison of a HasOne relationship with null. + FieldChainRequirements leftChainRequirements = comparisonOperator == ComparisonOperator.Equals + ? FieldChainRequirements.EndsInAttribute | FieldChainRequirements.EndsInToOne + : FieldChainRequirements.EndsInAttribute; - EatText(operatorName); - EatSingleCharacterToken(TokenKind.OpenParen); + QueryExpression leftTerm = ParseCountOrField(leftChainRequirements); - // Allow equality comparison of a HasOne relationship with null. - FieldChainRequirements leftChainRequirements = comparisonOperator == ComparisonOperator.Equals - ? FieldChainRequirements.EndsInAttribute | FieldChainRequirements.EndsInToOne - : FieldChainRequirements.EndsInAttribute; + EatSingleCharacterToken(TokenKind.Comma); - QueryExpression leftTerm = ParseCountOrField(leftChainRequirements); + QueryExpression rightTerm = ParseCountOrConstantOrNullOrField(FieldChainRequirements.EndsInAttribute); - EatSingleCharacterToken(TokenKind.Comma); + EatSingleCharacterToken(TokenKind.CloseParen); - QueryExpression rightTerm = ParseCountOrConstantOrNullOrField(FieldChainRequirements.EndsInAttribute); + if (leftTerm is ResourceFieldChainExpression leftChain) + { + if (leftChainRequirements.HasFlag(FieldChainRequirements.EndsInToOne) && rightTerm is not NullConstantExpression) + { + // Run another pass over left chain to have it fail when chain ends in relationship. + OnResolveFieldChain(leftChain.ToString(), FieldChainRequirements.EndsInAttribute); + } - EatSingleCharacterToken(TokenKind.CloseParen); + PropertyInfo leftProperty = leftChain.Fields[^1].Property; - if (leftTerm is ResourceFieldChainExpression leftChain) + if (leftProperty.Name == nameof(Identifiable.Id) && rightTerm is LiteralConstantExpression rightConstant) { - if (leftChainRequirements.HasFlag(FieldChainRequirements.EndsInToOne) && rightTerm is not NullConstantExpression) - { - // Run another pass over left chain to have it fail when chain ends in relationship. - OnResolveFieldChain(leftChain.ToString(), FieldChainRequirements.EndsInAttribute); - } + string id = DeObfuscateStringId(leftProperty.ReflectedType!, rightConstant.Value); + rightTerm = new LiteralConstantExpression(id); + } + } - PropertyInfo leftProperty = leftChain.Fields[^1].Property; + return new ComparisonExpression(comparisonOperator, leftTerm, rightTerm); + } - if (leftProperty.Name == nameof(Identifiable.Id) && rightTerm is LiteralConstantExpression rightConstant) - { - string id = DeObfuscateStringId(leftProperty.ReflectedType!, rightConstant.Value); - rightTerm = new LiteralConstantExpression(id); - } - } + protected MatchTextExpression ParseTextMatch(string matchFunctionName) + { + EatText(matchFunctionName); + EatSingleCharacterToken(TokenKind.OpenParen); - return new ComparisonExpression(comparisonOperator, leftTerm, rightTerm); - } + ResourceFieldChainExpression targetAttribute = ParseFieldChain(FieldChainRequirements.EndsInAttribute, null); - protected MatchTextExpression ParseTextMatch(string matchFunctionName) - { - EatText(matchFunctionName); - EatSingleCharacterToken(TokenKind.OpenParen); + EatSingleCharacterToken(TokenKind.Comma); - ResourceFieldChainExpression targetAttribute = ParseFieldChain(FieldChainRequirements.EndsInAttribute, null); + LiteralConstantExpression constant = ParseConstant(); - EatSingleCharacterToken(TokenKind.Comma); + EatSingleCharacterToken(TokenKind.CloseParen); - LiteralConstantExpression constant = ParseConstant(); + var matchKind = Enum.Parse(matchFunctionName.Pascalize()); + return new MatchTextExpression(targetAttribute, constant, matchKind); + } - EatSingleCharacterToken(TokenKind.CloseParen); + protected AnyExpression ParseAny() + { + EatText(Keywords.Any); + EatSingleCharacterToken(TokenKind.OpenParen); - var matchKind = Enum.Parse(matchFunctionName.Pascalize()); - return new MatchTextExpression(targetAttribute, constant, matchKind); - } + ResourceFieldChainExpression targetAttribute = ParseFieldChain(FieldChainRequirements.EndsInAttribute, null); - protected AnyExpression ParseAny() - { - EatText(Keywords.Any); - EatSingleCharacterToken(TokenKind.OpenParen); + EatSingleCharacterToken(TokenKind.Comma); - ResourceFieldChainExpression targetAttribute = ParseFieldChain(FieldChainRequirements.EndsInAttribute, null); + ImmutableHashSet.Builder constantsBuilder = ImmutableHashSet.CreateBuilder(); - EatSingleCharacterToken(TokenKind.Comma); + LiteralConstantExpression constant = ParseConstant(); + constantsBuilder.Add(constant); - ImmutableHashSet.Builder constantsBuilder = ImmutableHashSet.CreateBuilder(); + EatSingleCharacterToken(TokenKind.Comma); - LiteralConstantExpression constant = ParseConstant(); - constantsBuilder.Add(constant); + constant = ParseConstant(); + constantsBuilder.Add(constant); + while (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Comma) + { EatSingleCharacterToken(TokenKind.Comma); constant = ParseConstant(); constantsBuilder.Add(constant); + } - while (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Comma) - { - EatSingleCharacterToken(TokenKind.Comma); - - constant = ParseConstant(); - constantsBuilder.Add(constant); - } + EatSingleCharacterToken(TokenKind.CloseParen); - EatSingleCharacterToken(TokenKind.CloseParen); + IImmutableSet constantSet = constantsBuilder.ToImmutable(); - IImmutableSet constantSet = constantsBuilder.ToImmutable(); + PropertyInfo targetAttributeProperty = targetAttribute.Fields[^1].Property; - PropertyInfo targetAttributeProperty = targetAttribute.Fields[^1].Property; + if (targetAttributeProperty.Name == nameof(Identifiable.Id)) + { + constantSet = DeObfuscateIdConstants(constantSet, targetAttributeProperty); + } - if (targetAttributeProperty.Name == nameof(Identifiable.Id)) - { - constantSet = DeObfuscateIdConstants(constantSet, targetAttributeProperty); - } + return new AnyExpression(targetAttribute, constantSet); + } - return new AnyExpression(targetAttribute, constantSet); - } + private IImmutableSet DeObfuscateIdConstants(IImmutableSet constantSet, + PropertyInfo targetAttributeProperty) + { + ImmutableHashSet.Builder idConstantsBuilder = ImmutableHashSet.CreateBuilder(); - private IImmutableSet DeObfuscateIdConstants(IImmutableSet constantSet, - PropertyInfo targetAttributeProperty) + foreach (LiteralConstantExpression idConstant in constantSet) { - ImmutableHashSet.Builder idConstantsBuilder = ImmutableHashSet.CreateBuilder(); + string stringId = idConstant.Value; + string id = DeObfuscateStringId(targetAttributeProperty.ReflectedType!, stringId); - foreach (LiteralConstantExpression idConstant in constantSet) - { - string stringId = idConstant.Value; - string id = DeObfuscateStringId(targetAttributeProperty.ReflectedType!, stringId); + idConstantsBuilder.Add(new LiteralConstantExpression(id)); + } - idConstantsBuilder.Add(new LiteralConstantExpression(id)); - } + return idConstantsBuilder.ToImmutable(); + } - return idConstantsBuilder.ToImmutable(); - } + protected HasExpression ParseHas() + { + EatText(Keywords.Has); + EatSingleCharacterToken(TokenKind.OpenParen); - protected HasExpression ParseHas() + ResourceFieldChainExpression targetCollection = ParseFieldChain(FieldChainRequirements.EndsInToMany, null); + FilterExpression? filter = null; + + if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Comma) { - EatText(Keywords.Has); - EatSingleCharacterToken(TokenKind.OpenParen); + EatSingleCharacterToken(TokenKind.Comma); - ResourceFieldChainExpression targetCollection = ParseFieldChain(FieldChainRequirements.EndsInToMany, null); - FilterExpression? filter = null; + filter = ParseFilterInHas((HasManyAttribute)targetCollection.Fields[^1]); + } - if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Comma) - { - EatSingleCharacterToken(TokenKind.Comma); + EatSingleCharacterToken(TokenKind.CloseParen); - filter = ParseFilterInHas((HasManyAttribute)targetCollection.Fields[^1]); - } + return new HasExpression(targetCollection, filter); + } - EatSingleCharacterToken(TokenKind.CloseParen); + private FilterExpression ParseFilterInHas(HasManyAttribute hasManyRelationship) + { + ResourceType outerScopeBackup = _resourceTypeInScope!; - return new HasExpression(targetCollection, filter); - } + _resourceTypeInScope = hasManyRelationship.RightType; - private FilterExpression ParseFilterInHas(HasManyAttribute hasManyRelationship) - { - ResourceType outerScopeBackup = _resourceTypeInScope!; + FilterExpression filter = ParseFilter(); - _resourceTypeInScope = hasManyRelationship.RightType; + _resourceTypeInScope = outerScopeBackup; + return filter; + } - FilterExpression filter = ParseFilter(); + protected QueryExpression ParseCountOrField(FieldChainRequirements chainRequirements) + { + CountExpression? count = TryParseCount(); - _resourceTypeInScope = outerScopeBackup; - return filter; + if (count != null) + { + return count; } - protected QueryExpression ParseCountOrField(FieldChainRequirements chainRequirements) - { - CountExpression? count = TryParseCount(); + return ParseFieldChain(chainRequirements, "Count function or field name expected."); + } - if (count != null) - { - return count; - } + protected QueryExpression ParseCountOrConstantOrNullOrField(FieldChainRequirements chainRequirements) + { + CountExpression? count = TryParseCount(); - return ParseFieldChain(chainRequirements, "Count function or field name expected."); + if (count != null) + { + return count; } - protected QueryExpression ParseCountOrConstantOrNullOrField(FieldChainRequirements chainRequirements) + IdentifierExpression? constantOrNull = TryParseConstantOrNull(); + + if (constantOrNull != null) { - CountExpression? count = TryParseCount(); + return constantOrNull; + } - if (count != null) + 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) { - return count; + TokenStack.Pop(); + return NullConstantExpression.Instance; } - IdentifierExpression? constantOrNull = TryParseConstantOrNull(); - - if (constantOrNull != null) + if (nextToken.Kind == TokenKind.QuotedText) { - return constantOrNull; + TokenStack.Pop(); + return new LiteralConstantExpression(nextToken.Value!); } - - return ParseFieldChain(chainRequirements, "Count function, value between quotes, null or field name expected."); } - protected IdentifierExpression? TryParseConstantOrNull() + return null; + } + + protected LiteralConstantExpression ParseConstant() + { + if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.QuotedText) { - if (TokenStack.TryPeek(out Token? nextToken)) - { - if (nextToken.Kind == TokenKind.Text && nextToken.Value == Keywords.Null) - { - TokenStack.Pop(); - return NullConstantExpression.Instance; - } + return new LiteralConstantExpression(token.Value!); + } - if (nextToken.Kind == TokenKind.QuotedText) - { - TokenStack.Pop(); - return new LiteralConstantExpression(nextToken.Value!); - } - } + throw new QueryParseException("Value between quotes expected."); + } - return null; - } + private string DeObfuscateStringId(Type resourceClrType, string stringId) + { + IIdentifiable tempResource = _resourceFactory.CreateInstance(resourceClrType); + tempResource.StringId = stringId; + return tempResource.GetTypedId().ToString()!; + } - protected LiteralConstantExpression ParseConstant() + protected override IImmutableList OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) + { + if (chainRequirements == FieldChainRequirements.EndsInToMany) { - if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.QuotedText) - { - return new LiteralConstantExpression(token.Value!); - } - - throw new QueryParseException("Value between quotes expected."); + return ChainResolver.ResolveToOneChainEndingInToMany(_resourceTypeInScope!, path, _validateSingleFieldCallback); } - private string DeObfuscateStringId(Type resourceClrType, string stringId) + if (chainRequirements == FieldChainRequirements.EndsInAttribute) { - IIdentifiable tempResource = _resourceFactory.CreateInstance(resourceClrType); - tempResource.StringId = stringId; - return tempResource.GetTypedId().ToString()!; + return ChainResolver.ResolveToOneChainEndingInAttribute(_resourceTypeInScope!, path, _validateSingleFieldCallback); } - protected override IImmutableList OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) + if (chainRequirements.HasFlag(FieldChainRequirements.EndsInAttribute) && chainRequirements.HasFlag(FieldChainRequirements.EndsInToOne)) { - if (chainRequirements == FieldChainRequirements.EndsInToMany) - { - return ChainResolver.ResolveToOneChainEndingInToMany(_resourceTypeInScope!, path, _validateSingleFieldCallback); - } - - if (chainRequirements == FieldChainRequirements.EndsInAttribute) - { - return ChainResolver.ResolveToOneChainEndingInAttribute(_resourceTypeInScope!, path, _validateSingleFieldCallback); - } - - if (chainRequirements.HasFlag(FieldChainRequirements.EndsInAttribute) && chainRequirements.HasFlag(FieldChainRequirements.EndsInToOne)) - { - return ChainResolver.ResolveToOneChainEndingInAttributeOrToOne(_resourceTypeInScope!, path, _validateSingleFieldCallback); - } - - throw new InvalidOperationException($"Unexpected combination of chain requirement flags '{chainRequirements}'."); + return ChainResolver.ResolveToOneChainEndingInAttributeOrToOne(_resourceTypeInScope!, 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 index 012fd617c3..95e51dca92 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/IncludeParser.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/IncludeParser.cs @@ -1,79 +1,75 @@ -using System; -using System.Collections.Generic; using System.Collections.Immutable; -using System.Linq; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Queries.Internal.Parsing +namespace JsonApiDotNetCore.Queries.Internal.Parsing; + +[PublicAPI] +public class IncludeParser : QueryExpressionParser { - [PublicAPI] - public class IncludeParser : QueryExpressionParser - { - private static readonly IncludeChainConverter IncludeChainConverter = new(); + private static readonly IncludeChainConverter IncludeChainConverter = new(); - private readonly Action? _validateSingleRelationshipCallback; - private ResourceType? _resourceTypeInScope; + private readonly Action? _validateSingleRelationshipCallback; + private ResourceType? _resourceTypeInScope; - public IncludeParser(Action? validateSingleRelationshipCallback = null) - { - _validateSingleRelationshipCallback = validateSingleRelationshipCallback; - } + public IncludeParser(Action? validateSingleRelationshipCallback = null) + { + _validateSingleRelationshipCallback = validateSingleRelationshipCallback; + } - public IncludeExpression Parse(string source, ResourceType resourceTypeInScope, int? maximumDepth) - { - ArgumentGuard.NotNull(resourceTypeInScope, nameof(resourceTypeInScope)); + public IncludeExpression Parse(string source, ResourceType resourceTypeInScope, int? maximumDepth) + { + ArgumentGuard.NotNull(resourceTypeInScope, nameof(resourceTypeInScope)); - _resourceTypeInScope = resourceTypeInScope; + _resourceTypeInScope = resourceTypeInScope; - Tokenize(source); + Tokenize(source); - IncludeExpression expression = ParseInclude(maximumDepth); + IncludeExpression expression = ParseInclude(maximumDepth); - AssertTokenStackIsEmpty(); + AssertTokenStackIsEmpty(); - return expression; - } + return expression; + } - protected IncludeExpression ParseInclude(int? maximumDepth) - { - ResourceFieldChainExpression firstChain = ParseFieldChain(FieldChainRequirements.IsRelationship, "Relationship name expected."); + protected IncludeExpression ParseInclude(int? maximumDepth) + { + ResourceFieldChainExpression firstChain = ParseFieldChain(FieldChainRequirements.IsRelationship, "Relationship name expected."); - List chains = firstChain.AsList(); + List chains = firstChain.AsList(); - while (TokenStack.Any()) - { - EatSingleCharacterToken(TokenKind.Comma); + while (TokenStack.Any()) + { + EatSingleCharacterToken(TokenKind.Comma); - ResourceFieldChainExpression nextChain = ParseFieldChain(FieldChainRequirements.IsRelationship, "Relationship name expected."); - chains.Add(nextChain); - } + ResourceFieldChainExpression nextChain = ParseFieldChain(FieldChainRequirements.IsRelationship, "Relationship name expected."); + chains.Add(nextChain); + } - ValidateMaximumIncludeDepth(maximumDepth, chains); + ValidateMaximumIncludeDepth(maximumDepth, chains); - return IncludeChainConverter.FromRelationshipChains(chains); - } + return IncludeChainConverter.FromRelationshipChains(chains); + } - private static void ValidateMaximumIncludeDepth(int? maximumDepth, IEnumerable chains) + private static void ValidateMaximumIncludeDepth(int? maximumDepth, IEnumerable chains) + { + if (maximumDepth != null) { - if (maximumDepth != null) + foreach (ResourceFieldChainExpression chain in chains) { - foreach (ResourceFieldChainExpression chain in chains) + if (chain.Fields.Count > maximumDepth) { - if (chain.Fields.Count > maximumDepth) - { - string path = string.Join('.', chain.Fields.Select(field => field.PublicName)); - throw new QueryParseException($"Including '{path}' exceeds the maximum inclusion depth of {maximumDepth}."); - } + string path = string.Join('.', chain.Fields.Select(field => field.PublicName)); + throw new QueryParseException($"Including '{path}' exceeds the maximum inclusion depth of {maximumDepth}."); } } } + } - protected override IImmutableList OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) - { - return ChainResolver.ResolveRelationshipChain(_resourceTypeInScope!, path, _validateSingleRelationshipCallback); - } + protected override IImmutableList OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) + { + return ChainResolver.ResolveRelationshipChain(_resourceTypeInScope!, path, _validateSingleRelationshipCallback); } } diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/Keywords.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/Keywords.cs index 0bf32ff17e..dd0bda51b7 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/Keywords.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/Keywords.cs @@ -3,25 +3,24 @@ #pragma warning disable AV1008 // Class should not be static #pragma warning disable AV1010 // Member hides inherited member -namespace JsonApiDotNetCore.Queries.Internal.Parsing +namespace JsonApiDotNetCore.Queries.Internal.Parsing; + +[PublicAPI] +public static class Keywords { - [PublicAPI] - 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"; - } + 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 index dd76b4e58f..29c7713b11 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/PaginationParser.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/PaginationParser.cs @@ -1,112 +1,109 @@ -using System; using System.Collections.Immutable; -using System.Linq; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Queries.Internal.Parsing +namespace JsonApiDotNetCore.Queries.Internal.Parsing; + +[PublicAPI] +public class PaginationParser : QueryExpressionParser { - [PublicAPI] - public class PaginationParser : QueryExpressionParser + private readonly Action? _validateSingleFieldCallback; + private ResourceType? _resourceTypeInScope; + + public PaginationParser(Action? validateSingleFieldCallback = null) { - private readonly Action? _validateSingleFieldCallback; - private ResourceType? _resourceTypeInScope; + _validateSingleFieldCallback = validateSingleFieldCallback; + } - public PaginationParser(Action? validateSingleFieldCallback = null) - { - _validateSingleFieldCallback = validateSingleFieldCallback; - } + public PaginationQueryStringValueExpression Parse(string source, ResourceType resourceTypeInScope) + { + ArgumentGuard.NotNull(resourceTypeInScope, nameof(resourceTypeInScope)); - public PaginationQueryStringValueExpression Parse(string source, ResourceType resourceTypeInScope) - { - ArgumentGuard.NotNull(resourceTypeInScope, nameof(resourceTypeInScope)); + _resourceTypeInScope = resourceTypeInScope; - _resourceTypeInScope = resourceTypeInScope; + Tokenize(source); - Tokenize(source); + PaginationQueryStringValueExpression expression = ParsePagination(); - PaginationQueryStringValueExpression expression = ParsePagination(); + AssertTokenStackIsEmpty(); - AssertTokenStackIsEmpty(); + return expression; + } - return expression; - } + protected PaginationQueryStringValueExpression ParsePagination() + { + ImmutableArray.Builder elementsBuilder = + ImmutableArray.CreateBuilder(); + + PaginationElementQueryStringValueExpression element = ParsePaginationElement(); + elementsBuilder.Add(element); - protected PaginationQueryStringValueExpression ParsePagination() + while (TokenStack.Any()) { - ImmutableArray.Builder elementsBuilder = - ImmutableArray.CreateBuilder(); + EatSingleCharacterToken(TokenKind.Comma); - PaginationElementQueryStringValueExpression element = ParsePaginationElement(); + element = ParsePaginationElement(); elementsBuilder.Add(element); - - while (TokenStack.Any()) - { - EatSingleCharacterToken(TokenKind.Comma); - - element = ParsePaginationElement(); - elementsBuilder.Add(element); - } - - return new PaginationQueryStringValueExpression(elementsBuilder.ToImmutable()); } - protected PaginationElementQueryStringValueExpression ParsePaginationElement() - { - int? number = TryParseNumber(); + return new PaginationQueryStringValueExpression(elementsBuilder.ToImmutable()); + } - if (number != null) - { - return new PaginationElementQueryStringValueExpression(null, number.Value); - } + protected PaginationElementQueryStringValueExpression ParsePaginationElement() + { + int? number = TryParseNumber(); - ResourceFieldChainExpression scope = ParseFieldChain(FieldChainRequirements.EndsInToMany, "Number or relationship name expected."); + if (number != null) + { + return new PaginationElementQueryStringValueExpression(null, number.Value); + } - EatSingleCharacterToken(TokenKind.Colon); + ResourceFieldChainExpression scope = ParseFieldChain(FieldChainRequirements.EndsInToMany, "Number or relationship name expected."); - number = TryParseNumber(); + EatSingleCharacterToken(TokenKind.Colon); - if (number == null) - { - throw new QueryParseException("Number expected."); - } + number = TryParseNumber(); - return new PaginationElementQueryStringValueExpression(scope, number.Value); + if (number == null) + { + throw new QueryParseException("Number expected."); } - protected int? TryParseNumber() + return new PaginationElementQueryStringValueExpression(scope, number.Value); + } + + protected int? TryParseNumber() + { + if (TokenStack.TryPeek(out Token? nextToken)) { - if (TokenStack.TryPeek(out Token? nextToken)) + int number; + + if (nextToken.Kind == TokenKind.Minus) { - int number; + TokenStack.Pop(); - if (nextToken.Kind == TokenKind.Minus) + if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.Text && int.TryParse(token.Value, out number)) { - 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."); + return -number; } - if (nextToken.Kind == TokenKind.Text && int.TryParse(nextToken.Value, out number)) - { - TokenStack.Pop(); - return number; - } + throw new QueryParseException("Digits expected."); } - return null; + if (nextToken.Kind == TokenKind.Text && int.TryParse(nextToken.Value, out number)) + { + TokenStack.Pop(); + return number; + } } - protected override IImmutableList OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) - { - return ChainResolver.ResolveToManyChain(_resourceTypeInScope!, path, _validateSingleFieldCallback); - } + return null; + } + + protected override IImmutableList OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) + { + return ChainResolver.ResolveToManyChain(_resourceTypeInScope!, path, _validateSingleFieldCallback); } } diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryExpressionParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryExpressionParser.cs index f0e1392e30..4dc7230c24 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryExpressionParser.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryExpressionParser.cs @@ -1,92 +1,89 @@ -using System.Collections.Generic; using System.Collections.Immutable; -using System.Linq; using JetBrains.Annotations; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Queries.Internal.Parsing +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. +/// +[PublicAPI] +public abstract class QueryExpressionParser { + protected Stack TokenStack { get; private set; } = null!; + private protected ResourceFieldChainResolver ChainResolver { get; } = new(); + /// - /// The base class for parsing query string parameters, using the Recursive Descent algorithm. + /// Takes a dotted path and walks the resource graph to produce a chain of fields. /// - /// - /// 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. - /// - [PublicAPI] - public abstract class QueryExpressionParser - { - protected Stack TokenStack { get; private set; } = null!; - private protected ResourceFieldChainResolver ChainResolver { get; } = new(); + protected abstract IImmutableList OnResolveFieldChain(string path, FieldChainRequirements chainRequirements); - /// - /// Takes a dotted path and walks the resource graph to produce a chain of fields. - /// - protected abstract IImmutableList OnResolveFieldChain(string path, FieldChainRequirements chainRequirements); + protected virtual void Tokenize(string source) + { + var tokenizer = new QueryTokenizer(source); + TokenStack = new Stack(tokenizer.EnumerateTokens().Reverse()); + } - protected virtual void Tokenize(string source) + protected ResourceFieldChainExpression ParseFieldChain(FieldChainRequirements chainRequirements, string? alternativeErrorMessage) + { + if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.Text) { - var tokenizer = new QueryTokenizer(source); - TokenStack = new Stack(tokenizer.EnumerateTokens().Reverse()); - } + IImmutableList chain = OnResolveFieldChain(token.Value!, chainRequirements); - protected ResourceFieldChainExpression ParseFieldChain(FieldChainRequirements chainRequirements, string? alternativeErrorMessage) - { - if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.Text) + if (chain.Any()) { - IImmutableList chain = OnResolveFieldChain(token.Value!, chainRequirements); - - if (chain.Any()) - { - return new ResourceFieldChainExpression(chain); - } + return new ResourceFieldChainExpression(chain); } - - throw new QueryParseException(alternativeErrorMessage ?? "Field name expected."); } - protected CountExpression? TryParseCount() + throw new QueryParseException(alternativeErrorMessage ?? "Field name expected."); + } + + protected CountExpression? TryParseCount() + { + if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Text && nextToken.Value == Keywords.Count) { - if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Text && nextToken.Value == Keywords.Count) - { - TokenStack.Pop(); + TokenStack.Pop(); - EatSingleCharacterToken(TokenKind.OpenParen); + EatSingleCharacterToken(TokenKind.OpenParen); - ResourceFieldChainExpression targetCollection = ParseFieldChain(FieldChainRequirements.EndsInToMany, null); + ResourceFieldChainExpression targetCollection = ParseFieldChain(FieldChainRequirements.EndsInToMany, null); - EatSingleCharacterToken(TokenKind.CloseParen); + EatSingleCharacterToken(TokenKind.CloseParen); - return new CountExpression(targetCollection); - } - - return null; + return new CountExpression(targetCollection); } - protected void EatText(string text) + return null; + } + + protected void EatText(string text) + { + if (!TokenStack.TryPop(out Token? token) || token.Kind != TokenKind.Text || token.Value != text) { - if (!TokenStack.TryPop(out Token? token) || token.Kind != TokenKind.Text || token.Value != text) - { - throw new QueryParseException($"{text} expected."); - } + throw new QueryParseException($"{text} expected."); } + } - protected void EatSingleCharacterToken(TokenKind kind) + protected void EatSingleCharacterToken(TokenKind kind) + { + if (!TokenStack.TryPop(out Token? token) || token.Kind != 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."); - } + char ch = QueryTokenizer.SingleCharacterToTokenKinds.Single(pair => pair.Value == kind).Key; + throw new QueryParseException($"{ch} expected."); } + } - protected void AssertTokenStackIsEmpty() + protected void AssertTokenStackIsEmpty() + { + if (TokenStack.Any()) { - if (TokenStack.Any()) - { - throw new QueryParseException("End of expression expected."); - } + 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 index 26010e3528..2265ca56da 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryParseException.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryParseException.cs @@ -1,14 +1,12 @@ -using System; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Queries.Internal.Parsing +namespace JsonApiDotNetCore.Queries.Internal.Parsing; + +[PublicAPI] +public sealed class QueryParseException : Exception { - [PublicAPI] - public sealed class QueryParseException : Exception + public QueryParseException(string message) + : base(message) { - public QueryParseException(string message) - : base(message) - { - } } } diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryStringParameterScopeParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryStringParameterScopeParser.cs index c25ce3a1c3..3cba8e4515 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryStringParameterScopeParser.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryStringParameterScopeParser.cs @@ -1,78 +1,76 @@ -using System; using System.Collections.Immutable; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Queries.Internal.Parsing +namespace JsonApiDotNetCore.Queries.Internal.Parsing; + +[PublicAPI] +public class QueryStringParameterScopeParser : QueryExpressionParser { - [PublicAPI] - public class QueryStringParameterScopeParser : QueryExpressionParser + private readonly FieldChainRequirements _chainRequirements; + private readonly Action? _validateSingleFieldCallback; + private ResourceType? _resourceTypeInScope; + + public QueryStringParameterScopeParser(FieldChainRequirements chainRequirements, + Action? validateSingleFieldCallback = null) { - private readonly FieldChainRequirements _chainRequirements; - private readonly Action? _validateSingleFieldCallback; - private ResourceType? _resourceTypeInScope; + _chainRequirements = chainRequirements; + _validateSingleFieldCallback = validateSingleFieldCallback; + } - public QueryStringParameterScopeParser(FieldChainRequirements chainRequirements, - Action? validateSingleFieldCallback = null) - { - _chainRequirements = chainRequirements; - _validateSingleFieldCallback = validateSingleFieldCallback; - } + public QueryStringParameterScopeExpression Parse(string source, ResourceType resourceTypeInScope) + { + ArgumentGuard.NotNull(resourceTypeInScope, nameof(resourceTypeInScope)); - public QueryStringParameterScopeExpression Parse(string source, ResourceType resourceTypeInScope) - { - ArgumentGuard.NotNull(resourceTypeInScope, nameof(resourceTypeInScope)); + _resourceTypeInScope = resourceTypeInScope; - _resourceTypeInScope = resourceTypeInScope; + Tokenize(source); - Tokenize(source); + QueryStringParameterScopeExpression expression = ParseQueryStringParameterScope(); - QueryStringParameterScopeExpression expression = ParseQueryStringParameterScope(); + AssertTokenStackIsEmpty(); - AssertTokenStackIsEmpty(); + return expression; + } - return expression; + protected QueryStringParameterScopeExpression ParseQueryStringParameterScope() + { + if (!TokenStack.TryPop(out Token? token) || token.Kind != TokenKind.Text) + { + throw new QueryParseException("Parameter name expected."); } - 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!); - var name = new LiteralConstantExpression(token.Value!); + ResourceFieldChainExpression? scope = null; - ResourceFieldChainExpression? scope = null; + if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.OpenBracket) + { + TokenStack.Pop(); - if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.OpenBracket) - { - TokenStack.Pop(); + scope = ParseFieldChain(_chainRequirements, null); - scope = ParseFieldChain(_chainRequirements, null); + EatSingleCharacterToken(TokenKind.CloseBracket); + } - EatSingleCharacterToken(TokenKind.CloseBracket); - } + return new QueryStringParameterScopeExpression(name, scope); + } - return new QueryStringParameterScopeExpression(name, scope); + protected override IImmutableList 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(_resourceTypeInScope!, path, _validateSingleFieldCallback); } - protected override IImmutableList OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) + if (chainRequirements == FieldChainRequirements.IsRelationship) { - 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(_resourceTypeInScope!, path, _validateSingleFieldCallback); - } - - if (chainRequirements == FieldChainRequirements.IsRelationship) - { - return ChainResolver.ResolveRelationshipChain(_resourceTypeInScope!, path, _validateSingleFieldCallback); - } - - throw new InvalidOperationException($"Unexpected combination of chain requirement flags '{chainRequirements}'."); + return ChainResolver.ResolveRelationshipChain(_resourceTypeInScope!, 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 index 518531902a..6676cae30f 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryTokenizer.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryTokenizer.cs @@ -1,142 +1,140 @@ -using System.Collections.Generic; using System.Collections.ObjectModel; using System.Text; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Queries.Internal.Parsing +namespace JsonApiDotNetCore.Queries.Internal.Parsing; + +[PublicAPI] +public sealed class QueryTokenizer { - [PublicAPI] - 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(); - private int _offset; - private bool _isInQuotedSection; - - public QueryTokenizer(string source) + public static readonly IReadOnlyDictionary SingleCharacterToTokenKinds = new ReadOnlyDictionary( + new Dictionary { - ArgumentGuard.NotNull(source, nameof(source)); + ['('] = TokenKind.OpenParen, + [')'] = TokenKind.CloseParen, + ['['] = TokenKind.OpenBracket, + [']'] = TokenKind.CloseBracket, + [','] = TokenKind.Comma, + [':'] = TokenKind.Colon, + ['-'] = TokenKind.Minus + }); + + private readonly string _source; + private readonly StringBuilder _textBuffer = new(); + private int _offset; + private bool _isInQuotedSection; + + public QueryTokenizer(string source) + { + ArgumentGuard.NotNull(source, nameof(source)); - _source = source; - } + _source = source; + } + + public IEnumerable EnumerateTokens() + { + _textBuffer.Clear(); + _isInQuotedSection = false; + _offset = 0; - public IEnumerable EnumerateTokens() + while (_offset < _source.Length) { - _textBuffer.Clear(); - _isInQuotedSection = false; - _offset = 0; + char ch = _source[_offset]; - while (_offset < _source.Length) + if (ch == '\'') { - char ch = _source[_offset]; + if (_isInQuotedSection) + { + char? peeked = PeekChar(); + + if (peeked == '\'') + { + _textBuffer.Append(ch); + _offset += 2; + continue; + } - if (ch == '\'') + _isInQuotedSection = false; + + Token literalToken = ProduceTokenFromTextBuffer(true)!; + yield return literalToken; + } + else { - if (_isInQuotedSection) + if (_textBuffer.Length > 0) { - char? peeked = PeekChar(); + throw new QueryParseException("Unexpected ' outside text."); + } - if (peeked == '\'') - { - _textBuffer.Append(ch); - _offset += 2; - continue; - } + _isInQuotedSection = true; + } + } + else + { + TokenKind? singleCharacterTokenKind = _isInQuotedSection ? null : TryGetSingleCharacterTokenKind(ch); - _isInQuotedSection = false; + if (singleCharacterTokenKind != null && !IsMinusInsideText(singleCharacterTokenKind.Value)) + { + Token? identifierToken = ProduceTokenFromTextBuffer(false); - Token literalToken = ProduceTokenFromTextBuffer(true)!; - yield return literalToken; - } - else + if (identifierToken != null) { - if (_textBuffer.Length > 0) - { - throw new QueryParseException("Unexpected ' outside text."); - } - - _isInQuotedSection = true; + yield return identifierToken; } + + yield return new Token(singleCharacterTokenKind.Value); } else { - TokenKind? singleCharacterTokenKind = _isInQuotedSection ? null : TryGetSingleCharacterTokenKind(ch); - - if (singleCharacterTokenKind != null && !IsMinusInsideText(singleCharacterTokenKind.Value)) + if (_textBuffer.Length == 0 && ch == ' ' && !_isInQuotedSection) { - Token? identifierToken = ProduceTokenFromTextBuffer(false); - - if (identifierToken != null) - { - yield return identifierToken; - } - - yield return new Token(singleCharacterTokenKind.Value); + throw new QueryParseException("Unexpected whitespace."); } - else - { - if (_textBuffer.Length == 0 && ch == ' ' && !_isInQuotedSection) - { - throw new QueryParseException("Unexpected whitespace."); - } - _textBuffer.Append(ch); - } + _textBuffer.Append(ch); } - - _offset++; - } - - if (_isInQuotedSection) - { - throw new QueryParseException("' expected."); } - Token? lastToken = ProduceTokenFromTextBuffer(false); - - if (lastToken != null) - { - yield return lastToken; - } + _offset++; } - private bool IsMinusInsideText(TokenKind kind) + if (_isInQuotedSection) { - return kind == TokenKind.Minus && _textBuffer.Length > 0; + throw new QueryParseException("' expected."); } - private char? PeekChar() - { - return _offset + 1 < _source.Length ? _source[_offset + 1] : null; - } + Token? lastToken = ProduceTokenFromTextBuffer(false); - private static TokenKind? TryGetSingleCharacterTokenKind(char ch) + if (lastToken != null) { - return SingleCharacterToTokenKinds.TryGetValue(ch, out TokenKind tokenKind) ? tokenKind : null; + yield return lastToken; } + } - 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); - } + private bool IsMinusInsideText(TokenKind kind) + { + return kind == TokenKind.Minus && _textBuffer.Length > 0; + } + + private char? PeekChar() + { + return _offset + 1 < _source.Length ? _source[_offset + 1] : null; + } - return null; + private static TokenKind? TryGetSingleCharacterTokenKind(char ch) + { + return SingleCharacterToTokenKinds.TryGetValue(ch, out TokenKind tokenKind) ? tokenKind : 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 index 3233ec92d1..33f3643aa8 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldChainResolver.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldChainResolver.cs @@ -1,259 +1,256 @@ -using System; using System.Collections.Immutable; -using System.Linq; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Queries.Internal.Parsing +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 { /// - /// Provides helper methods to resolve a chain of fields (relationships and attributes) from the resource graph. + /// Resolves a chain of relationships that ends in a to-many relationship, for example: blogs.owner.articles.comments /// - internal sealed class ResourceFieldChainResolver + public IImmutableList ResolveToManyChain(ResourceType resourceType, string path, + Action? validateCallback = null) { - /// - /// Resolves a chain of relationships that ends in a to-many relationship, for example: blogs.owner.articles.comments - /// - public IImmutableList ResolveToManyChain(ResourceType resourceType, string path, - Action? validateCallback = null) - { - ImmutableArray.Builder chainBuilder = ImmutableArray.CreateBuilder(); + ImmutableArray.Builder chainBuilder = ImmutableArray.CreateBuilder(); - string[] publicNameParts = path.Split("."); - ResourceType nextResourceType = resourceType; + string[] publicNameParts = path.Split("."); + ResourceType nextResourceType = resourceType; - foreach (string publicName in publicNameParts[..^1]) - { - RelationshipAttribute relationship = GetRelationship(publicName, nextResourceType, path); + foreach (string publicName in publicNameParts[..^1]) + { + RelationshipAttribute relationship = GetRelationship(publicName, nextResourceType, path); - validateCallback?.Invoke(relationship, nextResourceType, path); + validateCallback?.Invoke(relationship, nextResourceType, path); - chainBuilder.Add(relationship); - nextResourceType = relationship.RightType; - } + chainBuilder.Add(relationship); + nextResourceType = relationship.RightType; + } - string lastName = publicNameParts[^1]; - RelationshipAttribute lastToManyRelationship = GetToManyRelationship(lastName, nextResourceType, path); + string lastName = publicNameParts[^1]; + RelationshipAttribute lastToManyRelationship = GetToManyRelationship(lastName, nextResourceType, path); - validateCallback?.Invoke(lastToManyRelationship, nextResourceType, path); + validateCallback?.Invoke(lastToManyRelationship, nextResourceType, path); - chainBuilder.Add(lastToManyRelationship); - return chainBuilder.ToImmutable(); - } + chainBuilder.Add(lastToManyRelationship); + return chainBuilder.ToImmutable(); + } + + /// + /// Resolves a chain of relationships. + /// + /// blogs.articles.comments + /// + /// + /// author.address + /// + /// + /// articles.revisions.author + /// + /// + public IImmutableList ResolveRelationshipChain(ResourceType resourceType, string path, + Action? validateCallback = null) + { + ImmutableArray.Builder chainBuilder = ImmutableArray.CreateBuilder(); + ResourceType nextResourceType = resourceType; - /// - /// Resolves a chain of relationships. - /// - /// blogs.articles.comments - /// - /// - /// author.address - /// - /// - /// articles.revisions.author - /// - /// - public IImmutableList ResolveRelationshipChain(ResourceType resourceType, string path, - Action? validateCallback = null) + foreach (string publicName in path.Split(".")) { - ImmutableArray.Builder chainBuilder = ImmutableArray.CreateBuilder(); - ResourceType nextResourceType = resourceType; + RelationshipAttribute relationship = GetRelationship(publicName, nextResourceType, path); - foreach (string publicName in path.Split(".")) - { - RelationshipAttribute relationship = GetRelationship(publicName, nextResourceType, path); + validateCallback?.Invoke(relationship, nextResourceType, path); - validateCallback?.Invoke(relationship, nextResourceType, path); + chainBuilder.Add(relationship); + nextResourceType = relationship.RightType; + } - chainBuilder.Add(relationship); - nextResourceType = relationship.RightType; - } + return chainBuilder.ToImmutable(); + } - return chainBuilder.ToImmutable(); - } + /// + /// Resolves a chain of to-one relationships that ends in an attribute. + /// + /// author.address.country.name + /// + /// name + /// + public IImmutableList ResolveToOneChainEndingInAttribute(ResourceType resourceType, string path, + Action? validateCallback = null) + { + ImmutableArray.Builder chainBuilder = ImmutableArray.CreateBuilder(); - /// - /// Resolves a chain of to-one relationships that ends in an attribute. - /// - /// author.address.country.name - /// - /// name - /// - public IImmutableList ResolveToOneChainEndingInAttribute(ResourceType resourceType, string path, - Action? validateCallback = null) + string[] publicNameParts = path.Split("."); + ResourceType nextResourceType = resourceType; + + foreach (string publicName in publicNameParts[..^1]) { - ImmutableArray.Builder chainBuilder = ImmutableArray.CreateBuilder(); + RelationshipAttribute toOneRelationship = GetToOneRelationship(publicName, nextResourceType, path); - string[] publicNameParts = path.Split("."); - ResourceType nextResourceType = resourceType; + validateCallback?.Invoke(toOneRelationship, nextResourceType, path); - foreach (string publicName in publicNameParts[..^1]) - { - RelationshipAttribute toOneRelationship = GetToOneRelationship(publicName, nextResourceType, path); + chainBuilder.Add(toOneRelationship); + nextResourceType = toOneRelationship.RightType; + } - validateCallback?.Invoke(toOneRelationship, nextResourceType, path); + string lastName = publicNameParts[^1]; + AttrAttribute lastAttribute = GetAttribute(lastName, nextResourceType, path); - chainBuilder.Add(toOneRelationship); - nextResourceType = toOneRelationship.RightType; - } + validateCallback?.Invoke(lastAttribute, nextResourceType, path); - string lastName = publicNameParts[^1]; - AttrAttribute lastAttribute = GetAttribute(lastName, nextResourceType, path); + chainBuilder.Add(lastAttribute); + return chainBuilder.ToImmutable(); + } - validateCallback?.Invoke(lastAttribute, nextResourceType, path); + /// + /// Resolves a chain of to-one relationships that ends in a to-many relationship. + /// + /// article.comments + /// + /// + /// comments + /// + /// + public IImmutableList ResolveToOneChainEndingInToMany(ResourceType resourceType, string path, + Action? validateCallback = null) + { + ImmutableArray.Builder chainBuilder = ImmutableArray.CreateBuilder(); - chainBuilder.Add(lastAttribute); - return chainBuilder.ToImmutable(); - } + string[] publicNameParts = path.Split("."); + ResourceType nextResourceType = resourceType; - /// - /// Resolves a chain of to-one relationships that ends in a to-many relationship. - /// - /// article.comments - /// - /// - /// comments - /// - /// - public IImmutableList ResolveToOneChainEndingInToMany(ResourceType resourceType, string path, - Action? validateCallback = null) + foreach (string publicName in publicNameParts[..^1]) { - ImmutableArray.Builder chainBuilder = ImmutableArray.CreateBuilder(); + RelationshipAttribute toOneRelationship = GetToOneRelationship(publicName, nextResourceType, path); - string[] publicNameParts = path.Split("."); - ResourceType nextResourceType = resourceType; + validateCallback?.Invoke(toOneRelationship, nextResourceType, path); - foreach (string publicName in publicNameParts[..^1]) - { - RelationshipAttribute toOneRelationship = GetToOneRelationship(publicName, nextResourceType, path); + chainBuilder.Add(toOneRelationship); + nextResourceType = toOneRelationship.RightType; + } - validateCallback?.Invoke(toOneRelationship, nextResourceType, path); + string lastName = publicNameParts[^1]; - chainBuilder.Add(toOneRelationship); - nextResourceType = toOneRelationship.RightType; - } + RelationshipAttribute toManyRelationship = GetToManyRelationship(lastName, nextResourceType, path); - string lastName = publicNameParts[^1]; + validateCallback?.Invoke(toManyRelationship, nextResourceType, path); - RelationshipAttribute toManyRelationship = GetToManyRelationship(lastName, nextResourceType, path); + chainBuilder.Add(toManyRelationship); + return chainBuilder.ToImmutable(); + } - validateCallback?.Invoke(toManyRelationship, nextResourceType, path); + /// + /// 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 IImmutableList ResolveToOneChainEndingInAttributeOrToOne(ResourceType resourceType, string path, + Action? validateCallback = null) + { + ImmutableArray.Builder chainBuilder = ImmutableArray.CreateBuilder(); - chainBuilder.Add(toManyRelationship); - return chainBuilder.ToImmutable(); - } + string[] publicNameParts = path.Split("."); + ResourceType nextResourceType = resourceType; - /// - /// 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 IImmutableList ResolveToOneChainEndingInAttributeOrToOne(ResourceType resourceType, string path, - Action? validateCallback = null) + foreach (string publicName in publicNameParts[..^1]) { - ImmutableArray.Builder chainBuilder = ImmutableArray.CreateBuilder(); - - string[] publicNameParts = path.Split("."); - ResourceType nextResourceType = resourceType; + RelationshipAttribute toOneRelationship = GetToOneRelationship(publicName, nextResourceType, path); - foreach (string publicName in publicNameParts[..^1]) - { - RelationshipAttribute toOneRelationship = GetToOneRelationship(publicName, nextResourceType, path); + validateCallback?.Invoke(toOneRelationship, nextResourceType, path); - validateCallback?.Invoke(toOneRelationship, nextResourceType, path); + chainBuilder.Add(toOneRelationship); + nextResourceType = toOneRelationship.RightType; + } - chainBuilder.Add(toOneRelationship); - nextResourceType = toOneRelationship.RightType; - } + string lastName = publicNameParts[^1]; + ResourceFieldAttribute lastField = GetField(lastName, nextResourceType, path); - string lastName = publicNameParts[^1]; - ResourceFieldAttribute lastField = GetField(lastName, nextResourceType, path); + if (lastField is HasManyAttribute) + { + throw new QueryParseException(path == lastName + ? $"Field '{lastName}' must be an attribute or a to-one relationship on resource type '{nextResourceType.PublicName}'." + : $"Field '{lastName}' in '{path}' must be an attribute or a to-one relationship on resource type '{nextResourceType.PublicName}'."); + } - if (lastField is HasManyAttribute) - { - throw new QueryParseException(path == lastName - ? $"Field '{lastName}' must be an attribute or a to-one relationship on resource type '{nextResourceType.PublicName}'." - : $"Field '{lastName}' in '{path}' must be an attribute or a to-one relationship on resource type '{nextResourceType.PublicName}'."); - } + validateCallback?.Invoke(lastField, nextResourceType, path); - validateCallback?.Invoke(lastField, nextResourceType, path); + chainBuilder.Add(lastField); + return chainBuilder.ToImmutable(); + } - chainBuilder.Add(lastField); - return chainBuilder.ToImmutable(); - } + private RelationshipAttribute GetRelationship(string publicName, ResourceType resourceType, string path) + { + RelationshipAttribute? relationship = resourceType.FindRelationshipByPublicName(publicName); - private RelationshipAttribute GetRelationship(string publicName, ResourceType resourceType, string path) + if (relationship == null) { - RelationshipAttribute? relationship = resourceType.FindRelationshipByPublicName(publicName); - - if (relationship == null) - { - throw new QueryParseException(path == publicName - ? $"Relationship '{publicName}' does not exist on resource type '{resourceType.PublicName}'." - : $"Relationship '{publicName}' in '{path}' does not exist on resource type '{resourceType.PublicName}'."); - } - - return relationship; + throw new QueryParseException(path == publicName + ? $"Relationship '{publicName}' does not exist on resource type '{resourceType.PublicName}'." + : $"Relationship '{publicName}' in '{path}' does not exist on resource type '{resourceType.PublicName}'."); } - private RelationshipAttribute GetToManyRelationship(string publicName, ResourceType resourceType, string path) - { - RelationshipAttribute relationship = GetRelationship(publicName, resourceType, path); + return relationship; + } - if (relationship is not HasManyAttribute) - { - throw new QueryParseException(path == publicName - ? $"Relationship '{publicName}' must be a to-many relationship on resource type '{resourceType.PublicName}'." - : $"Relationship '{publicName}' in '{path}' must be a to-many relationship on resource type '{resourceType.PublicName}'."); - } + private RelationshipAttribute GetToManyRelationship(string publicName, ResourceType resourceType, string path) + { + RelationshipAttribute relationship = GetRelationship(publicName, resourceType, path); - return relationship; + if (relationship is not HasManyAttribute) + { + throw new QueryParseException(path == publicName + ? $"Relationship '{publicName}' must be a to-many relationship on resource type '{resourceType.PublicName}'." + : $"Relationship '{publicName}' in '{path}' must be a to-many relationship on resource type '{resourceType.PublicName}'."); } - private RelationshipAttribute GetToOneRelationship(string publicName, ResourceType resourceType, string path) - { - RelationshipAttribute relationship = GetRelationship(publicName, resourceType, path); + return relationship; + } - if (relationship is not HasOneAttribute) - { - throw new QueryParseException(path == publicName - ? $"Relationship '{publicName}' must be a to-one relationship on resource type '{resourceType.PublicName}'." - : $"Relationship '{publicName}' in '{path}' must be a to-one relationship on resource type '{resourceType.PublicName}'."); - } + private RelationshipAttribute GetToOneRelationship(string publicName, ResourceType resourceType, string path) + { + RelationshipAttribute relationship = GetRelationship(publicName, resourceType, path); - return relationship; + if (relationship is not HasOneAttribute) + { + throw new QueryParseException(path == publicName + ? $"Relationship '{publicName}' must be a to-one relationship on resource type '{resourceType.PublicName}'." + : $"Relationship '{publicName}' in '{path}' must be a to-one relationship on resource type '{resourceType.PublicName}'."); } - private AttrAttribute GetAttribute(string publicName, ResourceType resourceType, string path) - { - AttrAttribute? attribute = resourceType.FindAttributeByPublicName(publicName); + return relationship; + } - if (attribute == null) - { - throw new QueryParseException(path == publicName - ? $"Attribute '{publicName}' does not exist on resource type '{resourceType.PublicName}'." - : $"Attribute '{publicName}' in '{path}' does not exist on resource type '{resourceType.PublicName}'."); - } + private AttrAttribute GetAttribute(string publicName, ResourceType resourceType, string path) + { + AttrAttribute? attribute = resourceType.FindAttributeByPublicName(publicName); - return attribute; + if (attribute == null) + { + throw new QueryParseException(path == publicName + ? $"Attribute '{publicName}' does not exist on resource type '{resourceType.PublicName}'." + : $"Attribute '{publicName}' in '{path}' does not exist on resource type '{resourceType.PublicName}'."); } - public ResourceFieldAttribute GetField(string publicName, ResourceType resourceType, string path) - { - ResourceFieldAttribute? field = resourceType.Fields.FirstOrDefault(nextField => nextField.PublicName == publicName); + return attribute; + } - if (field == null) - { - throw new QueryParseException(path == publicName - ? $"Field '{publicName}' does not exist on resource type '{resourceType.PublicName}'." - : $"Field '{publicName}' in '{path}' does not exist on resource type '{resourceType.PublicName}'."); - } + public ResourceFieldAttribute GetField(string publicName, ResourceType resourceType, string path) + { + ResourceFieldAttribute? field = resourceType.Fields.FirstOrDefault(nextField => nextField.PublicName == publicName); - return field; + if (field == null) + { + throw new QueryParseException(path == publicName + ? $"Field '{publicName}' does not exist on resource type '{resourceType.PublicName}'." + : $"Field '{publicName}' in '{path}' does not exist on resource type '{resourceType.PublicName}'."); } + + return field; } } diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/SortParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/SortParser.cs index a78ec99b66..38a263e063 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/SortParser.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/SortParser.cs @@ -1,92 +1,89 @@ -using System; using System.Collections.Immutable; -using System.Linq; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Queries.Internal.Parsing +namespace JsonApiDotNetCore.Queries.Internal.Parsing; + +[PublicAPI] +public class SortParser : QueryExpressionParser { - [PublicAPI] - public class SortParser : QueryExpressionParser + private readonly Action? _validateSingleFieldCallback; + private ResourceType? _resourceTypeInScope; + + public SortParser(Action? validateSingleFieldCallback = null) { - private readonly Action? _validateSingleFieldCallback; - private ResourceType? _resourceTypeInScope; + _validateSingleFieldCallback = validateSingleFieldCallback; + } - public SortParser(Action? validateSingleFieldCallback = null) - { - _validateSingleFieldCallback = validateSingleFieldCallback; - } + public SortExpression Parse(string source, ResourceType resourceTypeInScope) + { + ArgumentGuard.NotNull(resourceTypeInScope, nameof(resourceTypeInScope)); - public SortExpression Parse(string source, ResourceType resourceTypeInScope) - { - ArgumentGuard.NotNull(resourceTypeInScope, nameof(resourceTypeInScope)); + _resourceTypeInScope = resourceTypeInScope; - _resourceTypeInScope = resourceTypeInScope; + Tokenize(source); - Tokenize(source); + SortExpression expression = ParseSort(); - SortExpression expression = ParseSort(); + AssertTokenStackIsEmpty(); - AssertTokenStackIsEmpty(); + return expression; + } - return expression; - } + protected SortExpression ParseSort() + { + SortElementExpression firstElement = ParseSortElement(); - protected SortExpression ParseSort() - { - SortElementExpression firstElement = ParseSortElement(); + ImmutableArray.Builder elementsBuilder = ImmutableArray.CreateBuilder(); + elementsBuilder.Add(firstElement); - ImmutableArray.Builder elementsBuilder = ImmutableArray.CreateBuilder(); - elementsBuilder.Add(firstElement); + while (TokenStack.Any()) + { + EatSingleCharacterToken(TokenKind.Comma); - while (TokenStack.Any()) - { - EatSingleCharacterToken(TokenKind.Comma); + SortElementExpression nextElement = ParseSortElement(); + elementsBuilder.Add(nextElement); + } - SortElementExpression nextElement = ParseSortElement(); - elementsBuilder.Add(nextElement); - } + return new SortExpression(elementsBuilder.ToImmutable()); + } - return new SortExpression(elementsBuilder.ToImmutable()); - } + protected SortElementExpression ParseSortElement() + { + bool isAscending = true; - protected SortElementExpression ParseSortElement() + if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Minus) { - bool isAscending = true; + TokenStack.Pop(); + isAscending = false; + } - if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Minus) - { - TokenStack.Pop(); - isAscending = false; - } + CountExpression? count = TryParseCount(); - CountExpression? count = TryParseCount(); + if (count != null) + { + return new SortElementExpression(count, isAscending); + } - if (count != null) - { - return new SortElementExpression(count, isAscending); - } + string 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); + } - string 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 IImmutableList OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) + { + if (chainRequirements == FieldChainRequirements.EndsInToMany) + { + return ChainResolver.ResolveToOneChainEndingInToMany(_resourceTypeInScope!, path); } - protected override IImmutableList OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) + if (chainRequirements == FieldChainRequirements.EndsInAttribute) { - if (chainRequirements == FieldChainRequirements.EndsInToMany) - { - return ChainResolver.ResolveToOneChainEndingInToMany(_resourceTypeInScope!, path); - } - - if (chainRequirements == FieldChainRequirements.EndsInAttribute) - { - return ChainResolver.ResolveToOneChainEndingInAttribute(_resourceTypeInScope!, path, _validateSingleFieldCallback); - } - - throw new InvalidOperationException($"Unexpected combination of chain requirement flags '{chainRequirements}'."); + return ChainResolver.ResolveToOneChainEndingInAttribute(_resourceTypeInScope!, 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 index 2a2fa60e5d..b4e54f0c46 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldSetParser.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldSetParser.cs @@ -1,65 +1,62 @@ -using System; using System.Collections.Immutable; -using System.Linq; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Queries.Internal.Parsing +namespace JsonApiDotNetCore.Queries.Internal.Parsing; + +[PublicAPI] +public class SparseFieldSetParser : QueryExpressionParser { - [PublicAPI] - public class SparseFieldSetParser : QueryExpressionParser + private readonly Action? _validateSingleFieldCallback; + private ResourceType? _resourceType; + + public SparseFieldSetParser(Action? validateSingleFieldCallback = null) { - private readonly Action? _validateSingleFieldCallback; - private ResourceType? _resourceType; + _validateSingleFieldCallback = validateSingleFieldCallback; + } - public SparseFieldSetParser(Action? validateSingleFieldCallback = null) - { - _validateSingleFieldCallback = validateSingleFieldCallback; - } + public SparseFieldSetExpression? Parse(string source, ResourceType resourceType) + { + ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - public SparseFieldSetExpression? Parse(string source, ResourceType resourceType) - { - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + _resourceType = resourceType; - _resourceType = resourceType; + Tokenize(source); - Tokenize(source); + SparseFieldSetExpression? expression = ParseSparseFieldSet(); - SparseFieldSetExpression? expression = ParseSparseFieldSet(); + AssertTokenStackIsEmpty(); - AssertTokenStackIsEmpty(); + return expression; + } - return expression; - } + protected SparseFieldSetExpression? ParseSparseFieldSet() + { + ImmutableHashSet.Builder fieldSetBuilder = ImmutableHashSet.CreateBuilder(); - protected SparseFieldSetExpression? ParseSparseFieldSet() + while (TokenStack.Any()) { - ImmutableHashSet.Builder fieldSetBuilder = ImmutableHashSet.CreateBuilder(); - - while (TokenStack.Any()) + if (fieldSetBuilder.Count > 0) { - if (fieldSetBuilder.Count > 0) - { - EatSingleCharacterToken(TokenKind.Comma); - } - - ResourceFieldChainExpression nextChain = ParseFieldChain(FieldChainRequirements.EndsInAttribute, "Field name expected."); - ResourceFieldAttribute nextField = nextChain.Fields.Single(); - fieldSetBuilder.Add(nextField); + EatSingleCharacterToken(TokenKind.Comma); } - return fieldSetBuilder.Any() ? new SparseFieldSetExpression(fieldSetBuilder.ToImmutable()) : null; + ResourceFieldChainExpression nextChain = ParseFieldChain(FieldChainRequirements.EndsInAttribute, "Field name expected."); + ResourceFieldAttribute nextField = nextChain.Fields.Single(); + fieldSetBuilder.Add(nextField); } - protected override IImmutableList OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) - { - ResourceFieldAttribute field = ChainResolver.GetField(path, _resourceType!, path); + return fieldSetBuilder.Any() ? new SparseFieldSetExpression(fieldSetBuilder.ToImmutable()) : null; + } + + protected override IImmutableList OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) + { + ResourceFieldAttribute field = ChainResolver.GetField(path, _resourceType!, path); - _validateSingleFieldCallback?.Invoke(field, _resourceType!, path); + _validateSingleFieldCallback?.Invoke(field, _resourceType!, path); - return ImmutableArray.Create(field); - } + return ImmutableArray.Create(field); } } diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldTypeParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldTypeParser.cs index d9c97dd220..e7c96d21d5 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldTypeParser.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldTypeParser.cs @@ -1,75 +1,73 @@ -using System; using System.Collections.Immutable; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Queries.Internal.Parsing +namespace JsonApiDotNetCore.Queries.Internal.Parsing; + +[PublicAPI] +public class SparseFieldTypeParser : QueryExpressionParser { - [PublicAPI] - public class SparseFieldTypeParser : QueryExpressionParser - { - private readonly IResourceGraph _resourceGraph; + private readonly IResourceGraph _resourceGraph; - public SparseFieldTypeParser(IResourceGraph resourceGraph) - { - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); + public SparseFieldTypeParser(IResourceGraph resourceGraph) + { + ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); - _resourceGraph = resourceGraph; - } + _resourceGraph = resourceGraph; + } - public ResourceType Parse(string source) - { - Tokenize(source); + public ResourceType Parse(string source) + { + Tokenize(source); - ResourceType resourceType = ParseSparseFieldTarget(); + ResourceType resourceType = ParseSparseFieldTarget(); - AssertTokenStackIsEmpty(); + AssertTokenStackIsEmpty(); - return resourceType; - } + return resourceType; + } - private ResourceType ParseSparseFieldTarget() + private ResourceType ParseSparseFieldTarget() + { + if (!TokenStack.TryPop(out Token? token) || token.Kind != TokenKind.Text) { - if (!TokenStack.TryPop(out Token? token) || token.Kind != TokenKind.Text) - { - throw new QueryParseException("Parameter name expected."); - } + throw new QueryParseException("Parameter name expected."); + } - EatSingleCharacterToken(TokenKind.OpenBracket); + EatSingleCharacterToken(TokenKind.OpenBracket); - ResourceType resourceType = ParseResourceName(); + ResourceType resourceType = ParseResourceName(); - EatSingleCharacterToken(TokenKind.CloseBracket); + EatSingleCharacterToken(TokenKind.CloseBracket); - return resourceType; - } + return resourceType; + } - private ResourceType ParseResourceName() + private ResourceType ParseResourceName() + { + if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.Text) { - if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.Text) - { - return GetResourceType(token.Value!); - } - - throw new QueryParseException("Resource type expected."); + return GetResourceType(token.Value!); } - private ResourceType GetResourceType(string publicName) - { - ResourceType? resourceType = _resourceGraph.FindResourceType(publicName); - - if (resourceType == null) - { - throw new QueryParseException($"Resource type '{publicName}' does not exist."); - } + throw new QueryParseException("Resource type expected."); + } - return resourceType; - } + private ResourceType GetResourceType(string publicName) + { + ResourceType? resourceType = _resourceGraph.FindResourceType(publicName); - protected override IImmutableList OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) + if (resourceType == null) { - throw new NotSupportedException(); + throw new QueryParseException($"Resource type '{publicName}' does not exist."); } + + return resourceType; + } + + protected override IImmutableList OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) + { + throw new NotSupportedException(); } } diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/Token.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/Token.cs index 6965562d44..14b6289b83 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/Token.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/Token.cs @@ -1,22 +1,21 @@ using JetBrains.Annotations; -namespace JsonApiDotNetCore.Queries.Internal.Parsing +namespace JsonApiDotNetCore.Queries.Internal.Parsing; + +[PublicAPI] +public sealed class Token { - [PublicAPI] - public sealed class Token - { - public TokenKind Kind { get; } - public string? Value { get; } + public TokenKind Kind { get; } + public string? Value { get; } - public Token(TokenKind kind, string? value = null) - { - Kind = kind; - Value = value; - } + public Token(TokenKind kind, string? value = null) + { + Kind = kind; + Value = value; + } - public override string ToString() - { - return Value == null ? Kind.ToString() : $"{Kind}: {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 index 3658c82f18..75b6952f5a 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/TokenKind.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/TokenKind.cs @@ -1,15 +1,14 @@ -namespace JsonApiDotNetCore.Queries.Internal.Parsing +namespace JsonApiDotNetCore.Queries.Internal.Parsing; + +public enum TokenKind { - public enum TokenKind - { - OpenParen, - CloseParen, - OpenBracket, - CloseBracket, - Comma, - Colon, - Minus, - Text, - QuotedText - } + 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 index d1a55551de..f7b95c3c86 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; using System.Collections.Immutable; -using System.Linq; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Diagnostics; @@ -9,545 +6,538 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Queries.Internal +namespace JsonApiDotNetCore.Queries.Internal; + +/// +[PublicAPI] +public class QueryLayerComposer : IQueryLayerComposer { + private readonly CollectionConverter _collectionConverter = new(); + private readonly IEnumerable _constraintProviders; + private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; + private readonly IJsonApiOptions _options; + private readonly IPaginationContext _paginationContext; + private readonly ITargetedFields _targetedFields; + private readonly IEvaluatedIncludeCache _evaluatedIncludeCache; + private readonly ISparseFieldSetCache _sparseFieldSetCache; + + public QueryLayerComposer(IEnumerable constraintProviders, IResourceDefinitionAccessor resourceDefinitionAccessor, + IJsonApiOptions options, IPaginationContext paginationContext, ITargetedFields targetedFields, IEvaluatedIncludeCache evaluatedIncludeCache, + ISparseFieldSetCache sparseFieldSetCache) + { + ArgumentGuard.NotNull(constraintProviders, nameof(constraintProviders)); + ArgumentGuard.NotNull(resourceDefinitionAccessor, nameof(resourceDefinitionAccessor)); + ArgumentGuard.NotNull(options, nameof(options)); + ArgumentGuard.NotNull(paginationContext, nameof(paginationContext)); + ArgumentGuard.NotNull(targetedFields, nameof(targetedFields)); + ArgumentGuard.NotNull(evaluatedIncludeCache, nameof(evaluatedIncludeCache)); + ArgumentGuard.NotNull(sparseFieldSetCache, nameof(sparseFieldSetCache)); + + _constraintProviders = constraintProviders; + _resourceDefinitionAccessor = resourceDefinitionAccessor; + _options = options; + _paginationContext = paginationContext; + _targetedFields = targetedFields; + _evaluatedIncludeCache = evaluatedIncludeCache; + _sparseFieldSetCache = sparseFieldSetCache; + } + /// - [PublicAPI] - public class QueryLayerComposer : IQueryLayerComposer + public FilterExpression? GetPrimaryFilterFromConstraints(ResourceType primaryResourceType) { - private readonly CollectionConverter _collectionConverter = new(); - private readonly IEnumerable _constraintProviders; - private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; - private readonly IJsonApiOptions _options; - private readonly IPaginationContext _paginationContext; - private readonly ITargetedFields _targetedFields; - private readonly IEvaluatedIncludeCache _evaluatedIncludeCache; - private readonly ISparseFieldSetCache _sparseFieldSetCache; - - public QueryLayerComposer(IEnumerable constraintProviders, IResourceDefinitionAccessor resourceDefinitionAccessor, - IJsonApiOptions options, IPaginationContext paginationContext, ITargetedFields targetedFields, IEvaluatedIncludeCache evaluatedIncludeCache, - ISparseFieldSetCache sparseFieldSetCache) - { - ArgumentGuard.NotNull(constraintProviders, nameof(constraintProviders)); - ArgumentGuard.NotNull(resourceDefinitionAccessor, nameof(resourceDefinitionAccessor)); - ArgumentGuard.NotNull(options, nameof(options)); - ArgumentGuard.NotNull(paginationContext, nameof(paginationContext)); - ArgumentGuard.NotNull(targetedFields, nameof(targetedFields)); - ArgumentGuard.NotNull(evaluatedIncludeCache, nameof(evaluatedIncludeCache)); - ArgumentGuard.NotNull(sparseFieldSetCache, nameof(sparseFieldSetCache)); - - _constraintProviders = constraintProviders; - _resourceDefinitionAccessor = resourceDefinitionAccessor; - _options = options; - _paginationContext = paginationContext; - _targetedFields = targetedFields; - _evaluatedIncludeCache = evaluatedIncludeCache; - _sparseFieldSetCache = sparseFieldSetCache; - } + ExpressionInScope[] constraints = _constraintProviders.SelectMany(provider => provider.GetConstraints()).ToArray(); - /// - public FilterExpression? GetPrimaryFilterFromConstraints(ResourceType primaryResourceType) - { - ExpressionInScope[] constraints = _constraintProviders.SelectMany(provider => provider.GetConstraints()).ToArray(); + // @formatter:wrap_chained_method_calls chop_always + // @formatter:keep_existing_linebreaks true - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true + FilterExpression[] filtersInTopScope = constraints + .Where(constraint => constraint.Scope == null) + .Select(constraint => constraint.Expression) + .OfType() + .ToArray(); - FilterExpression[] filtersInTopScope = constraints - .Where(constraint => constraint.Scope == null) - .Select(constraint => constraint.Expression) - .OfType() - .ToArray(); + // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_chained_method_calls restore - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore + return GetFilter(filtersInTopScope, primaryResourceType); + } - return GetFilter(filtersInTopScope, primaryResourceType); - } + /// + public FilterExpression? GetSecondaryFilterFromConstraints(TId primaryId, HasManyAttribute hasManyRelationship) + { + ArgumentGuard.NotNull(hasManyRelationship, nameof(hasManyRelationship)); - /// - public FilterExpression? GetSecondaryFilterFromConstraints(TId primaryId, HasManyAttribute hasManyRelationship) + if (hasManyRelationship.InverseNavigationProperty == null) { - ArgumentGuard.NotNull(hasManyRelationship, nameof(hasManyRelationship)); + return null; + } - if (hasManyRelationship.InverseNavigationProperty == null) - { - return null; - } + RelationshipAttribute? inverseRelationship = + hasManyRelationship.RightType.FindRelationshipByPropertyName(hasManyRelationship.InverseNavigationProperty.Name); - RelationshipAttribute? inverseRelationship = - hasManyRelationship.RightType.FindRelationshipByPropertyName(hasManyRelationship.InverseNavigationProperty.Name); + if (inverseRelationship == null) + { + return null; + } - if (inverseRelationship == null) - { - return null; - } + ExpressionInScope[] constraints = _constraintProviders.SelectMany(provider => provider.GetConstraints()).ToArray(); - ExpressionInScope[] constraints = _constraintProviders.SelectMany(provider => provider.GetConstraints()).ToArray(); + var secondaryScope = new ResourceFieldChainExpression(hasManyRelationship); - var secondaryScope = new ResourceFieldChainExpression(hasManyRelationship); + // @formatter:wrap_chained_method_calls chop_always + // @formatter:keep_existing_linebreaks true - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true + FilterExpression[] filtersInSecondaryScope = constraints + .Where(constraint => secondaryScope.Equals(constraint.Scope)) + .Select(constraint => constraint.Expression) + .OfType() + .ToArray(); - FilterExpression[] filtersInSecondaryScope = constraints - .Where(constraint => secondaryScope.Equals(constraint.Scope)) - .Select(constraint => constraint.Expression) - .OfType() - .ToArray(); + // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_chained_method_calls restore - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore + FilterExpression? primaryFilter = GetFilter(Array.Empty(), hasManyRelationship.LeftType); + FilterExpression? secondaryFilter = GetFilter(filtersInSecondaryScope, hasManyRelationship.RightType); - FilterExpression? primaryFilter = GetFilter(Array.Empty(), hasManyRelationship.LeftType); - FilterExpression? secondaryFilter = GetFilter(filtersInSecondaryScope, hasManyRelationship.RightType); + FilterExpression inverseFilter = GetInverseRelationshipFilter(primaryId, hasManyRelationship, inverseRelationship); - FilterExpression inverseFilter = GetInverseRelationshipFilter(primaryId, hasManyRelationship, inverseRelationship); + return LogicalExpression.Compose(LogicalOperator.And, inverseFilter, primaryFilter, secondaryFilter); + } - return LogicalExpression.Compose(LogicalOperator.And, inverseFilter, primaryFilter, secondaryFilter); - } + private static FilterExpression GetInverseRelationshipFilter(TId primaryId, HasManyAttribute relationship, RelationshipAttribute inverseRelationship) + { + return inverseRelationship is HasManyAttribute hasManyInverseRelationship + ? GetInverseHasManyRelationshipFilter(primaryId, relationship, hasManyInverseRelationship) + : GetInverseHasOneRelationshipFilter(primaryId, relationship, (HasOneAttribute)inverseRelationship); + } - private static FilterExpression GetInverseRelationshipFilter(TId primaryId, HasManyAttribute relationship, - RelationshipAttribute inverseRelationship) - { - return inverseRelationship is HasManyAttribute hasManyInverseRelationship - ? GetInverseHasManyRelationshipFilter(primaryId, relationship, hasManyInverseRelationship) - : GetInverseHasOneRelationshipFilter(primaryId, relationship, (HasOneAttribute)inverseRelationship); - } + private static FilterExpression GetInverseHasOneRelationshipFilter(TId primaryId, HasManyAttribute relationship, HasOneAttribute inverseRelationship) + { + AttrAttribute idAttribute = GetIdAttribute(relationship.LeftType); + var idChain = new ResourceFieldChainExpression(ImmutableArray.Create(inverseRelationship, idAttribute)); - private static FilterExpression GetInverseHasOneRelationshipFilter(TId primaryId, HasManyAttribute relationship, - HasOneAttribute inverseRelationship) - { - AttrAttribute idAttribute = GetIdAttribute(relationship.LeftType); - var idChain = new ResourceFieldChainExpression(ImmutableArray.Create(inverseRelationship, idAttribute)); + return new ComparisonExpression(ComparisonOperator.Equals, idChain, new LiteralConstantExpression(primaryId!.ToString()!)); + } - return new ComparisonExpression(ComparisonOperator.Equals, idChain, new LiteralConstantExpression(primaryId!.ToString()!)); - } + private static FilterExpression GetInverseHasManyRelationshipFilter(TId primaryId, HasManyAttribute relationship, HasManyAttribute inverseRelationship) + { + AttrAttribute idAttribute = GetIdAttribute(relationship.LeftType); + var idChain = new ResourceFieldChainExpression(ImmutableArray.Create(idAttribute)); + var idComparison = new ComparisonExpression(ComparisonOperator.Equals, idChain, new LiteralConstantExpression(primaryId!.ToString()!)); - private static FilterExpression GetInverseHasManyRelationshipFilter(TId primaryId, HasManyAttribute relationship, - HasManyAttribute inverseRelationship) - { - AttrAttribute idAttribute = GetIdAttribute(relationship.LeftType); - var idChain = new ResourceFieldChainExpression(ImmutableArray.Create(idAttribute)); - var idComparison = new ComparisonExpression(ComparisonOperator.Equals, idChain, new LiteralConstantExpression(primaryId!.ToString()!)); + return new HasExpression(new ResourceFieldChainExpression(inverseRelationship), idComparison); + } - return new HasExpression(new ResourceFieldChainExpression(inverseRelationship), idComparison); - } + /// + public QueryLayer ComposeFromConstraints(ResourceType requestResourceType) + { + ArgumentGuard.NotNull(requestResourceType, nameof(requestResourceType)); - /// - public QueryLayer ComposeFromConstraints(ResourceType requestResourceType) - { - ArgumentGuard.NotNull(requestResourceType, nameof(requestResourceType)); + ExpressionInScope[] constraints = _constraintProviders.SelectMany(provider => provider.GetConstraints()).ToArray(); - ExpressionInScope[] constraints = _constraintProviders.SelectMany(provider => provider.GetConstraints()).ToArray(); + QueryLayer topLayer = ComposeTopLayer(constraints, requestResourceType); + topLayer.Include = ComposeChildren(topLayer, constraints); - QueryLayer topLayer = ComposeTopLayer(constraints, requestResourceType); - topLayer.Include = ComposeChildren(topLayer, constraints); + _evaluatedIncludeCache.Set(topLayer.Include); - _evaluatedIncludeCache.Set(topLayer.Include); + return topLayer; + } - return topLayer; - } + private QueryLayer ComposeTopLayer(IEnumerable constraints, ResourceType resourceType) + { + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Top-level query composition"); - private QueryLayer ComposeTopLayer(IEnumerable constraints, ResourceType resourceType) - { - using IDisposable _ = CodeTimingSessionManager.Current.Measure("Top-level query composition"); + // @formatter:wrap_chained_method_calls chop_always + // @formatter:keep_existing_linebreaks true - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true + QueryExpression[] expressionsInTopScope = constraints + .Where(constraint => constraint.Scope == null) + .Select(constraint => constraint.Expression) + .ToArray(); - QueryExpression[] expressionsInTopScope = constraints - .Where(constraint => constraint.Scope == null) - .Select(constraint => constraint.Expression) - .ToArray(); + // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_chained_method_calls restore - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore + PaginationExpression topPagination = GetPagination(expressionsInTopScope, resourceType); + _paginationContext.PageSize = topPagination.PageSize; + _paginationContext.PageNumber = topPagination.PageNumber; - PaginationExpression topPagination = GetPagination(expressionsInTopScope, resourceType); - _paginationContext.PageSize = topPagination.PageSize; - _paginationContext.PageNumber = topPagination.PageNumber; + return new QueryLayer(resourceType) + { + Filter = GetFilter(expressionsInTopScope, resourceType), + Sort = GetSort(expressionsInTopScope, resourceType), + Pagination = topPagination, + Projection = GetProjectionForSparseAttributeSet(resourceType) + }; + } - return new QueryLayer(resourceType) - { - Filter = GetFilter(expressionsInTopScope, resourceType), - Sort = GetSort(expressionsInTopScope, resourceType), - Pagination = ((JsonApiOptions)_options).DisableTopPagination ? null : topPagination, - Projection = GetProjectionForSparseAttributeSet(resourceType) - }; - } + private IncludeExpression ComposeChildren(QueryLayer topLayer, ICollection constraints) + { + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Nested query composition"); - private IncludeExpression ComposeChildren(QueryLayer topLayer, ICollection constraints) - { - using IDisposable _ = CodeTimingSessionManager.Current.Measure("Nested query composition"); + // @formatter:wrap_chained_method_calls chop_always + // @formatter:keep_existing_linebreaks true - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true + IncludeExpression include = constraints + .Where(constraint => constraint.Scope == null) + .Select(constraint => constraint.Expression) + .OfType() + .FirstOrDefault() ?? IncludeExpression.Empty; - IncludeExpression include = constraints - .Where(constraint => constraint.Scope == null) - .Select(constraint => constraint.Expression) - .OfType() - .FirstOrDefault() ?? IncludeExpression.Empty; + // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_chained_method_calls restore - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore + IImmutableSet includeElements = ProcessIncludeSet(include.Elements, topLayer, new List(), constraints); - IImmutableSet includeElements = - ProcessIncludeSet(include.Elements, topLayer, new List(), constraints); + return !ReferenceEquals(includeElements, include.Elements) + ? includeElements.Any() ? new IncludeExpression(includeElements) : IncludeExpression.Empty + : include; + } - return !ReferenceEquals(includeElements, include.Elements) - ? includeElements.Any() ? new IncludeExpression(includeElements) : IncludeExpression.Empty - : include; - } + private IImmutableSet ProcessIncludeSet(IImmutableSet includeElements, QueryLayer parentLayer, + ICollection parentRelationshipChain, ICollection constraints) + { + IImmutableSet includeElementsEvaluated = GetIncludeElements(includeElements, parentLayer.ResourceType); - private IImmutableSet ProcessIncludeSet(IImmutableSet includeElements, QueryLayer parentLayer, - ICollection parentRelationshipChain, ICollection constraints) - { - IImmutableSet includeElementsEvaluated = GetIncludeElements(includeElements, parentLayer.ResourceType); + var updatesInChildren = new Dictionary>(); - var updatesInChildren = new Dictionary>(); + foreach (IncludeElementExpression includeElement in includeElementsEvaluated) + { + parentLayer.Projection ??= new Dictionary(); - foreach (IncludeElementExpression includeElement in includeElementsEvaluated) + if (!parentLayer.Projection.ContainsKey(includeElement.Relationship)) { - parentLayer.Projection ??= new Dictionary(); - - if (!parentLayer.Projection.ContainsKey(includeElement.Relationship)) + var relationshipChain = new List(parentRelationshipChain) { - var relationshipChain = new List(parentRelationshipChain) - { - includeElement.Relationship - }; + includeElement.Relationship + }; - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true + // @formatter:wrap_chained_method_calls chop_always + // @formatter:keep_existing_linebreaks true - QueryExpression[] expressionsInCurrentScope = constraints - .Where(constraint => - constraint.Scope != null && constraint.Scope.Fields.SequenceEqual(relationshipChain)) - .Select(constraint => constraint.Expression) - .ToArray(); + QueryExpression[] expressionsInCurrentScope = constraints + .Where(constraint => + constraint.Scope != null && constraint.Scope.Fields.SequenceEqual(relationshipChain)) + .Select(constraint => constraint.Expression) + .ToArray(); - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore + // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_chained_method_calls restore - ResourceType resourceType = includeElement.Relationship.RightType; - bool isToManyRelationship = includeElement.Relationship is HasManyAttribute; + ResourceType resourceType = includeElement.Relationship.RightType; + bool isToManyRelationship = includeElement.Relationship is HasManyAttribute; - var child = new QueryLayer(resourceType) - { - Filter = isToManyRelationship ? GetFilter(expressionsInCurrentScope, resourceType) : null, - Sort = isToManyRelationship ? GetSort(expressionsInCurrentScope, resourceType) : null, - Pagination = isToManyRelationship - ? ((JsonApiOptions)_options).DisableChildrenPagination ? null : GetPagination(expressionsInCurrentScope, resourceType) - : null, - Projection = GetProjectionForSparseAttributeSet(resourceType) - }; + var child = new QueryLayer(resourceType) + { + Filter = isToManyRelationship ? GetFilter(expressionsInCurrentScope, resourceType) : null, + Sort = isToManyRelationship ? GetSort(expressionsInCurrentScope, resourceType) : null, + Pagination = isToManyRelationship ? GetPagination(expressionsInCurrentScope, resourceType) : null, + Projection = GetProjectionForSparseAttributeSet(resourceType) + }; - parentLayer.Projection.Add(includeElement.Relationship, child); + parentLayer.Projection.Add(includeElement.Relationship, child); - IImmutableSet updatedChildren = ProcessIncludeSet(includeElement.Children, child, relationshipChain, constraints); + IImmutableSet updatedChildren = ProcessIncludeSet(includeElement.Children, child, relationshipChain, constraints); - if (!ReferenceEquals(includeElement.Children, updatedChildren)) - { - updatesInChildren.Add(includeElement, updatedChildren); - } + if (!ReferenceEquals(includeElement.Children, updatedChildren)) + { + updatesInChildren.Add(includeElement, updatedChildren); } } - - return !updatesInChildren.Any() ? includeElementsEvaluated : ApplyIncludeElementUpdates(includeElementsEvaluated, updatesInChildren); } - private static IImmutableSet ApplyIncludeElementUpdates(IImmutableSet includeElements, - IDictionary> updatesInChildren) - { - ImmutableHashSet.Builder newElementsBuilder = ImmutableHashSet.CreateBuilder(); - newElementsBuilder.AddRange(includeElements); + return !updatesInChildren.Any() ? includeElementsEvaluated : ApplyIncludeElementUpdates(includeElementsEvaluated, updatesInChildren); + } - foreach ((IncludeElementExpression existingElement, IImmutableSet updatedChildren) in updatesInChildren) - { - newElementsBuilder.Remove(existingElement); - newElementsBuilder.Add(new IncludeElementExpression(existingElement.Relationship, updatedChildren)); - } + private static IImmutableSet ApplyIncludeElementUpdates(IImmutableSet includeElements, + IDictionary> updatesInChildren) + { + ImmutableHashSet.Builder newElementsBuilder = ImmutableHashSet.CreateBuilder(); + newElementsBuilder.AddRange(includeElements); - return newElementsBuilder.ToImmutable(); + foreach ((IncludeElementExpression existingElement, IImmutableSet updatedChildren) in updatesInChildren) + { + newElementsBuilder.Remove(existingElement); + newElementsBuilder.Add(new IncludeElementExpression(existingElement.Relationship, updatedChildren)); } - /// - public QueryLayer ComposeForGetById(TId id, ResourceType primaryResourceType, TopFieldSelection fieldSelection) - { - ArgumentGuard.NotNull(primaryResourceType, nameof(primaryResourceType)); + return newElementsBuilder.ToImmutable(); + } + + /// + public QueryLayer ComposeForGetById(TId id, ResourceType primaryResourceType, TopFieldSelection fieldSelection) + { + ArgumentGuard.NotNull(primaryResourceType, nameof(primaryResourceType)); - AttrAttribute idAttribute = GetIdAttribute(primaryResourceType); + AttrAttribute idAttribute = GetIdAttribute(primaryResourceType); - QueryLayer queryLayer = ComposeFromConstraints(primaryResourceType); - queryLayer.Sort = null; - queryLayer.Pagination = null; - queryLayer.Filter = CreateFilterByIds(id.AsArray(), idAttribute, queryLayer.Filter); + QueryLayer queryLayer = ComposeFromConstraints(primaryResourceType); + queryLayer.Sort = null; + queryLayer.Pagination = null; + queryLayer.Filter = CreateFilterByIds(id.AsArray(), idAttribute, queryLayer.Filter); - if (fieldSelection == TopFieldSelection.OnlyIdAttribute) + if (fieldSelection == TopFieldSelection.OnlyIdAttribute) + { + queryLayer.Projection = new Dictionary { - queryLayer.Projection = new Dictionary - { - [idAttribute] = null - }; - } - else if (fieldSelection == TopFieldSelection.WithAllAttributes && queryLayer.Projection != null) + [idAttribute] = null + }; + } + else if (fieldSelection == TopFieldSelection.WithAllAttributes && queryLayer.Projection != null) + { + // Discard any top-level ?fields[]= or attribute exclusions from resource definition, because we need the full database row. + while (queryLayer.Projection.Any(pair => pair.Key is AttrAttribute)) { - // Discard any top-level ?fields[]= or attribute exclusions from resource definition, because we need the full database row. - while (queryLayer.Projection.Any(pair => pair.Key is AttrAttribute)) - { - queryLayer.Projection.Remove(queryLayer.Projection.First(pair => pair.Key is AttrAttribute)); - } + queryLayer.Projection.Remove(queryLayer.Projection.First(pair => pair.Key is AttrAttribute)); } - - return queryLayer; } - /// - public QueryLayer ComposeSecondaryLayerForRelationship(ResourceType secondaryResourceType) - { - ArgumentGuard.NotNull(secondaryResourceType, nameof(secondaryResourceType)); + return queryLayer; + } - QueryLayer secondaryLayer = ComposeFromConstraints(secondaryResourceType); - secondaryLayer.Projection = GetProjectionForRelationship(secondaryResourceType); - secondaryLayer.Include = null; + /// + public QueryLayer ComposeSecondaryLayerForRelationship(ResourceType secondaryResourceType) + { + ArgumentGuard.NotNull(secondaryResourceType, nameof(secondaryResourceType)); - return secondaryLayer; - } + QueryLayer secondaryLayer = ComposeFromConstraints(secondaryResourceType); + secondaryLayer.Projection = GetProjectionForRelationship(secondaryResourceType); + secondaryLayer.Include = null; - private IDictionary GetProjectionForRelationship(ResourceType secondaryResourceType) - { - IImmutableSet secondaryAttributeSet = _sparseFieldSetCache.GetIdAttributeSetForRelationshipQuery(secondaryResourceType); + return secondaryLayer; + } - return secondaryAttributeSet.ToDictionary(key => (ResourceFieldAttribute)key, _ => (QueryLayer?)null); - } + private IDictionary GetProjectionForRelationship(ResourceType secondaryResourceType) + { + IImmutableSet secondaryAttributeSet = _sparseFieldSetCache.GetIdAttributeSetForRelationshipQuery(secondaryResourceType); - /// - public QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer, ResourceType primaryResourceType, TId primaryId, - RelationshipAttribute relationship) - { - ArgumentGuard.NotNull(secondaryLayer, nameof(secondaryLayer)); - ArgumentGuard.NotNull(primaryResourceType, nameof(primaryResourceType)); - ArgumentGuard.NotNull(relationship, nameof(relationship)); + return secondaryAttributeSet.ToDictionary(key => (ResourceFieldAttribute)key, _ => (QueryLayer?)null); + } - IncludeExpression? innerInclude = secondaryLayer.Include; - secondaryLayer.Include = null; + /// + public QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer, ResourceType primaryResourceType, TId primaryId, + RelationshipAttribute relationship) + { + ArgumentGuard.NotNull(secondaryLayer, nameof(secondaryLayer)); + ArgumentGuard.NotNull(primaryResourceType, nameof(primaryResourceType)); + ArgumentGuard.NotNull(relationship, nameof(relationship)); - IImmutableSet primaryAttributeSet = _sparseFieldSetCache.GetIdAttributeSetForRelationshipQuery(primaryResourceType); + IncludeExpression? innerInclude = secondaryLayer.Include; + secondaryLayer.Include = null; - Dictionary primaryProjection = - primaryAttributeSet.ToDictionary(key => (ResourceFieldAttribute)key, _ => (QueryLayer?)null); + IImmutableSet primaryAttributeSet = _sparseFieldSetCache.GetIdAttributeSetForRelationshipQuery(primaryResourceType); - primaryProjection[relationship] = secondaryLayer; + Dictionary primaryProjection = + primaryAttributeSet.ToDictionary(key => (ResourceFieldAttribute)key, _ => (QueryLayer?)null); - FilterExpression? primaryFilter = GetFilter(Array.Empty(), primaryResourceType); - AttrAttribute primaryIdAttribute = GetIdAttribute(primaryResourceType); + primaryProjection[relationship] = secondaryLayer; - return new QueryLayer(primaryResourceType) - { - Include = RewriteIncludeForSecondaryEndpoint(innerInclude, relationship), - Filter = CreateFilterByIds(primaryId.AsArray(), primaryIdAttribute, primaryFilter), - Projection = primaryProjection - }; - } + FilterExpression? primaryFilter = GetFilter(Array.Empty(), primaryResourceType); + AttrAttribute primaryIdAttribute = GetIdAttribute(primaryResourceType); - private IncludeExpression RewriteIncludeForSecondaryEndpoint(IncludeExpression? relativeInclude, RelationshipAttribute secondaryRelationship) + return new QueryLayer(primaryResourceType) { - IncludeElementExpression parentElement = relativeInclude != null - ? new IncludeElementExpression(secondaryRelationship, relativeInclude.Elements) - : new IncludeElementExpression(secondaryRelationship); + Include = RewriteIncludeForSecondaryEndpoint(innerInclude, relationship), + Filter = CreateFilterByIds(primaryId.AsArray(), primaryIdAttribute, primaryFilter), + Projection = primaryProjection + }; + } - return new IncludeExpression(ImmutableHashSet.Create(parentElement)); - } + private IncludeExpression RewriteIncludeForSecondaryEndpoint(IncludeExpression? relativeInclude, RelationshipAttribute secondaryRelationship) + { + IncludeElementExpression parentElement = relativeInclude != null + ? new IncludeElementExpression(secondaryRelationship, relativeInclude.Elements) + : new IncludeElementExpression(secondaryRelationship); - private FilterExpression? CreateFilterByIds(IReadOnlyCollection ids, AttrAttribute idAttribute, FilterExpression? existingFilter) - { - var idChain = new ResourceFieldChainExpression(idAttribute); + return new IncludeExpression(ImmutableHashSet.Create(parentElement)); + } - FilterExpression? filter = null; + private FilterExpression? CreateFilterByIds(IReadOnlyCollection ids, AttrAttribute idAttribute, FilterExpression? existingFilter) + { + var idChain = new ResourceFieldChainExpression(idAttribute); - if (ids.Count == 1) - { - var constant = new LiteralConstantExpression(ids.Single()!.ToString()!); - filter = new ComparisonExpression(ComparisonOperator.Equals, idChain, constant); - } - else if (ids.Count > 1) - { - ImmutableHashSet constants = ids.Select(id => new LiteralConstantExpression(id!.ToString()!)).ToImmutableHashSet(); - filter = new AnyExpression(idChain, constants); - } + FilterExpression? filter = null; - return LogicalExpression.Compose(LogicalOperator.And, filter, existingFilter); + if (ids.Count == 1) + { + var constant = new LiteralConstantExpression(ids.Single()!.ToString()!); + filter = new ComparisonExpression(ComparisonOperator.Equals, idChain, constant); } - - /// - public QueryLayer ComposeForUpdate(TId id, ResourceType primaryResourceType) + else if (ids.Count > 1) { - ArgumentGuard.NotNull(primaryResourceType, nameof(primaryResourceType)); + ImmutableHashSet constants = ids.Select(id => new LiteralConstantExpression(id!.ToString()!)).ToImmutableHashSet(); + filter = new AnyExpression(idChain, constants); + } - IImmutableSet includeElements = _targetedFields.Relationships - .Select(relationship => new IncludeElementExpression(relationship)).ToImmutableHashSet(); + return LogicalExpression.Compose(LogicalOperator.And, filter, existingFilter); + } - AttrAttribute primaryIdAttribute = GetIdAttribute(primaryResourceType); + /// + public QueryLayer ComposeForUpdate(TId id, ResourceType primaryResourceType) + { + ArgumentGuard.NotNull(primaryResourceType, nameof(primaryResourceType)); - QueryLayer primaryLayer = ComposeTopLayer(Array.Empty(), primaryResourceType); - primaryLayer.Include = includeElements.Any() ? new IncludeExpression(includeElements) : IncludeExpression.Empty; - primaryLayer.Sort = null; - primaryLayer.Pagination = null; - primaryLayer.Filter = CreateFilterByIds(id.AsArray(), primaryIdAttribute, primaryLayer.Filter); - primaryLayer.Projection = null; + IImmutableSet includeElements = _targetedFields.Relationships + .Select(relationship => new IncludeElementExpression(relationship)).ToImmutableHashSet(); - return primaryLayer; - } + AttrAttribute primaryIdAttribute = GetIdAttribute(primaryResourceType); + + QueryLayer primaryLayer = ComposeTopLayer(Array.Empty(), primaryResourceType); + primaryLayer.Include = includeElements.Any() ? new IncludeExpression(includeElements) : IncludeExpression.Empty; + primaryLayer.Sort = null; + primaryLayer.Pagination = null; + primaryLayer.Filter = CreateFilterByIds(id.AsArray(), primaryIdAttribute, primaryLayer.Filter); + primaryLayer.Projection = null; + + return primaryLayer; + } - /// - public IEnumerable<(QueryLayer, RelationshipAttribute)> ComposeForGetTargetedSecondaryResourceIds(IIdentifiable primaryResource) + /// + public IEnumerable<(QueryLayer, RelationshipAttribute)> ComposeForGetTargetedSecondaryResourceIds(IIdentifiable primaryResource) + { + ArgumentGuard.NotNull(primaryResource, nameof(primaryResource)); + + foreach (RelationshipAttribute relationship in _targetedFields.Relationships) { - ArgumentGuard.NotNull(primaryResource, nameof(primaryResource)); + object? rightValue = relationship.GetValue(primaryResource); + ICollection rightResourceIds = _collectionConverter.ExtractResources(rightValue); - foreach (RelationshipAttribute relationship in _targetedFields.Relationships) + if (rightResourceIds.Any()) { - object? rightValue = relationship.GetValue(primaryResource); - ICollection rightResourceIds = _collectionConverter.ExtractResources(rightValue); - - if (rightResourceIds.Any()) - { - QueryLayer queryLayer = ComposeForGetRelationshipRightIds(relationship, rightResourceIds); - yield return (queryLayer, relationship); - } + QueryLayer queryLayer = ComposeForGetRelationshipRightIds(relationship, rightResourceIds); + yield return (queryLayer, relationship); } } + } - /// - public QueryLayer ComposeForGetRelationshipRightIds(RelationshipAttribute relationship, ICollection rightResourceIds) - { - ArgumentGuard.NotNull(relationship, nameof(relationship)); - ArgumentGuard.NotNull(rightResourceIds, nameof(rightResourceIds)); + /// + public QueryLayer ComposeForGetRelationshipRightIds(RelationshipAttribute relationship, ICollection rightResourceIds) + { + ArgumentGuard.NotNull(relationship, nameof(relationship)); + ArgumentGuard.NotNull(rightResourceIds, nameof(rightResourceIds)); - AttrAttribute rightIdAttribute = GetIdAttribute(relationship.RightType); + AttrAttribute rightIdAttribute = GetIdAttribute(relationship.RightType); - object[] typedIds = rightResourceIds.Select(resource => resource.GetTypedId()).ToArray(); + object[] typedIds = rightResourceIds.Select(resource => resource.GetTypedId()).ToArray(); - FilterExpression? baseFilter = GetFilter(Array.Empty(), relationship.RightType); - FilterExpression? filter = CreateFilterByIds(typedIds, rightIdAttribute, baseFilter); + FilterExpression? baseFilter = GetFilter(Array.Empty(), relationship.RightType); + FilterExpression? filter = CreateFilterByIds(typedIds, rightIdAttribute, baseFilter); - return new QueryLayer(relationship.RightType) + return new QueryLayer(relationship.RightType) + { + Include = IncludeExpression.Empty, + Filter = filter, + Projection = new Dictionary { - Include = IncludeExpression.Empty, - Filter = filter, - Projection = new Dictionary - { - [rightIdAttribute] = null - } - }; - } + [rightIdAttribute] = null + } + }; + } - /// - public QueryLayer ComposeForHasMany(HasManyAttribute hasManyRelationship, TId leftId, ICollection rightResourceIds) - { - ArgumentGuard.NotNull(hasManyRelationship, nameof(hasManyRelationship)); - ArgumentGuard.NotNull(rightResourceIds, nameof(rightResourceIds)); + /// + public QueryLayer ComposeForHasMany(HasManyAttribute hasManyRelationship, TId leftId, ICollection rightResourceIds) + { + ArgumentGuard.NotNull(hasManyRelationship, nameof(hasManyRelationship)); + ArgumentGuard.NotNull(rightResourceIds, nameof(rightResourceIds)); - AttrAttribute leftIdAttribute = GetIdAttribute(hasManyRelationship.LeftType); - AttrAttribute rightIdAttribute = GetIdAttribute(hasManyRelationship.RightType); - object[] rightTypedIds = rightResourceIds.Select(resource => resource.GetTypedId()).ToArray(); + AttrAttribute leftIdAttribute = GetIdAttribute(hasManyRelationship.LeftType); + AttrAttribute rightIdAttribute = GetIdAttribute(hasManyRelationship.RightType); + object[] rightTypedIds = rightResourceIds.Select(resource => resource.GetTypedId()).ToArray(); - FilterExpression? leftFilter = CreateFilterByIds(leftId.AsArray(), leftIdAttribute, null); - FilterExpression? rightFilter = CreateFilterByIds(rightTypedIds, rightIdAttribute, null); + FilterExpression? leftFilter = CreateFilterByIds(leftId.AsArray(), leftIdAttribute, null); + FilterExpression? rightFilter = CreateFilterByIds(rightTypedIds, rightIdAttribute, null); - return new QueryLayer(hasManyRelationship.LeftType) + return new QueryLayer(hasManyRelationship.LeftType) + { + Include = new IncludeExpression(ImmutableHashSet.Create(new IncludeElementExpression(hasManyRelationship))), + Filter = leftFilter, + Projection = new Dictionary { - Include = new IncludeExpression(ImmutableHashSet.Create(new IncludeElementExpression(hasManyRelationship))), - Filter = leftFilter, - Projection = new Dictionary + [hasManyRelationship] = new(hasManyRelationship.RightType) { - [hasManyRelationship] = new(hasManyRelationship.RightType) + Filter = rightFilter, + Projection = new Dictionary { - Filter = rightFilter, - Projection = new Dictionary - { - [rightIdAttribute] = null - } - }, - [leftIdAttribute] = null - } - }; - } - - protected virtual IImmutableSet GetIncludeElements(IImmutableSet includeElements, - ResourceType resourceType) - { - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - - return _resourceDefinitionAccessor.OnApplyIncludes(resourceType, includeElements); - } + [rightIdAttribute] = null + } + }, + [leftIdAttribute] = null + } + }; + } - protected virtual FilterExpression? GetFilter(IReadOnlyCollection expressionsInScope, ResourceType resourceType) - { - ArgumentGuard.NotNull(expressionsInScope, nameof(expressionsInScope)); - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + protected virtual IImmutableSet GetIncludeElements(IImmutableSet includeElements, + ResourceType resourceType) + { + ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - FilterExpression[] filters = expressionsInScope.OfType().ToArray(); - FilterExpression? filter = LogicalExpression.Compose(LogicalOperator.And, filters); + return _resourceDefinitionAccessor.OnApplyIncludes(resourceType, includeElements); + } - return _resourceDefinitionAccessor.OnApplyFilter(resourceType, filter); - } + protected virtual FilterExpression? GetFilter(IReadOnlyCollection expressionsInScope, ResourceType resourceType) + { + ArgumentGuard.NotNull(expressionsInScope, nameof(expressionsInScope)); + ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - protected virtual SortExpression GetSort(IReadOnlyCollection expressionsInScope, ResourceType resourceType) - { - ArgumentGuard.NotNull(expressionsInScope, nameof(expressionsInScope)); - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + FilterExpression[] filters = expressionsInScope.OfType().ToArray(); + FilterExpression? filter = LogicalExpression.Compose(LogicalOperator.And, filters); - SortExpression? sort = expressionsInScope.OfType().FirstOrDefault(); + return _resourceDefinitionAccessor.OnApplyFilter(resourceType, filter); + } - sort = _resourceDefinitionAccessor.OnApplySort(resourceType, sort); + protected virtual SortExpression GetSort(IReadOnlyCollection expressionsInScope, ResourceType resourceType) + { + ArgumentGuard.NotNull(expressionsInScope, nameof(expressionsInScope)); + ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - if (sort == null) - { - AttrAttribute idAttribute = GetIdAttribute(resourceType); - var idAscendingSort = new SortElementExpression(new ResourceFieldChainExpression(idAttribute), true); - sort = new SortExpression(ImmutableArray.Create(idAscendingSort)); - } + SortExpression? sort = expressionsInScope.OfType().FirstOrDefault(); - return sort; - } + sort = _resourceDefinitionAccessor.OnApplySort(resourceType, sort); - protected virtual PaginationExpression GetPagination(IReadOnlyCollection expressionsInScope, ResourceType resourceType) + if (sort == null) { - ArgumentGuard.NotNull(expressionsInScope, nameof(expressionsInScope)); - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - - PaginationExpression? pagination = expressionsInScope.OfType().FirstOrDefault(); + AttrAttribute idAttribute = GetIdAttribute(resourceType); + var idAscendingSort = new SortElementExpression(new ResourceFieldChainExpression(idAttribute), true); + sort = new SortExpression(ImmutableArray.Create(idAscendingSort)); + } - pagination = _resourceDefinitionAccessor.OnApplyPagination(resourceType, pagination); + return sort; + } - pagination ??= new PaginationExpression(PageNumber.ValueOne, _options.DefaultPageSize); + protected virtual PaginationExpression GetPagination(IReadOnlyCollection expressionsInScope, ResourceType resourceType) + { + ArgumentGuard.NotNull(expressionsInScope, nameof(expressionsInScope)); + ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - return pagination; - } + PaginationExpression? pagination = expressionsInScope.OfType().FirstOrDefault(); - protected virtual IDictionary? GetProjectionForSparseAttributeSet(ResourceType resourceType) - { - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + pagination = _resourceDefinitionAccessor.OnApplyPagination(resourceType, pagination); - IImmutableSet fieldSet = _sparseFieldSetCache.GetSparseFieldSetForQuery(resourceType); + pagination ??= new PaginationExpression(PageNumber.ValueOne, _options.DefaultPageSize); - if (!fieldSet.Any()) - { - return null; - } + return pagination; + } - HashSet attributeSet = fieldSet.OfType().ToHashSet(); - AttrAttribute idAttribute = GetIdAttribute(resourceType); - attributeSet.Add(idAttribute); + protected virtual IDictionary? GetProjectionForSparseAttributeSet(ResourceType resourceType) + { + ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - return attributeSet.ToDictionary(key => (ResourceFieldAttribute)key, _ => (QueryLayer?)null); - } + IImmutableSet fieldSet = _sparseFieldSetCache.GetSparseFieldSetForQuery(resourceType); - private static AttrAttribute GetIdAttribute(ResourceType resourceType) + if (!fieldSet.Any()) { - return resourceType.GetAttributeByPropertyName(nameof(Identifiable.Id)); + return null; } + + HashSet attributeSet = fieldSet.OfType().ToHashSet(); + AttrAttribute idAttribute = GetIdAttribute(resourceType); + attributeSet.Add(idAttribute); + + return attributeSet.ToDictionary(key => (ResourceFieldAttribute)key, _ => (QueryLayer?)null); + } + + private static AttrAttribute GetIdAttribute(ResourceType resourceType) + { + return resourceType.GetAttributeByPropertyName(nameof(Identifiable.Id)); } } diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/IncludeClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/IncludeClauseBuilder.cs index 7f4cbc895e..6384600a58 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/IncludeClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/IncludeClauseBuilder.cs @@ -1,5 +1,3 @@ -using System.Collections.Generic; -using System.Linq; using System.Linq.Expressions; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; @@ -7,83 +5,82 @@ using JsonApiDotNetCore.Resources.Annotations; using Microsoft.EntityFrameworkCore; -namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding -{ - /// - /// Transforms into calls. - /// - [PublicAPI] - public class IncludeClauseBuilder : QueryClauseBuilder - { - private static readonly IncludeChainConverter IncludeChainConverter = new(); +namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding; - private readonly Expression _source; - private readonly ResourceType _resourceType; +/// +/// Transforms into calls. +/// +[PublicAPI] +public class IncludeClauseBuilder : QueryClauseBuilder +{ + private static readonly IncludeChainConverter IncludeChainConverter = new(); - public IncludeClauseBuilder(Expression source, LambdaScope lambdaScope, ResourceType resourceType) - : base(lambdaScope) - { - ArgumentGuard.NotNull(source, nameof(source)); - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + private readonly Expression _source; + private readonly ResourceType _resourceType; - _source = source; - _resourceType = resourceType; - } + public IncludeClauseBuilder(Expression source, LambdaScope lambdaScope, ResourceType resourceType) + : base(lambdaScope) + { + ArgumentGuard.NotNull(source, nameof(source)); + ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - public Expression ApplyInclude(IncludeExpression include) - { - ArgumentGuard.NotNull(include, nameof(include)); + _source = source; + _resourceType = resourceType; + } - return Visit(include, null); - } + public Expression ApplyInclude(IncludeExpression include) + { + ArgumentGuard.NotNull(include, nameof(include)); - public override Expression VisitInclude(IncludeExpression expression, object? argument) - { - Expression source = ApplyEagerLoads(_source, _resourceType.EagerLoads, null); + return Visit(include, null); + } - foreach (ResourceFieldChainExpression chain in IncludeChainConverter.GetRelationshipChains(expression)) - { - source = ProcessRelationshipChain(chain, source); - } + public override Expression VisitInclude(IncludeExpression expression, object? argument) + { + Expression source = ApplyEagerLoads(_source, _resourceType.EagerLoads, null); - return source; + foreach (ResourceFieldChainExpression chain in IncludeChainConverter.GetRelationshipChains(expression)) + { + source = ProcessRelationshipChain(chain, source); } - private Expression ProcessRelationshipChain(ResourceFieldChainExpression chain, Expression source) - { - string? path = null; - Expression result = source; + return source; + } - foreach (RelationshipAttribute relationship in chain.Fields.Cast()) - { - path = path == null ? relationship.Property.Name : $"{path}.{relationship.Property.Name}"; + private Expression ProcessRelationshipChain(ResourceFieldChainExpression chain, Expression source) + { + string? path = null; + Expression result = source; - result = ApplyEagerLoads(result, relationship.RightType.EagerLoads, path); - } + foreach (RelationshipAttribute relationship in chain.Fields.Cast()) + { + path = path == null ? relationship.Property.Name : $"{path}.{relationship.Property.Name}"; - return IncludeExtensionMethodCall(result, path!); + result = ApplyEagerLoads(result, relationship.RightType.EagerLoads, path); } - private Expression ApplyEagerLoads(Expression source, IEnumerable eagerLoads, string? pathPrefix) - { - Expression result = source; + return IncludeExtensionMethodCall(result, path!); + } - foreach (EagerLoadAttribute eagerLoad in eagerLoads) - { - string path = pathPrefix != null ? $"{pathPrefix}.{eagerLoad.Property.Name}" : eagerLoad.Property.Name; - result = IncludeExtensionMethodCall(result, path); + private Expression ApplyEagerLoads(Expression source, IEnumerable eagerLoads, string? pathPrefix) + { + Expression result = source; - result = ApplyEagerLoads(result, eagerLoad.Children, path); - } + foreach (EagerLoadAttribute eagerLoad in eagerLoads) + { + string path = pathPrefix != null ? $"{pathPrefix}.{eagerLoad.Property.Name}" : eagerLoad.Property.Name; + result = IncludeExtensionMethodCall(result, path); - return result; + result = ApplyEagerLoads(result, eagerLoad.Children, path); } - private Expression IncludeExtensionMethodCall(Expression source, string navigationPropertyPath) - { - Expression navigationExpression = Expression.Constant(navigationPropertyPath); + return result; + } - return Expression.Call(typeof(EntityFrameworkQueryableExtensions), "Include", LambdaScope.Parameter.Type.AsArray(), source, navigationExpression); - } + private Expression IncludeExtensionMethodCall(Expression source, string navigationPropertyPath) + { + Expression navigationExpression = Expression.Constant(navigationPropertyPath); + + return Expression.Call(typeof(EntityFrameworkQueryableExtensions), "Include", LambdaScope.Parameter.Type.AsArray(), source, navigationExpression); } } diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaParameterNameFactory.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaParameterNameFactory.cs index c1050a9d8a..864b71c843 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaParameterNameFactory.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaParameterNameFactory.cs @@ -1,51 +1,49 @@ -using System.Collections.Generic; using Humanizer; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding +namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding; + +/// +/// Produces unique names for lambda parameters. +/// +[PublicAPI] +public sealed class LambdaParameterNameFactory { - /// - /// Produces unique names for lambda parameters. - /// - [PublicAPI] - public sealed class LambdaParameterNameFactory - { - private readonly HashSet _namesInScope = new(); + private readonly HashSet _namesInScope = new(); - public LambdaParameterNameScope Create(string typeName) - { - ArgumentGuard.NotNullNorEmpty(typeName, nameof(typeName)); + public LambdaParameterNameScope Create(string typeName) + { + ArgumentGuard.NotNullNorEmpty(typeName, nameof(typeName)); - string parameterName = typeName.Camelize(); - parameterName = EnsureNameIsUnique(parameterName); + string parameterName = typeName.Camelize(); + parameterName = EnsureNameIsUnique(parameterName); - _namesInScope.Add(parameterName); - return new LambdaParameterNameScope(parameterName, this); - } + _namesInScope.Add(parameterName); + return new LambdaParameterNameScope(parameterName, this); + } - private string EnsureNameIsUnique(string name) + private string EnsureNameIsUnique(string name) + { + if (!_namesInScope.Contains(name)) { - if (!_namesInScope.Contains(name)) - { - return name; - } - - int counter = 1; - string alternativeName; - - do - { - counter++; - alternativeName = name + counter; - } - while (_namesInScope.Contains(alternativeName)); - - return alternativeName; + return name; } - public void Release(string parameterName) + int counter = 1; + string alternativeName; + + do { - _namesInScope.Remove(parameterName); + 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 index 6d555ed23e..2bad41d310 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaParameterNameScope.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaParameterNameScope.cs @@ -1,27 +1,25 @@ -using System; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding +namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding; + +[PublicAPI] +public sealed class LambdaParameterNameScope : IDisposable { - [PublicAPI] - public sealed class LambdaParameterNameScope : IDisposable - { - private readonly LambdaParameterNameFactory _owner; + private readonly LambdaParameterNameFactory _owner; - public string Name { get; } + public string Name { get; } - public LambdaParameterNameScope(string name, LambdaParameterNameFactory owner) - { - ArgumentGuard.NotNullNorEmpty(name, nameof(name)); - ArgumentGuard.NotNull(owner, nameof(owner)); + public LambdaParameterNameScope(string name, LambdaParameterNameFactory owner) + { + ArgumentGuard.NotNullNorEmpty(name, nameof(name)); + ArgumentGuard.NotNull(owner, nameof(owner)); - Name = name; - _owner = owner; - } + Name = name; + _owner = owner; + } - public void Dispose() - { - _owner.Release(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 index d80b373e3a..8be9e4263e 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScope.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScope.cs @@ -1,34 +1,32 @@ -using System; using System.Linq.Expressions; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding +namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding; + +/// +/// Contains details on a lambda expression, such as the name of the selector "x" in "x => x.Name". +/// +[PublicAPI] +public sealed class LambdaScope : IDisposable { - /// - /// Contains details on a lambda expression, such as the name of the selector "x" in "x => x.Name". - /// - [PublicAPI] - public sealed class LambdaScope : IDisposable - { - private readonly LambdaParameterNameScope _parameterNameScope; + private readonly LambdaParameterNameScope _parameterNameScope; - public ParameterExpression Parameter { get; } - public Expression Accessor { get; } + public ParameterExpression Parameter { get; } + public Expression Accessor { get; } - public LambdaScope(LambdaParameterNameFactory nameFactory, Type elementType, Expression? accessorExpression) - { - ArgumentGuard.NotNull(nameFactory, nameof(nameFactory)); - ArgumentGuard.NotNull(elementType, nameof(elementType)); + public LambdaScope(LambdaParameterNameFactory nameFactory, Type elementType, Expression? accessorExpression) + { + ArgumentGuard.NotNull(nameFactory, nameof(nameFactory)); + ArgumentGuard.NotNull(elementType, nameof(elementType)); - _parameterNameScope = nameFactory.Create(elementType.Name); - Parameter = Expression.Parameter(elementType, _parameterNameScope.Name); + _parameterNameScope = nameFactory.Create(elementType.Name); + Parameter = Expression.Parameter(elementType, _parameterNameScope.Name); - Accessor = accessorExpression ?? Parameter; - } + Accessor = accessorExpression ?? Parameter; + } - public void Dispose() - { - _parameterNameScope.Dispose(); - } + public void Dispose() + { + _parameterNameScope.Dispose(); } } diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScopeFactory.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScopeFactory.cs index d5b55fec13..3ba7c5aab9 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScopeFactory.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScopeFactory.cs @@ -1,26 +1,24 @@ -using System; using System.Linq.Expressions; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding +namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding; + +[PublicAPI] +public sealed class LambdaScopeFactory { - [PublicAPI] - public sealed class LambdaScopeFactory - { - private readonly LambdaParameterNameFactory _nameFactory; + private readonly LambdaParameterNameFactory _nameFactory; - public LambdaScopeFactory(LambdaParameterNameFactory nameFactory) - { - ArgumentGuard.NotNull(nameFactory, nameof(nameFactory)); + public LambdaScopeFactory(LambdaParameterNameFactory nameFactory) + { + ArgumentGuard.NotNull(nameFactory, nameof(nameFactory)); - _nameFactory = nameFactory; - } + _nameFactory = nameFactory; + } - public LambdaScope CreateScope(Type elementType, Expression? accessorExpression = null) - { - ArgumentGuard.NotNull(elementType, nameof(elementType)); + public LambdaScope CreateScope(Type elementType, Expression? accessorExpression = null) + { + ArgumentGuard.NotNull(elementType, nameof(elementType)); - return new LambdaScope(_nameFactory, elementType, accessorExpression); - } + return new LambdaScope(_nameFactory, elementType, accessorExpression); } } diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/OrderClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/OrderClauseBuilder.cs index e2692b75de..7ae8dd2392 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/OrderClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/OrderClauseBuilder.cs @@ -1,75 +1,72 @@ -using System; -using System.Linq; using System.Linq.Expressions; using JetBrains.Annotations; using JsonApiDotNetCore.Queries.Expressions; -namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding +namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding; + +/// +/// Transforms into +/// calls. +/// +[PublicAPI] +public class OrderClauseBuilder : QueryClauseBuilder { - /// - /// Transforms into - /// calls. - /// - [PublicAPI] - public class OrderClauseBuilder : QueryClauseBuilder + private readonly Expression _source; + private readonly Type _extensionType; + + public OrderClauseBuilder(Expression source, LambdaScope lambdaScope, Type extensionType) + : base(lambdaScope) { - private readonly Expression _source; - private readonly Type _extensionType; + ArgumentGuard.NotNull(source, nameof(source)); + ArgumentGuard.NotNull(extensionType, nameof(extensionType)); - public OrderClauseBuilder(Expression source, LambdaScope lambdaScope, Type extensionType) - : base(lambdaScope) - { - ArgumentGuard.NotNull(source, nameof(source)); - ArgumentGuard.NotNull(extensionType, nameof(extensionType)); + _source = source; + _extensionType = extensionType; + } - _source = source; - _extensionType = extensionType; - } + public Expression ApplyOrderBy(SortExpression expression) + { + ArgumentGuard.NotNull(expression, nameof(expression)); - public Expression ApplyOrderBy(SortExpression expression) - { - ArgumentGuard.NotNull(expression, nameof(expression)); + return Visit(expression, null); + } - return Visit(expression, null); - } + public override Expression VisitSort(SortExpression expression, Expression? argument) + { + Expression? sortExpression = null; - public override Expression VisitSort(SortExpression expression, Expression? argument) + foreach (SortElementExpression sortElement in expression.Elements) { - Expression? sortExpression = null; - - foreach (SortElementExpression sortElement in expression.Elements) - { - sortExpression = Visit(sortElement, sortExpression); - } - - return sortExpression!; + sortExpression = Visit(sortElement, 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); + return sortExpression!; + } - string operationName = GetOperationName(previousExpression != null, expression.IsAscending); + public override Expression VisitSortElement(SortElementExpression expression, Expression? previousExpression) + { + Expression body = expression.Count != null ? Visit(expression.Count, null) : Visit(expression.TargetAttribute!, null); - return ExtensionMethodCall(previousExpression ?? _source, operationName, body.Type, lambda); - } + LambdaExpression lambda = Expression.Lambda(body, LambdaScope.Parameter); - private static string GetOperationName(bool hasPrecedingSort, bool isAscending) - { - if (hasPrecedingSort) - { - return isAscending ? "ThenBy" : "ThenByDescending"; - } + string operationName = GetOperationName(previousExpression != null, expression.IsAscending); - return isAscending ? "OrderBy" : "OrderByDescending"; - } + return ExtensionMethodCall(previousExpression ?? _source, operationName, body.Type, lambda); + } - private Expression ExtensionMethodCall(Expression source, string operationName, Type keyType, LambdaExpression keySelector) + private static string GetOperationName(bool hasPrecedingSort, bool isAscending) + { + if (hasPrecedingSort) { - Type[] typeArguments = ArrayFactory.Create(LambdaScope.Parameter.Type, keyType); - return Expression.Call(_extensionType, operationName, typeArguments, source, keySelector); + return isAscending ? "ThenBy" : "ThenByDescending"; } + + return isAscending ? "OrderBy" : "OrderByDescending"; + } + + private Expression ExtensionMethodCall(Expression source, string operationName, Type keyType, LambdaExpression keySelector) + { + Type[] typeArguments = ArrayFactory.Create(LambdaScope.Parameter.Type, keyType); + return Expression.Call(_extensionType, operationName, typeArguments, source, keySelector); } } diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryClauseBuilder.cs index 60b25fb9a6..bf14c70b6d 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryClauseBuilder.cs @@ -1,116 +1,86 @@ -using System; -using System.Collections.Generic; -using System.Linq; using System.Linq.Expressions; using System.Reflection; using JsonApiDotNetCore.Queries.Expressions; -namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding -{ - /// - /// Base class for transforming trees into system trees. - /// - public abstract class QueryClauseBuilder : QueryExpressionVisitor - { - protected LambdaScope LambdaScope { get; } +namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding; - protected QueryClauseBuilder(LambdaScope lambdaScope) - { - ArgumentGuard.NotNull(lambdaScope, nameof(lambdaScope)); +/// +/// Base class for transforming trees into system trees. +/// +public abstract class QueryClauseBuilder : QueryExpressionVisitor +{ + protected LambdaScope LambdaScope { get; } - LambdaScope = lambdaScope; - } + protected QueryClauseBuilder(LambdaScope lambdaScope) + { + ArgumentGuard.NotNull(lambdaScope, nameof(lambdaScope)); - public override Expression VisitCount(CountExpression expression, TArgument argument) - { - Expression collectionExpression = Visit(expression.TargetCollection, argument); + LambdaScope = lambdaScope; + } - Expression? propertyExpression = GetCollectionCount(collectionExpression); + public override Expression VisitCount(CountExpression expression, TArgument argument) + { + Expression collectionExpression = Visit(expression.TargetCollection, argument); - if (propertyExpression == null) - { - throw new InvalidOperationException($"Field '{expression.TargetCollection}' must be a collection."); - } + Expression? propertyExpression = GetCollectionCount(collectionExpression); - return propertyExpression; + if (propertyExpression == null) + { + throw new InvalidOperationException($"Field '{expression.TargetCollection}' must be a collection."); } - private static Expression? GetCollectionCount(Expression? collectionExpression) - { - if (collectionExpression != null) - { - var properties = new HashSet(collectionExpression.Type.GetProperties()); + return propertyExpression; + } - if (collectionExpression.Type.IsInterface) - { - foreach (PropertyInfo item in collectionExpression.Type.GetInterfaces().SelectMany(@interface => @interface.GetProperties())) - { - properties.Add(item); - } - } + private static Expression? GetCollectionCount(Expression? collectionExpression) + { + if (collectionExpression != null) + { + var properties = new HashSet(collectionExpression.Type.GetProperties()); - foreach (PropertyInfo property in properties) + if (collectionExpression.Type.IsInterface) + { + foreach (PropertyInfo item in collectionExpression.Type.GetInterfaces().SelectMany(@interface => @interface.GetProperties())) { - if (property.Name is "Count" or "Length") - { - return Expression.Property(collectionExpression, property); - } + properties.Add(item); } } - return null; - } - - public override Expression VisitResourceFieldChain(ResourceFieldChainExpression expression, TArgument argument) - { - string[] components = expression.Fields.Select(field => field.Property.Name).ToArray(); - - return CreatePropertyExpressionFromComponents(LambdaScope.Accessor, components); - } - - private static MemberExpression CreatePropertyExpressionFromComponents(Expression source, IEnumerable components) - { - MemberExpression? property = null; - - foreach (string propertyName in components) + foreach (PropertyInfo property in properties) { - Type parentType = property == null ? source.Type : property.Type; - - if (parentType.GetProperty(propertyName) == null) + if (property.Name is "Count" or "Length") { - throw new InvalidOperationException($"Type '{parentType.Name}' does not contain a property named '{propertyName}'."); + return Expression.Property(collectionExpression, property); } - - 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 + return null; + } - // This method can be used to change a query like: - // SELECT ... FROM ... WHERE x."Age" = 3 - // into: - // SELECT ... FROM ... WHERE x."Age" = @p0 + public override Expression VisitResourceFieldChain(ResourceFieldChainExpression expression, TArgument argument) + { + string[] components = expression.Fields.Select(field => field.Property.Name).ToArray(); - // 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; + return CreatePropertyExpressionFromComponents(LambdaScope.Accessor, components); + } - MethodInfo tupleCreateMethod = typeof(Tuple).GetMethods() - .Single(method => method.Name == "Create" && method.IsGenericMethod && method.GetGenericArguments().Length == 1); + private static MemberExpression CreatePropertyExpressionFromComponents(Expression source, IEnumerable components) + { + MemberExpression? property = null; - MethodInfo constructedTupleCreateMethod = tupleCreateMethod.MakeGenericMethod(type); + foreach (string propertyName in components) + { + Type parentType = property == null ? source.Type : property.Type; - ConstantExpression constantExpression = Expression.Constant(value, type); + if (parentType.GetProperty(propertyName) == null) + { + throw new InvalidOperationException($"Type '{parentType.Name}' does not contain a property named '{propertyName}'."); + } - MethodCallExpression tupleCreateCall = Expression.Call(constructedTupleCreateMethod, constantExpression); - return Expression.Property(tupleCreateCall, "Item1"); + property = property == null ? Expression.Property(source, propertyName) : Expression.Property(property, propertyName); } + + return property!; } } diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryableBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryableBuilder.cs index 99036e5d1d..7ce50c02f1 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryableBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryableBuilder.cs @@ -1,5 +1,3 @@ -using System; -using System.Collections.Generic; using System.Linq.Expressions; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; @@ -8,113 +6,112 @@ using JsonApiDotNetCore.Resources.Annotations; using Microsoft.EntityFrameworkCore.Metadata; -namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding +namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding; + +/// +/// Drives conversion from into system trees. +/// +[PublicAPI] +public class QueryableBuilder { - /// - /// Drives conversion from into system trees. - /// - [PublicAPI] - public class QueryableBuilder + private readonly Expression _source; + private readonly Type _elementType; + private readonly Type _extensionType; + private readonly LambdaParameterNameFactory _nameFactory; + private readonly IResourceFactory _resourceFactory; + private readonly IModel _entityModel; + private readonly LambdaScopeFactory _lambdaScopeFactory; + + public QueryableBuilder(Expression source, Type elementType, Type extensionType, LambdaParameterNameFactory nameFactory, IResourceFactory resourceFactory, + IModel entityModel, LambdaScopeFactory? lambdaScopeFactory = null) { - private readonly Expression _source; - private readonly Type _elementType; - private readonly Type _extensionType; - private readonly LambdaParameterNameFactory _nameFactory; - private readonly IResourceFactory _resourceFactory; - private readonly IModel _entityModel; - private readonly LambdaScopeFactory _lambdaScopeFactory; - - public QueryableBuilder(Expression source, Type elementType, Type extensionType, LambdaParameterNameFactory nameFactory, - IResourceFactory resourceFactory, IModel entityModel, LambdaScopeFactory? lambdaScopeFactory = null) - { - ArgumentGuard.NotNull(source, nameof(source)); - ArgumentGuard.NotNull(elementType, nameof(elementType)); - ArgumentGuard.NotNull(extensionType, nameof(extensionType)); - ArgumentGuard.NotNull(nameFactory, nameof(nameFactory)); - ArgumentGuard.NotNull(resourceFactory, nameof(resourceFactory)); - ArgumentGuard.NotNull(entityModel, nameof(entityModel)); - - _source = source; - _elementType = elementType; - _extensionType = extensionType; - _nameFactory = nameFactory; - _resourceFactory = resourceFactory; - _entityModel = entityModel; - _lambdaScopeFactory = lambdaScopeFactory ?? new LambdaScopeFactory(_nameFactory); - } - - public virtual Expression ApplyQuery(QueryLayer layer) - { - ArgumentGuard.NotNull(layer, nameof(layer)); - - Expression expression = _source; - - if (layer.Include != null) - { - expression = ApplyInclude(expression, layer.Include, layer.ResourceType); - } - - if (layer.Filter != null) - { - expression = ApplyFilter(expression, layer.Filter); - } - - if (layer.Sort != null) - { - expression = ApplySort(expression, layer.Sort); - } + ArgumentGuard.NotNull(source, nameof(source)); + ArgumentGuard.NotNull(elementType, nameof(elementType)); + ArgumentGuard.NotNull(extensionType, nameof(extensionType)); + ArgumentGuard.NotNull(nameFactory, nameof(nameFactory)); + ArgumentGuard.NotNull(resourceFactory, nameof(resourceFactory)); + ArgumentGuard.NotNull(entityModel, nameof(entityModel)); + + _source = source; + _elementType = elementType; + _extensionType = extensionType; + _nameFactory = nameFactory; + _resourceFactory = resourceFactory; + _entityModel = entityModel; + _lambdaScopeFactory = lambdaScopeFactory ?? new LambdaScopeFactory(_nameFactory); + } - if (layer.Pagination != null) - { - expression = ApplyPagination(expression, layer.Pagination); - } + public virtual Expression ApplyQuery(QueryLayer layer) + { + ArgumentGuard.NotNull(layer, nameof(layer)); - if (!layer.Projection.IsNullOrEmpty()) - { - expression = ApplyProjection(expression, layer.Projection, layer.ResourceType); - } + Expression expression = _source; - return expression; + if (layer.Include != null) + { + expression = ApplyInclude(expression, layer.Include, layer.ResourceType); } - protected virtual Expression ApplyInclude(Expression source, IncludeExpression include, ResourceType resourceType) + if (layer.Filter != null) { - using LambdaScope lambdaScope = _lambdaScopeFactory.CreateScope(_elementType); - - var builder = new IncludeClauseBuilder(source, lambdaScope, resourceType); - return builder.ApplyInclude(include); + expression = ApplyFilter(expression, layer.Filter); } - protected virtual Expression ApplyFilter(Expression source, FilterExpression filter) + if (layer.Sort != null) { - using LambdaScope lambdaScope = _lambdaScopeFactory.CreateScope(_elementType); - - var builder = new WhereClauseBuilder(source, lambdaScope, _extensionType, _nameFactory); - return builder.ApplyWhere(filter); + expression = ApplySort(expression, layer.Sort); } - protected virtual Expression ApplySort(Expression source, SortExpression sort) + if (layer.Pagination != null) { - using LambdaScope lambdaScope = _lambdaScopeFactory.CreateScope(_elementType); - - var builder = new OrderClauseBuilder(source, lambdaScope, _extensionType); - return builder.ApplyOrderBy(sort); + expression = ApplyPagination(expression, layer.Pagination); } - protected virtual Expression ApplyPagination(Expression source, PaginationExpression pagination) + if (!layer.Projection.IsNullOrEmpty()) { - using LambdaScope lambdaScope = _lambdaScopeFactory.CreateScope(_elementType); - - var builder = new SkipTakeClauseBuilder(source, lambdaScope, _extensionType); - return builder.ApplySkipTake(pagination); + expression = ApplyProjection(expression, layer.Projection, layer.ResourceType); } - protected virtual Expression ApplyProjection(Expression source, IDictionary projection, ResourceType resourceType) - { - using LambdaScope lambdaScope = _lambdaScopeFactory.CreateScope(_elementType); + return expression; + } - var builder = new SelectClauseBuilder(source, lambdaScope, _entityModel, _extensionType, _nameFactory, _resourceFactory); - return builder.ApplySelect(projection, resourceType); - } + protected virtual Expression ApplyInclude(Expression source, IncludeExpression include, ResourceType resourceType) + { + using LambdaScope lambdaScope = _lambdaScopeFactory.CreateScope(_elementType); + + var builder = new IncludeClauseBuilder(source, lambdaScope, resourceType); + return builder.ApplyInclude(include); + } + + protected virtual Expression ApplyFilter(Expression source, FilterExpression filter) + { + using LambdaScope lambdaScope = _lambdaScopeFactory.CreateScope(_elementType); + + var builder = new WhereClauseBuilder(source, lambdaScope, _extensionType, _nameFactory); + return builder.ApplyWhere(filter); + } + + protected virtual Expression ApplySort(Expression source, SortExpression sort) + { + using LambdaScope lambdaScope = _lambdaScopeFactory.CreateScope(_elementType); + + var builder = new OrderClauseBuilder(source, lambdaScope, _extensionType); + return builder.ApplyOrderBy(sort); + } + + protected virtual Expression ApplyPagination(Expression source, PaginationExpression pagination) + { + using LambdaScope lambdaScope = _lambdaScopeFactory.CreateScope(_elementType); + + var builder = new SkipTakeClauseBuilder(source, lambdaScope, _extensionType); + return builder.ApplySkipTake(pagination); + } + + protected virtual Expression ApplyProjection(Expression source, IDictionary projection, ResourceType resourceType) + { + using LambdaScope lambdaScope = _lambdaScopeFactory.CreateScope(_elementType); + + var builder = new SelectClauseBuilder(source, lambdaScope, _entityModel, _extensionType, _nameFactory, _resourceFactory); + return builder.ApplySelect(projection, resourceType); } } diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs index c34b92c0dc..3931cdc180 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs @@ -1,6 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; using System.Linq.Expressions; using System.Reflection; using JetBrains.Annotations; @@ -8,233 +5,231 @@ using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata; -namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding +namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding; + +/// +/// Transforms into +/// calls. +/// +[PublicAPI] +public class SelectClauseBuilder : QueryClauseBuilder { - /// - /// Transforms into - /// calls. - /// - [PublicAPI] - public class SelectClauseBuilder : QueryClauseBuilder + private static readonly CollectionConverter CollectionConverter = new(); + 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; + + public SelectClauseBuilder(Expression source, LambdaScope lambdaScope, IModel entityModel, Type extensionType, LambdaParameterNameFactory nameFactory, + IResourceFactory resourceFactory) + : base(lambdaScope) { - private static readonly CollectionConverter CollectionConverter = new(); - 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; - - public SelectClauseBuilder(Expression source, LambdaScope lambdaScope, IModel entityModel, Type extensionType, LambdaParameterNameFactory nameFactory, - IResourceFactory resourceFactory) - : base(lambdaScope) + ArgumentGuard.NotNull(source, nameof(source)); + ArgumentGuard.NotNull(entityModel, nameof(entityModel)); + ArgumentGuard.NotNull(extensionType, nameof(extensionType)); + ArgumentGuard.NotNull(nameFactory, nameof(nameFactory)); + ArgumentGuard.NotNull(resourceFactory, nameof(resourceFactory)); + + _source = source; + _entityModel = entityModel; + _extensionType = extensionType; + _nameFactory = nameFactory; + _resourceFactory = resourceFactory; + } + + public Expression ApplySelect(IDictionary selectors, ResourceType resourceType) + { + ArgumentGuard.NotNull(selectors, nameof(selectors)); + + if (!selectors.Any()) { - ArgumentGuard.NotNull(source, nameof(source)); - ArgumentGuard.NotNull(entityModel, nameof(entityModel)); - ArgumentGuard.NotNull(extensionType, nameof(extensionType)); - ArgumentGuard.NotNull(nameFactory, nameof(nameFactory)); - ArgumentGuard.NotNull(resourceFactory, nameof(resourceFactory)); - - _source = source; - _entityModel = entityModel; - _extensionType = extensionType; - _nameFactory = nameFactory; - _resourceFactory = resourceFactory; + return _source; } - public Expression ApplySelect(IDictionary selectors, ResourceType resourceType) - { - ArgumentGuard.NotNull(selectors, nameof(selectors)); + Expression bodyInitializer = CreateLambdaBodyInitializer(selectors, resourceType, LambdaScope, false); - if (!selectors.Any()) - { - return _source; - } + LambdaExpression lambda = Expression.Lambda(bodyInitializer, LambdaScope.Parameter); + + return SelectExtensionMethodCall(_source, LambdaScope.Parameter.Type, lambda); + } - Expression bodyInitializer = CreateLambdaBodyInitializer(selectors, resourceType, LambdaScope, false); + private Expression CreateLambdaBodyInitializer(IDictionary selectors, ResourceType resourceType, + LambdaScope lambdaScope, bool lambdaAccessorRequiresTestForNull) + { + ICollection propertySelectors = ToPropertySelectors(selectors, resourceType, lambdaScope.Accessor.Type); - LambdaExpression lambda = Expression.Lambda(bodyInitializer, LambdaScope.Parameter); + MemberBinding[] propertyAssignments = + propertySelectors.Select(selector => CreatePropertyAssignment(selector, lambdaScope)).Cast().ToArray(); - return SelectExtensionMethodCall(_source, LambdaScope.Parameter.Type, lambda); - } + NewExpression newExpression = _resourceFactory.CreateNewExpression(lambdaScope.Accessor.Type); + Expression memberInit = Expression.MemberInit(newExpression, propertyAssignments); - private Expression CreateLambdaBodyInitializer(IDictionary selectors, ResourceType resourceType, - LambdaScope lambdaScope, bool lambdaAccessorRequiresTestForNull) + if (!lambdaAccessorRequiresTestForNull) { - ICollection propertySelectors = ToPropertySelectors(selectors, resourceType, lambdaScope.Accessor.Type); + return memberInit; + } - MemberBinding[] propertyAssignments = - propertySelectors.Select(selector => CreatePropertyAssignment(selector, lambdaScope)).Cast().ToArray(); + return TestForNull(lambdaScope.Accessor, memberInit); + } - NewExpression newExpression = _resourceFactory.CreateNewExpression(lambdaScope.Accessor.Type); - Expression memberInit = Expression.MemberInit(newExpression, propertyAssignments); + private ICollection ToPropertySelectors(IDictionary resourceFieldSelectors, + ResourceType resourceType, Type elementType) + { + var propertySelectors = new Dictionary(); - if (!lambdaAccessorRequiresTestForNull) - { - return memberInit; - } + // If a read-only attribute is selected, its calculated value likely depends on another property, so select all properties. + bool includesReadOnlyAttribute = resourceFieldSelectors.Any(selector => + selector.Key is AttrAttribute attribute && attribute.Property.SetMethod == null); - return TestForNull(lambdaScope.Accessor, memberInit); - } + // Only selecting relationships implicitly means to select all attributes too. + bool containsOnlyRelationships = resourceFieldSelectors.All(selector => selector.Key is RelationshipAttribute); - private ICollection ToPropertySelectors(IDictionary resourceFieldSelectors, - ResourceType resourceType, Type elementType) + if (includesReadOnlyAttribute || containsOnlyRelationships) { - var propertySelectors = new Dictionary(); - - // If a read-only attribute is selected, its calculated value likely depends on another property, so select all properties. - bool includesReadOnlyAttribute = resourceFieldSelectors.Any(selector => - selector.Key is AttrAttribute attribute && attribute.Property.SetMethod == null); + IncludeAllProperties(elementType, propertySelectors); + } - // Only selecting relationships implicitly means to select all attributes too. - bool containsOnlyRelationships = resourceFieldSelectors.All(selector => selector.Key is RelationshipAttribute); + IncludeFieldSelection(resourceFieldSelectors, propertySelectors); - if (includesReadOnlyAttribute || containsOnlyRelationships) - { - IncludeAllProperties(elementType, propertySelectors); - } + IncludeEagerLoads(resourceType, propertySelectors); - IncludeFieldSelection(resourceFieldSelectors, propertySelectors); + return propertySelectors.Values; + } - IncludeEagerLoads(resourceType, propertySelectors); + private void IncludeAllProperties(Type elementType, Dictionary propertySelectors) + { + IEntityType entityModel = _entityModel.GetEntityTypes().Single(type => type.ClrType == elementType); + IEnumerable entityProperties = entityModel.GetProperties().Where(property => !property.IsShadowProperty()).ToArray(); - return propertySelectors.Values; + foreach (IProperty entityProperty in entityProperties) + { + var propertySelector = new PropertySelector(entityProperty.PropertyInfo!); + IncludeWritableProperty(propertySelector, propertySelectors); } + } - private void IncludeAllProperties(Type elementType, Dictionary propertySelectors) + private static void IncludeFieldSelection(IDictionary resourceFieldSelectors, + Dictionary propertySelectors) + { + foreach ((ResourceFieldAttribute resourceField, QueryLayer? queryLayer) in resourceFieldSelectors) { - IEntityType entityModel = _entityModel.GetEntityTypes().Single(type => type.ClrType == elementType); - IEnumerable entityProperties = entityModel.GetProperties().Where(property => !property.IsShadowProperty()).ToArray(); - - foreach (IProperty entityProperty in entityProperties) - { - var propertySelector = new PropertySelector(entityProperty.PropertyInfo); - IncludeWritableProperty(propertySelector, propertySelectors); - } + var propertySelector = new PropertySelector(resourceField.Property, queryLayer); + IncludeWritableProperty(propertySelector, propertySelectors); } + } - private static void IncludeFieldSelection(IDictionary resourceFieldSelectors, - Dictionary propertySelectors) + private static void IncludeWritableProperty(PropertySelector propertySelector, Dictionary propertySelectors) + { + if (propertySelector.Property.SetMethod != null) { - foreach ((ResourceFieldAttribute resourceField, QueryLayer? queryLayer) in resourceFieldSelectors) - { - var propertySelector = new PropertySelector(resourceField.Property, queryLayer); - IncludeWritableProperty(propertySelector, propertySelectors); - } + propertySelectors[propertySelector.Property] = propertySelector; } + } - private static void IncludeWritableProperty(PropertySelector propertySelector, Dictionary propertySelectors) + private static void IncludeEagerLoads(ResourceType resourceType, Dictionary propertySelectors) + { + foreach (EagerLoadAttribute eagerLoad in resourceType.EagerLoads) { - if (propertySelector.Property.SetMethod != null) + var propertySelector = new PropertySelector(eagerLoad.Property); + + // When an entity navigation property is decorated with both EagerLoadAttribute and RelationshipAttribute, + // it may already exist with a sub-layer. So do not overwrite in that case. + if (!propertySelectors.ContainsKey(propertySelector.Property)) { propertySelectors[propertySelector.Property] = propertySelector; } } + } - private static void IncludeEagerLoads(ResourceType resourceType, Dictionary propertySelectors) - { - foreach (EagerLoadAttribute eagerLoad in resourceType.EagerLoads) - { - var propertySelector = new PropertySelector(eagerLoad.Property); - - // When an entity navigation property is decorated with both EagerLoadAttribute and RelationshipAttribute, - // it may already exist with a sub-layer. So do not overwrite in that case. - if (!propertySelectors.ContainsKey(propertySelector.Property)) - { - propertySelectors[propertySelector.Property] = propertySelector; - } - } - } + private MemberAssignment CreatePropertyAssignment(PropertySelector selector, LambdaScope lambdaScope) + { + MemberExpression propertyAccess = Expression.Property(lambdaScope.Accessor, selector.Property); - private MemberAssignment CreatePropertyAssignment(PropertySelector selector, LambdaScope lambdaScope) + Expression assignmentRightHandSide = propertyAccess; + + if (selector.NextLayer != null) { - MemberExpression propertyAccess = Expression.Property(lambdaScope.Accessor, selector.Property); + var lambdaScopeFactory = new LambdaScopeFactory(_nameFactory); - Expression assignmentRightHandSide = propertyAccess; + assignmentRightHandSide = CreateAssignmentRightHandSideForLayer(selector.NextLayer, lambdaScope, propertyAccess, + selector.Property, lambdaScopeFactory); + } - if (selector.NextLayer != null) - { - var lambdaScopeFactory = new LambdaScopeFactory(_nameFactory); + return Expression.Bind(selector.Property, assignmentRightHandSide); + } - assignmentRightHandSide = CreateAssignmentRightHandSideForLayer(selector.NextLayer, lambdaScope, propertyAccess, - selector.Property, lambdaScopeFactory); - } + private Expression CreateAssignmentRightHandSideForLayer(QueryLayer layer, LambdaScope outerLambdaScope, MemberExpression propertyAccess, + PropertyInfo selectorPropertyInfo, LambdaScopeFactory lambdaScopeFactory) + { + Type? collectionElementType = CollectionConverter.FindCollectionElementType(selectorPropertyInfo.PropertyType); + Type bodyElementType = collectionElementType ?? selectorPropertyInfo.PropertyType; - return Expression.Bind(selector.Property, assignmentRightHandSide); + if (collectionElementType != null) + { + return CreateCollectionInitializer(outerLambdaScope, selectorPropertyInfo, bodyElementType, layer, lambdaScopeFactory); } - private Expression CreateAssignmentRightHandSideForLayer(QueryLayer layer, LambdaScope outerLambdaScope, MemberExpression propertyAccess, - PropertyInfo selectorPropertyInfo, LambdaScopeFactory lambdaScopeFactory) + if (layer.Projection.IsNullOrEmpty()) { - Type? collectionElementType = CollectionConverter.FindCollectionElementType(selectorPropertyInfo.PropertyType); - Type bodyElementType = collectionElementType ?? selectorPropertyInfo.PropertyType; + return propertyAccess; + } - if (collectionElementType != null) - { - return CreateCollectionInitializer(outerLambdaScope, selectorPropertyInfo, bodyElementType, layer, lambdaScopeFactory); - } + using LambdaScope scope = lambdaScopeFactory.CreateScope(bodyElementType, propertyAccess); + return CreateLambdaBodyInitializer(layer.Projection, layer.ResourceType, scope, true); + } - if (layer.Projection.IsNullOrEmpty()) - { - return propertyAccess; - } + private Expression CreateCollectionInitializer(LambdaScope lambdaScope, PropertyInfo collectionProperty, Type elementType, QueryLayer layer, + LambdaScopeFactory lambdaScopeFactory) + { + MemberExpression propertyExpression = Expression.Property(lambdaScope.Accessor, collectionProperty); - using LambdaScope scope = lambdaScopeFactory.CreateScope(bodyElementType, propertyAccess); - return CreateLambdaBodyInitializer(layer.Projection, layer.ResourceType, scope, true); - } + var builder = new QueryableBuilder(propertyExpression, elementType, typeof(Enumerable), _nameFactory, _resourceFactory, _entityModel, + lambdaScopeFactory); - private Expression CreateCollectionInitializer(LambdaScope lambdaScope, PropertyInfo collectionProperty, Type elementType, QueryLayer layer, - LambdaScopeFactory lambdaScopeFactory) - { - MemberExpression propertyExpression = Expression.Property(lambdaScope.Accessor, collectionProperty); + Expression layerExpression = builder.ApplyQuery(layer); + + string operationName = CollectionConverter.TypeCanContainHashSet(collectionProperty.PropertyType) ? "ToHashSet" : "ToList"; + return CopyCollectionExtensionMethodCall(layerExpression, operationName, elementType); + } - var builder = new QueryableBuilder(propertyExpression, elementType, typeof(Enumerable), _nameFactory, _resourceFactory, _entityModel, - lambdaScopeFactory); + private static Expression TestForNull(Expression expressionToTest, Expression ifFalseExpression) + { + BinaryExpression equalsNull = Expression.Equal(expressionToTest, NullConstant); + return Expression.Condition(equalsNull, Expression.Convert(NullConstant, expressionToTest.Type), ifFalseExpression); + } - Expression layerExpression = builder.ApplyQuery(layer); + private static Expression CopyCollectionExtensionMethodCall(Expression source, string operationName, Type elementType) + { + return Expression.Call(typeof(Enumerable), operationName, elementType.AsArray(), source); + } - string operationName = CollectionConverter.TypeCanContainHashSet(collectionProperty.PropertyType) ? "ToHashSet" : "ToList"; - return CopyCollectionExtensionMethodCall(layerExpression, operationName, elementType); - } + private Expression SelectExtensionMethodCall(Expression source, Type elementType, Expression selectorBody) + { + Type[] typeArguments = ArrayFactory.Create(elementType, elementType); + return Expression.Call(_extensionType, "Select", typeArguments, source, selectorBody); + } - 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 sealed class PropertySelector + { + public PropertyInfo Property { get; } + public QueryLayer? NextLayer { get; } - private static Expression CopyCollectionExtensionMethodCall(Expression source, string operationName, Type elementType) + public PropertySelector(PropertyInfo property, QueryLayer? nextLayer = null) { - return Expression.Call(typeof(Enumerable), operationName, elementType.AsArray(), source); - } + ArgumentGuard.NotNull(property, nameof(property)); - private Expression SelectExtensionMethodCall(Expression source, Type elementType, Expression selectorBody) - { - Type[] typeArguments = ArrayFactory.Create(elementType, elementType); - return Expression.Call(_extensionType, "Select", typeArguments, source, selectorBody); + Property = property; + NextLayer = nextLayer; } - private sealed class PropertySelector + public override string ToString() { - public PropertyInfo Property { get; } - public QueryLayer? NextLayer { get; } - - public PropertySelector(PropertyInfo property, QueryLayer? nextLayer = null) - { - ArgumentGuard.NotNull(property, nameof(property)); - - Property = property; - NextLayer = nextLayer; - } - - public override string ToString() - { - return $"Property: {(NextLayer != null ? $"{Property.Name}..." : Property.Name)}"; - } + 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 index ce02edb368..4bb9bfd6f5 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SkipTakeClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SkipTakeClauseBuilder.cs @@ -1,61 +1,59 @@ -using System; -using System.Linq; using System.Linq.Expressions; using JetBrains.Annotations; using JsonApiDotNetCore.Queries.Expressions; -namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding +namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding; + +/// +/// Transforms into and +/// calls. +/// +[PublicAPI] +public class SkipTakeClauseBuilder : QueryClauseBuilder { - /// - /// Transforms into and calls. - /// - [PublicAPI] - public class SkipTakeClauseBuilder : QueryClauseBuilder + private readonly Expression _source; + private readonly Type _extensionType; + + public SkipTakeClauseBuilder(Expression source, LambdaScope lambdaScope, Type extensionType) + : base(lambdaScope) { - private readonly Expression _source; - private readonly Type _extensionType; + ArgumentGuard.NotNull(source, nameof(source)); + ArgumentGuard.NotNull(extensionType, nameof(extensionType)); - public SkipTakeClauseBuilder(Expression source, LambdaScope lambdaScope, Type extensionType) - : base(lambdaScope) - { - ArgumentGuard.NotNull(source, nameof(source)); - ArgumentGuard.NotNull(extensionType, nameof(extensionType)); + _source = source; + _extensionType = extensionType; + } - _source = source; - _extensionType = extensionType; - } + public Expression ApplySkipTake(PaginationExpression expression) + { + ArgumentGuard.NotNull(expression, nameof(expression)); - public Expression ApplySkipTake(PaginationExpression expression) - { - ArgumentGuard.NotNull(expression, nameof(expression)); + return Visit(expression, null); + } - return Visit(expression, null); - } + public override Expression VisitPagination(PaginationExpression expression, object? argument) + { + Expression skipTakeExpression = _source; - public override Expression VisitPagination(PaginationExpression expression, object? argument) + if (expression.PageSize != null) { - Expression skipTakeExpression = _source; + int skipValue = (expression.PageNumber.OneBasedValue - 1) * expression.PageSize.Value; - if (expression.PageSize != null) + if (skipValue > 0) { - int skipValue = (expression.PageNumber.OneBasedValue - 1) * expression.PageSize.Value; - - if (skipValue > 0) - { - skipTakeExpression = ExtensionMethodCall(skipTakeExpression, "Skip", skipValue); - } - - skipTakeExpression = ExtensionMethodCall(skipTakeExpression, "Take", expression.PageSize.Value); + skipTakeExpression = ExtensionMethodCall(skipTakeExpression, "Skip", skipValue); } - return skipTakeExpression; + skipTakeExpression = ExtensionMethodCall(skipTakeExpression, "Take", expression.PageSize.Value); } - private Expression ExtensionMethodCall(Expression source, string operationName, int value) - { - Expression constant = CreateTupleAccessExpressionForConstant(value, typeof(int)); + return skipTakeExpression; + } - return Expression.Call(_extensionType, operationName, LambdaScope.Parameter.Type.AsArray(), source, constant); - } + private Expression ExtensionMethodCall(Expression source, string operationName, int value) + { + Expression constant = value.CreateTupleAccessExpressionForConstant(typeof(int)); + + return Expression.Call(_extensionType, operationName, LambdaScope.Parameter.Type.AsArray(), source, constant); } } diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/WhereClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/WhereClauseBuilder.cs index fcb934d0f9..e34da3a495 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/WhereClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/WhereClauseBuilder.cs @@ -1,291 +1,287 @@ -using System; using System.Collections; -using System.Collections.Generic; -using System.Linq; using System.Linq.Expressions; using JetBrains.Annotations; using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources.Internal; -namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding +namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding; + +/// +/// Transforms into +/// calls. +/// +[PublicAPI] +public class WhereClauseBuilder : QueryClauseBuilder { - /// - /// Transforms into - /// calls. - /// - [PublicAPI] - public class WhereClauseBuilder : QueryClauseBuilder + private static readonly CollectionConverter CollectionConverter = new(); + private static readonly ConstantExpression NullConstant = Expression.Constant(null); + + private readonly Expression _source; + private readonly Type _extensionType; + private readonly LambdaParameterNameFactory _nameFactory; + + public WhereClauseBuilder(Expression source, LambdaScope lambdaScope, Type extensionType, LambdaParameterNameFactory nameFactory) + : base(lambdaScope) { - private static readonly CollectionConverter CollectionConverter = new(); - private static readonly ConstantExpression NullConstant = Expression.Constant(null); + ArgumentGuard.NotNull(source, nameof(source)); + ArgumentGuard.NotNull(extensionType, nameof(extensionType)); + ArgumentGuard.NotNull(nameFactory, nameof(nameFactory)); - private readonly Expression _source; - private readonly Type _extensionType; - private readonly LambdaParameterNameFactory _nameFactory; + _source = source; + _extensionType = extensionType; + _nameFactory = nameFactory; + } - public WhereClauseBuilder(Expression source, LambdaScope lambdaScope, Type extensionType, LambdaParameterNameFactory nameFactory) - : base(lambdaScope) - { - ArgumentGuard.NotNull(source, nameof(source)); - ArgumentGuard.NotNull(extensionType, nameof(extensionType)); - ArgumentGuard.NotNull(nameFactory, nameof(nameFactory)); + public Expression ApplyWhere(FilterExpression filter) + { + ArgumentGuard.NotNull(filter, nameof(filter)); - _source = source; - _extensionType = extensionType; - _nameFactory = nameFactory; - } + LambdaExpression lambda = GetPredicateLambda(filter); - public Expression ApplyWhere(FilterExpression filter) - { - ArgumentGuard.NotNull(filter, nameof(filter)); + return WhereExtensionMethodCall(lambda); + } - LambdaExpression lambda = GetPredicateLambda(filter); + private LambdaExpression GetPredicateLambda(FilterExpression filter) + { + Expression body = Visit(filter, null); + return Expression.Lambda(body, LambdaScope.Parameter); + } - return WhereExtensionMethodCall(lambda); - } + private Expression WhereExtensionMethodCall(LambdaExpression predicate) + { + return Expression.Call(_extensionType, "Where", LambdaScope.Parameter.Type.AsArray(), _source, predicate); + } - private LambdaExpression GetPredicateLambda(FilterExpression filter) - { - Expression body = Visit(filter, null); - return Expression.Lambda(body, LambdaScope.Parameter); - } + public override Expression VisitHas(HasExpression expression, Type? argument) + { + Expression property = Visit(expression.TargetCollection, argument); - private Expression WhereExtensionMethodCall(LambdaExpression predicate) - { - return Expression.Call(_extensionType, "Where", LambdaScope.Parameter.Type.AsArray(), _source, predicate); - } + Type? elementType = CollectionConverter.FindCollectionElementType(property.Type); - public override Expression VisitHas(HasExpression expression, Type? argument) + if (elementType == null) { - Expression property = Visit(expression.TargetCollection, argument); + throw new InvalidOperationException("Expression must be a collection."); + } - Type? elementType = CollectionConverter.FindCollectionElementType(property.Type); + Expression? predicate = null; - if (elementType == null) - { - throw new InvalidOperationException("Expression must be a collection."); - } + if (expression.Filter != null) + { + var lambdaScopeFactory = new LambdaScopeFactory(_nameFactory); + using LambdaScope lambdaScope = lambdaScopeFactory.CreateScope(elementType); - Expression? predicate = null; + var builder = new WhereClauseBuilder(property, lambdaScope, typeof(Enumerable), _nameFactory); + predicate = builder.GetPredicateLambda(expression.Filter); + } - if (expression.Filter != null) - { - var lambdaScopeFactory = new LambdaScopeFactory(_nameFactory); - using LambdaScope lambdaScope = lambdaScopeFactory.CreateScope(elementType); + return AnyExtensionMethodCall(elementType, property, predicate); + } - var builder = new WhereClauseBuilder(property, lambdaScope, typeof(Enumerable), _nameFactory); - predicate = builder.GetPredicateLambda(expression.Filter); - } + private static MethodCallExpression AnyExtensionMethodCall(Type elementType, Expression source, Expression? predicate) + { + return predicate != null + ? Expression.Call(typeof(Enumerable), "Any", elementType.AsArray(), source, predicate) + : Expression.Call(typeof(Enumerable), "Any", elementType.AsArray(), source); + } - return AnyExtensionMethodCall(elementType, property, predicate); - } + public override Expression VisitMatchText(MatchTextExpression expression, Type? argument) + { + Expression property = Visit(expression.TargetAttribute, argument); - private static MethodCallExpression AnyExtensionMethodCall(Type elementType, Expression source, Expression? predicate) + if (property.Type != typeof(string)) { - return predicate != null - ? Expression.Call(typeof(Enumerable), "Any", elementType.AsArray(), source, predicate) - : Expression.Call(typeof(Enumerable), "Any", elementType.AsArray(), source); + throw new InvalidOperationException("Expression must be a string."); } - public override Expression VisitMatchText(MatchTextExpression expression, Type? argument) - { - Expression property = Visit(expression.TargetAttribute, argument); - - if (property.Type != typeof(string)) - { - throw new InvalidOperationException("Expression must be a string."); - } - - Expression text = Visit(expression.TextValue, property.Type); - - if (expression.MatchKind == TextMatchKind.StartsWith) - { - return Expression.Call(property, "StartsWith", null, text); - } + Expression text = Visit(expression.TextValue, property.Type); - if (expression.MatchKind == TextMatchKind.EndsWith) - { - return Expression.Call(property, "EndsWith", null, text); - } - - return Expression.Call(property, "Contains", null, text); + if (expression.MatchKind == TextMatchKind.StartsWith) + { + return Expression.Call(property, "StartsWith", null, text); } - public override Expression VisitAny(AnyExpression expression, Type? argument) + if (expression.MatchKind == TextMatchKind.EndsWith) { - Expression property = Visit(expression.TargetAttribute, argument); + return Expression.Call(property, "EndsWith", null, text); + } - var valueList = (IList)Activator.CreateInstance(typeof(List<>).MakeGenericType(property.Type))!; + return Expression.Call(property, "Contains", null, text); + } - foreach (LiteralConstantExpression constant in expression.Constants) - { - object? value = ConvertTextToTargetType(constant.Value, property.Type); - valueList.Add(value); - } + public override Expression VisitAny(AnyExpression expression, Type? argument) + { + Expression property = Visit(expression.TargetAttribute, argument); - ConstantExpression collection = Expression.Constant(valueList); - return ContainsExtensionMethodCall(collection, property); - } + var valueList = (IList)Activator.CreateInstance(typeof(List<>).MakeGenericType(property.Type))!; - private static Expression ContainsExtensionMethodCall(Expression collection, Expression value) + foreach (LiteralConstantExpression constant in expression.Constants) { - return Expression.Call(typeof(Enumerable), "Contains", value.Type.AsArray(), collection, value); + object? value = ConvertTextToTargetType(constant.Value, property.Type); + valueList.Add(value); } - public override Expression VisitLogical(LogicalExpression expression, Type? argument) - { - var termQueue = new Queue(expression.Terms.Select(filter => Visit(filter, argument))); + ConstantExpression collection = Expression.Constant(valueList); + return ContainsExtensionMethodCall(collection, property); + } - if (expression.Operator == LogicalOperator.And) - { - return Compose(termQueue, Expression.AndAlso); - } + private static Expression ContainsExtensionMethodCall(Expression collection, Expression value) + { + return Expression.Call(typeof(Enumerable), "Contains", value.Type.AsArray(), collection, value); + } - if (expression.Operator == LogicalOperator.Or) - { - return Compose(termQueue, Expression.OrElse); - } + public override Expression VisitLogical(LogicalExpression expression, Type? argument) + { + var termQueue = new Queue(expression.Terms.Select(filter => Visit(filter, argument))); - throw new InvalidOperationException($"Unknown logical operator '{expression.Operator}'."); + if (expression.Operator == LogicalOperator.And) + { + return Compose(termQueue, Expression.AndAlso); } - private static BinaryExpression Compose(Queue argumentQueue, Func applyOperator) + if (expression.Operator == LogicalOperator.Or) { - Expression left = argumentQueue.Dequeue(); - Expression right = argumentQueue.Dequeue(); + return Compose(termQueue, Expression.OrElse); + } - BinaryExpression tempExpression = applyOperator(left, right); + throw new InvalidOperationException($"Unknown logical operator '{expression.Operator}'."); + } - while (argumentQueue.Any()) - { - Expression nextArgument = argumentQueue.Dequeue(); - tempExpression = applyOperator(tempExpression, nextArgument); - } + private static BinaryExpression Compose(Queue argumentQueue, Func applyOperator) + { + Expression left = argumentQueue.Dequeue(); + Expression right = argumentQueue.Dequeue(); - return tempExpression; - } + BinaryExpression tempExpression = applyOperator(left, right); - public override Expression VisitNot(NotExpression expression, Type? argument) + while (argumentQueue.Any()) { - Expression child = Visit(expression.Child, argument); - return Expression.Not(child); + Expression nextArgument = argumentQueue.Dequeue(); + tempExpression = applyOperator(tempExpression, nextArgument); } - public override Expression VisitComparison(ComparisonExpression expression, Type? argument) - { - Type commonType = ResolveCommonType(expression.Left, expression.Right); + return tempExpression; + } - Expression left = WrapInConvert(Visit(expression.Left, commonType), commonType); - Expression right = WrapInConvert(Visit(expression.Right, commonType), commonType); + public override Expression VisitNot(NotExpression expression, Type? argument) + { + Expression child = Visit(expression.Child, argument); + return Expression.Not(child); + } - 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); - } - } + public override Expression VisitComparison(ComparisonExpression expression, Type? argument) + { + Type commonType = ResolveCommonType(expression.Left, expression.Right); - throw new InvalidOperationException($"Unknown comparison operator '{expression.Operator}'."); - } + Expression left = WrapInConvert(Visit(expression.Left, commonType), commonType); + Expression right = WrapInConvert(Visit(expression.Right, commonType), commonType); - private Type ResolveCommonType(QueryExpression left, QueryExpression right) + switch (expression.Operator) { - Type leftType = ResolveFixedType(left); - - if (RuntimeTypeConverter.CanContainNull(leftType)) + case ComparisonOperator.Equals: { - return leftType; + return Expression.Equal(left, right); } - - if (right is NullConstantExpression) + case ComparisonOperator.LessThan: { - return typeof(Nullable<>).MakeGenericType(leftType); + return Expression.LessThan(left, right); } - - Type? rightType = TryResolveFixedType(right); - - if (rightType != null && RuntimeTypeConverter.CanContainNull(rightType)) + case ComparisonOperator.LessOrEqual: { - return rightType; + 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 ResolveCommonType(QueryExpression left, QueryExpression right) + { + Type leftType = ResolveFixedType(left); + if (RuntimeTypeConverter.CanContainNull(leftType)) + { return leftType; } - private Type ResolveFixedType(QueryExpression expression) + if (right is NullConstantExpression) { - Expression result = Visit(expression, null); - return result.Type; + return typeof(Nullable<>).MakeGenericType(leftType); } - private Type? TryResolveFixedType(QueryExpression expression) + Type? rightType = TryResolveFixedType(right); + + if (rightType != null && RuntimeTypeConverter.CanContainNull(rightType)) { - if (expression is CountExpression) - { - return typeof(int); - } + return rightType; + } - if (expression is ResourceFieldChainExpression chain) - { - Expression child = Visit(chain, null); - return child.Type; - } + return leftType; + } - return null; - } + private Type ResolveFixedType(QueryExpression expression) + { + Expression result = Visit(expression, null); + return result.Type; + } - private static Expression WrapInConvert(Expression expression, Type? targetType) + private Type? TryResolveFixedType(QueryExpression expression) + { + if (expression is CountExpression) { - 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); - } + return typeof(int); } - public override Expression VisitNullConstant(NullConstantExpression expression, Type? expressionType) + if (expression is ResourceFieldChainExpression chain) { - return NullConstant; + Expression child = Visit(chain, null); + return child.Type; } - public override Expression VisitLiteralConstant(LiteralConstantExpression expression, Type? expressionType) - { - object? convertedValue = expressionType != null ? ConvertTextToTargetType(expression.Value, expressionType) : expression.Value; + return null; + } - return CreateTupleAccessExpressionForConstant(convertedValue, expressionType ?? typeof(string)); + 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) + { + object? convertedValue = expressionType != null ? ConvertTextToTargetType(expression.Value, expressionType) : expression.Value; + + return convertedValue.CreateTupleAccessExpressionForConstant(expressionType ?? typeof(string)); + } - private static object? ConvertTextToTargetType(string text, Type targetType) + private static object? ConvertTextToTargetType(string text, Type targetType) + { + try { - try - { - return RuntimeTypeConverter.ConvertType(text, targetType); - } - catch (FormatException exception) - { - throw new InvalidQueryException("Query creation failed due to incompatible types.", exception); - } + return RuntimeTypeConverter.ConvertType(text, targetType); + } + catch (FormatException exception) + { + throw new InvalidQueryException("Query creation failed due to incompatible types.", exception); } } } diff --git a/src/JsonApiDotNetCore/Queries/Internal/SparseFieldSetCache.cs b/src/JsonApiDotNetCore/Queries/Internal/SparseFieldSetCache.cs index 2915882d10..0cad7968c4 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/SparseFieldSetCache.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/SparseFieldSetCache.cs @@ -1,172 +1,165 @@ -using System; using System.Collections.Concurrent; -using System.Collections.Generic; using System.Collections.Immutable; -using System.Linq; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Queries.Internal +namespace JsonApiDotNetCore.Queries.Internal; + +/// +public sealed class SparseFieldSetCache : ISparseFieldSetCache { - /// - public sealed class SparseFieldSetCache : ISparseFieldSetCache - { - private static readonly ConcurrentDictionary ViewableFieldSetCache = new(); + private static readonly ConcurrentDictionary ViewableFieldSetCache = new(); - private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; - private readonly Lazy>> _lazySourceTable; - private readonly IDictionary> _visitedTable; + private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; + private readonly Lazy>> _lazySourceTable; + private readonly IDictionary> _visitedTable; - public SparseFieldSetCache(IEnumerable constraintProviders, IResourceDefinitionAccessor resourceDefinitionAccessor) - { - ArgumentGuard.NotNull(constraintProviders, nameof(constraintProviders)); - ArgumentGuard.NotNull(resourceDefinitionAccessor, nameof(resourceDefinitionAccessor)); + public SparseFieldSetCache(IEnumerable constraintProviders, IResourceDefinitionAccessor resourceDefinitionAccessor) + { + ArgumentGuard.NotNull(constraintProviders, nameof(constraintProviders)); + ArgumentGuard.NotNull(resourceDefinitionAccessor, nameof(resourceDefinitionAccessor)); - _resourceDefinitionAccessor = resourceDefinitionAccessor; - _lazySourceTable = new Lazy>>(() => BuildSourceTable(constraintProviders)); - _visitedTable = new Dictionary>(); - } + _resourceDefinitionAccessor = resourceDefinitionAccessor; + _lazySourceTable = new Lazy>>(() => BuildSourceTable(constraintProviders)); + _visitedTable = new Dictionary>(); + } - private static IDictionary> BuildSourceTable( - IEnumerable constraintProviders) - { - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true + private static IDictionary> BuildSourceTable(IEnumerable constraintProviders) + { + // @formatter:wrap_chained_method_calls chop_always + // @formatter:keep_existing_linebreaks true - KeyValuePair[] sparseFieldTables = constraintProviders - .SelectMany(provider => provider.GetConstraints()) - .Where(constraint => constraint.Scope == null) - .Select(constraint => constraint.Expression) - .OfType() - .Select(expression => expression.Table) - .SelectMany(table => table) - .ToArray(); + KeyValuePair[] sparseFieldTables = constraintProviders + .SelectMany(provider => provider.GetConstraints()) + .Where(constraint => constraint.Scope == null) + .Select(constraint => constraint.Expression) + .OfType() + .Select(expression => expression.Table) + .SelectMany(table => table) + .ToArray(); - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore + // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_chained_method_calls restore - var mergedTable = new Dictionary.Builder>(); + var mergedTable = new Dictionary.Builder>(); - foreach ((ResourceType resourceType, SparseFieldSetExpression sparseFieldSet) in sparseFieldTables) + foreach ((ResourceType resourceType, SparseFieldSetExpression sparseFieldSet) in sparseFieldTables) + { + if (!mergedTable.ContainsKey(resourceType)) { - if (!mergedTable.ContainsKey(resourceType)) - { - mergedTable[resourceType] = ImmutableHashSet.CreateBuilder(); - } - - AddSparseFieldsToSet(sparseFieldSet.Fields, mergedTable[resourceType]); + mergedTable[resourceType] = ImmutableHashSet.CreateBuilder(); } - return mergedTable.ToDictionary(pair => pair.Key, pair => (IImmutableSet)pair.Value.ToImmutable()); + AddSparseFieldsToSet(sparseFieldSet.Fields, mergedTable[resourceType]); } - private static void AddSparseFieldsToSet(IImmutableSet sparseFieldsToAdd, - ImmutableHashSet.Builder sparseFieldSetBuilder) + return mergedTable.ToDictionary(pair => pair.Key, pair => (IImmutableSet)pair.Value.ToImmutable()); + } + + private static void AddSparseFieldsToSet(IImmutableSet sparseFieldsToAdd, + ImmutableHashSet.Builder sparseFieldSetBuilder) + { + foreach (ResourceFieldAttribute field in sparseFieldsToAdd) { - foreach (ResourceFieldAttribute field in sparseFieldsToAdd) - { - sparseFieldSetBuilder.Add(field); - } + sparseFieldSetBuilder.Add(field); } + } - /// - public IImmutableSet GetSparseFieldSetForQuery(ResourceType resourceType) + /// + public IImmutableSet GetSparseFieldSetForQuery(ResourceType resourceType) + { + ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + + if (!_visitedTable.ContainsKey(resourceType)) { - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + SparseFieldSetExpression? inputExpression = _lazySourceTable.Value.TryGetValue(resourceType, out IImmutableSet? inputFields) + ? new SparseFieldSetExpression(inputFields) + : null; - if (!_visitedTable.ContainsKey(resourceType)) - { - SparseFieldSetExpression? inputExpression = - _lazySourceTable.Value.TryGetValue(resourceType, out IImmutableSet? inputFields) - ? new SparseFieldSetExpression(inputFields) - : null; + SparseFieldSetExpression? outputExpression = _resourceDefinitionAccessor.OnApplySparseFieldSet(resourceType, inputExpression); - SparseFieldSetExpression? outputExpression = _resourceDefinitionAccessor.OnApplySparseFieldSet(resourceType, inputExpression); + IImmutableSet outputFields = outputExpression == null + ? ImmutableHashSet.Empty + : outputExpression.Fields; - IImmutableSet outputFields = outputExpression == null - ? ImmutableHashSet.Empty - : outputExpression.Fields; + _visitedTable[resourceType] = outputFields; + } - _visitedTable[resourceType] = outputFields; - } + return _visitedTable[resourceType]; + } - return _visitedTable[resourceType]; - } + /// + public IImmutableSet GetIdAttributeSetForRelationshipQuery(ResourceType resourceType) + { + ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - /// - public IImmutableSet GetIdAttributeSetForRelationshipQuery(ResourceType resourceType) - { - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + AttrAttribute idAttribute = resourceType.GetAttributeByPropertyName(nameof(Identifiable.Id)); + var inputExpression = new SparseFieldSetExpression(ImmutableHashSet.Create(idAttribute)); - AttrAttribute idAttribute = resourceType.GetAttributeByPropertyName(nameof(Identifiable.Id)); - var inputExpression = new SparseFieldSetExpression(ImmutableHashSet.Create(idAttribute)); + // Intentionally not cached, as we are fetching ID only (ignoring any sparse fieldset that came from query string). + SparseFieldSetExpression? outputExpression = _resourceDefinitionAccessor.OnApplySparseFieldSet(resourceType, inputExpression); - // Intentionally not cached, as we are fetching ID only (ignoring any sparse fieldset that came from query string). - SparseFieldSetExpression? outputExpression = _resourceDefinitionAccessor.OnApplySparseFieldSet(resourceType, inputExpression); + ImmutableHashSet outputAttributes = outputExpression == null + ? ImmutableHashSet.Empty + : outputExpression.Fields.OfType().ToImmutableHashSet(); - ImmutableHashSet outputAttributes = outputExpression == null - ? ImmutableHashSet.Empty - : outputExpression.Fields.OfType().ToImmutableHashSet(); + outputAttributes = outputAttributes.Add(idAttribute); + return outputAttributes; + } - outputAttributes = outputAttributes.Add(idAttribute); - return outputAttributes; - } + /// + public IImmutableSet GetSparseFieldSetForSerializer(ResourceType resourceType) + { + ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - /// - public IImmutableSet GetSparseFieldSetForSerializer(ResourceType resourceType) + if (!_visitedTable.ContainsKey(resourceType)) { - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + SparseFieldSetExpression inputExpression = _lazySourceTable.Value.TryGetValue(resourceType, out IImmutableSet? inputFields) + ? new SparseFieldSetExpression(inputFields) + : GetCachedViewableFieldSet(resourceType); - if (!_visitedTable.ContainsKey(resourceType)) - { - SparseFieldSetExpression inputExpression = - _lazySourceTable.Value.TryGetValue(resourceType, out IImmutableSet? inputFields) - ? new SparseFieldSetExpression(inputFields) - : GetCachedViewableFieldSet(resourceType); + SparseFieldSetExpression? outputExpression = _resourceDefinitionAccessor.OnApplySparseFieldSet(resourceType, inputExpression); - SparseFieldSetExpression? outputExpression = _resourceDefinitionAccessor.OnApplySparseFieldSet(resourceType, inputExpression); + IImmutableSet outputFields = outputExpression == null + ? GetCachedViewableFieldSet(resourceType).Fields + : inputExpression.Fields.Intersect(outputExpression.Fields); - IImmutableSet outputFields = outputExpression == null - ? GetCachedViewableFieldSet(resourceType).Fields - : inputExpression.Fields.Intersect(outputExpression.Fields); + _visitedTable[resourceType] = outputFields; + } - _visitedTable[resourceType] = outputFields; - } + return _visitedTable[resourceType]; + } - return _visitedTable[resourceType]; + private static SparseFieldSetExpression GetCachedViewableFieldSet(ResourceType resourceType) + { + if (!ViewableFieldSetCache.TryGetValue(resourceType, out SparseFieldSetExpression? fieldSet)) + { + IImmutableSet viewableFields = GetViewableFields(resourceType); + fieldSet = new SparseFieldSetExpression(viewableFields); + ViewableFieldSetCache[resourceType] = fieldSet; } - private static SparseFieldSetExpression GetCachedViewableFieldSet(ResourceType resourceType) - { - if (!ViewableFieldSetCache.TryGetValue(resourceType, out SparseFieldSetExpression? fieldSet)) - { - IImmutableSet viewableFields = GetViewableFields(resourceType); - fieldSet = new SparseFieldSetExpression(viewableFields); - ViewableFieldSetCache[resourceType] = fieldSet; - } + return fieldSet; + } - return fieldSet; - } + private static IImmutableSet GetViewableFields(ResourceType resourceType) + { + ImmutableHashSet.Builder fieldSetBuilder = ImmutableHashSet.CreateBuilder(); - private static IImmutableSet GetViewableFields(ResourceType resourceType) + foreach (AttrAttribute attribute in resourceType.Attributes.Where(attr => attr.Capabilities.HasFlag(AttrCapabilities.AllowView))) { - ImmutableHashSet.Builder fieldSetBuilder = ImmutableHashSet.CreateBuilder(); - - foreach (AttrAttribute attribute in resourceType.Attributes.Where(attr => attr.Capabilities.HasFlag(AttrCapabilities.AllowView))) - { - fieldSetBuilder.Add(attribute); - } + fieldSetBuilder.Add(attribute); + } - fieldSetBuilder.AddRange(resourceType.Relationships); + fieldSetBuilder.AddRange(resourceType.Relationships); - return fieldSetBuilder.ToImmutable(); - } + return fieldSetBuilder.ToImmutable(); + } - public void Reset() - { - _visitedTable.Clear(); - } + public void Reset() + { + _visitedTable.Clear(); } } diff --git a/src/JsonApiDotNetCore/Queries/Internal/SystemExpressionExtensions.cs b/src/JsonApiDotNetCore/Queries/Internal/SystemExpressionExtensions.cs new file mode 100644 index 0000000000..a314d5f20a --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Internal/SystemExpressionExtensions.cs @@ -0,0 +1,33 @@ +using System.Linq.Expressions; +using System.Reflection; + +namespace JsonApiDotNetCore.Queries.Internal; + +internal static class SystemExpressionExtensions +{ + public static Expression CreateTupleAccessExpressionForConstant(this 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 tupleCreateUnboundMethod = typeof(Tuple).GetMethods() + .Single(method => method.Name == "Create" && method.IsGenericMethod && method.GetGenericArguments().Length == 1); + + MethodInfo tupleCreateClosedMethod = tupleCreateUnboundMethod.MakeGenericMethod(type); + + ConstantExpression constantExpression = Expression.Constant(value, type); + + MethodCallExpression tupleCreateCall = Expression.Call(tupleCreateClosedMethod, constantExpression); + return Expression.Property(tupleCreateCall, "Item1"); + } +} diff --git a/src/JsonApiDotNetCore/Queries/PaginationContext.cs b/src/JsonApiDotNetCore/Queries/PaginationContext.cs index 466659fe22..6dfa698044 100644 --- a/src/JsonApiDotNetCore/Queries/PaginationContext.cs +++ b/src/JsonApiDotNetCore/Queries/PaginationContext.cs @@ -1,25 +1,23 @@ -using System; using JsonApiDotNetCore.Configuration; -namespace JsonApiDotNetCore.Queries +namespace JsonApiDotNetCore.Queries; + +/// +internal sealed class PaginationContext : IPaginationContext { /// - internal sealed class PaginationContext : IPaginationContext - { - /// - public PageNumber PageNumber { get; set; } = PageNumber.ValueOne; + public PageNumber PageNumber { get; set; } = PageNumber.ValueOne; - /// - public PageSize? PageSize { get; set; } + /// + public PageSize? PageSize { get; set; } - /// - public bool IsPageFull { get; set; } + /// + public bool IsPageFull { get; set; } - /// - public int? TotalResourceCount { get; set; } + /// + public int? TotalResourceCount { get; set; } - /// - public int? TotalPageCount => - TotalResourceCount == null || PageSize == null ? null : (int?)Math.Ceiling((decimal)TotalResourceCount.Value / PageSize.Value); - } + /// + 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 index 9d32ca89d9..be1d657cfe 100644 --- a/src/JsonApiDotNetCore/Queries/QueryLayer.cs +++ b/src/JsonApiDotNetCore/Queries/QueryLayer.cs @@ -1,126 +1,123 @@ -using System; -using System.Collections.Generic; using System.Text; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Queries +namespace JsonApiDotNetCore.Queries; + +/// +/// A nested data structure that contains constraints per resource type. +/// +[PublicAPI] +public sealed class QueryLayer { - /// - /// A nested data structure that contains constraints per resource type. - /// - [PublicAPI] - public sealed class QueryLayer - { - public ResourceType ResourceType { get; } + public ResourceType ResourceType { 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 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(ResourceType resourceType) - { - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + public QueryLayer(ResourceType resourceType) + { + ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - ResourceType = resourceType; - } + ResourceType = resourceType; + } - public override string ToString() - { - var builder = new StringBuilder(); + public override string ToString() + { + var builder = new StringBuilder(); - var writer = new IndentingStringWriter(builder); - WriteLayer(writer, this); + var writer = new IndentingStringWriter(builder); + WriteLayer(writer, this); - return builder.ToString(); - } + return builder.ToString(); + } - private static void WriteLayer(IndentingStringWriter writer, QueryLayer layer, string? prefix = null) + private static void WriteLayer(IndentingStringWriter writer, QueryLayer layer, string? prefix = null) + { + writer.WriteLine($"{prefix}{nameof(QueryLayer)}<{layer.ResourceType.ClrType.Name}>"); + + using (writer.Indent()) { - writer.WriteLine($"{prefix}{nameof(QueryLayer)}<{layer.ResourceType.ClrType.Name}>"); + if (layer.Include != null) + { + writer.WriteLine($"{nameof(Include)}: {layer.Include}"); + } - using (writer.Indent()) + if (layer.Filter != null) { - if (layer.Include != null) - { - writer.WriteLine($"{nameof(Include)}: {layer.Include}"); - } + writer.WriteLine($"{nameof(Filter)}: {layer.Filter}"); + } - if (layer.Filter != null) - { - writer.WriteLine($"{nameof(Filter)}: {layer.Filter}"); - } + if (layer.Sort != null) + { + writer.WriteLine($"{nameof(Sort)}: {layer.Sort}"); + } - if (layer.Sort != null) - { - writer.WriteLine($"{nameof(Sort)}: {layer.Sort}"); - } + if (layer.Pagination != null) + { + writer.WriteLine($"{nameof(Pagination)}: {layer.Pagination}"); + } - if (layer.Pagination != null) - { - writer.WriteLine($"{nameof(Pagination)}: {layer.Pagination}"); - } + if (!layer.Projection.IsNullOrEmpty()) + { + writer.WriteLine(nameof(Projection)); - if (!layer.Projection.IsNullOrEmpty()) + using (writer.Indent()) { - writer.WriteLine(nameof(Projection)); - - using (writer.Indent()) + foreach ((ResourceFieldAttribute field, QueryLayer? nextLayer) in layer.Projection) { - foreach ((ResourceFieldAttribute field, QueryLayer? nextLayer) in layer.Projection) + if (nextLayer == null) + { + writer.WriteLine(field.ToString()); + } + else { - if (nextLayer == null) - { - writer.WriteLine(field.ToString()); - } - else - { - WriteLayer(writer, nextLayer, $"{field.PublicName}: "); - } + WriteLayer(writer, nextLayer, $"{field.PublicName}: "); } } } } } + } + + private sealed class IndentingStringWriter : IDisposable + { + private readonly StringBuilder _builder; + private int _indentDepth; - private sealed class IndentingStringWriter : IDisposable + public IndentingStringWriter(StringBuilder builder) { - private readonly StringBuilder _builder; - private int _indentDepth; + _builder = builder; + } - public IndentingStringWriter(StringBuilder builder) + public void WriteLine(string? line) + { + if (_indentDepth > 0) { - _builder = builder; + _builder.Append(new string(' ', _indentDepth * 2)); } - public void WriteLine(string? line) - { - if (_indentDepth > 0) - { - _builder.Append(new string(' ', _indentDepth * 2)); - } - - _builder.AppendLine(line); - } + _builder.AppendLine(line); + } - public IndentingStringWriter Indent() - { - WriteLine("{"); - _indentDepth++; - return this; - } + public IndentingStringWriter Indent() + { + WriteLine("{"); + _indentDepth++; + return this; + } - public void Dispose() + public void Dispose() + { + if (_indentDepth > 0) { - if (_indentDepth > 0) - { - _indentDepth--; - WriteLine("}"); - } + _indentDepth--; + WriteLine("}"); } } } diff --git a/src/JsonApiDotNetCore/Queries/TopFieldSelection.cs b/src/JsonApiDotNetCore/Queries/TopFieldSelection.cs index d5203ff537..d2ebabde52 100644 --- a/src/JsonApiDotNetCore/Queries/TopFieldSelection.cs +++ b/src/JsonApiDotNetCore/Queries/TopFieldSelection.cs @@ -1,23 +1,22 @@ -namespace JsonApiDotNetCore.Queries +namespace JsonApiDotNetCore.Queries; + +/// +/// Indicates how to override sparse fieldset selection coming from constraints. +/// +public enum TopFieldSelection { /// - /// Indicates how to override sparse fieldset selection coming from constraints. + /// Preserves the existing selection of attributes and/or relationships. /// - public enum TopFieldSelection - { - /// - /// Preserves the existing selection of attributes and/or relationships. - /// - PreserveExisting, + PreserveExisting, - /// - /// Preserves included relationships, but selects all resource attributes. - /// - WithAllAttributes, + /// + /// Preserves included relationships, but selects all resource attributes. + /// + WithAllAttributes, - /// - /// Discards any included relationships and selects only resource ID. - /// - OnlyIdAttribute - } + /// + /// Discards any included relationships and selects only resource ID. + /// + OnlyIdAttribute } diff --git a/src/JsonApiDotNetCore/QueryStrings/IFilterQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/IFilterQueryStringParameterReader.cs index 49caf84786..b9a7fb8c6b 100644 --- a/src/JsonApiDotNetCore/QueryStrings/IFilterQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/IFilterQueryStringParameterReader.cs @@ -1,13 +1,12 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Queries; -namespace JsonApiDotNetCore.QueryStrings +namespace JsonApiDotNetCore.QueryStrings; + +/// +/// Reads the 'filter' query string parameter and produces a set of query constraints from it. +/// +[PublicAPI] +public interface IFilterQueryStringParameterReader : IQueryStringParameterReader, IQueryConstraintProvider { - /// - /// Reads the 'filter' query string parameter and produces a set of query constraints from it. - /// - [PublicAPI] - public interface IFilterQueryStringParameterReader : IQueryStringParameterReader, IQueryConstraintProvider - { - } } diff --git a/src/JsonApiDotNetCore/QueryStrings/IIncludeQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/IIncludeQueryStringParameterReader.cs index e348f3635c..1993d249fc 100644 --- a/src/JsonApiDotNetCore/QueryStrings/IIncludeQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/IIncludeQueryStringParameterReader.cs @@ -1,13 +1,12 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Queries; -namespace JsonApiDotNetCore.QueryStrings +namespace JsonApiDotNetCore.QueryStrings; + +/// +/// Reads the 'include' query string parameter and produces a set of query constraints from it. +/// +[PublicAPI] +public interface IIncludeQueryStringParameterReader : IQueryStringParameterReader, IQueryConstraintProvider { - /// - /// Reads the 'include' query string parameter and produces a set of query constraints from it. - /// - [PublicAPI] - public interface IIncludeQueryStringParameterReader : IQueryStringParameterReader, IQueryConstraintProvider - { - } } diff --git a/src/JsonApiDotNetCore/QueryStrings/IPaginationQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/IPaginationQueryStringParameterReader.cs index c41f417435..198bff6ff8 100644 --- a/src/JsonApiDotNetCore/QueryStrings/IPaginationQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/IPaginationQueryStringParameterReader.cs @@ -1,13 +1,12 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Queries; -namespace JsonApiDotNetCore.QueryStrings +namespace JsonApiDotNetCore.QueryStrings; + +/// +/// Reads the 'page' query string parameter and produces a set of query constraints from it. +/// +[PublicAPI] +public interface IPaginationQueryStringParameterReader : IQueryStringParameterReader, IQueryConstraintProvider { - /// - /// Reads the 'page' query string parameter and produces a set of query constraints from it. - /// - [PublicAPI] - public interface IPaginationQueryStringParameterReader : IQueryStringParameterReader, IQueryConstraintProvider - { - } } diff --git a/src/JsonApiDotNetCore/QueryStrings/IQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/IQueryStringParameterReader.cs index ebfdac0976..d4aa74df03 100644 --- a/src/JsonApiDotNetCore/QueryStrings/IQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/IQueryStringParameterReader.cs @@ -1,31 +1,30 @@ using JsonApiDotNetCore.Controllers.Annotations; using Microsoft.Extensions.Primitives; -namespace JsonApiDotNetCore.QueryStrings +namespace JsonApiDotNetCore.QueryStrings; + +/// +/// The interface to implement for processing a specific type of query string parameter. +/// +public interface IQueryStringParameterReader { /// - /// The interface to implement for processing a specific type of query string parameter. + /// Indicates whether this reader supports empty query string parameter values. /// - public interface IQueryStringParameterReader - { - /// - /// Indicates whether this reader supports empty query string parameter values. - /// - bool AllowEmptyValue { get; } + bool AllowEmptyValue { get; } - /// - /// Indicates whether usage of this query string parameter is blocked using on a controller. - /// - bool IsEnabled(DisableQueryStringAttribute disableQueryStringAttribute); + /// + /// 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); + /// + /// 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); - } + /// + /// 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 index 04d3ffe26f..935785658b 100644 --- a/src/JsonApiDotNetCore/QueryStrings/IQueryStringReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/IQueryStringReader.cs @@ -1,18 +1,17 @@ using JsonApiDotNetCore.Controllers.Annotations; -namespace JsonApiDotNetCore.QueryStrings +namespace JsonApiDotNetCore.QueryStrings; + +/// +/// Reads and processes the various query string parameters for a HTTP request. +/// +public interface IQueryStringReader { /// - /// Reads and processes the various query string parameters for a HTTP request. + /// Reads and processes the key/value pairs from the request query string. /// - 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); - } + /// + /// The if set on the controller that is targeted by the current request. + /// + void ReadAll(DisableQueryStringAttribute? disableQueryStringAttribute); } diff --git a/src/JsonApiDotNetCore/QueryStrings/IRequestQueryStringAccessor.cs b/src/JsonApiDotNetCore/QueryStrings/IRequestQueryStringAccessor.cs index 52a5a5eace..60b7a460a8 100644 --- a/src/JsonApiDotNetCore/QueryStrings/IRequestQueryStringAccessor.cs +++ b/src/JsonApiDotNetCore/QueryStrings/IRequestQueryStringAccessor.cs @@ -1,12 +1,11 @@ using Microsoft.AspNetCore.Http; -namespace JsonApiDotNetCore.QueryStrings +namespace JsonApiDotNetCore.QueryStrings; + +/// +/// Provides access to the query string of a URL in a HTTP request. +/// +public interface IRequestQueryStringAccessor { - /// - /// Provides access to the query string of a URL in a HTTP request. - /// - public interface IRequestQueryStringAccessor - { - IQueryCollection Query { get; } - } + IQueryCollection Query { get; } } diff --git a/src/JsonApiDotNetCore/QueryStrings/IResourceDefinitionQueryableParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/IResourceDefinitionQueryableParameterReader.cs index baea3e2938..9ef114e4b3 100644 --- a/src/JsonApiDotNetCore/QueryStrings/IResourceDefinitionQueryableParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/IResourceDefinitionQueryableParameterReader.cs @@ -2,14 +2,13 @@ using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Resources; -namespace JsonApiDotNetCore.QueryStrings +namespace JsonApiDotNetCore.QueryStrings; + +/// +/// Reads custom query string parameters for which handlers on are registered and produces a set of +/// query constraints from it. +/// +[PublicAPI] +public interface IResourceDefinitionQueryableParameterReader : IQueryStringParameterReader, IQueryConstraintProvider { - /// - /// Reads custom query string parameters for which handlers on are registered and produces a set of - /// query constraints from it. - /// - [PublicAPI] - public interface IResourceDefinitionQueryableParameterReader : IQueryStringParameterReader, IQueryConstraintProvider - { - } } diff --git a/src/JsonApiDotNetCore/QueryStrings/ISortQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/ISortQueryStringParameterReader.cs index 1fe5f4cb51..5cb221a399 100644 --- a/src/JsonApiDotNetCore/QueryStrings/ISortQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/ISortQueryStringParameterReader.cs @@ -1,13 +1,12 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Queries; -namespace JsonApiDotNetCore.QueryStrings +namespace JsonApiDotNetCore.QueryStrings; + +/// +/// Reads the 'sort' query string parameter and produces a set of query constraints from it. +/// +[PublicAPI] +public interface ISortQueryStringParameterReader : IQueryStringParameterReader, IQueryConstraintProvider { - /// - /// Reads the 'sort' query string parameter and produces a set of query constraints from it. - /// - [PublicAPI] - public interface ISortQueryStringParameterReader : IQueryStringParameterReader, IQueryConstraintProvider - { - } } diff --git a/src/JsonApiDotNetCore/QueryStrings/ISparseFieldSetQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/ISparseFieldSetQueryStringParameterReader.cs index 7a307f1f40..e943121ecc 100644 --- a/src/JsonApiDotNetCore/QueryStrings/ISparseFieldSetQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/ISparseFieldSetQueryStringParameterReader.cs @@ -1,13 +1,12 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Queries; -namespace JsonApiDotNetCore.QueryStrings +namespace JsonApiDotNetCore.QueryStrings; + +/// +/// Reads the 'fields' query string parameter and produces a set of query constraints from it. +/// +[PublicAPI] +public interface ISparseFieldSetQueryStringParameterReader : IQueryStringParameterReader, IQueryConstraintProvider { - /// - /// Reads the 'fields' query string parameter and produces a set of query constraints from it. - /// - [PublicAPI] - public interface ISparseFieldSetQueryStringParameterReader : IQueryStringParameterReader, IQueryConstraintProvider - { - } } diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/FilterQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/FilterQueryStringParameterReader.cs index 30d1e5d904..4fcd3e63d9 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/FilterQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/FilterQueryStringParameterReader.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; using System.Collections.Immutable; -using System.Linq; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers.Annotations; @@ -14,168 +11,166 @@ using JsonApiDotNetCore.Resources.Annotations; using Microsoft.Extensions.Primitives; -namespace JsonApiDotNetCore.QueryStrings.Internal +namespace JsonApiDotNetCore.QueryStrings.Internal; + +[PublicAPI] +public class FilterQueryStringParameterReader : QueryStringParameterReader, IFilterQueryStringParameterReader { - [PublicAPI] - public class FilterQueryStringParameterReader : QueryStringParameterReader, IFilterQueryStringParameterReader - { - private static readonly LegacyFilterNotationConverter LegacyConverter = new(); + private static readonly LegacyFilterNotationConverter LegacyConverter = new(); - private readonly IJsonApiOptions _options; - private readonly QueryStringParameterScopeParser _scopeParser; - private readonly FilterParser _filterParser; - private readonly ImmutableArray.Builder _filtersInGlobalScope = ImmutableArray.CreateBuilder(); - private readonly Dictionary.Builder> _filtersPerScope = new(); + private readonly IJsonApiOptions _options; + private readonly QueryStringParameterScopeParser _scopeParser; + private readonly FilterParser _filterParser; + private readonly ImmutableArray.Builder _filtersInGlobalScope = ImmutableArray.CreateBuilder(); + private readonly Dictionary.Builder> _filtersPerScope = new(); - private string? _lastParameterName; + private string? _lastParameterName; - public bool AllowEmptyValue => false; + public bool AllowEmptyValue => false; - public FilterQueryStringParameterReader(IJsonApiRequest request, IResourceGraph resourceGraph, IResourceFactory resourceFactory, - IJsonApiOptions options) - : base(request, resourceGraph) - { - ArgumentGuard.NotNull(options, nameof(options)); + public FilterQueryStringParameterReader(IJsonApiRequest request, IResourceGraph resourceGraph, IResourceFactory resourceFactory, IJsonApiOptions options) + : base(request, resourceGraph) + { + ArgumentGuard.NotNull(options, nameof(options)); - _options = options; - _scopeParser = new QueryStringParameterScopeParser(FieldChainRequirements.EndsInToMany); - _filterParser = new FilterParser(resourceFactory, ValidateSingleField); - } + _options = options; + _scopeParser = new QueryStringParameterScopeParser(FieldChainRequirements.EndsInToMany); + _filterParser = new FilterParser(resourceFactory, ValidateSingleField); + } - protected void ValidateSingleField(ResourceFieldAttribute field, ResourceType resourceType, string path) + protected void ValidateSingleField(ResourceFieldAttribute field, ResourceType resourceType, string path) + { + if (field is AttrAttribute attribute && !attribute.Capabilities.HasFlag(AttrCapabilities.AllowFilter)) { - 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."); - } + 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) - { - ArgumentGuard.NotNull(disableQueryStringAttribute, nameof(disableQueryStringAttribute)); + /// + public virtual bool IsEnabled(DisableQueryStringAttribute disableQueryStringAttribute) + { + ArgumentGuard.NotNull(disableQueryStringAttribute, nameof(disableQueryStringAttribute)); - return !IsAtomicOperationsRequest && !disableQueryStringAttribute.ContainsParameter(JsonApiQueryStringParameters.Filter); - } + return !IsAtomicOperationsRequest && !disableQueryStringAttribute.ContainsParameter(JsonApiQueryStringParameters.Filter); + } - /// - public virtual bool CanRead(string parameterName) - { - ArgumentGuard.NotNullNorEmpty(parameterName, nameof(parameterName)); + /// + public virtual bool CanRead(string parameterName) + { + ArgumentGuard.NotNullNorEmpty(parameterName, nameof(parameterName)); - bool isNested = parameterName.StartsWith("filter[", StringComparison.Ordinal) && parameterName.EndsWith("]", StringComparison.Ordinal); - return parameterName == "filter" || isNested; - } + bool isNested = parameterName.StartsWith("filter[", StringComparison.Ordinal) && parameterName.EndsWith("]", StringComparison.Ordinal); + return parameterName == "filter" || isNested; + } - /// - public virtual void Read(string parameterName, StringValues parameterValue) - { - _lastParameterName = parameterName; + /// + public virtual void Read(string parameterName, StringValues parameterValue) + { + _lastParameterName = parameterName; - foreach (string value in parameterValue.SelectMany(ExtractParameterValue)) - { - ReadSingleValue(parameterName, value); - } + foreach (string value in parameterValue.SelectMany(ExtractParameterValue)) + { + ReadSingleValue(parameterName, value); } + } - private IEnumerable ExtractParameterValue(string parameterValue) + private IEnumerable ExtractParameterValue(string parameterValue) + { + if (_options.EnableLegacyFilterNotation) { - if (_options.EnableLegacyFilterNotation) + foreach (string condition in LegacyConverter.ExtractConditions(parameterValue)) { - foreach (string condition in LegacyConverter.ExtractConditions(parameterValue)) - { - yield return condition; - } - } - else - { - yield return parameterValue; + yield return condition; } } - - private void ReadSingleValue(string parameterName, string parameterValue) + else { - try - { - string name = parameterName; - string value = parameterValue; - - if (_options.EnableLegacyFilterNotation) - { - (name, value) = LegacyConverter.Convert(name, value); - } - - ResourceFieldChainExpression? scope = GetScope(name); - FilterExpression filter = GetFilter(value, scope); - - StoreFilterInScope(filter, scope); - } - catch (QueryParseException exception) - { - throw new InvalidQueryStringParameterException(_lastParameterName!, "The specified filter is invalid.", exception.Message, exception); - } + yield return parameterValue; } + } - private ResourceFieldChainExpression? GetScope(string parameterName) + private void ReadSingleValue(string parameterName, string parameterValue) + { + try { - QueryStringParameterScopeExpression parameterScope = _scopeParser.Parse(parameterName, RequestResourceType); + string name = parameterName; + string value = parameterValue; - if (parameterScope.Scope == null) + if (_options.EnableLegacyFilterNotation) { - AssertIsCollectionRequest(); + (name, value) = LegacyConverter.Convert(name, value); } - return parameterScope.Scope; - } + ResourceFieldChainExpression? scope = GetScope(name); + FilterExpression filter = GetFilter(value, scope); - private FilterExpression GetFilter(string parameterValue, ResourceFieldChainExpression? scope) + StoreFilterInScope(filter, scope); + } + catch (QueryParseException exception) { - ResourceType resourceTypeInScope = GetResourceTypeForScope(scope); - return _filterParser.Parse(parameterValue, resourceTypeInScope); + throw new InvalidQueryStringParameterException(_lastParameterName!, "The specified filter is invalid.", exception.Message, exception); } + } - private void StoreFilterInScope(FilterExpression filter, ResourceFieldChainExpression? scope) - { - if (scope == null) - { - _filtersInGlobalScope.Add(filter); - } - else - { - if (!_filtersPerScope.ContainsKey(scope)) - { - _filtersPerScope[scope] = ImmutableArray.CreateBuilder(); - } + private ResourceFieldChainExpression? GetScope(string parameterName) + { + QueryStringParameterScopeExpression parameterScope = _scopeParser.Parse(parameterName, RequestResourceType); - _filtersPerScope[scope].Add(filter); - } + if (parameterScope.Scope == null) + { + AssertIsCollectionRequest(); } - /// - public virtual IReadOnlyCollection GetConstraints() + return parameterScope.Scope; + } + + private FilterExpression GetFilter(string parameterValue, ResourceFieldChainExpression? scope) + { + ResourceType resourceTypeInScope = GetResourceTypeForScope(scope); + return _filterParser.Parse(parameterValue, resourceTypeInScope); + } + + private void StoreFilterInScope(FilterExpression filter, ResourceFieldChainExpression? scope) + { + if (scope == null) { - return EnumerateFiltersInScopes().ToArray(); + _filtersInGlobalScope.Add(filter); } - - private IEnumerable EnumerateFiltersInScopes() + else { - if (_filtersInGlobalScope.Any()) + if (!_filtersPerScope.ContainsKey(scope)) { - FilterExpression filter = MergeFilters(_filtersInGlobalScope.ToImmutable()); - yield return new ExpressionInScope(null, filter); + _filtersPerScope[scope] = ImmutableArray.CreateBuilder(); } - foreach ((ResourceFieldChainExpression scope, ImmutableArray.Builder filtersBuilder) in _filtersPerScope) - { - FilterExpression filter = MergeFilters(filtersBuilder.ToImmutable()); - yield return new ExpressionInScope(scope, filter); - } + _filtersPerScope[scope].Add(filter); } + } - private static FilterExpression MergeFilters(IImmutableList filters) + /// + public virtual IReadOnlyCollection GetConstraints() + { + return EnumerateFiltersInScopes().ToArray(); + } + + private IEnumerable EnumerateFiltersInScopes() + { + if (_filtersInGlobalScope.Any()) + { + FilterExpression filter = MergeFilters(_filtersInGlobalScope.ToImmutable()); + yield return new ExpressionInScope(null, filter); + } + + foreach ((ResourceFieldChainExpression scope, ImmutableArray.Builder filtersBuilder) in _filtersPerScope) { - return filters.Count > 1 ? new LogicalExpression(LogicalOperator.Or, filters) : filters.First(); + FilterExpression filter = MergeFilters(filtersBuilder.ToImmutable()); + yield return new ExpressionInScope(scope, filter); } } + + private static FilterExpression MergeFilters(IImmutableList 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 index f5e98b9a20..51b815c2ef 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/IncludeQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/IncludeQueryStringParameterReader.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers.Annotations; @@ -10,81 +9,80 @@ using JsonApiDotNetCore.Resources.Annotations; using Microsoft.Extensions.Primitives; -namespace JsonApiDotNetCore.QueryStrings.Internal +namespace JsonApiDotNetCore.QueryStrings.Internal; + +[PublicAPI] +public class IncludeQueryStringParameterReader : QueryStringParameterReader, IIncludeQueryStringParameterReader { - [PublicAPI] - public class IncludeQueryStringParameterReader : QueryStringParameterReader, IIncludeQueryStringParameterReader - { - private readonly IJsonApiOptions _options; - private readonly IncludeParser _includeParser; + private readonly IJsonApiOptions _options; + private readonly IncludeParser _includeParser; - private IncludeExpression? _includeExpression; - private string? _lastParameterName; + private IncludeExpression? _includeExpression; + private string? _lastParameterName; - public bool AllowEmptyValue => false; + public bool AllowEmptyValue => false; - public IncludeQueryStringParameterReader(IJsonApiRequest request, IResourceGraph resourceGraph, IJsonApiOptions options) - : base(request, resourceGraph) - { - ArgumentGuard.NotNull(options, nameof(options)); + public IncludeQueryStringParameterReader(IJsonApiRequest request, IResourceGraph resourceGraph, IJsonApiOptions options) + : base(request, resourceGraph) + { + ArgumentGuard.NotNull(options, nameof(options)); - _options = options; - _includeParser = new IncludeParser(ValidateSingleRelationship); - } + _options = options; + _includeParser = new IncludeParser(ValidateSingleRelationship); + } - protected void ValidateSingleRelationship(RelationshipAttribute relationship, ResourceType resourceType, string path) + protected void ValidateSingleRelationship(RelationshipAttribute relationship, ResourceType resourceType, string path) + { + if (!relationship.CanInclude) { - if (!relationship.CanInclude) - { - throw new InvalidQueryStringParameterException(_lastParameterName!, "Including the requested relationship is not allowed.", - path == relationship.PublicName - ? $"Including the relationship '{relationship.PublicName}' on '{resourceType.PublicName}' is not allowed." - : $"Including the relationship '{relationship.PublicName}' in '{path}' on '{resourceType.PublicName}' is not allowed."); - } + throw new InvalidQueryStringParameterException(_lastParameterName!, "Including the requested relationship is not allowed.", + path == relationship.PublicName + ? $"Including the relationship '{relationship.PublicName}' on '{resourceType.PublicName}' is not allowed." + : $"Including the relationship '{relationship.PublicName}' in '{path}' on '{resourceType.PublicName}' is not allowed."); } + } - /// - public virtual bool IsEnabled(DisableQueryStringAttribute disableQueryStringAttribute) - { - ArgumentGuard.NotNull(disableQueryStringAttribute, nameof(disableQueryStringAttribute)); + /// + public virtual bool IsEnabled(DisableQueryStringAttribute disableQueryStringAttribute) + { + ArgumentGuard.NotNull(disableQueryStringAttribute, nameof(disableQueryStringAttribute)); - return !IsAtomicOperationsRequest && !disableQueryStringAttribute.ContainsParameter(JsonApiQueryStringParameters.Include); - } + return !IsAtomicOperationsRequest && !disableQueryStringAttribute.ContainsParameter(JsonApiQueryStringParameters.Include); + } - /// - public virtual bool CanRead(string parameterName) - { - return parameterName == "include"; - } + /// + public virtual bool CanRead(string parameterName) + { + return parameterName == "include"; + } - /// - public virtual void Read(string parameterName, StringValues parameterValue) - { - _lastParameterName = parameterName; + /// + 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); - } + try + { + _includeExpression = GetInclude(parameterValue); } - - private IncludeExpression GetInclude(string parameterValue) + catch (QueryParseException exception) { - return _includeParser.Parse(parameterValue, RequestResourceType, _options.MaximumIncludeDepth); + throw new InvalidQueryStringParameterException(parameterName, "The specified include is invalid.", exception.Message, exception); } + } - /// - public virtual IReadOnlyCollection GetConstraints() - { - ExpressionInScope expressionInScope = _includeExpression != null - ? new ExpressionInScope(null, _includeExpression) - : new ExpressionInScope(null, IncludeExpression.Empty); + private IncludeExpression GetInclude(string parameterValue) + { + return _includeParser.Parse(parameterValue, RequestResourceType, _options.MaximumIncludeDepth); + } - return expressionInScope.AsArray(); - } + /// + public virtual IReadOnlyCollection GetConstraints() + { + ExpressionInScope expressionInScope = _includeExpression != null + ? new ExpressionInScope(null, _includeExpression) + : new ExpressionInScope(null, IncludeExpression.Empty); + + return expressionInScope.AsArray(); } } diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/LegacyFilterNotationConverter.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/LegacyFilterNotationConverter.cs index 0b47e4427b..68d4555e26 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/LegacyFilterNotationConverter.cs +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/LegacyFilterNotationConverter.cs @@ -1,142 +1,138 @@ -using System; -using System.Collections.Generic; using JetBrains.Annotations; using JsonApiDotNetCore.Queries.Internal.Parsing; -namespace JsonApiDotNetCore.QueryStrings.Internal +namespace JsonApiDotNetCore.QueryStrings.Internal; + +[PublicAPI] +public sealed class LegacyFilterNotationConverter { - [PublicAPI] - public sealed class LegacyFilterNotationConverter - { - private const string ParameterNamePrefix = "filter["; - private const string ParameterNameSuffix = "]"; - private const string OutputParameterName = "filter"; + 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 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() + { + ["eq:"] = Keywords.Equals, + ["lt:"] = Keywords.LessThan, + ["le:"] = Keywords.LessOrEqual, + ["gt:"] = Keywords.GreaterThan, + ["ge:"] = Keywords.GreaterOrEqual, + ["like:"] = Keywords.Contains + }; + + public IEnumerable ExtractConditions(string parameterValue) + { + ArgumentGuard.NotNullNorEmpty(parameterValue, nameof(parameterValue)); - private static readonly Dictionary PrefixConversionTable = new() + if (parameterValue.StartsWith(ExpressionPrefix, StringComparison.Ordinal) || parameterValue.StartsWith(InPrefix, StringComparison.Ordinal) || + parameterValue.StartsWith(NotInPrefix, StringComparison.Ordinal)) { - ["eq:"] = Keywords.Equals, - ["lt:"] = Keywords.LessThan, - ["le:"] = Keywords.LessOrEqual, - ["gt:"] = Keywords.GreaterThan, - ["ge:"] = Keywords.GreaterOrEqual, - ["like:"] = Keywords.Contains - }; - - public IEnumerable ExtractConditions(string parameterValue) + yield return parameterValue; + } + else { - ArgumentGuard.NotNullNorEmpty(parameterValue, nameof(parameterValue)); - - if (parameterValue.StartsWith(ExpressionPrefix, StringComparison.Ordinal) || parameterValue.StartsWith(InPrefix, StringComparison.Ordinal) || - parameterValue.StartsWith(NotInPrefix, StringComparison.Ordinal)) + foreach (string condition in parameterValue.Split(',')) { - yield return parameterValue; - } - else - { - foreach (string condition in parameterValue.Split(',')) - { - yield return condition; - } + yield return condition; } } + } - public (string parameterName, string parameterValue) Convert(string parameterName, string parameterValue) - { - ArgumentGuard.NotNullNorEmpty(parameterName, nameof(parameterName)); - ArgumentGuard.NotNullNorEmpty(parameterValue, nameof(parameterValue)); - - if (parameterValue.StartsWith(ExpressionPrefix, StringComparison.Ordinal)) - { - string expression = parameterValue[ExpressionPrefix.Length..]; - return (parameterName, expression); - } + public (string parameterName, string parameterValue) Convert(string parameterName, string parameterValue) + { + ArgumentGuard.NotNullNorEmpty(parameterName, nameof(parameterName)); + ArgumentGuard.NotNullNorEmpty(parameterValue, nameof(parameterValue)); - string attributeName = ExtractAttributeName(parameterName); + if (parameterValue.StartsWith(ExpressionPrefix, StringComparison.Ordinal)) + { + string expression = parameterValue[ExpressionPrefix.Length..]; + return (parameterName, expression); + } - foreach ((string prefix, string keyword) in PrefixConversionTable) - { - if (parameterValue.StartsWith(prefix, StringComparison.Ordinal)) - { - string value = parameterValue[prefix.Length..]; - string escapedValue = EscapeQuotes(value); - string expression = $"{keyword}({attributeName},'{escapedValue}')"; - - return (OutputParameterName, expression); - } - } + string attributeName = ExtractAttributeName(parameterName); - if (parameterValue.StartsWith(NotEqualsPrefix, StringComparison.Ordinal)) + foreach ((string prefix, string keyword) in PrefixConversionTable) + { + if (parameterValue.StartsWith(prefix, StringComparison.Ordinal)) { - string value = parameterValue[NotEqualsPrefix.Length..]; + string value = parameterValue[prefix.Length..]; string escapedValue = EscapeQuotes(value); - string expression = $"{Keywords.Not}({Keywords.Equals}({attributeName},'{escapedValue}'))"; + string expression = $"{keyword}({attributeName},'{escapedValue}')"; return (OutputParameterName, expression); } + } - if (parameterValue.StartsWith(InPrefix, StringComparison.Ordinal)) - { - string[] valueParts = parameterValue[InPrefix.Length..].Split(","); - string valueList = $"'{string.Join("','", valueParts)}'"; - string expression = $"{Keywords.Any}({attributeName},{valueList})"; - - return (OutputParameterName, expression); - } + if (parameterValue.StartsWith(NotEqualsPrefix, StringComparison.Ordinal)) + { + string value = parameterValue[NotEqualsPrefix.Length..]; + string escapedValue = EscapeQuotes(value); + string expression = $"{Keywords.Not}({Keywords.Equals}({attributeName},'{escapedValue}'))"; - if (parameterValue.StartsWith(NotInPrefix, StringComparison.Ordinal)) - { - string[] valueParts = parameterValue[NotInPrefix.Length..].Split(","); - string valueList = $"'{string.Join("','", valueParts)}'"; - string expression = $"{Keywords.Not}({Keywords.Any}({attributeName},{valueList}))"; + return (OutputParameterName, expression); + } - return (OutputParameterName, expression); - } + if (parameterValue.StartsWith(InPrefix, StringComparison.Ordinal)) + { + string[] valueParts = parameterValue[InPrefix.Length..].Split(","); + string valueList = $"'{string.Join("','", valueParts)}'"; + string expression = $"{Keywords.Any}({attributeName},{valueList})"; - if (parameterValue == "isnull:") - { - string expression = $"{Keywords.Equals}({attributeName},null)"; - return (OutputParameterName, expression); - } + return (OutputParameterName, expression); + } - if (parameterValue == "isnotnull:") - { - string expression = $"{Keywords.Not}({Keywords.Equals}({attributeName},null))"; - return (OutputParameterName, expression); - } + if (parameterValue.StartsWith(NotInPrefix, StringComparison.Ordinal)) + { + string[] valueParts = parameterValue[NotInPrefix.Length..].Split(","); + string valueList = $"'{string.Join("','", valueParts)}'"; + string expression = $"{Keywords.Not}({Keywords.Any}({attributeName},{valueList}))"; - { - string escapedValue = EscapeQuotes(parameterValue); - string expression = $"{Keywords.Equals}({attributeName},'{escapedValue}')"; + return (OutputParameterName, expression); + } - return (OutputParameterName, expression); - } + if (parameterValue == "isnull:") + { + string expression = $"{Keywords.Equals}({attributeName},null)"; + return (OutputParameterName, expression); } - private static string ExtractAttributeName(string parameterName) + if (parameterValue == "isnotnull:") { - if (parameterName.StartsWith(ParameterNamePrefix, StringComparison.Ordinal) && - parameterName.EndsWith(ParameterNameSuffix, StringComparison.Ordinal)) - { - string attributeName = parameterName.Substring(ParameterNamePrefix.Length, - parameterName.Length - ParameterNamePrefix.Length - ParameterNameSuffix.Length); + string expression = $"{Keywords.Not}({Keywords.Equals}({attributeName},null))"; + return (OutputParameterName, expression); + } - if (attributeName.Length > 0) - { - return attributeName; - } - } + { + string escapedValue = EscapeQuotes(parameterValue); + string expression = $"{Keywords.Equals}({attributeName},'{escapedValue}')"; - throw new QueryParseException("Expected field name between brackets in filter parameter name."); + return (OutputParameterName, expression); } + } - private static string EscapeQuotes(string text) + private static string ExtractAttributeName(string parameterName) + { + if (parameterName.StartsWith(ParameterNamePrefix, StringComparison.Ordinal) && parameterName.EndsWith(ParameterNameSuffix, StringComparison.Ordinal)) { - return text.Replace("'", "''"); + 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/PaginationQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/PaginationQueryStringParameterReader.cs index 023a4a67bd..743faee492 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/PaginationQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/PaginationQueryStringParameterReader.cs @@ -1,6 +1,4 @@ -using System.Collections.Generic; using System.Collections.Immutable; -using System.Linq; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers.Annotations; @@ -11,201 +9,200 @@ using JsonApiDotNetCore.Queries.Internal.Parsing; using Microsoft.Extensions.Primitives; -namespace JsonApiDotNetCore.QueryStrings.Internal +namespace JsonApiDotNetCore.QueryStrings.Internal; + +[PublicAPI] +public class PaginationQueryStringParameterReader : QueryStringParameterReader, IPaginationQueryStringParameterReader { - [PublicAPI] - public class PaginationQueryStringParameterReader : QueryStringParameterReader, IPaginationQueryStringParameterReader - { - private const string PageSizeParameterName = "page[size]"; - private const string PageNumberParameterName = "page[number]"; + private const string PageSizeParameterName = "page[size]"; + private const string PageNumberParameterName = "page[number]"; - private readonly IJsonApiOptions _options; - private readonly PaginationParser _paginationParser; + private readonly IJsonApiOptions _options; + private readonly PaginationParser _paginationParser; - private PaginationQueryStringValueExpression? _pageSizeConstraint; - private PaginationQueryStringValueExpression? _pageNumberConstraint; + private PaginationQueryStringValueExpression? _pageSizeConstraint; + private PaginationQueryStringValueExpression? _pageNumberConstraint; - public bool AllowEmptyValue => false; + public bool AllowEmptyValue => false; - public PaginationQueryStringParameterReader(IJsonApiRequest request, IResourceGraph resourceGraph, IJsonApiOptions options) - : base(request, resourceGraph) - { - ArgumentGuard.NotNull(options, nameof(options)); + public PaginationQueryStringParameterReader(IJsonApiRequest request, IResourceGraph resourceGraph, IJsonApiOptions options) + : base(request, resourceGraph) + { + ArgumentGuard.NotNull(options, nameof(options)); - _options = options; - _paginationParser = new PaginationParser(); - } + _options = options; + _paginationParser = new PaginationParser(); + } - /// - public virtual bool IsEnabled(DisableQueryStringAttribute disableQueryStringAttribute) - { - ArgumentGuard.NotNull(disableQueryStringAttribute, nameof(disableQueryStringAttribute)); + /// + public virtual bool IsEnabled(DisableQueryStringAttribute disableQueryStringAttribute) + { + ArgumentGuard.NotNull(disableQueryStringAttribute, nameof(disableQueryStringAttribute)); - return !IsAtomicOperationsRequest && !disableQueryStringAttribute.ContainsParameter(JsonApiQueryStringParameters.Page); - } + return !IsAtomicOperationsRequest && !disableQueryStringAttribute.ContainsParameter(JsonApiQueryStringParameters.Page); + } - /// - public virtual bool CanRead(string parameterName) - { - return parameterName is PageSizeParameterName or PageNumberParameterName; - } + /// + public virtual bool CanRead(string parameterName) + { + return parameterName is PageSizeParameterName or PageNumberParameterName; + } - /// - public virtual void Read(string parameterName, StringValues parameterValue) + /// + public virtual void Read(string parameterName, StringValues parameterValue) + { + try { - try + PaginationQueryStringValueExpression constraint = GetPageConstraint(parameterValue); + + if (constraint.Elements.Any(element => element.Scope == null)) + { + AssertIsCollectionRequest(); + } + + if (parameterName == PageSizeParameterName) { - PaginationQueryStringValueExpression constraint = GetPageConstraint(parameterValue); - - if (constraint.Elements.Any(element => element.Scope == null)) - { - AssertIsCollectionRequest(); - } - - if (parameterName == PageSizeParameterName) - { - ValidatePageSize(constraint); - _pageSizeConstraint = constraint; - } - else - { - ValidatePageNumber(constraint); - _pageNumberConstraint = constraint; - } + ValidatePageSize(constraint); + _pageSizeConstraint = constraint; } - catch (QueryParseException exception) + else { - throw new InvalidQueryStringParameterException(parameterName, "The specified paging is invalid.", exception.Message, exception); + ValidatePageNumber(constraint); + _pageNumberConstraint = constraint; } } - - private PaginationQueryStringValueExpression GetPageConstraint(string parameterValue) + catch (QueryParseException exception) { - return _paginationParser.Parse(parameterValue, RequestResourceType); + throw new InvalidQueryStringParameterException(parameterName, "The specified paging is invalid.", exception.Message, exception); } + } - protected virtual void ValidatePageSize(PaginationQueryStringValueExpression constraint) + private PaginationQueryStringValueExpression GetPageConstraint(string parameterValue) + { + return _paginationParser.Parse(parameterValue, RequestResourceType); + } + + protected virtual void ValidatePageSize(PaginationQueryStringValueExpression constraint) + { + if (_options.MaximumPageSize != null) { - if (_options.MaximumPageSize != null) + if (constraint.Elements.Any(element => element.Value > _options.MaximumPageSize.Value)) { - 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."); - } + throw new QueryParseException($"Page size cannot be higher than {_options.MaximumPageSize}."); } - if (constraint.Elements.Any(element => element.Value < 0)) + if (constraint.Elements.Any(element => element.Value == 0)) { - throw new QueryParseException("Page size cannot be negative."); + throw new QueryParseException("Page size cannot be unconstrained."); } } - [AssertionMethod] - protected virtual void ValidatePageNumber(PaginationQueryStringValueExpression constraint) + if (constraint.Elements.Any(element => element.Value < 0)) { - 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."); - } + throw new QueryParseException("Page size cannot be negative."); } + } - /// - public virtual IReadOnlyCollection GetConstraints() + [AssertionMethod] + protected virtual void ValidatePageNumber(PaginationQueryStringValueExpression constraint) + { + if (_options.MaximumPageNumber != null && constraint.Elements.Any(element => element.Value > _options.MaximumPageNumber.OneBasedValue)) { - var paginationState = new PaginationState(); - - foreach (PaginationElementQueryStringValueExpression element in _pageSizeConstraint?.Elements ?? - ImmutableArray.Empty) - { - MutablePaginationEntry entry = paginationState.ResolveEntryInScope(element.Scope); - entry.PageSize = element.Value == 0 ? null : new PageSize(element.Value); - entry.HasSetPageSize = true; - } + throw new QueryParseException($"Page number cannot be higher than {_options.MaximumPageNumber}."); + } - foreach (PaginationElementQueryStringValueExpression element in _pageNumberConstraint?.Elements ?? - ImmutableArray.Empty) - { - MutablePaginationEntry entry = paginationState.ResolveEntryInScope(element.Scope); - entry.PageNumber = new PageNumber(element.Value); - } + if (constraint.Elements.Any(element => element.Value < 1)) + { + throw new QueryParseException("Page number cannot be negative or zero."); + } + } - paginationState.ApplyOptions(_options); + /// + public virtual IReadOnlyCollection GetConstraints() + { + var paginationState = new PaginationState(); - return paginationState.GetExpressionsInScope(); + foreach (PaginationElementQueryStringValueExpression element in _pageSizeConstraint?.Elements ?? + ImmutableArray.Empty) + { + MutablePaginationEntry entry = paginationState.ResolveEntryInScope(element.Scope); + entry.PageSize = element.Value == 0 ? null : new PageSize(element.Value); + entry.HasSetPageSize = true; } - private sealed class PaginationState + foreach (PaginationElementQueryStringValueExpression element in _pageNumberConstraint?.Elements ?? + ImmutableArray.Empty) { - private readonly MutablePaginationEntry _globalScope = new(); - private readonly Dictionary _nestedScopes = new(); + MutablePaginationEntry entry = paginationState.ResolveEntryInScope(element.Scope); + entry.PageNumber = new PageNumber(element.Value); + } - public MutablePaginationEntry ResolveEntryInScope(ResourceFieldChainExpression? scope) - { - if (scope == null) - { - return _globalScope; - } + paginationState.ApplyOptions(_options); - if (!_nestedScopes.ContainsKey(scope)) - { - _nestedScopes.Add(scope, new MutablePaginationEntry()); - } + return paginationState.GetExpressionsInScope(); + } - return _nestedScopes[scope]; - } + private sealed class PaginationState + { + private readonly MutablePaginationEntry _globalScope = new(); + private readonly Dictionary _nestedScopes = new(); - public void ApplyOptions(IJsonApiOptions options) + public MutablePaginationEntry ResolveEntryInScope(ResourceFieldChainExpression? scope) + { + if (scope == null) { - ApplyOptionsInEntry(_globalScope, options); - - foreach ((_, MutablePaginationEntry entry) in _nestedScopes) - { - ApplyOptionsInEntry(entry, options); - } + return _globalScope; } - private void ApplyOptionsInEntry(MutablePaginationEntry entry, IJsonApiOptions options) + if (!_nestedScopes.ContainsKey(scope)) { - if (!entry.HasSetPageSize) - { - entry.PageSize = options.DefaultPageSize; - } - - entry.PageNumber ??= PageNumber.ValueOne; + _nestedScopes.Add(scope, new MutablePaginationEntry()); } - public IReadOnlyCollection GetExpressionsInScope() + return _nestedScopes[scope]; + } + + public void ApplyOptions(IJsonApiOptions options) + { + ApplyOptionsInEntry(_globalScope, options); + + foreach ((_, MutablePaginationEntry entry) in _nestedScopes) { - return EnumerateExpressionsInScope().ToArray(); + ApplyOptionsInEntry(entry, options); } + } - private IEnumerable EnumerateExpressionsInScope() + private void ApplyOptionsInEntry(MutablePaginationEntry entry, IJsonApiOptions options) + { + if (!entry.HasSetPageSize) { - yield return new ExpressionInScope(null, new PaginationExpression(_globalScope.PageNumber!, _globalScope.PageSize)); - - foreach ((ResourceFieldChainExpression scope, MutablePaginationEntry entry) in _nestedScopes) - { - yield return new ExpressionInScope(scope, new PaginationExpression(entry.PageNumber!, entry.PageSize)); - } + entry.PageSize = options.DefaultPageSize; } + + entry.PageNumber ??= PageNumber.ValueOne; } - private sealed class MutablePaginationEntry + public IReadOnlyCollection GetExpressionsInScope() { - public PageSize? PageSize { get; set; } - public bool HasSetPageSize { get; set; } + return EnumerateExpressionsInScope().ToArray(); + } - public PageNumber? PageNumber { get; set; } + private IEnumerable EnumerateExpressionsInScope() + { + yield return new ExpressionInScope(null, new PaginationExpression(_globalScope.PageNumber!, _globalScope.PageSize)); + + foreach ((ResourceFieldChainExpression scope, MutablePaginationEntry 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 index 4513f8e23a..103429aa81 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/QueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/QueryStringParameterReader.cs @@ -4,51 +4,50 @@ using JsonApiDotNetCore.Queries.Internal.Parsing; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.QueryStrings.Internal +namespace JsonApiDotNetCore.QueryStrings.Internal; + +public abstract class QueryStringParameterReader { - public abstract class QueryStringParameterReader - { - private readonly IResourceGraph _resourceGraph; - private readonly bool _isCollectionRequest; + private readonly IResourceGraph _resourceGraph; + private readonly bool _isCollectionRequest; - protected ResourceType RequestResourceType { get; } - protected bool IsAtomicOperationsRequest { get; } + protected ResourceType RequestResourceType { get; } + protected bool IsAtomicOperationsRequest { get; } - protected QueryStringParameterReader(IJsonApiRequest request, IResourceGraph resourceGraph) - { - ArgumentGuard.NotNull(request, nameof(request)); - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); - - _resourceGraph = resourceGraph; - _isCollectionRequest = request.IsCollection; - // There are currently no query string readers that work with operations, so non-nullable for convenience. - RequestResourceType = (request.SecondaryResourceType ?? request.PrimaryResourceType)!; - IsAtomicOperationsRequest = request.Kind == EndpointKind.AtomicOperations; - } + protected QueryStringParameterReader(IJsonApiRequest request, IResourceGraph resourceGraph) + { + ArgumentGuard.NotNull(request, nameof(request)); + ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); + + _resourceGraph = resourceGraph; + _isCollectionRequest = request.IsCollection; + // There are currently no query string readers that work with operations, so non-nullable for convenience. + RequestResourceType = (request.SecondaryResourceType ?? request.PrimaryResourceType)!; + IsAtomicOperationsRequest = request.Kind == EndpointKind.AtomicOperations; + } - protected ResourceType GetResourceTypeForScope(ResourceFieldChainExpression? scope) + protected ResourceType GetResourceTypeForScope(ResourceFieldChainExpression? scope) + { + if (scope == null) { - if (scope == null) - { - return RequestResourceType; - } - - ResourceFieldAttribute lastField = scope.Fields[^1]; + return RequestResourceType; + } - if (lastField is RelationshipAttribute relationship) - { - return relationship.RightType; - } + ResourceFieldAttribute lastField = scope.Fields[^1]; - return _resourceGraph.GetResourceType(lastField.Property.PropertyType); + if (lastField is RelationshipAttribute relationship) + { + return relationship.RightType; } - protected void AssertIsCollectionRequest() + return _resourceGraph.GetResourceType(lastField.Property.PropertyType); + } + + protected void AssertIsCollectionRequest() + { + if (!_isCollectionRequest) { - if (!_isCollectionRequest) - { - throw new QueryParseException("This query string parameter can only be used on a collection of resources (not on a single resource)."); - } + 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 index 52bfa648fe..78e6fe9e92 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/QueryStringReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/QueryStringReader.cs @@ -1,6 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers.Annotations; @@ -9,68 +6,67 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Primitives; -namespace JsonApiDotNetCore.QueryStrings.Internal +namespace JsonApiDotNetCore.QueryStrings.Internal; + +/// +[PublicAPI] +public class QueryStringReader : IQueryStringReader { - /// - [PublicAPI] - 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) { - private readonly IJsonApiOptions _options; - private readonly IRequestQueryStringAccessor _queryStringAccessor; - private readonly IEnumerable _parameterReaders; - private readonly ILogger _logger; + ArgumentGuard.NotNull(loggerFactory, nameof(loggerFactory)); + ArgumentGuard.NotNull(options, nameof(options)); + ArgumentGuard.NotNull(queryStringAccessor, nameof(queryStringAccessor)); + ArgumentGuard.NotNull(parameterReaders, nameof(parameterReaders)); - public QueryStringReader(IJsonApiOptions options, IRequestQueryStringAccessor queryStringAccessor, - IEnumerable parameterReaders, ILoggerFactory loggerFactory) - { - ArgumentGuard.NotNull(loggerFactory, nameof(loggerFactory)); - ArgumentGuard.NotNull(options, nameof(options)); - ArgumentGuard.NotNull(queryStringAccessor, nameof(queryStringAccessor)); - ArgumentGuard.NotNull(parameterReaders, nameof(parameterReaders)); + _options = options; + _queryStringAccessor = queryStringAccessor; + _parameterReaders = parameterReaders; + _logger = loggerFactory.CreateLogger(); + } - _options = options; - _queryStringAccessor = queryStringAccessor; - _parameterReaders = parameterReaders; - _logger = loggerFactory.CreateLogger(); - } + /// + public virtual void ReadAll(DisableQueryStringAttribute? disableQueryStringAttribute) + { + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Parse query string"); - /// - public virtual void ReadAll(DisableQueryStringAttribute? disableQueryStringAttribute) - { - using IDisposable _ = CodeTimingSessionManager.Current.Measure("Parse query string"); + DisableQueryStringAttribute disableQueryStringAttributeNotNull = disableQueryStringAttribute ?? DisableQueryStringAttribute.Empty; - DisableQueryStringAttribute disableQueryStringAttributeNotNull = disableQueryStringAttribute ?? DisableQueryStringAttribute.Empty; + foreach ((string parameterName, StringValues parameterValue) in _queryStringAccessor.Query) + { + IQueryStringParameterReader? reader = _parameterReaders.FirstOrDefault(nextReader => nextReader.CanRead(parameterName)); - foreach ((string parameterName, StringValues parameterValue) in _queryStringAccessor.Query) + if (reader != null) { - IQueryStringParameterReader? reader = _parameterReaders.FirstOrDefault(nextReader => nextReader.CanRead(parameterName)); + _logger.LogDebug($"Query string parameter '{parameterName}' with value '{parameterValue}' was accepted by {reader.GetType().Name}."); - if (reader != null) + if (!reader.AllowEmptyValue && string.IsNullOrEmpty(parameterValue)) { - _logger.LogDebug($"Query string parameter '{parameterName}' with value '{parameterValue}' was accepted by {reader.GetType().Name}."); - - if (!reader.AllowEmptyValue && string.IsNullOrEmpty(parameterValue)) - { - throw new InvalidQueryStringParameterException(parameterName, "Missing query string parameter value.", - $"Missing value for '{parameterName}' query string parameter."); - } - - if (!reader.IsEnabled(disableQueryStringAttributeNotNull)) - { - 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."); + throw new InvalidQueryStringParameterException(parameterName, "Missing query string parameter value.", + $"Missing value for '{parameterName}' query string parameter."); } - else if (!_options.AllowUnknownQueryStringParameters) + + if (!reader.IsEnabled(disableQueryStringAttributeNotNull)) { - 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."); + 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/QueryStrings/Internal/RequestQueryStringAccessor.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/RequestQueryStringAccessor.cs index 1ca5fa55f5..2492f01001 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/RequestQueryStringAccessor.cs +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/RequestQueryStringAccessor.cs @@ -1,31 +1,29 @@ -using System; using Microsoft.AspNetCore.Http; -namespace JsonApiDotNetCore.QueryStrings.Internal +namespace JsonApiDotNetCore.QueryStrings.Internal; + +/// +internal sealed class RequestQueryStringAccessor : IRequestQueryStringAccessor { - /// - internal sealed class RequestQueryStringAccessor : IRequestQueryStringAccessor - { - private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IHttpContextAccessor _httpContextAccessor; - public IQueryCollection Query + public IQueryCollection Query + { + get { - get + if (_httpContextAccessor.HttpContext == null) { - if (_httpContextAccessor.HttpContext == null) - { - throw new InvalidOperationException("An active HTTP request is required."); - } - - return _httpContextAccessor.HttpContext.Request.Query; + throw new InvalidOperationException("An active HTTP request is required."); } + + return _httpContextAccessor.HttpContext.Request.Query; } + } - public RequestQueryStringAccessor(IHttpContextAccessor httpContextAccessor) - { - ArgumentGuard.NotNull(httpContextAccessor, nameof(httpContextAccessor)); + public RequestQueryStringAccessor(IHttpContextAccessor httpContextAccessor) + { + ArgumentGuard.NotNull(httpContextAccessor, nameof(httpContextAccessor)); - _httpContextAccessor = httpContextAccessor; - } + _httpContextAccessor = httpContextAccessor; } } diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/ResourceDefinitionQueryableParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/ResourceDefinitionQueryableParameterReader.cs index bc53a9ed4d..51c5de0583 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/ResourceDefinitionQueryableParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/ResourceDefinitionQueryableParameterReader.cs @@ -1,5 +1,3 @@ -using System; -using System.Collections.Generic; using JetBrains.Annotations; using JsonApiDotNetCore.Controllers.Annotations; using JsonApiDotNetCore.Errors; @@ -9,71 +7,70 @@ using JsonApiDotNetCore.Resources; using Microsoft.Extensions.Primitives; -namespace JsonApiDotNetCore.QueryStrings.Internal -{ - /// - [PublicAPI] - public class ResourceDefinitionQueryableParameterReader : IResourceDefinitionQueryableParameterReader - { - private readonly IJsonApiRequest _request; - private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; - private readonly List _constraints = new(); - - public bool AllowEmptyValue => false; +namespace JsonApiDotNetCore.QueryStrings.Internal; - public ResourceDefinitionQueryableParameterReader(IJsonApiRequest request, IResourceDefinitionAccessor resourceDefinitionAccessor) - { - ArgumentGuard.NotNull(request, nameof(request)); - ArgumentGuard.NotNull(resourceDefinitionAccessor, nameof(resourceDefinitionAccessor)); +/// +[PublicAPI] +public class ResourceDefinitionQueryableParameterReader : IResourceDefinitionQueryableParameterReader +{ + private readonly IJsonApiRequest _request; + private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; + private readonly List _constraints = new(); - _request = request; - _resourceDefinitionAccessor = resourceDefinitionAccessor; - } + public bool AllowEmptyValue => false; - /// - public virtual bool IsEnabled(DisableQueryStringAttribute disableQueryStringAttribute) - { - return true; - } + public ResourceDefinitionQueryableParameterReader(IJsonApiRequest request, IResourceDefinitionAccessor resourceDefinitionAccessor) + { + ArgumentGuard.NotNull(request, nameof(request)); + ArgumentGuard.NotNull(resourceDefinitionAccessor, nameof(resourceDefinitionAccessor)); - /// - public virtual bool CanRead(string parameterName) - { - if (_request.Kind == EndpointKind.AtomicOperations) - { - return false; - } + _request = request; + _resourceDefinitionAccessor = resourceDefinitionAccessor; + } - object? queryableHandler = GetQueryableHandler(parameterName); - return queryableHandler != null; - } + /// + public virtual bool IsEnabled(DisableQueryStringAttribute disableQueryStringAttribute) + { + return true; + } - /// - public virtual void Read(string parameterName, StringValues parameterValue) + /// + public virtual bool CanRead(string parameterName) + { + if (_request.Kind == EndpointKind.AtomicOperations) { - object queryableHandler = GetQueryableHandler(parameterName)!; - var expressionInScope = new ExpressionInScope(null, new QueryableHandlerExpression(queryableHandler, parameterValue)); - _constraints.Add(expressionInScope); + return false; } - private object? GetQueryableHandler(string parameterName) - { - Type resourceClrType = (_request.SecondaryResourceType ?? _request.PrimaryResourceType)!.ClrType; - object? handler = _resourceDefinitionAccessor.GetQueryableHandlerForQueryStringParameter(resourceClrType, parameterName); + object? queryableHandler = GetQueryableHandler(parameterName); + return queryableHandler != null; + } - if (handler != null && _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."); - } + /// + public virtual void Read(string parameterName, StringValues parameterValue) + { + object queryableHandler = GetQueryableHandler(parameterName)!; + var expressionInScope = new ExpressionInScope(null, new QueryableHandlerExpression(queryableHandler, parameterValue)); + _constraints.Add(expressionInScope); + } - return handler; - } + private object? GetQueryableHandler(string parameterName) + { + Type resourceClrType = (_request.SecondaryResourceType ?? _request.PrimaryResourceType)!.ClrType; + object? handler = _resourceDefinitionAccessor.GetQueryableHandlerForQueryStringParameter(resourceClrType, parameterName); - /// - public virtual IReadOnlyCollection GetConstraints() + if (handler != null && _request.Kind != EndpointKind.Primary) { - return _constraints; + 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."); } + + return handler; + } + + /// + public virtual IReadOnlyCollection GetConstraints() + { + return _constraints; } } diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/SortQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/SortQueryStringParameterReader.cs index d231f176f5..5fa60e7f66 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/SortQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/SortQueryStringParameterReader.cs @@ -1,5 +1,3 @@ -using System; -using System.Collections.Generic; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers.Annotations; @@ -11,92 +9,91 @@ using JsonApiDotNetCore.Resources.Annotations; using Microsoft.Extensions.Primitives; -namespace JsonApiDotNetCore.QueryStrings.Internal +namespace JsonApiDotNetCore.QueryStrings.Internal; + +[PublicAPI] +public class SortQueryStringParameterReader : QueryStringParameterReader, ISortQueryStringParameterReader { - [PublicAPI] - public class SortQueryStringParameterReader : QueryStringParameterReader, ISortQueryStringParameterReader - { - private readonly QueryStringParameterScopeParser _scopeParser; - private readonly SortParser _sortParser; - private readonly List _constraints = new(); - private string? _lastParameterName; + private readonly QueryStringParameterScopeParser _scopeParser; + private readonly SortParser _sortParser; + private readonly List _constraints = new(); + private string? _lastParameterName; - public bool AllowEmptyValue => false; + public bool AllowEmptyValue => false; - public SortQueryStringParameterReader(IJsonApiRequest request, IResourceGraph resourceGraph) - : base(request, resourceGraph) - { - _scopeParser = new QueryStringParameterScopeParser(FieldChainRequirements.EndsInToMany); - _sortParser = new SortParser(ValidateSingleField); - } + public SortQueryStringParameterReader(IJsonApiRequest request, IResourceGraph resourceGraph) + : base(request, resourceGraph) + { + _scopeParser = new QueryStringParameterScopeParser(FieldChainRequirements.EndsInToMany); + _sortParser = new SortParser(ValidateSingleField); + } - protected void ValidateSingleField(ResourceFieldAttribute field, ResourceType resourceType, string path) + protected void ValidateSingleField(ResourceFieldAttribute field, ResourceType resourceType, string path) + { + if (field is AttrAttribute attribute && !attribute.Capabilities.HasFlag(AttrCapabilities.AllowSort)) { - 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."); - } + 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) - { - ArgumentGuard.NotNull(disableQueryStringAttribute, nameof(disableQueryStringAttribute)); + /// + public virtual bool IsEnabled(DisableQueryStringAttribute disableQueryStringAttribute) + { + ArgumentGuard.NotNull(disableQueryStringAttribute, nameof(disableQueryStringAttribute)); - return !IsAtomicOperationsRequest && !disableQueryStringAttribute.ContainsParameter(JsonApiQueryStringParameters.Sort); - } + return !IsAtomicOperationsRequest && !disableQueryStringAttribute.ContainsParameter(JsonApiQueryStringParameters.Sort); + } - /// - public virtual bool CanRead(string parameterName) - { - ArgumentGuard.NotNullNorEmpty(parameterName, nameof(parameterName)); + /// + public virtual bool CanRead(string parameterName) + { + ArgumentGuard.NotNullNorEmpty(parameterName, nameof(parameterName)); - bool isNested = parameterName.StartsWith("sort[", StringComparison.Ordinal) && parameterName.EndsWith("]", StringComparison.Ordinal); - return parameterName == "sort" || isNested; - } + bool 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); - } - } + /// + public virtual void Read(string parameterName, StringValues parameterValue) + { + _lastParameterName = parameterName; - private ResourceFieldChainExpression? GetScope(string parameterName) + try { - QueryStringParameterScopeExpression parameterScope = _scopeParser.Parse(parameterName, RequestResourceType); - - if (parameterScope.Scope == null) - { - AssertIsCollectionRequest(); - } + ResourceFieldChainExpression? scope = GetScope(parameterName); + SortExpression sort = GetSort(parameterValue, scope); - return parameterScope.Scope; + var expressionInScope = new ExpressionInScope(scope, sort); + _constraints.Add(expressionInScope); } - - private SortExpression GetSort(string parameterValue, ResourceFieldChainExpression? scope) + catch (QueryParseException exception) { - ResourceType resourceTypeInScope = GetResourceTypeForScope(scope); - return _sortParser.Parse(parameterValue, resourceTypeInScope); + throw new InvalidQueryStringParameterException(parameterName, "The specified sort is invalid.", exception.Message, exception); } + } + + private ResourceFieldChainExpression? GetScope(string parameterName) + { + QueryStringParameterScopeExpression parameterScope = _scopeParser.Parse(parameterName, RequestResourceType); - /// - public virtual IReadOnlyCollection GetConstraints() + if (parameterScope.Scope == null) { - return _constraints; + AssertIsCollectionRequest(); } + + return parameterScope.Scope; + } + + private SortExpression GetSort(string parameterValue, ResourceFieldChainExpression? scope) + { + ResourceType resourceTypeInScope = GetResourceTypeForScope(scope); + return _sortParser.Parse(parameterValue, resourceTypeInScope); + } + + /// + public virtual IReadOnlyCollection GetConstraints() + { + return _constraints; } } diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/SparseFieldSetQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/SparseFieldSetQueryStringParameterReader.cs index 07ba665f18..d8f1e858ea 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/SparseFieldSetQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/SparseFieldSetQueryStringParameterReader.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; using System.Collections.Immutable; -using System.Linq; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers.Annotations; @@ -14,97 +11,96 @@ using JsonApiDotNetCore.Resources.Annotations; using Microsoft.Extensions.Primitives; -namespace JsonApiDotNetCore.QueryStrings.Internal +namespace JsonApiDotNetCore.QueryStrings.Internal; + +[PublicAPI] +public class SparseFieldSetQueryStringParameterReader : QueryStringParameterReader, ISparseFieldSetQueryStringParameterReader { - [PublicAPI] - public class SparseFieldSetQueryStringParameterReader : QueryStringParameterReader, ISparseFieldSetQueryStringParameterReader - { - private readonly SparseFieldTypeParser _sparseFieldTypeParser; - private readonly SparseFieldSetParser _sparseFieldSetParser; + private readonly SparseFieldTypeParser _sparseFieldTypeParser; + private readonly SparseFieldSetParser _sparseFieldSetParser; - private readonly ImmutableDictionary.Builder _sparseFieldTableBuilder = - ImmutableDictionary.CreateBuilder(); + private readonly ImmutableDictionary.Builder _sparseFieldTableBuilder = + ImmutableDictionary.CreateBuilder(); - private string? _lastParameterName; + private string? _lastParameterName; - /// - bool IQueryStringParameterReader.AllowEmptyValue => true; + /// + bool IQueryStringParameterReader.AllowEmptyValue => true; - public SparseFieldSetQueryStringParameterReader(IJsonApiRequest request, IResourceGraph resourceGraph) - : base(request, resourceGraph) - { - _sparseFieldTypeParser = new SparseFieldTypeParser(resourceGraph); - _sparseFieldSetParser = new SparseFieldSetParser(ValidateSingleField); - } + public SparseFieldSetQueryStringParameterReader(IJsonApiRequest request, IResourceGraph resourceGraph) + : base(request, resourceGraph) + { + _sparseFieldTypeParser = new SparseFieldTypeParser(resourceGraph); + _sparseFieldSetParser = new SparseFieldSetParser(ValidateSingleField); + } - protected void ValidateSingleField(ResourceFieldAttribute field, ResourceType resourceType, string path) + protected void ValidateSingleField(ResourceFieldAttribute field, ResourceType resourceType, string path) + { + if (field is AttrAttribute attribute && !attribute.Capabilities.HasFlag(AttrCapabilities.AllowView)) { - if (field is AttrAttribute attribute && !attribute.Capabilities.HasFlag(AttrCapabilities.AllowView)) - { - throw new InvalidQueryStringParameterException(_lastParameterName!, "Retrieving the requested attribute is not allowed.", - $"Retrieving the attribute '{attribute.PublicName}' is not allowed."); - } + 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) - { - ArgumentGuard.NotNull(disableQueryStringAttribute, nameof(disableQueryStringAttribute)); + /// + public virtual bool IsEnabled(DisableQueryStringAttribute disableQueryStringAttribute) + { + ArgumentGuard.NotNull(disableQueryStringAttribute, nameof(disableQueryStringAttribute)); - return !IsAtomicOperationsRequest && !disableQueryStringAttribute.ContainsParameter(JsonApiQueryStringParameters.Fields); - } + return !IsAtomicOperationsRequest && !disableQueryStringAttribute.ContainsParameter(JsonApiQueryStringParameters.Fields); + } - /// - public virtual bool CanRead(string parameterName) - { - ArgumentGuard.NotNullNorEmpty(parameterName, nameof(parameterName)); + /// + public virtual bool CanRead(string parameterName) + { + ArgumentGuard.NotNullNorEmpty(parameterName, nameof(parameterName)); - return parameterName.StartsWith("fields[", StringComparison.Ordinal) && parameterName.EndsWith("]", StringComparison.Ordinal); - } + return parameterName.StartsWith("fields[", StringComparison.Ordinal) && parameterName.EndsWith("]", StringComparison.Ordinal); + } - /// - public virtual void Read(string parameterName, StringValues parameterValue) - { - _lastParameterName = parameterName; - - try - { - ResourceType targetResourceType = GetSparseFieldType(parameterName); - SparseFieldSetExpression sparseFieldSet = GetSparseFieldSet(parameterValue, targetResourceType); - - _sparseFieldTableBuilder[targetResourceType] = sparseFieldSet; - } - catch (QueryParseException exception) - { - throw new InvalidQueryStringParameterException(parameterName, "The specified fieldset is invalid.", exception.Message, exception); - } - } + /// + public virtual void Read(string parameterName, StringValues parameterValue) + { + _lastParameterName = parameterName; - private ResourceType GetSparseFieldType(string parameterName) + try { - return _sparseFieldTypeParser.Parse(parameterName); - } + ResourceType targetResourceType = GetSparseFieldType(parameterName); + SparseFieldSetExpression sparseFieldSet = GetSparseFieldSet(parameterValue, targetResourceType); - private SparseFieldSetExpression GetSparseFieldSet(string parameterValue, ResourceType resourceType) + _sparseFieldTableBuilder[targetResourceType] = sparseFieldSet; + } + catch (QueryParseException exception) { - SparseFieldSetExpression? sparseFieldSet = _sparseFieldSetParser.Parse(parameterValue, resourceType); + throw new InvalidQueryStringParameterException(parameterName, "The specified fieldset is invalid.", exception.Message, exception); + } + } - if (sparseFieldSet == null) - { - // We add ID on an incoming empty fieldset, so that callers can distinguish between no fieldset and an empty one. - AttrAttribute idAttribute = resourceType.GetAttributeByPropertyName(nameof(Identifiable.Id)); - return new SparseFieldSetExpression(ImmutableHashSet.Create(idAttribute)); - } + private ResourceType GetSparseFieldType(string parameterName) + { + return _sparseFieldTypeParser.Parse(parameterName); + } - return sparseFieldSet; - } + private SparseFieldSetExpression GetSparseFieldSet(string parameterValue, ResourceType resourceType) + { + SparseFieldSetExpression? sparseFieldSet = _sparseFieldSetParser.Parse(parameterValue, resourceType); - /// - public virtual IReadOnlyCollection GetConstraints() + if (sparseFieldSet == null) { - return _sparseFieldTableBuilder.Any() - ? new ExpressionInScope(null, new SparseFieldTableExpression(_sparseFieldTableBuilder.ToImmutable())).AsArray() - : Array.Empty(); + // We add ID on an incoming empty fieldset, so that callers can distinguish between no fieldset and an empty one. + AttrAttribute idAttribute = resourceType.GetAttributeByPropertyName(nameof(Identifiable.Id)); + return new SparseFieldSetExpression(ImmutableHashSet.Create(idAttribute)); } + + return sparseFieldSet; + } + + /// + public virtual IReadOnlyCollection GetConstraints() + { + return _sparseFieldTableBuilder.Any() + ? new ExpressionInScope(null, new SparseFieldTableExpression(_sparseFieldTableBuilder.ToImmutable())).AsArray() + : Array.Empty(); } } diff --git a/src/JsonApiDotNetCore/QueryStrings/JsonApiQueryStringParameters.cs b/src/JsonApiDotNetCore/QueryStrings/JsonApiQueryStringParameters.cs index 9c220686ac..c1f59ceab6 100644 --- a/src/JsonApiDotNetCore/QueryStrings/JsonApiQueryStringParameters.cs +++ b/src/JsonApiDotNetCore/QueryStrings/JsonApiQueryStringParameters.cs @@ -1,20 +1,18 @@ -using System; using JsonApiDotNetCore.Controllers.Annotations; -namespace JsonApiDotNetCore.QueryStrings +namespace JsonApiDotNetCore.QueryStrings; + +/// +/// Lists query string parameters used by . +/// +[Flags] +public enum JsonApiQueryStringParameters { - /// - /// Lists query string parameters used by . - /// - [Flags] - public enum JsonApiQueryStringParameters - { - None = 0, - Filter = 1, - Sort = 2, - Include = 4, - Page = 8, - Fields = 16, - All = Filter | Sort | Include | Page | Fields - } + None = 0, + Filter = 1, + Sort = 2, + Include = 4, + Page = 8, + Fields = 16, + All = Filter | Sort | Include | Page | Fields } diff --git a/src/JsonApiDotNetCore/Repositories/DataStoreUpdateException.cs b/src/JsonApiDotNetCore/Repositories/DataStoreUpdateException.cs index 4ba6b8fc3b..e823b50077 100644 --- a/src/JsonApiDotNetCore/Repositories/DataStoreUpdateException.cs +++ b/src/JsonApiDotNetCore/Repositories/DataStoreUpdateException.cs @@ -1,17 +1,15 @@ -using System; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Repositories +namespace JsonApiDotNetCore.Repositories; + +/// +/// The error that is thrown when the underlying data store is unable to persist changes. +/// +[PublicAPI] +public sealed class DataStoreUpdateException : Exception { - /// - /// The error that is thrown when the underlying data store is unable to persist changes. - /// - [PublicAPI] - public sealed class DataStoreUpdateException : Exception + public DataStoreUpdateException(Exception? innerException) + : base("Failed to persist changes in the underlying data store.", innerException) { - public DataStoreUpdateException(Exception? innerException) - : base("Failed to persist changes in the underlying data store.", innerException) - { - } } } diff --git a/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs b/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs index 6fec658c4e..e9a9488b7b 100644 --- a/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs +++ b/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs @@ -1,63 +1,60 @@ -using System; -using System.Linq; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; -namespace JsonApiDotNetCore.Repositories +namespace JsonApiDotNetCore.Repositories; + +[PublicAPI] +public static class DbContextExtensions { - [PublicAPI] - public static class DbContextExtensions + /// + /// If not already tracked, attaches the specified resource to the change tracker in state. + /// + public static IIdentifiable GetTrackedOrAttach(this DbContext dbContext, IIdentifiable resource) { - /// - /// If not already tracked, attaches the specified resource to the change tracker in state. - /// - public static IIdentifiable GetTrackedOrAttach(this DbContext dbContext, IIdentifiable resource) - { - ArgumentGuard.NotNull(dbContext, nameof(dbContext)); - ArgumentGuard.NotNull(resource, nameof(resource)); - - var trackedIdentifiable = (IIdentifiable?)dbContext.GetTrackedIdentifiable(resource); + ArgumentGuard.NotNull(dbContext, nameof(dbContext)); + ArgumentGuard.NotNull(resource, nameof(resource)); - if (trackedIdentifiable == null) - { - dbContext.Entry(resource).State = EntityState.Unchanged; - trackedIdentifiable = resource; - } + var trackedIdentifiable = (IIdentifiable?)dbContext.GetTrackedIdentifiable(resource); - return trackedIdentifiable; + if (trackedIdentifiable == null) + { + dbContext.Entry(resource).State = EntityState.Unchanged; + trackedIdentifiable = resource; } - /// - /// Searches the change tracker for an entity that matches the type and ID of . - /// - public static object? GetTrackedIdentifiable(this DbContext dbContext, IIdentifiable identifiable) - { - ArgumentGuard.NotNull(dbContext, nameof(dbContext)); - ArgumentGuard.NotNull(identifiable, nameof(identifiable)); + return trackedIdentifiable; + } - Type resourceClrType = identifiable.GetType(); - string? stringId = identifiable.StringId; + /// + /// Searches the change tracker for an entity that matches the type and ID of . + /// + public static object? GetTrackedIdentifiable(this DbContext dbContext, IIdentifiable identifiable) + { + ArgumentGuard.NotNull(dbContext, nameof(dbContext)); + ArgumentGuard.NotNull(identifiable, nameof(identifiable)); - EntityEntry? entityEntry = dbContext.ChangeTracker.Entries().FirstOrDefault(entry => IsResource(entry, resourceClrType, stringId)); + Type resourceClrType = identifiable.GetType(); + string? stringId = identifiable.StringId; - return entityEntry?.Entity; - } + EntityEntry? entityEntry = dbContext.ChangeTracker.Entries().FirstOrDefault(entry => IsResource(entry, resourceClrType, stringId)); - private static bool IsResource(EntityEntry entry, Type resourceClrType, string? stringId) - { - return entry.Entity.GetType() == resourceClrType && ((IIdentifiable)entry.Entity).StringId == stringId; - } + return entityEntry?.Entity; + } - /// - /// Detaches all entities from the change tracker. - /// - public static void ResetChangeTracker(this DbContext dbContext) - { - ArgumentGuard.NotNull(dbContext, nameof(dbContext)); + private static bool IsResource(EntityEntry entry, Type resourceClrType, string? stringId) + { + return entry.Entity.GetType() == resourceClrType && ((IIdentifiable)entry.Entity).StringId == stringId; + } - dbContext.ChangeTracker.Clear(); - } + /// + /// Detaches all entities from the change tracker. + /// + public static void ResetChangeTracker(this DbContext dbContext) + { + ArgumentGuard.NotNull(dbContext, nameof(dbContext)); + + dbContext.ChangeTracker.Clear(); } } diff --git a/src/JsonApiDotNetCore/Repositories/DbContextResolver.cs b/src/JsonApiDotNetCore/Repositories/DbContextResolver.cs index 61dfcb388d..c8013a5f0a 100644 --- a/src/JsonApiDotNetCore/Repositories/DbContextResolver.cs +++ b/src/JsonApiDotNetCore/Repositories/DbContextResolver.cs @@ -1,30 +1,29 @@ using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; -namespace JsonApiDotNetCore.Repositories +namespace JsonApiDotNetCore.Repositories; + +/// +[PublicAPI] +public sealed class DbContextResolver : IDbContextResolver + where TDbContext : DbContext { - /// - [PublicAPI] - public sealed class DbContextResolver : IDbContextResolver - where TDbContext : DbContext - { - private readonly TDbContext _dbContext; + private readonly TDbContext _dbContext; - public DbContextResolver(TDbContext dbContext) - { - ArgumentGuard.NotNull(dbContext, nameof(dbContext)); + public DbContextResolver(TDbContext dbContext) + { + ArgumentGuard.NotNull(dbContext, nameof(dbContext)); - _dbContext = dbContext; - } + _dbContext = dbContext; + } - public DbContext GetContext() - { - return _dbContext; - } + public DbContext GetContext() + { + return _dbContext; + } - public TDbContext GetTypedContext() - { - return _dbContext; - } + public TDbContext GetTypedContext() + { + return _dbContext; } } diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 2092f8935a..52c07c5244 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -1,11 +1,6 @@ -using System; using System.Collections; -using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; -using System.Linq; using System.Linq.Expressions; -using System.Threading; -using System.Threading.Tasks; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Diagnostics; @@ -21,560 +16,559 @@ using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCore.Repositories +namespace JsonApiDotNetCore.Repositories; + +/// +/// Implements the foundational Repository layer in the JsonApiDotNetCore architecture that uses Entity Framework Core. +/// +[PublicAPI] +public class EntityFrameworkCoreRepository : IResourceRepository, IRepositorySupportsTransaction + where TResource : class, IIdentifiable { - /// - /// Implements the foundational Repository layer in the JsonApiDotNetCore architecture that uses Entity Framework Core. - /// - [PublicAPI] - public class EntityFrameworkCoreRepository : IResourceRepository, IRepositorySupportsTransaction - where TResource : class, IIdentifiable + private readonly CollectionConverter _collectionConverter = new(); + private readonly ITargetedFields _targetedFields; + private readonly DbContext _dbContext; + private readonly IResourceGraph _resourceGraph; + private readonly IResourceFactory _resourceFactory; + private readonly IEnumerable _constraintProviders; + private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; + private readonly TraceLogWriter> _traceWriter; + + /// + public virtual string? TransactionId => _dbContext.Database.CurrentTransaction?.TransactionId.ToString(); + + public EntityFrameworkCoreRepository(ITargetedFields targetedFields, IDbContextResolver dbContextResolver, IResourceGraph resourceGraph, + IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory, + IResourceDefinitionAccessor resourceDefinitionAccessor) { - private readonly CollectionConverter _collectionConverter = new(); - private readonly ITargetedFields _targetedFields; - private readonly DbContext _dbContext; - private readonly IResourceGraph _resourceGraph; - private readonly IResourceFactory _resourceFactory; - private readonly IEnumerable _constraintProviders; - private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; - private readonly TraceLogWriter> _traceWriter; - - /// - public virtual string? TransactionId => _dbContext.Database.CurrentTransaction?.TransactionId.ToString(); - - public EntityFrameworkCoreRepository(ITargetedFields targetedFields, IDbContextResolver dbContextResolver, IResourceGraph resourceGraph, - IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory, - IResourceDefinitionAccessor resourceDefinitionAccessor) - { - ArgumentGuard.NotNull(targetedFields, nameof(targetedFields)); - ArgumentGuard.NotNull(dbContextResolver, nameof(dbContextResolver)); - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); - ArgumentGuard.NotNull(resourceFactory, nameof(resourceFactory)); - ArgumentGuard.NotNull(constraintProviders, nameof(constraintProviders)); - ArgumentGuard.NotNull(loggerFactory, nameof(loggerFactory)); - ArgumentGuard.NotNull(resourceDefinitionAccessor, nameof(resourceDefinitionAccessor)); - - _targetedFields = targetedFields; - _dbContext = dbContextResolver.GetContext(); - _resourceGraph = resourceGraph; - _resourceFactory = resourceFactory; - _constraintProviders = constraintProviders; - _resourceDefinitionAccessor = resourceDefinitionAccessor; - _traceWriter = new TraceLogWriter>(loggerFactory); - } + ArgumentGuard.NotNull(targetedFields, nameof(targetedFields)); + ArgumentGuard.NotNull(dbContextResolver, nameof(dbContextResolver)); + ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); + ArgumentGuard.NotNull(resourceFactory, nameof(resourceFactory)); + ArgumentGuard.NotNull(constraintProviders, nameof(constraintProviders)); + ArgumentGuard.NotNull(loggerFactory, nameof(loggerFactory)); + ArgumentGuard.NotNull(resourceDefinitionAccessor, nameof(resourceDefinitionAccessor)); + + _targetedFields = targetedFields; + _dbContext = dbContextResolver.GetContext(); + _resourceGraph = resourceGraph; + _resourceFactory = resourceFactory; + _constraintProviders = constraintProviders; + _resourceDefinitionAccessor = resourceDefinitionAccessor; + _traceWriter = new TraceLogWriter>(loggerFactory); + } - /// - public virtual async Task> GetAsync(QueryLayer queryLayer, CancellationToken cancellationToken) + /// + public virtual async Task> GetAsync(QueryLayer queryLayer, CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new { - _traceWriter.LogMethodStart(new - { - queryLayer - }); + queryLayer + }); - ArgumentGuard.NotNull(queryLayer, nameof(queryLayer)); + ArgumentGuard.NotNull(queryLayer, nameof(queryLayer)); - using (CodeTimingSessionManager.Current.Measure("Repository - Get resource(s)")) - { - IQueryable query = ApplyQueryLayer(queryLayer); + using (CodeTimingSessionManager.Current.Measure("Repository - Get resource(s)")) + { + IQueryable query = ApplyQueryLayer(queryLayer); - using (CodeTimingSessionManager.Current.Measure("Execute SQL (data)", MeasurementSettings.ExcludeDatabaseInPercentages)) - { - return await query.ToListAsync(cancellationToken); - } + using (CodeTimingSessionManager.Current.Measure("Execute SQL (data)", MeasurementSettings.ExcludeDatabaseInPercentages)) + { + return await query.ToListAsync(cancellationToken); } } + } - /// - public virtual async Task CountAsync(FilterExpression? filter, CancellationToken cancellationToken) + /// + public virtual async Task CountAsync(FilterExpression? filter, CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new { - _traceWriter.LogMethodStart(new - { - filter - }); + filter + }); - using (CodeTimingSessionManager.Current.Measure("Repository - Count resources")) - { - ResourceType resourceType = _resourceGraph.GetResourceType(); + using (CodeTimingSessionManager.Current.Measure("Repository - Count resources")) + { + ResourceType resourceType = _resourceGraph.GetResourceType(); - var layer = new QueryLayer(resourceType) - { - Filter = filter - }; + var layer = new QueryLayer(resourceType) + { + Filter = filter + }; - IQueryable query = ApplyQueryLayer(layer); + IQueryable query = ApplyQueryLayer(layer); - using (CodeTimingSessionManager.Current.Measure("Execute SQL (count)", MeasurementSettings.ExcludeDatabaseInPercentages)) - { - return await query.CountAsync(cancellationToken); - } + using (CodeTimingSessionManager.Current.Measure("Execute SQL (count)", MeasurementSettings.ExcludeDatabaseInPercentages)) + { + return await query.CountAsync(cancellationToken); } } + } - protected virtual IQueryable ApplyQueryLayer(QueryLayer queryLayer) + protected virtual IQueryable ApplyQueryLayer(QueryLayer queryLayer) + { + _traceWriter.LogMethodStart(new { - _traceWriter.LogMethodStart(new - { - queryLayer - }); + queryLayer + }); - ArgumentGuard.NotNull(queryLayer, nameof(queryLayer)); + ArgumentGuard.NotNull(queryLayer, nameof(queryLayer)); - using (CodeTimingSessionManager.Current.Measure("Convert QueryLayer to System.Expression")) - { - IQueryable source = GetAll(); + using (CodeTimingSessionManager.Current.Measure("Convert QueryLayer to System.Expression")) + { + IQueryable source = GetAll(); - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true + // @formatter:wrap_chained_method_calls chop_always + // @formatter:keep_existing_linebreaks true - QueryableHandlerExpression[] queryableHandlers = _constraintProviders - .SelectMany(provider => provider.GetConstraints()) - .Where(expressionInScope => expressionInScope.Scope == null) - .Select(expressionInScope => expressionInScope.Expression) - .OfType() - .ToArray(); + QueryableHandlerExpression[] queryableHandlers = _constraintProviders + .SelectMany(provider => provider.GetConstraints()) + .Where(expressionInScope => expressionInScope.Scope == null) + .Select(expressionInScope => expressionInScope.Expression) + .OfType() + .ToArray(); - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore + // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_chained_method_calls restore - foreach (QueryableHandlerExpression queryableHandler in queryableHandlers) - { - source = queryableHandler.Apply(source); - } + foreach (QueryableHandlerExpression queryableHandler in queryableHandlers) + { + source = queryableHandler.Apply(source); + } - var nameFactory = new LambdaParameterNameFactory(); + var nameFactory = new LambdaParameterNameFactory(); - var builder = new QueryableBuilder(source.Expression, source.ElementType, typeof(Queryable), nameFactory, _resourceFactory, _dbContext.Model); + var builder = new QueryableBuilder(source.Expression, source.ElementType, typeof(Queryable), nameFactory, _resourceFactory, _dbContext.Model); - Expression expression = builder.ApplyQuery(queryLayer); + Expression expression = builder.ApplyQuery(queryLayer); - using (CodeTimingSessionManager.Current.Measure("Convert System.Expression to IQueryable")) - { - return source.Provider.CreateQuery(expression); - } + using (CodeTimingSessionManager.Current.Measure("Convert System.Expression to IQueryable")) + { + return source.Provider.CreateQuery(expression); } } + } - protected virtual IQueryable GetAll() - { - return _dbContext.Set(); - } + protected virtual IQueryable GetAll() + { + return _dbContext.Set(); + } - /// - public virtual Task GetForCreateAsync(TId id, CancellationToken cancellationToken) + /// + public virtual Task GetForCreateAsync(TId id, CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new { - _traceWriter.LogMethodStart(new - { - id - }); + id + }); - var resource = _resourceFactory.CreateInstance(); - resource.Id = id; + var resource = _resourceFactory.CreateInstance(); + resource.Id = id; - return Task.FromResult(resource); - } + return Task.FromResult(resource); + } - /// - public virtual async Task CreateAsync(TResource resourceFromRequest, TResource resourceForDatabase, CancellationToken cancellationToken) + /// + public virtual async Task CreateAsync(TResource resourceFromRequest, TResource resourceForDatabase, CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new { - _traceWriter.LogMethodStart(new - { - resourceFromRequest, - resourceForDatabase - }); + resourceFromRequest, + resourceForDatabase + }); - ArgumentGuard.NotNull(resourceFromRequest, nameof(resourceFromRequest)); - ArgumentGuard.NotNull(resourceForDatabase, nameof(resourceForDatabase)); + ArgumentGuard.NotNull(resourceFromRequest, nameof(resourceFromRequest)); + ArgumentGuard.NotNull(resourceForDatabase, nameof(resourceForDatabase)); - using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Create resource"); + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Create resource"); - foreach (RelationshipAttribute relationship in _targetedFields.Relationships) - { - object? rightValue = relationship.GetValue(resourceFromRequest); + foreach (RelationshipAttribute relationship in _targetedFields.Relationships) + { + object? rightValue = relationship.GetValue(resourceFromRequest); - object? rightValueEvaluated = await VisitSetRelationshipAsync(resourceForDatabase, relationship, rightValue, WriteOperationKind.CreateResource, - cancellationToken); + object? rightValueEvaluated = await VisitSetRelationshipAsync(resourceForDatabase, relationship, rightValue, WriteOperationKind.CreateResource, + cancellationToken); - await UpdateRelationshipAsync(relationship, resourceForDatabase, rightValueEvaluated, cancellationToken); - } + await UpdateRelationshipAsync(relationship, resourceForDatabase, rightValueEvaluated, cancellationToken); + } - foreach (AttrAttribute attribute in _targetedFields.Attributes) - { - attribute.SetValue(resourceForDatabase, attribute.GetValue(resourceFromRequest)); - } + foreach (AttrAttribute attribute in _targetedFields.Attributes) + { + attribute.SetValue(resourceForDatabase, attribute.GetValue(resourceFromRequest)); + } - await _resourceDefinitionAccessor.OnWritingAsync(resourceForDatabase, WriteOperationKind.CreateResource, cancellationToken); + await _resourceDefinitionAccessor.OnWritingAsync(resourceForDatabase, WriteOperationKind.CreateResource, cancellationToken); - DbSet dbSet = _dbContext.Set(); - await dbSet.AddAsync(resourceForDatabase, cancellationToken); + DbSet dbSet = _dbContext.Set(); + await dbSet.AddAsync(resourceForDatabase, cancellationToken); - await SaveChangesAsync(cancellationToken); + await SaveChangesAsync(cancellationToken); - await _resourceDefinitionAccessor.OnWriteSucceededAsync(resourceForDatabase, WriteOperationKind.CreateResource, cancellationToken); + await _resourceDefinitionAccessor.OnWriteSucceededAsync(resourceForDatabase, WriteOperationKind.CreateResource, cancellationToken); - _dbContext.ResetChangeTracker(); - } + _dbContext.ResetChangeTracker(); + } - private async Task VisitSetRelationshipAsync(TResource leftResource, RelationshipAttribute relationship, object? rightValue, - WriteOperationKind writeOperation, CancellationToken cancellationToken) + private async Task VisitSetRelationshipAsync(TResource leftResource, RelationshipAttribute relationship, object? rightValue, + WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + if (relationship is HasOneAttribute hasOneRelationship) { - if (relationship is HasOneAttribute hasOneRelationship) - { - return await _resourceDefinitionAccessor.OnSetToOneRelationshipAsync(leftResource, hasOneRelationship, (IIdentifiable?)rightValue, - writeOperation, cancellationToken); - } - - if (relationship is HasManyAttribute hasManyRelationship) - { - HashSet rightResourceIdSet = _collectionConverter.ExtractResources(rightValue).ToHashSet(IdentifiableComparer.Instance); + return await _resourceDefinitionAccessor.OnSetToOneRelationshipAsync(leftResource, hasOneRelationship, (IIdentifiable?)rightValue, writeOperation, + cancellationToken); + } - await _resourceDefinitionAccessor.OnSetToManyRelationshipAsync(leftResource, hasManyRelationship, rightResourceIdSet, writeOperation, - cancellationToken); + if (relationship is HasManyAttribute hasManyRelationship) + { + HashSet rightResourceIdSet = _collectionConverter.ExtractResources(rightValue).ToHashSet(IdentifiableComparer.Instance); - return rightResourceIdSet; - } + await _resourceDefinitionAccessor.OnSetToManyRelationshipAsync(leftResource, hasManyRelationship, rightResourceIdSet, writeOperation, + cancellationToken); - return rightValue; + return rightResourceIdSet; } - /// - public virtual async Task GetForUpdateAsync(QueryLayer queryLayer, CancellationToken cancellationToken) + return rightValue; + } + + /// + public virtual async Task GetForUpdateAsync(QueryLayer queryLayer, CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new { - _traceWriter.LogMethodStart(new - { - queryLayer - }); + queryLayer + }); - ArgumentGuard.NotNull(queryLayer, nameof(queryLayer)); + ArgumentGuard.NotNull(queryLayer, nameof(queryLayer)); - using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Get resource for update"); + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Get resource for update"); - IReadOnlyCollection resources = await GetAsync(queryLayer, cancellationToken); - return resources.FirstOrDefault(); - } + IReadOnlyCollection resources = await GetAsync(queryLayer, cancellationToken); + return resources.FirstOrDefault(); + } - /// - public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource resourceFromDatabase, CancellationToken cancellationToken) + /// + public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource resourceFromDatabase, CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new { - _traceWriter.LogMethodStart(new - { - resourceFromRequest, - resourceFromDatabase - }); + resourceFromRequest, + resourceFromDatabase + }); - ArgumentGuard.NotNull(resourceFromRequest, nameof(resourceFromRequest)); - ArgumentGuard.NotNull(resourceFromDatabase, nameof(resourceFromDatabase)); + ArgumentGuard.NotNull(resourceFromRequest, nameof(resourceFromRequest)); + ArgumentGuard.NotNull(resourceFromDatabase, nameof(resourceFromDatabase)); - using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Update resource"); + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Update resource"); - foreach (RelationshipAttribute relationship in _targetedFields.Relationships) - { - object? rightValue = relationship.GetValue(resourceFromRequest); + foreach (RelationshipAttribute relationship in _targetedFields.Relationships) + { + object? rightValue = relationship.GetValue(resourceFromRequest); - object? rightValueEvaluated = await VisitSetRelationshipAsync(resourceFromDatabase, relationship, rightValue, WriteOperationKind.UpdateResource, - cancellationToken); + object? rightValueEvaluated = await VisitSetRelationshipAsync(resourceFromDatabase, relationship, rightValue, WriteOperationKind.UpdateResource, + cancellationToken); - AssertIsNotClearingRequiredToOneRelationship(relationship, resourceFromDatabase, rightValueEvaluated); + AssertIsNotClearingRequiredToOneRelationship(relationship, resourceFromDatabase, rightValueEvaluated); - await UpdateRelationshipAsync(relationship, resourceFromDatabase, rightValueEvaluated, cancellationToken); - } + await UpdateRelationshipAsync(relationship, resourceFromDatabase, rightValueEvaluated, cancellationToken); + } - foreach (AttrAttribute attribute in _targetedFields.Attributes) - { - attribute.SetValue(resourceFromDatabase, attribute.GetValue(resourceFromRequest)); - } + foreach (AttrAttribute attribute in _targetedFields.Attributes) + { + attribute.SetValue(resourceFromDatabase, attribute.GetValue(resourceFromRequest)); + } - await _resourceDefinitionAccessor.OnWritingAsync(resourceFromDatabase, WriteOperationKind.UpdateResource, cancellationToken); + await _resourceDefinitionAccessor.OnWritingAsync(resourceFromDatabase, WriteOperationKind.UpdateResource, cancellationToken); - await SaveChangesAsync(cancellationToken); + await SaveChangesAsync(cancellationToken); - await _resourceDefinitionAccessor.OnWriteSucceededAsync(resourceFromDatabase, WriteOperationKind.UpdateResource, cancellationToken); + await _resourceDefinitionAccessor.OnWriteSucceededAsync(resourceFromDatabase, WriteOperationKind.UpdateResource, cancellationToken); - _dbContext.ResetChangeTracker(); - } + _dbContext.ResetChangeTracker(); + } - protected void AssertIsNotClearingRequiredToOneRelationship(RelationshipAttribute relationship, TResource leftResource, object? rightValue) + protected void AssertIsNotClearingRequiredToOneRelationship(RelationshipAttribute relationship, TResource leftResource, object? rightValue) + { + if (relationship is HasOneAttribute) { - if (relationship is HasOneAttribute) - { - INavigation? navigation = GetNavigation(relationship); - bool isRelationshipRequired = navigation?.ForeignKey?.IsRequired ?? false; + INavigation? navigation = GetNavigation(relationship); + bool isRelationshipRequired = navigation?.ForeignKey.IsRequired ?? false; - bool isClearingRelationship = rightValue == null; + bool isClearingRelationship = rightValue == null; - if (isRelationshipRequired && isClearingRelationship) - { - string resourceName = _resourceGraph.GetResourceType().PublicName; - throw new CannotClearRequiredRelationshipException(relationship.PublicName, leftResource.StringId!, resourceName); - } + if (isRelationshipRequired && isClearingRelationship) + { + string resourceName = _resourceGraph.GetResourceType().PublicName; + throw new CannotClearRequiredRelationshipException(relationship.PublicName, leftResource.StringId!, resourceName); } } + } - /// - public virtual async Task DeleteAsync(TId id, CancellationToken cancellationToken) + /// + public virtual async Task DeleteAsync(TId id, CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new { - _traceWriter.LogMethodStart(new - { - id - }); + id + }); - using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Delete resource"); + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Delete resource"); - // This enables OnWritingAsync() to fetch the resource, which adds it to the change tracker. - // If so, we'll reuse the tracked resource instead of this placeholder resource. - var placeholderResource = _resourceFactory.CreateInstance(); - placeholderResource.Id = id; + // This enables OnWritingAsync() to fetch the resource, which adds it to the change tracker. + // If so, we'll reuse the tracked resource instead of this placeholder resource. + var placeholderResource = _resourceFactory.CreateInstance(); + placeholderResource.Id = id; - await _resourceDefinitionAccessor.OnWritingAsync(placeholderResource, WriteOperationKind.DeleteResource, cancellationToken); + await _resourceDefinitionAccessor.OnWritingAsync(placeholderResource, WriteOperationKind.DeleteResource, cancellationToken); - var resourceTracked = (TResource)_dbContext.GetTrackedOrAttach(placeholderResource); + var resourceTracked = (TResource)_dbContext.GetTrackedOrAttach(placeholderResource); - foreach (RelationshipAttribute relationship in _resourceGraph.GetResourceType().Relationships) + foreach (RelationshipAttribute relationship in _resourceGraph.GetResourceType().Relationships) + { + // Loads the data of the relationship, if in Entity Framework Core it is configured in such a way that loading + // the related entities into memory is required for successfully executing the selected deletion behavior. + if (RequiresLoadOfRelationshipForDeletion(relationship)) { - // Loads the data of the relationship, if in Entity Framework Core it is configured in such a way that loading - // the related entities into memory is required for successfully executing the selected deletion behavior. - if (RequiresLoadOfRelationshipForDeletion(relationship)) - { - NavigationEntry navigation = GetNavigationEntry(resourceTracked, relationship); - await navigation.LoadAsync(cancellationToken); - } + NavigationEntry navigation = GetNavigationEntry(resourceTracked, relationship); + await navigation.LoadAsync(cancellationToken); } + } - _dbContext.Remove(resourceTracked); + _dbContext.Remove(resourceTracked); - await SaveChangesAsync(cancellationToken); + await SaveChangesAsync(cancellationToken); - await _resourceDefinitionAccessor.OnWriteSucceededAsync(resourceTracked, WriteOperationKind.DeleteResource, cancellationToken); - } + await _resourceDefinitionAccessor.OnWriteSucceededAsync(resourceTracked, WriteOperationKind.DeleteResource, cancellationToken); + } - private NavigationEntry GetNavigationEntry(TResource resource, RelationshipAttribute relationship) - { - EntityEntry entityEntry = _dbContext.Entry(resource); + private NavigationEntry GetNavigationEntry(TResource resource, RelationshipAttribute relationship) + { + EntityEntry entityEntry = _dbContext.Entry(resource); - switch (relationship) + switch (relationship) + { + case HasOneAttribute hasOneRelationship: + { + return entityEntry.Reference(hasOneRelationship.Property.Name); + } + case HasManyAttribute hasManyRelationship: + { + return entityEntry.Collection(hasManyRelationship.Property.Name); + } + default: { - case HasOneAttribute hasOneRelationship: - { - return entityEntry.Reference(hasOneRelationship.Property.Name); - } - case HasManyAttribute hasManyRelationship: - { - return entityEntry.Collection(hasManyRelationship.Property.Name); - } - default: - { - throw new InvalidOperationException($"Unknown relationship type '{relationship.GetType().Name}'."); - } + throw new InvalidOperationException($"Unknown relationship type '{relationship.GetType().Name}'."); } } + } - private bool RequiresLoadOfRelationshipForDeletion(RelationshipAttribute relationship) - { - INavigation? navigation = GetNavigation(relationship); - bool isClearOfForeignKeyRequired = navigation?.ForeignKey.DeleteBehavior == DeleteBehavior.ClientSetNull; + private bool RequiresLoadOfRelationshipForDeletion(RelationshipAttribute relationship) + { + INavigation? navigation = GetNavigation(relationship); + bool isClearOfForeignKeyRequired = navigation?.ForeignKey.DeleteBehavior == DeleteBehavior.ClientSetNull; - bool hasForeignKeyAtLeftSide = HasForeignKeyAtLeftSide(relationship, navigation); + bool hasForeignKeyAtLeftSide = HasForeignKeyAtLeftSide(relationship, navigation); - return isClearOfForeignKeyRequired && !hasForeignKeyAtLeftSide; - } + return isClearOfForeignKeyRequired && !hasForeignKeyAtLeftSide; + } - private INavigation? GetNavigation(RelationshipAttribute relationship) - { - IEntityType? entityType = _dbContext.Model.FindEntityType(typeof(TResource)); - return entityType?.FindNavigation(relationship.Property.Name); - } + private INavigation? GetNavigation(RelationshipAttribute relationship) + { + IEntityType? entityType = _dbContext.Model.FindEntityType(typeof(TResource)); + return entityType?.FindNavigation(relationship.Property.Name); + } - private bool HasForeignKeyAtLeftSide(RelationshipAttribute relationship, INavigation? navigation) - { - return relationship is HasOneAttribute && navigation is { IsOnDependent: true }; - } + private bool HasForeignKeyAtLeftSide(RelationshipAttribute relationship, INavigation? navigation) + { + return relationship is HasOneAttribute && navigation is { IsOnDependent: true }; + } - /// - public virtual async Task SetRelationshipAsync(TResource leftResource, object? rightValue, CancellationToken cancellationToken) + /// + public virtual async Task SetRelationshipAsync(TResource leftResource, object? rightValue, CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new { - _traceWriter.LogMethodStart(new - { - leftResource, - rightValue - }); + leftResource, + rightValue + }); - ArgumentGuard.NotNull(leftResource, nameof(leftResource)); + ArgumentGuard.NotNull(leftResource, nameof(leftResource)); - using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Set relationship"); + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Set relationship"); - RelationshipAttribute relationship = _targetedFields.Relationships.Single(); + RelationshipAttribute relationship = _targetedFields.Relationships.Single(); - object? rightValueEvaluated = - await VisitSetRelationshipAsync(leftResource, relationship, rightValue, WriteOperationKind.SetRelationship, cancellationToken); + object? rightValueEvaluated = + await VisitSetRelationshipAsync(leftResource, relationship, rightValue, WriteOperationKind.SetRelationship, cancellationToken); - AssertIsNotClearingRequiredToOneRelationship(relationship, leftResource, rightValueEvaluated); + AssertIsNotClearingRequiredToOneRelationship(relationship, leftResource, rightValueEvaluated); - await UpdateRelationshipAsync(relationship, leftResource, rightValueEvaluated, cancellationToken); + await UpdateRelationshipAsync(relationship, leftResource, rightValueEvaluated, cancellationToken); - await _resourceDefinitionAccessor.OnWritingAsync(leftResource, WriteOperationKind.SetRelationship, cancellationToken); + await _resourceDefinitionAccessor.OnWritingAsync(leftResource, WriteOperationKind.SetRelationship, cancellationToken); - await SaveChangesAsync(cancellationToken); + await SaveChangesAsync(cancellationToken); - await _resourceDefinitionAccessor.OnWriteSucceededAsync(leftResource, WriteOperationKind.SetRelationship, cancellationToken); - } + await _resourceDefinitionAccessor.OnWriteSucceededAsync(leftResource, WriteOperationKind.SetRelationship, cancellationToken); + } - /// - public virtual async Task AddToToManyRelationshipAsync(TId leftId, ISet rightResourceIds, CancellationToken cancellationToken) + /// + public virtual async Task AddToToManyRelationshipAsync(TId leftId, ISet rightResourceIds, CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new { - _traceWriter.LogMethodStart(new - { - leftId, - rightResourceIds - }); + leftId, + rightResourceIds + }); - ArgumentGuard.NotNull(rightResourceIds, nameof(rightResourceIds)); + ArgumentGuard.NotNull(rightResourceIds, nameof(rightResourceIds)); - using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Add to to-many relationship"); + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Add to to-many relationship"); - var relationship = (HasManyAttribute)_targetedFields.Relationships.Single(); + var relationship = (HasManyAttribute)_targetedFields.Relationships.Single(); - await _resourceDefinitionAccessor.OnAddToRelationshipAsync(leftId, relationship, rightResourceIds, cancellationToken); + await _resourceDefinitionAccessor.OnAddToRelationshipAsync(leftId, relationship, rightResourceIds, cancellationToken); - if (rightResourceIds.Any()) - { - var leftPlaceholderResource = _resourceFactory.CreateInstance(); - leftPlaceholderResource.Id = leftId; + if (rightResourceIds.Any()) + { + var leftPlaceholderResource = _resourceFactory.CreateInstance(); + leftPlaceholderResource.Id = leftId; - var leftResourceTracked = (TResource)_dbContext.GetTrackedOrAttach(leftPlaceholderResource); + var leftResourceTracked = (TResource)_dbContext.GetTrackedOrAttach(leftPlaceholderResource); - await UpdateRelationshipAsync(relationship, leftResourceTracked, rightResourceIds, cancellationToken); + await UpdateRelationshipAsync(relationship, leftResourceTracked, rightResourceIds, cancellationToken); - await _resourceDefinitionAccessor.OnWritingAsync(leftResourceTracked, WriteOperationKind.AddToRelationship, cancellationToken); + await _resourceDefinitionAccessor.OnWritingAsync(leftResourceTracked, WriteOperationKind.AddToRelationship, cancellationToken); - await SaveChangesAsync(cancellationToken); + await SaveChangesAsync(cancellationToken); - await _resourceDefinitionAccessor.OnWriteSucceededAsync(leftResourceTracked, WriteOperationKind.AddToRelationship, cancellationToken); - } + await _resourceDefinitionAccessor.OnWriteSucceededAsync(leftResourceTracked, WriteOperationKind.AddToRelationship, cancellationToken); } + } - /// - public virtual async Task RemoveFromToManyRelationshipAsync(TResource leftResource, ISet rightResourceIds, - CancellationToken cancellationToken) + /// + public virtual async Task RemoveFromToManyRelationshipAsync(TResource leftResource, ISet rightResourceIds, + CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new { - _traceWriter.LogMethodStart(new - { - leftResource, - rightResourceIds - }); + leftResource, + rightResourceIds + }); - ArgumentGuard.NotNull(leftResource, nameof(leftResource)); - ArgumentGuard.NotNull(rightResourceIds, nameof(rightResourceIds)); + ArgumentGuard.NotNull(leftResource, nameof(leftResource)); + ArgumentGuard.NotNull(rightResourceIds, nameof(rightResourceIds)); - using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Remove from to-many relationship"); + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Remove from to-many relationship"); - var relationship = (HasManyAttribute)_targetedFields.Relationships.Single(); - HashSet rightResourceIdsToRemove = rightResourceIds.ToHashSet(IdentifiableComparer.Instance); + var relationship = (HasManyAttribute)_targetedFields.Relationships.Single(); + HashSet rightResourceIdsToRemove = rightResourceIds.ToHashSet(IdentifiableComparer.Instance); - await _resourceDefinitionAccessor.OnRemoveFromRelationshipAsync(leftResource, relationship, rightResourceIdsToRemove, cancellationToken); + await _resourceDefinitionAccessor.OnRemoveFromRelationshipAsync(leftResource, relationship, rightResourceIdsToRemove, cancellationToken); - if (rightResourceIdsToRemove.Any()) - { - var leftResourceTracked = (TResource)_dbContext.GetTrackedOrAttach(leftResource); + if (rightResourceIdsToRemove.Any()) + { + var leftResourceTracked = (TResource)_dbContext.GetTrackedOrAttach(leftResource); - // Make Entity Framework Core believe any additional resources added from ResourceDefinition already exist in database. - IIdentifiable[] extraResourceIdsToRemove = rightResourceIdsToRemove.Where(rightId => !rightResourceIds.Contains(rightId)).ToArray(); + // Make Entity Framework Core believe any additional resources added from ResourceDefinition already exist in database. + IIdentifiable[] extraResourceIdsToRemove = rightResourceIdsToRemove.Where(rightId => !rightResourceIds.Contains(rightId)).ToArray(); - object? rightValueStored = relationship.GetValue(leftResource); + object? rightValueStored = relationship.GetValue(leftResource); - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true + // @formatter:wrap_chained_method_calls chop_always + // @formatter:keep_existing_linebreaks true - IIdentifiable[] rightResourceIdsStored = _collectionConverter - .ExtractResources(rightValueStored) - .Concat(extraResourceIdsToRemove) - .Select(rightResource => _dbContext.GetTrackedOrAttach(rightResource)) - .ToArray(); + IIdentifiable[] rightResourceIdsStored = _collectionConverter + .ExtractResources(rightValueStored) + .Concat(extraResourceIdsToRemove) + .Select(rightResource => _dbContext.GetTrackedOrAttach(rightResource)) + .ToArray(); - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore + // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_chained_method_calls restore - rightValueStored = _collectionConverter.CopyToTypedCollection(rightResourceIdsStored, relationship.Property.PropertyType); - relationship.SetValue(leftResource, rightValueStored); + rightValueStored = _collectionConverter.CopyToTypedCollection(rightResourceIdsStored, relationship.Property.PropertyType); + relationship.SetValue(leftResource, rightValueStored); - MarkRelationshipAsLoaded(leftResource, relationship); + MarkRelationshipAsLoaded(leftResource, relationship); - HashSet rightResourceIdsToStore = rightResourceIdsStored.ToHashSet(IdentifiableComparer.Instance); - rightResourceIdsToStore.ExceptWith(rightResourceIdsToRemove); + HashSet rightResourceIdsToStore = rightResourceIdsStored.ToHashSet(IdentifiableComparer.Instance); + rightResourceIdsToStore.ExceptWith(rightResourceIdsToRemove); - if (!rightResourceIdsToStore.SetEquals(rightResourceIdsStored)) - { - AssertIsNotClearingRequiredToOneRelationship(relationship, leftResourceTracked, rightResourceIdsToStore); + if (!rightResourceIdsToStore.SetEquals(rightResourceIdsStored)) + { + AssertIsNotClearingRequiredToOneRelationship(relationship, leftResourceTracked, rightResourceIdsToStore); - await UpdateRelationshipAsync(relationship, leftResourceTracked, rightResourceIdsToStore, cancellationToken); + await UpdateRelationshipAsync(relationship, leftResourceTracked, rightResourceIdsToStore, cancellationToken); - await _resourceDefinitionAccessor.OnWritingAsync(leftResourceTracked, WriteOperationKind.RemoveFromRelationship, cancellationToken); + await _resourceDefinitionAccessor.OnWritingAsync(leftResourceTracked, WriteOperationKind.RemoveFromRelationship, cancellationToken); - await SaveChangesAsync(cancellationToken); + await SaveChangesAsync(cancellationToken); - await _resourceDefinitionAccessor.OnWriteSucceededAsync(leftResourceTracked, WriteOperationKind.RemoveFromRelationship, cancellationToken); - } + await _resourceDefinitionAccessor.OnWriteSucceededAsync(leftResourceTracked, WriteOperationKind.RemoveFromRelationship, cancellationToken); } } + } - private void MarkRelationshipAsLoaded(TResource leftResource, RelationshipAttribute relationship) - { - EntityEntry leftEntry = _dbContext.Entry(leftResource); - CollectionEntry rightCollectionEntry = leftEntry.Collection(relationship.Property.Name); - rightCollectionEntry.IsLoaded = true; - } + private void MarkRelationshipAsLoaded(TResource leftResource, RelationshipAttribute relationship) + { + EntityEntry leftEntry = _dbContext.Entry(leftResource); + CollectionEntry rightCollectionEntry = leftEntry.Collection(relationship.Property.Name); + rightCollectionEntry.IsLoaded = true; + } + + protected async Task UpdateRelationshipAsync(RelationshipAttribute relationship, TResource leftResource, object? valueToAssign, + CancellationToken cancellationToken) + { + object? trackedValueToAssign = EnsureRelationshipValueToAssignIsTracked(valueToAssign, relationship.Property.PropertyType); - protected async Task UpdateRelationshipAsync(RelationshipAttribute relationship, TResource leftResource, object? valueToAssign, - CancellationToken cancellationToken) + if (RequireLoadOfInverseRelationship(relationship, trackedValueToAssign)) { - object? trackedValueToAssign = EnsureRelationshipValueToAssignIsTracked(valueToAssign, relationship.Property.PropertyType); + EntityEntry entityEntry = _dbContext.Entry(trackedValueToAssign); + string inversePropertyName = relationship.InverseNavigationProperty!.Name; - if (RequireLoadOfInverseRelationship(relationship, trackedValueToAssign)) - { - EntityEntry entityEntry = _dbContext.Entry(trackedValueToAssign); - string inversePropertyName = relationship.InverseNavigationProperty!.Name; + await entityEntry.Reference(inversePropertyName).LoadAsync(cancellationToken); + } - await entityEntry.Reference(inversePropertyName).LoadAsync(cancellationToken); - } + relationship.SetValue(leftResource, trackedValueToAssign); + } - relationship.SetValue(leftResource, trackedValueToAssign); + private object? EnsureRelationshipValueToAssignIsTracked(object? rightValue, Type relationshipPropertyType) + { + if (rightValue == null) + { + return null; } - private object? EnsureRelationshipValueToAssignIsTracked(object? rightValue, Type relationshipPropertyType) - { - if (rightValue == null) - { - return null; - } + ICollection rightResources = _collectionConverter.ExtractResources(rightValue); + IIdentifiable[] rightResourcesTracked = rightResources.Select(rightResource => _dbContext.GetTrackedOrAttach(rightResource)).ToArray(); - ICollection rightResources = _collectionConverter.ExtractResources(rightValue); - IIdentifiable[] rightResourcesTracked = rightResources.Select(rightResource => _dbContext.GetTrackedOrAttach(rightResource)).ToArray(); + return rightValue is IEnumerable + ? _collectionConverter.CopyToTypedCollection(rightResourcesTracked, relationshipPropertyType) + : rightResourcesTracked.Single(); + } - return rightValue is IEnumerable - ? _collectionConverter.CopyToTypedCollection(rightResourcesTracked, relationshipPropertyType) - : rightResourcesTracked.Single(); - } + private bool RequireLoadOfInverseRelationship(RelationshipAttribute relationship, [NotNullWhen(true)] object? trackedValueToAssign) + { + // See https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/502. + return trackedValueToAssign != null && relationship is HasOneAttribute { IsOneToOne: true }; + } - private bool RequireLoadOfInverseRelationship(RelationshipAttribute relationship, [NotNullWhen(true)] object? trackedValueToAssign) - { - // See https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/502. - return trackedValueToAssign != null && relationship is HasOneAttribute { IsOneToOne: true }; - } + protected virtual async Task SaveChangesAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); - protected virtual async Task SaveChangesAsync(CancellationToken cancellationToken) + try { - cancellationToken.ThrowIfCancellationRequested(); - - try - { - using IDisposable _ = CodeTimingSessionManager.Current.Measure("Persist EF Core changes", MeasurementSettings.ExcludeDatabaseInPercentages); + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Persist EF Core changes", MeasurementSettings.ExcludeDatabaseInPercentages); - await _dbContext.SaveChangesAsync(cancellationToken); - } - catch (Exception exception) when (exception is DbUpdateException or InvalidOperationException) - { - _dbContext.ResetChangeTracker(); + await _dbContext.SaveChangesAsync(cancellationToken); + } + catch (Exception exception) when (exception is DbUpdateException or InvalidOperationException) + { + _dbContext.ResetChangeTracker(); - throw new DataStoreUpdateException(exception); - } + throw new DataStoreUpdateException(exception); } } } diff --git a/src/JsonApiDotNetCore/Repositories/IDbContextResolver.cs b/src/JsonApiDotNetCore/Repositories/IDbContextResolver.cs index 0a38f2dfcc..f137e8f453 100644 --- a/src/JsonApiDotNetCore/Repositories/IDbContextResolver.cs +++ b/src/JsonApiDotNetCore/Repositories/IDbContextResolver.cs @@ -1,12 +1,11 @@ using Microsoft.EntityFrameworkCore; -namespace JsonApiDotNetCore.Repositories +namespace JsonApiDotNetCore.Repositories; + +/// +/// Provides a method to resolve a . +/// +public interface IDbContextResolver { - /// - /// Provides a method to resolve a . - /// - public interface IDbContextResolver - { - DbContext GetContext(); - } + DbContext GetContext(); } diff --git a/src/JsonApiDotNetCore/Repositories/IRepositorySupportsTransaction.cs b/src/JsonApiDotNetCore/Repositories/IRepositorySupportsTransaction.cs index 0344e3cbf9..aac80d11df 100644 --- a/src/JsonApiDotNetCore/Repositories/IRepositorySupportsTransaction.cs +++ b/src/JsonApiDotNetCore/Repositories/IRepositorySupportsTransaction.cs @@ -1,16 +1,15 @@ using JetBrains.Annotations; -namespace JsonApiDotNetCore.Repositories +namespace JsonApiDotNetCore.Repositories; + +/// +/// Used to indicate that an supports execution inside a transaction. +/// +[PublicAPI] +public interface IRepositorySupportsTransaction { /// - /// Used to indicate that an supports execution inside a transaction. + /// Identifies the currently active transaction. /// - [PublicAPI] - public interface IRepositorySupportsTransaction - { - /// - /// Identifies the currently active transaction. - /// - string? TransactionId { get; } - } + string? TransactionId { get; } } diff --git a/src/JsonApiDotNetCore/Repositories/IResourceReadRepository.cs b/src/JsonApiDotNetCore/Repositories/IResourceReadRepository.cs index 1b8a69d6bc..1be60d3825 100644 --- a/src/JsonApiDotNetCore/Repositories/IResourceReadRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/IResourceReadRepository.cs @@ -1,34 +1,30 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using JetBrains.Annotations; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources; -namespace JsonApiDotNetCore.Repositories +namespace JsonApiDotNetCore.Repositories; + +/// +/// Groups read operations. +/// +/// +/// The resource type. +/// +/// +/// The resource identifier type. +/// +[PublicAPI] +public interface IResourceReadRepository + where TResource : class, IIdentifiable { /// - /// Groups read operations. + /// Executes a read query using the specified constraints and returns the collection of matching resources. /// - /// - /// The resource type. - /// - /// - /// The resource identifier type. - /// - [PublicAPI] - 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 queryLayer, CancellationToken cancellationToken); + Task> GetAsync(QueryLayer queryLayer, CancellationToken cancellationToken); - /// - /// Executes a read query using the specified filter and returns the count of matching resources. - /// - Task CountAsync(FilterExpression? filter, CancellationToken cancellationToken); - } + /// + /// Executes a read query using the specified filter and returns the count of matching resources. + /// + Task CountAsync(FilterExpression? filter, CancellationToken cancellationToken); } diff --git a/src/JsonApiDotNetCore/Repositories/IResourceRepository.cs b/src/JsonApiDotNetCore/Repositories/IResourceRepository.cs index 4f20bcaca1..26b7c4513e 100644 --- a/src/JsonApiDotNetCore/Repositories/IResourceRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/IResourceRepository.cs @@ -1,20 +1,19 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Resources; -namespace JsonApiDotNetCore.Repositories +namespace JsonApiDotNetCore.Repositories; + +/// +/// 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. +/// +[PublicAPI] +public interface IResourceRepository : IResourceReadRepository, IResourceWriteRepository + where TResource : class, IIdentifiable { - /// - /// Represents the foundational Resource Repository layer in the JsonApiDotNetCore architecture that provides data access to an underlying store. - /// - /// - /// The resource type. - /// - /// - /// The resource identifier type. - /// - [PublicAPI] - public interface IResourceRepository : IResourceReadRepository, IResourceWriteRepository - where TResource : class, IIdentifiable - { - } } diff --git a/src/JsonApiDotNetCore/Repositories/IResourceRepositoryAccessor.cs b/src/JsonApiDotNetCore/Repositories/IResourceRepositoryAccessor.cs index a2897a237d..9654365121 100644 --- a/src/JsonApiDotNetCore/Repositories/IResourceRepositoryAccessor.cs +++ b/src/JsonApiDotNetCore/Repositories/IResourceRepositoryAccessor.cs @@ -1,80 +1,76 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources; -namespace JsonApiDotNetCore.Repositories +namespace JsonApiDotNetCore.Repositories; + +/// +/// Retrieves an instance from the D/I container and invokes a method on it. +/// +public interface IResourceRepositoryAccessor { /// - /// Retrieves an instance from the D/I container and invokes a method on it. + /// Invokes . /// - public interface IResourceRepositoryAccessor - { - /// - /// Invokes . - /// - Task> GetAsync(QueryLayer queryLayer, CancellationToken cancellationToken) - where TResource : class, IIdentifiable; + Task> GetAsync(QueryLayer queryLayer, CancellationToken cancellationToken) + where TResource : class, IIdentifiable; - /// - /// Invokes for the specified resource type. - /// - Task> GetAsync(ResourceType resourceType, QueryLayer queryLayer, CancellationToken cancellationToken); + /// + /// Invokes for the specified resource type. + /// + Task> GetAsync(ResourceType resourceType, QueryLayer queryLayer, CancellationToken cancellationToken); - /// - /// Invokes for the specified resource type. - /// - Task CountAsync(ResourceType resourceType, FilterExpression? filter, CancellationToken cancellationToken); + /// + /// Invokes for the specified resource type. + /// + Task CountAsync(ResourceType resourceType, FilterExpression? filter, CancellationToken cancellationToken); - /// - /// Invokes . - /// - Task GetForCreateAsync(TId id, CancellationToken cancellationToken) - where TResource : class, IIdentifiable; + /// + /// Invokes . + /// + Task GetForCreateAsync(TId id, CancellationToken cancellationToken) + where TResource : class, IIdentifiable; - /// - /// Invokes . - /// - Task CreateAsync(TResource resourceFromRequest, TResource resourceForDatabase, CancellationToken cancellationToken) - where TResource : class, IIdentifiable; + /// + /// Invokes . + /// + Task CreateAsync(TResource resourceFromRequest, TResource resourceForDatabase, CancellationToken cancellationToken) + where TResource : class, IIdentifiable; - /// - /// Invokes . - /// - Task GetForUpdateAsync(QueryLayer queryLayer, CancellationToken cancellationToken) - where TResource : class, IIdentifiable; + /// + /// Invokes . + /// + Task GetForUpdateAsync(QueryLayer queryLayer, CancellationToken cancellationToken) + where TResource : class, IIdentifiable; - /// - /// Invokes . - /// - Task UpdateAsync(TResource resourceFromRequest, TResource resourceFromDatabase, CancellationToken cancellationToken) - where TResource : class, IIdentifiable; + /// + /// Invokes . + /// + Task UpdateAsync(TResource resourceFromRequest, TResource resourceFromDatabase, CancellationToken cancellationToken) + where TResource : class, IIdentifiable; - /// - /// Invokes for the specified resource type. - /// - Task DeleteAsync(TId id, CancellationToken cancellationToken) - where TResource : class, IIdentifiable; + /// + /// Invokes for the specified resource type. + /// + Task DeleteAsync(TId id, CancellationToken cancellationToken) + where TResource : class, IIdentifiable; - /// - /// Invokes . - /// - Task SetRelationshipAsync(TResource leftResource, object? rightValue, CancellationToken cancellationToken) - where TResource : class, IIdentifiable; + /// + /// Invokes . + /// + Task SetRelationshipAsync(TResource leftResource, object? rightValue, CancellationToken cancellationToken) + where TResource : class, IIdentifiable; - /// - /// Invokes for the specified resource type. - /// - Task AddToToManyRelationshipAsync(TId leftId, ISet rightResourceIds, CancellationToken cancellationToken) - where TResource : class, IIdentifiable; + /// + /// Invokes for the specified resource type. + /// + Task AddToToManyRelationshipAsync(TId leftId, ISet rightResourceIds, CancellationToken cancellationToken) + where TResource : class, IIdentifiable; - /// - /// Invokes . - /// - Task RemoveFromToManyRelationshipAsync(TResource leftResource, ISet rightResourceIds, CancellationToken cancellationToken) - where TResource : class, IIdentifiable; - } + /// + /// Invokes . + /// + Task RemoveFromToManyRelationshipAsync(TResource leftResource, ISet rightResourceIds, CancellationToken cancellationToken) + where TResource : class, IIdentifiable; } diff --git a/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs b/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs index 06a94748a0..ca0186d5b5 100644 --- a/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs @@ -1,66 +1,62 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using JetBrains.Annotations; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Resources; -namespace JsonApiDotNetCore.Repositories +namespace JsonApiDotNetCore.Repositories; + +/// +/// Groups write operations. +/// +/// +/// The resource type. +/// +/// +/// The resource identifier type. +/// +[PublicAPI] +public interface IResourceWriteRepository + where TResource : class, IIdentifiable { /// - /// Groups write operations. + /// Creates a new resource instance, in preparation for . /// - /// - /// The resource type. - /// - /// - /// The resource identifier type. - /// - [PublicAPI] - public interface IResourceWriteRepository - where TResource : class, IIdentifiable - { - /// - /// Creates a new resource instance, in preparation for . - /// - /// - /// This method can be overridden to assign resource-specific required relationships. - /// - Task GetForCreateAsync(TId id, CancellationToken cancellationToken); + /// + /// This method can be overridden to assign resource-specific required relationships. + /// + Task GetForCreateAsync(TId id, CancellationToken cancellationToken); - /// - /// Creates a new resource in the underlying data store. - /// - Task CreateAsync(TResource resourceFromRequest, TResource resourceForDatabase, CancellationToken cancellationToken); + /// + /// Creates a new resource in the underlying data store. + /// + Task CreateAsync(TResource resourceFromRequest, TResource resourceForDatabase, CancellationToken cancellationToken); - /// - /// Retrieves a resource with all of its attributes, including the set of targeted relationships, in preparation for . - /// - Task GetForUpdateAsync(QueryLayer queryLayer, CancellationToken cancellationToken); + /// + /// Retrieves a resource with all of its attributes, including the set of targeted relationships, in preparation for . + /// + Task GetForUpdateAsync(QueryLayer queryLayer, CancellationToken cancellationToken); - /// - /// Updates the attributes and relationships of an existing resource in the underlying data store. - /// - Task UpdateAsync(TResource resourceFromRequest, TResource resourceFromDatabase, CancellationToken cancellationToken); + /// + /// Updates the attributes and relationships of an existing resource in the underlying data store. + /// + Task UpdateAsync(TResource resourceFromRequest, TResource resourceFromDatabase, CancellationToken cancellationToken); - /// - /// Deletes an existing resource from the underlying data store. - /// - Task DeleteAsync(TId id, CancellationToken cancellationToken); + /// + /// Deletes an existing resource from the underlying data store. + /// + Task DeleteAsync(TId id, CancellationToken cancellationToken); - /// - /// Performs a complete replacement of the relationship in the underlying data store. - /// - Task SetRelationshipAsync(TResource leftResource, object? rightValue, CancellationToken cancellationToken); + /// + /// Performs a complete replacement of the relationship in the underlying data store. + /// + Task SetRelationshipAsync(TResource leftResource, object? rightValue, CancellationToken cancellationToken); - /// - /// Adds resources to a to-many relationship in the underlying data store. - /// - Task AddToToManyRelationshipAsync(TId leftId, ISet rightResourceIds, CancellationToken cancellationToken); + /// + /// Adds resources to a to-many relationship in the underlying data store. + /// + Task AddToToManyRelationshipAsync(TId leftId, ISet rightResourceIds, CancellationToken cancellationToken); - /// - /// Removes resources from a to-many relationship in the underlying data store. - /// - Task RemoveFromToManyRelationshipAsync(TResource leftResource, ISet rightResourceIds, CancellationToken cancellationToken); - } + /// + /// Removes resources from a to-many relationship in the underlying data store. + /// + Task RemoveFromToManyRelationshipAsync(TResource leftResource, ISet rightResourceIds, CancellationToken cancellationToken); } diff --git a/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs b/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs index 806e4dc700..0ba96126a1 100644 --- a/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs +++ b/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs @@ -1,7 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Errors; @@ -11,155 +7,154 @@ using JsonApiDotNetCore.Resources; using Microsoft.Extensions.DependencyInjection; -namespace JsonApiDotNetCore.Repositories +namespace JsonApiDotNetCore.Repositories; + +/// +[PublicAPI] +public class ResourceRepositoryAccessor : IResourceRepositoryAccessor { - /// - [PublicAPI] - public class ResourceRepositoryAccessor : IResourceRepositoryAccessor + private readonly IServiceProvider _serviceProvider; + private readonly IResourceGraph _resourceGraph; + private readonly IJsonApiRequest _request; + + public ResourceRepositoryAccessor(IServiceProvider serviceProvider, IResourceGraph resourceGraph, IJsonApiRequest request) { - private readonly IServiceProvider _serviceProvider; - private readonly IResourceGraph _resourceGraph; - private readonly IJsonApiRequest _request; + ArgumentGuard.NotNull(serviceProvider, nameof(serviceProvider)); + ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); + ArgumentGuard.NotNull(request, nameof(request)); - public ResourceRepositoryAccessor(IServiceProvider serviceProvider, IResourceGraph resourceGraph, IJsonApiRequest request) - { - ArgumentGuard.NotNull(serviceProvider, nameof(serviceProvider)); - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); - ArgumentGuard.NotNull(request, nameof(request)); + _serviceProvider = serviceProvider; + _resourceGraph = resourceGraph; + _request = request; + } - _serviceProvider = serviceProvider; - _resourceGraph = resourceGraph; - _request = request; - } + /// + public async Task> GetAsync(QueryLayer queryLayer, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + dynamic repository = ResolveReadRepository(typeof(TResource)); + return (IReadOnlyCollection)await repository.GetAsync(queryLayer, cancellationToken); + } - /// - public async Task> GetAsync(QueryLayer queryLayer, CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { - dynamic repository = ResolveReadRepository(typeof(TResource)); - return (IReadOnlyCollection)await repository.GetAsync(queryLayer, cancellationToken); - } + /// + public async Task> GetAsync(ResourceType resourceType, QueryLayer queryLayer, CancellationToken cancellationToken) + { + ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - /// - public async Task> GetAsync(ResourceType resourceType, QueryLayer queryLayer, CancellationToken cancellationToken) - { - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + dynamic repository = ResolveReadRepository(resourceType); + return (IReadOnlyCollection)await repository.GetAsync(queryLayer, cancellationToken); + } - dynamic repository = ResolveReadRepository(resourceType); - return (IReadOnlyCollection)await repository.GetAsync(queryLayer, cancellationToken); - } + /// + public async Task CountAsync(ResourceType resourceType, FilterExpression? filter, CancellationToken cancellationToken) + { + dynamic repository = ResolveReadRepository(resourceType); + return (int)await repository.CountAsync(filter, cancellationToken); + } - /// - public async Task CountAsync(ResourceType resourceType, FilterExpression? filter, CancellationToken cancellationToken) - { - dynamic repository = ResolveReadRepository(resourceType); - return (int)await repository.CountAsync(filter, cancellationToken); - } + /// + public async Task GetForCreateAsync(TId id, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + dynamic repository = GetWriteRepository(typeof(TResource)); + return await repository.GetForCreateAsync(id, cancellationToken); + } - /// - public async Task GetForCreateAsync(TId id, CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { - dynamic repository = GetWriteRepository(typeof(TResource)); - return await repository.GetForCreateAsync(id, cancellationToken); - } + /// + public async Task CreateAsync(TResource resourceFromRequest, TResource resourceForDatabase, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + dynamic repository = GetWriteRepository(typeof(TResource)); + await repository.CreateAsync(resourceFromRequest, resourceForDatabase, cancellationToken); + } - /// - public async Task CreateAsync(TResource resourceFromRequest, TResource resourceForDatabase, CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { - dynamic repository = GetWriteRepository(typeof(TResource)); - await repository.CreateAsync(resourceFromRequest, resourceForDatabase, cancellationToken); - } + /// + public async Task GetForUpdateAsync(QueryLayer queryLayer, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + dynamic repository = GetWriteRepository(typeof(TResource)); + return await repository.GetForUpdateAsync(queryLayer, cancellationToken); + } - /// - public async Task GetForUpdateAsync(QueryLayer queryLayer, CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { - dynamic repository = GetWriteRepository(typeof(TResource)); - return await repository.GetForUpdateAsync(queryLayer, cancellationToken); - } + /// + public async Task UpdateAsync(TResource resourceFromRequest, TResource resourceFromDatabase, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + dynamic repository = GetWriteRepository(typeof(TResource)); + await repository.UpdateAsync(resourceFromRequest, resourceFromDatabase, cancellationToken); + } - /// - public async Task UpdateAsync(TResource resourceFromRequest, TResource resourceFromDatabase, CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { - dynamic repository = GetWriteRepository(typeof(TResource)); - await repository.UpdateAsync(resourceFromRequest, resourceFromDatabase, cancellationToken); - } + /// + public async Task DeleteAsync(TId id, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + dynamic repository = GetWriteRepository(typeof(TResource)); + await repository.DeleteAsync(id, cancellationToken); + } - /// - public async Task DeleteAsync(TId id, CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { - dynamic repository = GetWriteRepository(typeof(TResource)); - await repository.DeleteAsync(id, cancellationToken); - } + /// + public async Task SetRelationshipAsync(TResource leftResource, object? rightValue, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + dynamic repository = GetWriteRepository(typeof(TResource)); + await repository.SetRelationshipAsync(leftResource, rightValue, cancellationToken); + } - /// - public async Task SetRelationshipAsync(TResource leftResource, object? rightValue, CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { - dynamic repository = GetWriteRepository(typeof(TResource)); - await repository.SetRelationshipAsync(leftResource, rightValue, cancellationToken); - } + /// + public async Task AddToToManyRelationshipAsync(TId leftId, ISet rightResourceIds, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + dynamic repository = GetWriteRepository(typeof(TResource)); + await repository.AddToToManyRelationshipAsync(leftId, rightResourceIds, cancellationToken); + } - /// - public async Task AddToToManyRelationshipAsync(TId leftId, ISet rightResourceIds, CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { - dynamic repository = GetWriteRepository(typeof(TResource)); - await repository.AddToToManyRelationshipAsync(leftId, rightResourceIds, cancellationToken); - } + /// + public async Task RemoveFromToManyRelationshipAsync(TResource leftResource, ISet rightResourceIds, + CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + dynamic repository = GetWriteRepository(typeof(TResource)); + await repository.RemoveFromToManyRelationshipAsync(leftResource, rightResourceIds, cancellationToken); + } - /// - public async Task RemoveFromToManyRelationshipAsync(TResource leftResource, ISet rightResourceIds, - CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { - dynamic repository = GetWriteRepository(typeof(TResource)); - await repository.RemoveFromToManyRelationshipAsync(leftResource, rightResourceIds, cancellationToken); - } + protected object ResolveReadRepository(Type resourceClrType) + { + ResourceType resourceType = _resourceGraph.GetResourceType(resourceClrType); + return ResolveReadRepository(resourceType); + } - protected object ResolveReadRepository(Type resourceClrType) - { - ResourceType resourceType = _resourceGraph.GetResourceType(resourceClrType); - return ResolveReadRepository(resourceType); - } + protected virtual object ResolveReadRepository(ResourceType resourceType) + { + Type resourceDefinitionType = typeof(IResourceReadRepository<,>).MakeGenericType(resourceType.ClrType, resourceType.IdentityClrType); + return _serviceProvider.GetRequiredService(resourceDefinitionType); + } - protected virtual object ResolveReadRepository(ResourceType resourceType) - { - Type resourceDefinitionType = typeof(IResourceReadRepository<,>).MakeGenericType(resourceType.ClrType, resourceType.IdentityClrType); - return _serviceProvider.GetRequiredService(resourceDefinitionType); - } + private object GetWriteRepository(Type resourceClrType) + { + object writeRepository = ResolveWriteRepository(resourceClrType); - private object GetWriteRepository(Type resourceClrType) + if (_request.TransactionId != null) { - object writeRepository = ResolveWriteRepository(resourceClrType); - - if (_request.TransactionId != null) + if (writeRepository is not IRepositorySupportsTransaction repository) { - if (writeRepository is not IRepositorySupportsTransaction repository) - { - ResourceType resourceType = _resourceGraph.GetResourceType(resourceClrType); - throw new MissingTransactionSupportException(resourceType.PublicName); - } - - if (repository.TransactionId != _request.TransactionId) - { - throw new NonParticipatingTransactionException(); - } + ResourceType resourceType = _resourceGraph.GetResourceType(resourceClrType); + throw new MissingTransactionSupportException(resourceType.PublicName); } - return writeRepository; + if (repository.TransactionId != _request.TransactionId) + { + throw new NonParticipatingTransactionException(); + } } - protected virtual object ResolveWriteRepository(Type resourceClrType) - { - ResourceType resourceType = _resourceGraph.GetResourceType(resourceClrType); + return writeRepository; + } - Type resourceDefinitionType = typeof(IResourceWriteRepository<,>).MakeGenericType(resourceType.ClrType, resourceType.IdentityClrType); - return _serviceProvider.GetRequiredService(resourceDefinitionType); - } + protected virtual object ResolveWriteRepository(Type resourceClrType) + { + ResourceType resourceType = _resourceGraph.GetResourceType(resourceClrType); + + Type resourceDefinitionType = typeof(IResourceWriteRepository<,>).MakeGenericType(resourceType.ClrType, resourceType.IdentityClrType); + return _serviceProvider.GetRequiredService(resourceDefinitionType); } } diff --git a/src/JsonApiDotNetCore/Resources/Annotations/AttrAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/AttrAttribute.cs index 79ffbbed3d..d3d6133f6e 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/AttrAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/AttrAttribute.cs @@ -1,58 +1,56 @@ -using System; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Resources.Annotations +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). +/// +[PublicAPI] +[AttributeUsage(AttributeTargets.Property)] +public sealed class AttrAttribute : ResourceFieldAttribute { + private AttrCapabilities? _capabilities; + + internal bool HasExplicitCapabilities => _capabilities != null; + /// - /// Used to expose a property on a resource class as a JSON:API attribute (https://jsonapi.org/format/#document-resource-object-attributes). + /// 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. /// - [PublicAPI] - [AttributeUsage(AttributeTargets.Property)] - public sealed class AttrAttribute : ResourceFieldAttribute + /// + /// + /// public class Author : Identifiable + /// { + /// [Attr(Capabilities = AttrCapabilities.AllowFilter | AttrCapabilities.AllowSort)] + /// public string Name { get; set; } + /// } + /// + /// + public AttrCapabilities Capabilities { - 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; + } + + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) { - get => _capabilities ?? default; - set => _capabilities = value; + return true; } - public override bool Equals(object? obj) + if (obj is null || GetType() != obj.GetType()) { - if (ReferenceEquals(this, obj)) - { - return true; - } - - if (obj is null || GetType() != obj.GetType()) - { - return false; - } + return false; + } - var other = (AttrAttribute)obj; + var other = (AttrAttribute)obj; - return Capabilities == other.Capabilities && base.Equals(other); - } + return Capabilities == other.Capabilities && base.Equals(other); + } - public override int GetHashCode() - { - return HashCode.Combine(Capabilities, base.GetHashCode()); - } + public override int GetHashCode() + { + return HashCode.Combine(Capabilities, base.GetHashCode()); } } diff --git a/src/JsonApiDotNetCore/Resources/Annotations/AttrCapabilities.cs b/src/JsonApiDotNetCore/Resources/Annotations/AttrCapabilities.cs index 9eb93c9377..c6f849fdff 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/AttrCapabilities.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/AttrCapabilities.cs @@ -1,40 +1,37 @@ -using System; +namespace JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Resources.Annotations +/// +/// Indicates capabilities that can be performed on an . +/// +[Flags] +public enum AttrCapabilities { + None = 0, + /// - /// Indicates capabilities that can be performed on an . + /// Whether or not GET requests can retrieve the attribute. Attempts to retrieve when disabled will return an HTTP 400 response. /// - [Flags] - public enum AttrCapabilities - { - None = 0, - - /// - /// Whether or not GET requests can retrieve the attribute. Attempts to retrieve when disabled will return an HTTP 400 response. - /// - AllowView = 1, + AllowView = 1, - /// - /// Whether or not POST requests can assign the attribute value. Attempts to assign when disabled will return an HTTP 422 response. - /// - AllowCreate = 2, + /// + /// Whether or not POST requests can assign the attribute value. Attempts to assign when disabled will return an HTTP 422 response. + /// + AllowCreate = 2, - /// - /// Whether or not PATCH requests can update the attribute value. Attempts to update when disabled will return an HTTP 422 response. - /// - AllowChange = 4, + /// + /// Whether or not PATCH requests can update the attribute value. Attempts to update when disabled will return an HTTP 422 response. + /// + AllowChange = 4, - /// - /// Whether or not an attribute can be filtered on via a query string parameter. Attempts to filter when disabled will return an HTTP 400 response. - /// - AllowFilter = 8, + /// + /// Whether or not an attribute can be filtered on via a query string parameter. Attempts to filter when disabled will return an HTTP 400 response. + /// + AllowFilter = 8, - /// - /// 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 = 16, + /// + /// 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 = 16, - All = AllowView | AllowCreate | AllowChange | AllowFilter | AllowSort - } + All = AllowView | AllowCreate | AllowChange | AllowFilter | AllowSort } diff --git a/src/JsonApiDotNetCore/Resources/Annotations/EagerLoadAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/EagerLoadAttribute.cs index bc81116763..7879d74e88 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/EagerLoadAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/EagerLoadAttribute.cs @@ -1,47 +1,44 @@ -using System; -using System.Collections.Generic; using System.Reflection; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Resources.Annotations +namespace JsonApiDotNetCore.Resources.Annotations; + +/// +/// Used to unconditionally load a related entity that is not exposed as a JSON:API relationship. +/// +/// +/// This is intended for calculated properties that are exposed as JSON:API attributes, which depend on a related entity to always be loaded. +/// Name.First + " " + Name.Last; +/// +/// [EagerLoad] +/// public Name Name { get; set; } +/// } +/// +/// public class Name // not exposed as resource, only database table +/// { +/// public string First { get; set; } +/// public string Last { get; set; } +/// } +/// +/// public class Blog : Identifiable +/// { +/// [HasOne] +/// public User Author { get; set; } +/// } +/// ]]> +/// +[PublicAPI] +[AttributeUsage(AttributeTargets.Property)] +public sealed class EagerLoadAttribute : Attribute { - /// - /// Used to unconditionally load a related entity that is not exposed as a JSON:API relationship. - /// - /// - /// This is intended for calculated properties that are exposed as JSON:API attributes, which depend on a related entity to always be loaded. - /// Name.First + " " + Name.Last; - /// - /// [EagerLoad] - /// public Name Name { get; set; } - /// } - /// - /// public class Name // not exposed as resource, only database table - /// { - /// public string First { get; set; } - /// public string Last { get; set; } - /// } - /// - /// public class Blog : Identifiable - /// { - /// [HasOne] - /// public User Author { get; set; } - /// } - /// ]]> - /// - [PublicAPI] - [AttributeUsage(AttributeTargets.Property)] - public sealed class EagerLoadAttribute : Attribute - { - // These properties are definitely assigned after building the resource graph, which is why they are declared as non-nullable. + // These properties are definitely assigned after building the resource graph, which is why they are declared as non-nullable. - public PropertyInfo Property { get; internal set; } = null!; + public PropertyInfo Property { get; internal set; } = null!; - public IReadOnlyCollection Children { get; internal set; } = null!; - } + public IReadOnlyCollection Children { get; internal set; } = null!; } diff --git a/src/JsonApiDotNetCore/Resources/Annotations/HasManyAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/HasManyAttribute.cs index 0adb22cb77..39bcf34b3f 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/HasManyAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/HasManyAttribute.cs @@ -1,47 +1,44 @@ -using System; -using System.Threading; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Resources.Annotations +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). +/// +/// +/// Articles { get; set; } +/// } +/// ]]> +/// +[PublicAPI] +[AttributeUsage(AttributeTargets.Property)] +public sealed class HasManyAttribute : RelationshipAttribute { + private readonly Lazy _lazyIsManyToMany; + /// - /// Used to expose a property on a resource class as a JSON:API to-many relationship - /// (https://jsonapi.org/format/#document-resource-object-relationships). + /// Inspects to determine if this is a many-to-many relationship. /// - /// - /// Articles { get; set; } - /// } - /// ]]> - /// - [PublicAPI] - [AttributeUsage(AttributeTargets.Property)] - public sealed class HasManyAttribute : RelationshipAttribute - { - private readonly Lazy _lazyIsManyToMany; + internal bool IsManyToMany => _lazyIsManyToMany.Value; - /// - /// Inspects to determine if this is a many-to-many relationship. - /// - internal bool IsManyToMany => _lazyIsManyToMany.Value; + public HasManyAttribute() + { + _lazyIsManyToMany = new Lazy(EvaluateIsManyToMany, LazyThreadSafetyMode.PublicationOnly); + } - public HasManyAttribute() + private bool EvaluateIsManyToMany() + { + if (InverseNavigationProperty != null) { - _lazyIsManyToMany = new Lazy(EvaluateIsManyToMany, LazyThreadSafetyMode.PublicationOnly); + Type? elementType = CollectionConverter.FindCollectionElementType(InverseNavigationProperty.PropertyType); + return elementType != null; } - private bool EvaluateIsManyToMany() - { - if (InverseNavigationProperty != null) - { - Type? elementType = CollectionConverter.FindCollectionElementType(InverseNavigationProperty.PropertyType); - return elementType != null; - } - - return false; - } + return false; } } diff --git a/src/JsonApiDotNetCore/Resources/Annotations/HasOneAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/HasOneAttribute.cs index 05e9483260..0a68f702d3 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/HasOneAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/HasOneAttribute.cs @@ -1,46 +1,43 @@ -using System; -using System.Threading; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Resources.Annotations +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). +/// +/// +/// +/// +[PublicAPI] +[AttributeUsage(AttributeTargets.Property)] +public sealed class HasOneAttribute : RelationshipAttribute { + private readonly Lazy _lazyIsOneToOne; + /// - /// Used to expose a property on a resource class as a JSON:API to-one relationship (https://jsonapi.org/format/#document-resource-object-relationships). + /// Inspects to determine if this is a one-to-one relationship. /// - /// - /// - /// - [PublicAPI] - [AttributeUsage(AttributeTargets.Property)] - public sealed class HasOneAttribute : RelationshipAttribute - { - private readonly Lazy _lazyIsOneToOne; + internal bool IsOneToOne => _lazyIsOneToOne.Value; - /// - /// Inspects to determine if this is a one-to-one relationship. - /// - internal bool IsOneToOne => _lazyIsOneToOne.Value; + public HasOneAttribute() + { + _lazyIsOneToOne = new Lazy(EvaluateIsOneToOne, LazyThreadSafetyMode.PublicationOnly); + } - public HasOneAttribute() + private bool EvaluateIsOneToOne() + { + if (InverseNavigationProperty != null) { - _lazyIsOneToOne = new Lazy(EvaluateIsOneToOne, LazyThreadSafetyMode.PublicationOnly); + Type? elementType = CollectionConverter.FindCollectionElementType(InverseNavigationProperty.PropertyType); + return elementType == null; } - private bool EvaluateIsOneToOne() - { - if (InverseNavigationProperty != null) - { - Type? elementType = CollectionConverter.FindCollectionElementType(InverseNavigationProperty.PropertyType); - return elementType == null; - } - - return false; - } + return false; } } diff --git a/src/JsonApiDotNetCore/Resources/Annotations/LinkTypes.cs b/src/JsonApiDotNetCore/Resources/Annotations/LinkTypes.cs index d655a8b882..61c7e9d927 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/LinkTypes.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/LinkTypes.cs @@ -1,15 +1,12 @@ -using System; +namespace JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Resources.Annotations +[Flags] +public enum LinkTypes { - [Flags] - public enum LinkTypes - { - Self = 1 << 0, - Related = 1 << 1, - Paging = 1 << 2, - NotConfigured = 1 << 3, - None = 1 << 4, - All = Self | Related | Paging - } + Self = 1 << 0, + Related = 1 << 1, + Paging = 1 << 2, + NotConfigured = 1 << 3, + None = 1 << 4, + All = Self | Related | Paging } diff --git a/src/JsonApiDotNetCore/Resources/Annotations/NoResourceAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/NoResourceAttribute.cs new file mode 100644 index 0000000000..8be53b0d03 --- /dev/null +++ b/src/JsonApiDotNetCore/Resources/Annotations/NoResourceAttribute.cs @@ -0,0 +1,13 @@ +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.Resources.Annotations; + +/// +/// When put on an Entity Framework Core entity, indicates that the type should not be added to the resource graph. This effectively suppresses the +/// warning at startup that this type does not implement . +/// +[PublicAPI] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] +public sealed class NoResourceAttribute : Attribute +{ +} diff --git a/src/JsonApiDotNetCore/Resources/Annotations/RelationshipAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/RelationshipAttribute.cs index 6324766f96..4e335d2c8c 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/RelationshipAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/RelationshipAttribute.cs @@ -1,105 +1,103 @@ -using System; using System.Reflection; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; // ReSharper disable NonReadonlyMemberInGetHashCode -namespace JsonApiDotNetCore.Resources.Annotations +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). +/// +[PublicAPI] +public abstract class RelationshipAttribute : ResourceFieldAttribute { + private protected static readonly CollectionConverter CollectionConverter = new(); + /// - /// Used to expose a property on a resource class as a JSON:API relationship (https://jsonapi.org/format/#document-resource-object-relationships). + /// The CLR type in which this relationship is declared. /// - [PublicAPI] - public abstract class RelationshipAttribute : ResourceFieldAttribute - { - private protected static readonly CollectionConverter CollectionConverter = new(); - - /// - /// The CLR type in which this relationship is declared. - /// - internal Type? LeftClrType { get; set; } + internal Type? LeftClrType { get; set; } - /// - /// The CLR type this relationship points to. In the case of a relationship, this value will be the collection element - /// type. - /// - /// - /// Tags { get; set; } // RightClrType: typeof(Tag) - /// ]]> - /// - internal Type? RightClrType { get; set; } + /// + /// The CLR type this relationship points to. In the case of a relationship, this value will be the collection element + /// type. + /// + /// + /// Tags { get; set; } // RightClrType: typeof(Tag) + /// ]]> + /// + internal Type? RightClrType { get; set; } - /// - /// The of the Entity Framework Core inverse navigation, which may or may not exist. Even if it exists, it may not be exposed - /// as a JSON:API relationship. - /// - /// - /// Articles { get; set; } - /// } - /// ]]> - /// - public PropertyInfo? InverseNavigationProperty { get; set; } + /// + /// The of the Entity Framework Core inverse navigation, which may or may not exist. Even if it exists, it may not be exposed + /// as a JSON:API relationship. + /// + /// + /// Articles { get; set; } + /// } + /// ]]> + /// + public PropertyInfo? InverseNavigationProperty { get; set; } - /// - /// The containing resource type in which this relationship is declared. - /// - public ResourceType LeftType { get; internal set; } = null!; + /// + /// The containing resource type in which this relationship is declared. + /// + public ResourceType LeftType { get; internal set; } = null!; - /// - /// The resource type this relationship points to. In the case of a relationship, this value will be the collection - /// element type. - /// - public ResourceType RightType { get; internal set; } = null!; + /// + /// The resource type this relationship points to. In the case of a relationship, this value will be the collection + /// element type. + /// + public ResourceType RightType { get; internal set; } = null!; - /// - /// Configures which links to show in the object for this relationship. Defaults to - /// , which falls back to and then falls back to - /// . - /// - public LinkTypes Links { get; set; } = LinkTypes.NotConfigured; + /// + /// Configures which links to show in the object for this relationship. Defaults to + /// , which falls back to and then falls back to + /// . + /// + public LinkTypes Links { get; set; } = LinkTypes.NotConfigured; - /// - /// 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; + /// + /// 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; - public override bool Equals(object? obj) + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) { - if (ReferenceEquals(this, obj)) - { - return true; - } - - if (obj is null || GetType() != obj.GetType()) - { - return false; - } - - var other = (RelationshipAttribute)obj; - - return LeftClrType == other.LeftClrType && RightClrType == other.RightClrType && Links == other.Links && CanInclude == other.CanInclude && - base.Equals(other); + return true; } - public override int GetHashCode() + if (obj is null || GetType() != obj.GetType()) { - return HashCode.Combine(LeftClrType, RightClrType, Links, CanInclude, base.GetHashCode()); + return false; } + + var other = (RelationshipAttribute)obj; + + return LeftClrType == other.LeftClrType && RightClrType == other.RightClrType && Links == other.Links && CanInclude == other.CanInclude && + base.Equals(other); + } + + public override int GetHashCode() + { + return HashCode.Combine(LeftClrType, RightClrType, Links, CanInclude, base.GetHashCode()); } } diff --git a/src/JsonApiDotNetCore/Resources/Annotations/ResourceAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/ResourceAttribute.cs index 4bb83a4e38..ca517e3e99 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/ResourceAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/ResourceAttribute.cs @@ -1,34 +1,32 @@ -using System; using JetBrains.Annotations; using JsonApiDotNetCore.Controllers; // ReSharper disable CheckNamespace #pragma warning disable AV1505 // Namespace should match with assembly name -namespace JsonApiDotNetCore.Resources.Annotations +namespace JsonApiDotNetCore.Resources.Annotations; + +/// +/// When put on a resource class, overrides the convention-based public resource name and auto-generates an ASP.NET controller. +/// +[PublicAPI] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] +public sealed class ResourceAttribute : Attribute { /// - /// When put on a resource class, overrides the convention-based public resource name and auto-generates an ASP.NET controller. + /// Optional. The publicly exposed name of this resource type. /// - [PublicAPI] - [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] - public sealed class ResourceAttribute : Attribute - { - /// - /// Optional. The publicly exposed name of this resource type. - /// - public string? PublicName { get; set; } + public string? PublicName { get; set; } - /// - /// The set of endpoints to auto-generate an ASP.NET controller for. Defaults to . Set to - /// to disable controller generation. - /// - public JsonApiEndpoints GenerateControllerEndpoints { get; set; } = JsonApiEndpoints.All; + /// + /// The set of endpoints to auto-generate an ASP.NET controller for. Defaults to . Set to + /// to disable controller generation. + /// + public JsonApiEndpoints GenerateControllerEndpoints { get; set; } = JsonApiEndpoints.All; - /// - /// Optional. The full namespace in which to auto-generate the ASP.NET controller. Defaults to the sibling namespace "Controllers". For example, a - /// resource class that is declared in namespace "ExampleCompany.ExampleApi.Models" will use "ExampleCompany.ExampleApi.Controllers" by default. - /// - public string? ControllerNamespace { get; set; } - } + /// + /// Optional. The full namespace in which to auto-generate the ASP.NET controller. Defaults to the sibling namespace "Controllers". For example, a + /// resource class that is declared in namespace "ExampleCompany.ExampleApi.Models" will use "ExampleCompany.ExampleApi.Controllers" by default. + /// + public string? ControllerNamespace { get; set; } } diff --git a/src/JsonApiDotNetCore/Resources/Annotations/ResourceFieldAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/ResourceFieldAttribute.cs index 8463689301..c7a44f59c8 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/ResourceFieldAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/ResourceFieldAttribute.cs @@ -1,127 +1,125 @@ -using System; using System.Reflection; using JetBrains.Annotations; // ReSharper disable NonReadonlyMemberInGetHashCode -namespace JsonApiDotNetCore.Resources.Annotations +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. +/// +[PublicAPI] +public abstract class ResourceFieldAttribute : Attribute { + // These are definitely assigned after building the resource graph, which is why their public equivalents are declared as non-nullable. + private string? _publicName; + private PropertyInfo? _property; + /// - /// 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. + /// The publicly exposed name of this JSON:API field. When not explicitly assigned, the configured naming convention is applied on the property name. /// - [PublicAPI] - public abstract class ResourceFieldAttribute : Attribute + public string PublicName { - // These are definitely assigned after building the resource graph, which is why their public equivalents are declared as non-nullable. - private string? _publicName; - private PropertyInfo? _property; - - /// - /// The publicly exposed name of this JSON:API field. When not explicitly assigned, the configured naming convention is applied on the property name. - /// - public string PublicName + get => _publicName!; + set { - get => _publicName!; - set + if (string.IsNullOrWhiteSpace(value)) { - if (string.IsNullOrWhiteSpace(value)) - { - throw new ArgumentException("Exposed name cannot be null, empty or contain only whitespace.", nameof(value)); - } - - _publicName = value; + throw new ArgumentException("Exposed name cannot be null, empty or contain only whitespace.", nameof(value)); } - } - /// - /// The resource property that this attribute is declared on. - /// - public PropertyInfo Property - { - get => _property!; - internal set - { - ArgumentGuard.NotNull(value, nameof(value)); - _property = value; - } + _publicName = value; } + } - /// - /// Gets the value of this field on the specified resource instance. Throws if the property is write-only or if the field does not belong to the - /// specified resource instance. - /// - public object? GetValue(object resource) + /// + /// The resource property that this attribute is declared on. + /// + public PropertyInfo Property + { + get => _property!; + internal set { - ArgumentGuard.NotNull(resource, nameof(resource)); + ArgumentGuard.NotNull(value, nameof(value)); + _property = value; + } + } - if (Property.GetMethod == null) - { - throw new InvalidOperationException($"Property '{Property.DeclaringType?.Name}.{Property.Name}' is write-only."); - } + /// + /// Gets the value of this field on the specified resource instance. Throws if the property is write-only or if the field does not belong to the + /// specified resource instance. + /// + public object? GetValue(object resource) + { + ArgumentGuard.NotNull(resource, nameof(resource)); - try - { - return Property.GetValue(resource); - } - catch (TargetException exception) - { - throw new InvalidOperationException( - $"Unable to get property value of '{Property.DeclaringType!.Name}.{Property.Name}' on instance of type '{resource.GetType().Name}'.", - exception); - } + if (Property.GetMethod == null) + { + throw new InvalidOperationException($"Property '{Property.DeclaringType?.Name}.{Property.Name}' is write-only."); } - /// - /// Sets the value of this field on the specified resource instance. Throws if the property is read-only or if the field does not belong to the specified - /// resource instance. - /// - public void SetValue(object resource, object? newValue) + try + { + return Property.GetValue(resource); + } + catch (TargetException exception) { - ArgumentGuard.NotNull(resource, nameof(resource)); + throw new InvalidOperationException( + $"Unable to get property value of '{Property.DeclaringType!.Name}.{Property.Name}' on instance of type '{resource.GetType().Name}'.", + exception); + } + } - if (Property.SetMethod == null) - { - throw new InvalidOperationException($"Property '{Property.DeclaringType?.Name}.{Property.Name}' is read-only."); - } + /// + /// Sets the value of this field on the specified resource instance. Throws if the property is read-only or if the field does not belong to the specified + /// resource instance. + /// + public void SetValue(object resource, object? newValue) + { + ArgumentGuard.NotNull(resource, nameof(resource)); - try - { - Property.SetValue(resource, newValue); - } - catch (TargetException exception) - { - throw new InvalidOperationException( - $"Unable to set property value of '{Property.DeclaringType!.Name}.{Property.Name}' on instance of type '{resource.GetType().Name}'.", - exception); - } + if (Property.SetMethod == null) + { + throw new InvalidOperationException($"Property '{Property.DeclaringType?.Name}.{Property.Name}' is read-only."); } - public override string? ToString() + try { - return _publicName ?? (_property != null ? _property.Name : base.ToString()); + Property.SetValue(resource, newValue); } - - public override bool Equals(object? obj) + catch (TargetException exception) { - if (ReferenceEquals(this, obj)) - { - return true; - } - - if (obj is null || GetType() != obj.GetType()) - { - return false; - } + throw new InvalidOperationException( + $"Unable to set property value of '{Property.DeclaringType!.Name}.{Property.Name}' on instance of type '{resource.GetType().Name}'.", + exception); + } + } - var other = (ResourceFieldAttribute)obj; + public override string? ToString() + { + return _publicName ?? (_property != null ? _property.Name : base.ToString()); + } - return _publicName == other._publicName && _property == other._property; + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) + { + return true; } - public override int GetHashCode() + if (obj is null || GetType() != obj.GetType()) { - return HashCode.Combine(_publicName, _property); + return false; } + + var other = (ResourceFieldAttribute)obj; + + return _publicName == other._publicName && _property == other._property; + } + + public override int GetHashCode() + { + return HashCode.Combine(_publicName, _property); } } diff --git a/src/JsonApiDotNetCore/Resources/Annotations/ResourceLinksAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/ResourceLinksAttribute.cs index 3e513d7175..de290b04f7 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/ResourceLinksAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/ResourceLinksAttribute.cs @@ -1,33 +1,31 @@ -using System; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; -namespace JsonApiDotNetCore.Resources.Annotations +namespace JsonApiDotNetCore.Resources.Annotations; + +/// +/// When put on a resource class, overrides global configuration for which links to render. +/// +[PublicAPI] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] +public sealed class ResourceLinksAttribute : Attribute { /// - /// When put on a resource class, overrides global configuration for which links to render. + /// Configures which links to show in the object for this resource type. Defaults to + /// , which falls back to . /// - [PublicAPI] - [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] - public sealed class ResourceLinksAttribute : Attribute - { - /// - /// Configures which links to show in the object for this resource type. Defaults to - /// , which falls back to . - /// - public LinkTypes TopLevelLinks { get; set; } = LinkTypes.NotConfigured; + public LinkTypes TopLevelLinks { get; set; } = LinkTypes.NotConfigured; - /// - /// Configures which links to show in the object for this resource type. Defaults to - /// , which falls back to . - /// - public LinkTypes ResourceLinks { get; set; } = LinkTypes.NotConfigured; + /// + /// Configures which links to show in the object for this resource type. Defaults to + /// , which falls back to . + /// + public LinkTypes ResourceLinks { get; set; } = LinkTypes.NotConfigured; - /// - /// Configures which links to show in the object for all relationships of this resource type. - /// Defaults to , which falls back to . This can be overruled per - /// relationship by setting . - /// - public LinkTypes RelationshipLinks { get; set; } = LinkTypes.NotConfigured; - } + /// + /// Configures which links to show in the object for all relationships of this resource type. + /// Defaults to , which falls back to . This can be overruled per + /// relationship by setting . + /// + public LinkTypes RelationshipLinks { get; set; } = LinkTypes.NotConfigured; } diff --git a/src/JsonApiDotNetCore/Resources/IIdentifiable.cs b/src/JsonApiDotNetCore/Resources/IIdentifiable.cs index ee1f73942a..2c6dc02025 100644 --- a/src/JsonApiDotNetCore/Resources/IIdentifiable.cs +++ b/src/JsonApiDotNetCore/Resources/IIdentifiable.cs @@ -1,32 +1,31 @@ -namespace JsonApiDotNetCore.Resources +namespace JsonApiDotNetCore.Resources; + +/// +/// Defines the basic contract for a JSON:API resource. All resource classes must implement . +/// +public interface IIdentifiable { /// - /// Defines the basic contract for a JSON:API resource. All resource classes must implement . + /// The value for element 'id' in a JSON:API request or response. /// - public interface IIdentifiable - { - /// - /// The value for element 'id' in a JSON:API request or response. - /// - string? StringId { get; set; } + string? StringId { get; set; } - /// - /// The value for element 'lid' in a JSON:API request. - /// - string? LocalId { get; set; } - } + /// + /// The value for element 'lid' in a JSON:API request. + /// + string? LocalId { 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 +{ /// - /// When implemented by a class, indicates to JsonApiDotNetCore that the class represents a JSON:API resource. + /// The typed identifier as used by the underlying data store (usually numeric or Guid). /// - /// - /// The resource identifier type. - /// - public interface IIdentifiable : IIdentifiable - { - /// - /// The typed identifier as used by the underlying data store (usually numeric or Guid). - /// - TId Id { get; set; } - } + TId Id { get; set; } } diff --git a/src/JsonApiDotNetCore/Resources/IResourceChangeTracker.cs b/src/JsonApiDotNetCore/Resources/IResourceChangeTracker.cs index df16dc603f..1f69735f92 100644 --- a/src/JsonApiDotNetCore/Resources/IResourceChangeTracker.cs +++ b/src/JsonApiDotNetCore/Resources/IResourceChangeTracker.cs @@ -1,34 +1,33 @@ -namespace JsonApiDotNetCore.Resources +namespace JsonApiDotNetCore.Resources; + +/// +/// Used to determine whether additional changes to a resource (side effects), not specified in a POST or PATCH request, have been applied. +/// +public interface IResourceChangeTracker + where TResource : class, IIdentifiable { /// - /// Used to determine whether additional changes to a resource (side effects), not specified in a POST or PATCH request, have been applied. + /// Sets the exposed resource attributes as stored in database, before applying the PATCH operation. For POST operations, this sets exposed resource + /// attributes to their default value. /// - public interface IResourceChangeTracker - where TResource : class, IIdentifiable - { - /// - /// Sets the exposed resource attributes as stored in database, before applying the PATCH operation. For POST operations, this sets exposed resource - /// attributes to their default value. - /// - void SetInitiallyStoredAttributeValues(TResource resource); + void SetInitiallyStoredAttributeValues(TResource resource); - /// - /// Sets the (subset of) exposed resource attributes from the POST or PATCH request. - /// - void SetRequestAttributeValues(TResource resource); + /// + /// Sets the (subset of) exposed resource attributes from the POST or PATCH request. + /// + void SetRequestAttributeValues(TResource resource); - /// - /// Sets the exposed resource attributes as stored in database, after applying the POST or PATCH operation. - /// - void SetFinallyStoredAttributeValues(TResource resource); + /// + /// Sets the exposed resource attributes as stored in database, after applying the POST or PATCH operation. + /// + void SetFinallyStoredAttributeValues(TResource resource); - /// - /// Validates if any exposed resource attributes that were not in the POST or PATCH request have been changed. And validates if the values from the - /// request are stored without modification. - /// - /// - /// true if the attribute values from the POST or PATCH request were the only changes; false, otherwise. - /// - bool HasImplicitChanges(); - } + /// + /// Validates if any exposed resource attributes that were not in the POST or PATCH request have been changed. And validates if the values from the + /// request are stored without modification. + /// + /// + /// true if the attribute values from the POST or PATCH request were the only changes; false, otherwise. + /// + bool HasImplicitChanges(); } diff --git a/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs b/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs index 9eed0aaa16..1a61868de5 100644 --- a/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs +++ b/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs @@ -1,330 +1,325 @@ -using System.Collections.Generic; using System.Collections.Immutable; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; using JetBrains.Annotations; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Resources +namespace JsonApiDotNetCore.Resources; + +/// +/// Provides an extensibility point to add business logic that is resource-oriented instead of endpoint-oriented. +/// +/// +/// The resource type. +/// +/// +/// The resource identifier type. +/// +[PublicAPI] +public interface IResourceDefinition + where TResource : class, IIdentifiable { /// - /// Provides an extensibility point to add business logic that is resource-oriented instead of endpoint-oriented. + /// Enables to extend, replace or remove includes that are being applied on this resource type. /// - /// - /// The resource type. - /// - /// - /// The resource identifier type. - /// - [PublicAPI] - public interface IResourceDefinition - where TResource : class, IIdentifiable - { - /// - /// 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). - /// - IImmutableSet OnApplyIncludes(IImmutableSet existingIncludes); + /// + /// 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). + /// + IImmutableSet OnApplyIncludes(IImmutableSet 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. - /// - FilterExpression? OnApplyFilter(FilterExpression? existingFilter); + /// + /// 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. + /// + FilterExpression? OnApplyFilter(FilterExpression? 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. - /// - SortExpression? OnApplySort(SortExpression? existingSort); + /// + /// 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. + /// + SortExpression? OnApplySort(SortExpression? existingSort); - /// - /// 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. - /// - PaginationExpression? OnApplyPagination(PaginationExpression? existingPagination); + /// + /// 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. + /// + PaginationExpression? OnApplyPagination(PaginationExpression? 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. - /// - SparseFieldSetExpression? OnApplySparseFieldSet(SparseFieldSetExpression? existingSparseFieldSet); + /// + /// 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. + /// + SparseFieldSetExpression? OnApplySparseFieldSet(SparseFieldSetExpression? 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); - /// } - /// ]]> - /// + /// + /// 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); + /// } + /// ]]> + /// #pragma warning disable AV1130 // Return type in method signature should be a collection interface instead of a concrete type - QueryStringParameterHandlers? OnRegisterQueryableHandlersForQueryStringParameters(); + QueryStringParameterHandlers? OnRegisterQueryableHandlersForQueryStringParameters(); #pragma warning restore AV1130 // Return type in method signature should be a collection interface instead of a concrete type - /// - /// Enables to add JSON:API meta information, specific to this resource. - /// - IDictionary? GetMeta(TResource resource); + /// + /// Enables to add JSON:API meta information, specific to this resource. + /// + IDictionary? GetMeta(TResource resource); - /// - /// Executes after the original version of the resource has been retrieved from the underlying data store, as part of a write request. - /// - /// Implementing this method enables to perform validations and make changes to , before the fields from the request are - /// copied into it. - /// - /// - /// For POST resource requests, this method is typically used to assign property default values or to set required relationships by side-loading the - /// related resources and linking them. - /// - /// - /// - /// The original resource retrieved from the underlying data store, or a freshly instantiated resource in case of a POST resource request. - /// - /// - /// Identifies the logical write operation for which this method was called. Possible values: , - /// and . Note this intentionally excludes - /// , and - /// , because for those endpoints no resource is retrieved upfront. - /// - /// - /// Propagates notification that request handling should be canceled. - /// - Task OnPrepareWriteAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken); + /// + /// Executes after the original version of the resource has been retrieved from the underlying data store, as part of a write request. + /// + /// Implementing this method enables to perform validations and make changes to , before the fields from the request are + /// copied into it. + /// + /// + /// For POST resource requests, this method is typically used to assign property default values or to set required relationships by side-loading the + /// related resources and linking them. + /// + /// + /// + /// The original resource retrieved from the underlying data store, or a freshly instantiated resource in case of a POST resource request. + /// + /// + /// Identifies the logical write operation for which this method was called. Possible values: , + /// and . Note this intentionally excludes + /// , and + /// , because for those endpoints no resource is retrieved upfront. + /// + /// + /// Propagates notification that request handling should be canceled. + /// + Task OnPrepareWriteAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken); - /// - /// Executes before setting (or clearing) the resource at the right side of a to-one relationship. - /// - /// Implementing this method enables to perform validations and change , before the relationship is updated. - /// - /// - /// - /// The original resource as retrieved from the underlying data store. The indication "left" specifies that is - /// declared on . - /// - /// - /// The to-one relationship being set. - /// - /// - /// The new resource identifier (or null to clear the relationship), coming from the request. - /// - /// - /// Identifies the logical write operation for which this method was called. Possible values: , - /// and . - /// - /// - /// Propagates notification that request handling should be canceled. - /// - /// - /// The replacement resource identifier, or null to clear the relationship. Returns by default. - /// - Task OnSetToOneRelationshipAsync(TResource leftResource, HasOneAttribute hasOneRelationship, IIdentifiable? rightResourceId, - WriteOperationKind writeOperation, CancellationToken cancellationToken); + /// + /// Executes before setting (or clearing) the resource at the right side of a to-one relationship. + /// + /// Implementing this method enables to perform validations and change , before the relationship is updated. + /// + /// + /// + /// The original resource as retrieved from the underlying data store. The indication "left" specifies that is + /// declared on . + /// + /// + /// The to-one relationship being set. + /// + /// + /// The new resource identifier (or null to clear the relationship), coming from the request. + /// + /// + /// Identifies the logical write operation for which this method was called. Possible values: , + /// and . + /// + /// + /// Propagates notification that request handling should be canceled. + /// + /// + /// The replacement resource identifier, or null to clear the relationship. Returns by default. + /// + Task OnSetToOneRelationshipAsync(TResource leftResource, HasOneAttribute hasOneRelationship, IIdentifiable? rightResourceId, + WriteOperationKind writeOperation, CancellationToken cancellationToken); - /// - /// Executes before setting the resources at the right side of a to-many relationship. This replaces on existing set. - /// - /// Implementing this method enables to perform validations and make changes to , before the relationship is updated. - /// - /// - /// - /// The original resource as retrieved from the underlying data store. The indication "left" specifies that is - /// declared on . - /// - /// - /// The to-many relationship being set. - /// - /// - /// The set of resource identifiers to replace any existing set with, coming from the request. - /// - /// - /// Identifies the logical write operation for which this method was called. Possible values: , - /// and . - /// - /// - /// Propagates notification that request handling should be canceled. - /// - Task OnSetToManyRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, - WriteOperationKind writeOperation, CancellationToken cancellationToken); + /// + /// Executes before setting the resources at the right side of a to-many relationship. This replaces on existing set. + /// + /// Implementing this method enables to perform validations and make changes to , before the relationship is updated. + /// + /// + /// + /// The original resource as retrieved from the underlying data store. The indication "left" specifies that is + /// declared on . + /// + /// + /// The to-many relationship being set. + /// + /// + /// The set of resource identifiers to replace any existing set with, coming from the request. + /// + /// + /// Identifies the logical write operation for which this method was called. Possible values: , + /// and . + /// + /// + /// Propagates notification that request handling should be canceled. + /// + Task OnSetToManyRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + WriteOperationKind writeOperation, CancellationToken cancellationToken); - /// - /// Executes before adding resources to the right side of a to-many relationship, as part of a POST relationship request. - /// - /// Implementing this method enables to perform validations and make changes to , before the relationship is updated. - /// - /// - /// - /// Identifier of the left resource. The indication "left" specifies that is declared on - /// . - /// - /// - /// The to-many relationship being added to. - /// - /// - /// The set of resource identifiers to add to the to-many relationship, coming from the request. - /// - /// - /// Propagates notification that request handling should be canceled. - /// - Task OnAddToRelationshipAsync(TId leftResourceId, HasManyAttribute hasManyRelationship, ISet rightResourceIds, - CancellationToken cancellationToken); + /// + /// Executes before adding resources to the right side of a to-many relationship, as part of a POST relationship request. + /// + /// Implementing this method enables to perform validations and make changes to , before the relationship is updated. + /// + /// + /// + /// Identifier of the left resource. The indication "left" specifies that is declared on + /// . + /// + /// + /// The to-many relationship being added to. + /// + /// + /// The set of resource identifiers to add to the to-many relationship, coming from the request. + /// + /// + /// Propagates notification that request handling should be canceled. + /// + Task OnAddToRelationshipAsync(TId leftResourceId, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + CancellationToken cancellationToken); - /// - /// Executes before removing resources from the right side of a to-many relationship, as part of a DELETE relationship request. - /// - /// Implementing this method enables to perform validations and make changes to , before the relationship is updated. - /// - /// - /// - /// The original resource as retrieved from the underlying data store. The indication "left" specifies that is - /// declared on . Be aware that for performance reasons, not the full relationship is populated, but only the subset of - /// resources to be removed. - /// - /// - /// The to-many relationship being removed from. - /// - /// - /// The set of resource identifiers to remove from the to-many relationship, coming from the request. - /// - /// - /// Propagates notification that request handling should be canceled. - /// - Task OnRemoveFromRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, - CancellationToken cancellationToken); + /// + /// Executes before removing resources from the right side of a to-many relationship, as part of a DELETE relationship request. + /// + /// Implementing this method enables to perform validations and make changes to , before the relationship is updated. + /// + /// + /// + /// The original resource as retrieved from the underlying data store. The indication "left" specifies that is + /// declared on . Be aware that for performance reasons, not the full relationship is populated, but only the subset of + /// resources to be removed. + /// + /// + /// The to-many relationship being removed from. + /// + /// + /// The set of resource identifiers to remove from the to-many relationship, coming from the request. + /// + /// + /// Propagates notification that request handling should be canceled. + /// + Task OnRemoveFromRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + CancellationToken cancellationToken); - /// - /// Executes before writing the changed resource to the underlying data store, as part of a write request. - /// - /// Implementing this method enables to perform validations and make changes to , after the fields from the request have been - /// copied into it. - /// - /// - /// An example usage is to set the last-modification timestamp, overwriting the value from the incoming request. - /// - /// - /// Another use case is to add a notification message to an outbox table, which gets committed along with the resource write in a single transaction (see - /// https://microservices.io/patterns/data/transactional-outbox.html). - /// - /// - /// - /// The original resource retrieved from the underlying data store (or a freshly instantiated resource in case of a POST resource request), updated with - /// the changes from the incoming request. Exception: In case is , - /// or , this is an empty object with only - /// the property set, because for those endpoints no resource is retrieved upfront. - /// - /// - /// Identifies the logical write operation for which this method was called. Possible values: , - /// , , - /// , and . - /// - /// - /// Propagates notification that request handling should be canceled. - /// - Task OnWritingAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken); + /// + /// Executes before writing the changed resource to the underlying data store, as part of a write request. + /// + /// Implementing this method enables to perform validations and make changes to , after the fields from the request have been + /// copied into it. + /// + /// + /// An example usage is to set the last-modification timestamp, overwriting the value from the incoming request. + /// + /// + /// Another use case is to add a notification message to an outbox table, which gets committed along with the resource write in a single transaction (see + /// https://microservices.io/patterns/data/transactional-outbox.html). + /// + /// + /// + /// The original resource retrieved from the underlying data store (or a freshly instantiated resource in case of a POST resource request), updated with + /// the changes from the incoming request. Exception: In case is , + /// or , this is an empty object with only + /// the property set, because for those endpoints no resource is retrieved upfront. + /// + /// + /// Identifies the logical write operation for which this method was called. Possible values: , + /// , , + /// , and . + /// + /// + /// Propagates notification that request handling should be canceled. + /// + Task OnWritingAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken); - /// - /// Executes after successfully writing the changed resource to the underlying data store, as part of a write request. - /// - /// Implementing this method enables to run additional logic, for example enqueue a notification message on a service bus. - /// - /// - /// - /// The resource as written to the underlying data store. - /// - /// - /// Identifies the logical write operation for which this method was called. Possible values: , - /// , , - /// , and . - /// - /// - /// Propagates notification that request handling should be canceled. - /// - Task OnWriteSucceededAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken); + /// + /// Executes after successfully writing the changed resource to the underlying data store, as part of a write request. + /// + /// Implementing this method enables to run additional logic, for example enqueue a notification message on a service bus. + /// + /// + /// + /// The resource as written to the underlying data store. + /// + /// + /// Identifies the logical write operation for which this method was called. Possible values: , + /// , , + /// , and . + /// + /// + /// Propagates notification that request handling should be canceled. + /// + Task OnWriteSucceededAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken); - /// - /// Executes after a resource has been deserialized from an incoming request body. - /// - /// - /// Implementing this method enables to change the incoming resource before it enters an ASP.NET Controller Action method. - /// - /// - /// Changing attributes on from this method may break detection of side effects on resource POST/PATCH requests, because - /// side effect detection considers any changes done from this method to be part of the incoming request body. So setting additional attributes from this - /// method (that were not sent by the client) are not considered side effects, resulting in incorrectly reporting that there were no side effects. - /// - /// - /// The deserialized resource. - /// - void OnDeserialize(TResource resource); + /// + /// Executes after a resource has been deserialized from an incoming request body. + /// + /// + /// Implementing this method enables to change the incoming resource before it enters an ASP.NET Controller Action method. + /// + /// + /// Changing attributes on from this method may break detection of side effects on resource POST/PATCH requests, because + /// side effect detection considers any changes done from this method to be part of the incoming request body. So setting additional attributes from this + /// method (that were not sent by the client) are not considered side effects, resulting in incorrectly reporting that there were no side effects. + /// + /// + /// The deserialized resource. + /// + void OnDeserialize(TResource resource); - /// - /// Executes before a (primary or included) resource is serialized into an outgoing response body. - /// - /// - /// Implementing this method enables to change the returned resource, for example scrub sensitive data or transform returned attribute values. - /// - /// - /// Changing attributes on from this method may break detection of side effects on resource POST/PATCH requests. What this - /// means is that if side effects were detected before, this is not re-evaluated after running this method, so it may incorrectly report side effects if - /// they were undone by this method. - /// - /// - /// The serialized resource. - /// - void OnSerialize(TResource resource); - } + /// + /// Executes before a (primary or included) resource is serialized into an outgoing response body. + /// + /// + /// Implementing this method enables to change the returned resource, for example scrub sensitive data or transform returned attribute values. + /// + /// + /// Changing attributes on from this method may break detection of side effects on resource POST/PATCH requests. What this + /// means is that if side effects were detected before, this is not re-evaluated after running this method, so it may incorrectly report side effects if + /// they were undone by this method. + /// + /// + /// The serialized resource. + /// + void OnSerialize(TResource resource); } diff --git a/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs b/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs index ed4dad1270..a6d229609a 100644 --- a/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs +++ b/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs @@ -1,111 +1,105 @@ -using System; -using System.Collections.Generic; using System.Collections.Immutable; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Resources +namespace JsonApiDotNetCore.Resources; + +/// +/// Retrieves an instance from the D/I container and invokes a callback on it. +/// +public interface IResourceDefinitionAccessor { /// - /// Retrieves an instance from the D/I container and invokes a callback on it. - /// - public interface IResourceDefinitionAccessor - { - /// - /// Invokes for the specified resource type. - /// - IImmutableSet OnApplyIncludes(ResourceType resourceType, IImmutableSet existingIncludes); - - /// - /// Invokes for the specified resource type. - /// - FilterExpression? OnApplyFilter(ResourceType resourceType, FilterExpression? existingFilter); - - /// - /// Invokes for the specified resource type. - /// - SortExpression? OnApplySort(ResourceType resourceType, SortExpression? existingSort); - - /// - /// Invokes for the specified resource type. - /// - PaginationExpression? OnApplyPagination(ResourceType resourceType, PaginationExpression? existingPagination); - - /// - /// Invokes for the specified resource type. - /// - SparseFieldSetExpression? OnApplySparseFieldSet(ResourceType resourceType, SparseFieldSetExpression? existingSparseFieldSet); - - /// - /// Invokes for the specified resource type, then - /// returns the expression for the specified parameter name. - /// - object? GetQueryableHandlerForQueryStringParameter(Type resourceClrType, string parameterName); - - /// - /// Invokes for the specified resource. - /// - IDictionary? GetMeta(ResourceType resourceType, IIdentifiable resourceInstance); - - /// - /// Invokes for the specified resource. - /// - Task OnPrepareWriteAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) - where TResource : class, IIdentifiable; - - /// - /// Invokes for the specified resource. - /// - public Task OnSetToOneRelationshipAsync(TResource leftResource, HasOneAttribute hasOneRelationship, - IIdentifiable? rightResourceId, WriteOperationKind writeOperation, CancellationToken cancellationToken) - where TResource : class, IIdentifiable; - - /// - /// Invokes for the specified resource. - /// - public Task OnSetToManyRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, - WriteOperationKind writeOperation, CancellationToken cancellationToken) - where TResource : class, IIdentifiable; - - /// - /// Invokes for the specified resource. - /// - public Task OnAddToRelationshipAsync(TId leftResourceId, HasManyAttribute hasManyRelationship, ISet rightResourceIds, - CancellationToken cancellationToken) - where TResource : class, IIdentifiable; - - /// - /// Invokes for the specified resource. - /// - public Task OnRemoveFromRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, - CancellationToken cancellationToken) - where TResource : class, IIdentifiable; - - /// - /// Invokes for the specified resource. - /// - Task OnWritingAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) - where TResource : class, IIdentifiable; - - /// - /// Invokes for the specified resource. - /// - Task OnWriteSucceededAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) - where TResource : class, IIdentifiable; - - /// - /// Invokes for the specified resource. - /// - void OnDeserialize(IIdentifiable resource); - - /// - /// Invokes for the specified resource. - /// - void OnSerialize(IIdentifiable resource); - } + /// Invokes for the specified resource type. + /// + IImmutableSet OnApplyIncludes(ResourceType resourceType, IImmutableSet existingIncludes); + + /// + /// Invokes for the specified resource type. + /// + FilterExpression? OnApplyFilter(ResourceType resourceType, FilterExpression? existingFilter); + + /// + /// Invokes for the specified resource type. + /// + SortExpression? OnApplySort(ResourceType resourceType, SortExpression? existingSort); + + /// + /// Invokes for the specified resource type. + /// + PaginationExpression? OnApplyPagination(ResourceType resourceType, PaginationExpression? existingPagination); + + /// + /// Invokes for the specified resource type. + /// + SparseFieldSetExpression? OnApplySparseFieldSet(ResourceType resourceType, SparseFieldSetExpression? existingSparseFieldSet); + + /// + /// Invokes for the specified resource type, then + /// returns the expression for the specified parameter name. + /// + object? GetQueryableHandlerForQueryStringParameter(Type resourceClrType, string parameterName); + + /// + /// Invokes for the specified resource. + /// + IDictionary? GetMeta(ResourceType resourceType, IIdentifiable resourceInstance); + + /// + /// Invokes for the specified resource. + /// + Task OnPrepareWriteAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) + where TResource : class, IIdentifiable; + + /// + /// Invokes for the specified resource. + /// + public Task OnSetToOneRelationshipAsync(TResource leftResource, HasOneAttribute hasOneRelationship, + IIdentifiable? rightResourceId, WriteOperationKind writeOperation, CancellationToken cancellationToken) + where TResource : class, IIdentifiable; + + /// + /// Invokes for the specified resource. + /// + public Task OnSetToManyRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + WriteOperationKind writeOperation, CancellationToken cancellationToken) + where TResource : class, IIdentifiable; + + /// + /// Invokes for the specified resource. + /// + public Task OnAddToRelationshipAsync(TId leftResourceId, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + CancellationToken cancellationToken) + where TResource : class, IIdentifiable; + + /// + /// Invokes for the specified resource. + /// + public Task OnRemoveFromRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + CancellationToken cancellationToken) + where TResource : class, IIdentifiable; + + /// + /// Invokes for the specified resource. + /// + Task OnWritingAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) + where TResource : class, IIdentifiable; + + /// + /// Invokes for the specified resource. + /// + Task OnWriteSucceededAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) + where TResource : class, IIdentifiable; + + /// + /// Invokes for the specified resource. + /// + void OnDeserialize(IIdentifiable resource); + + /// + /// Invokes for the specified resource. + /// + void OnSerialize(IIdentifiable resource); } diff --git a/src/JsonApiDotNetCore/Resources/IResourceFactory.cs b/src/JsonApiDotNetCore/Resources/IResourceFactory.cs index 1e37304528..2906049e84 100644 --- a/src/JsonApiDotNetCore/Resources/IResourceFactory.cs +++ b/src/JsonApiDotNetCore/Resources/IResourceFactory.cs @@ -1,27 +1,25 @@ -using System; using System.Linq.Expressions; -namespace JsonApiDotNetCore.Resources +namespace JsonApiDotNetCore.Resources; + +/// +/// Creates object instances for resource classes, which may have injectable dependencies. +/// +public interface IResourceFactory { /// - /// Creates object instances for resource classes, which may have injectable dependencies. + /// Creates a new resource object instance. /// - public interface IResourceFactory - { - /// - /// Creates a new resource object instance. - /// - public IIdentifiable CreateInstance(Type resourceClrType); + public IIdentifiable CreateInstance(Type resourceClrType); - /// - /// Creates a new resource object instance. - /// - public TResource CreateInstance() - where TResource : IIdentifiable; + /// + /// Creates a new resource object instance. + /// + public TResource CreateInstance() + where TResource : IIdentifiable; - /// - /// Returns an expression tree that represents creating a new resource object instance. - /// - public NewExpression CreateNewExpression(Type resourceClrType); - } + /// + /// Returns an expression tree that represents creating a new resource object instance. + /// + public NewExpression CreateNewExpression(Type resourceClrType); } diff --git a/src/JsonApiDotNetCore/Resources/ITargetedFields.cs b/src/JsonApiDotNetCore/Resources/ITargetedFields.cs index 1498f96744..32226e214d 100644 --- a/src/JsonApiDotNetCore/Resources/ITargetedFields.cs +++ b/src/JsonApiDotNetCore/Resources/ITargetedFields.cs @@ -1,26 +1,24 @@ -using System.Collections.Generic; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Resources +namespace JsonApiDotNetCore.Resources; + +/// +/// Container to register which resource fields (attributes and relationships) are targeted by a request. +/// +public interface ITargetedFields { /// - /// Container to register which resource fields (attributes and relationships) are targeted by a request. + /// The set of attributes that are targeted by a request. /// - public interface ITargetedFields - { - /// - /// The set of attributes that are targeted by a request. - /// - IReadOnlySet Attributes { get; } + IReadOnlySet Attributes { get; } - /// - /// The set of relationships that are targeted by a request. - /// - IReadOnlySet Relationships { get; } + /// + /// The set of relationships that are targeted by a request. + /// + IReadOnlySet Relationships { get; } - /// - /// Performs a shallow copy. - /// - void CopyFrom(ITargetedFields other); - } + /// + /// Performs a shallow copy. + /// + void CopyFrom(ITargetedFields other); } diff --git a/src/JsonApiDotNetCore/Resources/Identifiable.cs b/src/JsonApiDotNetCore/Resources/Identifiable.cs index 6a379bfcad..bb854dac23 100644 --- a/src/JsonApiDotNetCore/Resources/Identifiable.cs +++ b/src/JsonApiDotNetCore/Resources/Identifiable.cs @@ -1,47 +1,45 @@ -using System.Collections.Generic; using System.ComponentModel.DataAnnotations.Schema; using JsonApiDotNetCore.Resources.Internal; -namespace JsonApiDotNetCore.Resources +namespace JsonApiDotNetCore.Resources; + +/// +/// A convenient basic implementation of that provides conversion between typed and +/// . +/// +/// +/// The resource identifier type. +/// +public abstract class Identifiable : IIdentifiable { - /// - /// A convenient basic implementation of that provides conversion between typed and - /// . - /// - /// - /// The resource identifier type. - /// - public abstract class Identifiable : IIdentifiable - { - /// - public virtual TId Id { get; set; } = default!; + /// + public virtual TId Id { get; set; } = default!; - /// - [NotMapped] - public string? StringId - { - get => GetStringId(Id); - set => Id = GetTypedId(value); - } + /// + [NotMapped] + public string? StringId + { + get => GetStringId(Id); + set => Id = GetTypedId(value); + } - /// - [NotMapped] - public string? LocalId { get; set; } + /// + [NotMapped] + public string? LocalId { get; set; } - /// - /// 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 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 value == null ? default! : (TId)RuntimeTypeConverter.ConvertType(value, typeof(TId))!; - } + /// + /// Converts an incoming 'id' element from a JSON:API request to the typed resource identifier. + /// + protected virtual TId GetTypedId(string? value) + { + return value == null ? default! : (TId)RuntimeTypeConverter.ConvertType(value, typeof(TId))!; } } diff --git a/src/JsonApiDotNetCore/Resources/IdentifiableComparer.cs b/src/JsonApiDotNetCore/Resources/IdentifiableComparer.cs index 67c0cc833c..a59dc8f15f 100644 --- a/src/JsonApiDotNetCore/Resources/IdentifiableComparer.cs +++ b/src/JsonApiDotNetCore/Resources/IdentifiableComparer.cs @@ -1,46 +1,43 @@ -using System; -using System.Collections.Generic; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Resources +namespace JsonApiDotNetCore.Resources; + +/// +/// Compares `IIdentifiable` instances with each other based on their type and , falling back to +/// when both StringIds are null. +/// +[PublicAPI] +public sealed class IdentifiableComparer : IEqualityComparer { - /// - /// Compares `IIdentifiable` instances with each other based on their type and , falling back to - /// when both StringIds are null. - /// - [PublicAPI] - public sealed class IdentifiableComparer : IEqualityComparer + public static readonly IdentifiableComparer Instance = new(); + + private IdentifiableComparer() { - public static readonly IdentifiableComparer Instance = new(); + } - private IdentifiableComparer() + public bool Equals(IIdentifiable? left, IIdentifiable? right) + { + if (ReferenceEquals(left, right)) { + return true; } - public bool Equals(IIdentifiable? left, IIdentifiable? right) + if (left is null || right is null || left.GetType() != right.GetType()) { - if (ReferenceEquals(left, right)) - { - return true; - } - - if (left is null || right is null || left.GetType() != right.GetType()) - { - return false; - } - - if (left.StringId == null && right.StringId == null) - { - return left.LocalId == right.LocalId; - } - - return left.StringId == right.StringId; + return false; } - public int GetHashCode(IIdentifiable obj) + if (left.StringId == null && right.StringId == null) { - // LocalId is intentionally omitted here, it is okay for hashes to collide. - return HashCode.Combine(obj.GetType(), obj.StringId); + return left.LocalId == right.LocalId; } + + return left.StringId == right.StringId; + } + + public int GetHashCode(IIdentifiable obj) + { + // LocalId is intentionally omitted here, it is okay for hashes to collide. + return HashCode.Combine(obj.GetType(), obj.StringId); } } diff --git a/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs b/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs index 2adf4c2e2c..7e8064826a 100644 --- a/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs +++ b/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs @@ -1,39 +1,37 @@ -using System; using System.Reflection; using JsonApiDotNetCore.Resources.Internal; -namespace JsonApiDotNetCore.Resources +namespace JsonApiDotNetCore.Resources; + +internal static class IdentifiableExtensions { - internal static class IdentifiableExtensions + private const string IdPropertyName = nameof(Identifiable.Id); + + public static object GetTypedId(this IIdentifiable identifiable) { - private const string IdPropertyName = nameof(Identifiable.Id); + ArgumentGuard.NotNull(identifiable, nameof(identifiable)); + + PropertyInfo? property = identifiable.GetType().GetProperty(IdPropertyName); - public static object GetTypedId(this IIdentifiable identifiable) + if (property == null) { - ArgumentGuard.NotNull(identifiable, nameof(identifiable)); + throw new InvalidOperationException($"Resource of type '{identifiable.GetType()}' does not contain a property named '{IdPropertyName}'."); + } - PropertyInfo? property = identifiable.GetType().GetProperty(IdPropertyName); + object? propertyValue = property.GetValue(identifiable); - if (property == null) - { - throw new InvalidOperationException($"Resource of type '{identifiable.GetType()}' does not contain a property named '{IdPropertyName}'."); - } - - object? propertyValue = property.GetValue(identifiable); + // PERF: We want to throw when 'Id' is unassigned without doing an expensive reflection call, unless this is likely the case. + if (identifiable.StringId == null) + { + object? defaultValue = RuntimeTypeConverter.GetDefaultValue(property.PropertyType); - // PERF: We want to throw when 'Id' is unassigned without doing an expensive reflection call, unless this is likely the case. - if (identifiable.StringId == null) + if (Equals(propertyValue, defaultValue)) { - object? defaultValue = RuntimeTypeConverter.GetDefaultValue(property.PropertyType); - - if (Equals(propertyValue, defaultValue)) - { - throw new InvalidOperationException($"Property '{identifiable.GetType().Name}.{IdPropertyName}' should " + - $"have been assigned at this point, but it contains its default {property.PropertyType.Name} value '{propertyValue}'."); - } + throw new InvalidOperationException($"Property '{identifiable.GetType().Name}.{IdPropertyName}' should " + + $"have been assigned at this point, but it contains its default {property.PropertyType.Name} value '{propertyValue}'."); } - - return propertyValue!; } + + return propertyValue!; } } diff --git a/src/JsonApiDotNetCore/Resources/Internal/RuntimeTypeConverter.cs b/src/JsonApiDotNetCore/Resources/Internal/RuntimeTypeConverter.cs index 16c72ed604..8722458938 100644 --- a/src/JsonApiDotNetCore/Resources/Internal/RuntimeTypeConverter.cs +++ b/src/JsonApiDotNetCore/Resources/Internal/RuntimeTypeConverter.cs @@ -1,93 +1,98 @@ -using System; +using System.Globalization; using JetBrains.Annotations; #pragma warning disable AV1008 // Class should not be static -namespace JsonApiDotNetCore.Resources.Internal +namespace JsonApiDotNetCore.Resources.Internal; + +[PublicAPI] +public static class RuntimeTypeConverter { - [PublicAPI] - public static class RuntimeTypeConverter + public static object? ConvertType(object? value, Type type) { - public static object? ConvertType(object? value, Type type) - { - ArgumentGuard.NotNull(type, nameof(type)); + ArgumentGuard.NotNull(type, nameof(type)); - if (value == null) + if (value == null) + { + if (!CanContainNull(type)) { - if (!CanContainNull(type)) - { - string targetTypeName = type.GetFriendlyTypeName(); - throw new FormatException($"Failed to convert 'null' to type '{targetTypeName}'."); - } - - return null; + string targetTypeName = type.GetFriendlyTypeName(); + throw new FormatException($"Failed to convert 'null' to type '{targetTypeName}'."); } - Type runtimeType = value.GetType(); + return null; + } + + Type runtimeType = value.GetType(); + + if (type == runtimeType || type.IsAssignableFrom(runtimeType)) + { + return value; + } + + string? stringValue = value.ToString(); + + if (string.IsNullOrEmpty(stringValue)) + { + return GetDefaultValue(type); + } - if (type == runtimeType || type.IsAssignableFrom(runtimeType)) + bool isNullableTypeRequested = type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>); + Type nonNullableType = Nullable.GetUnderlyingType(type) ?? type; + + try + { + if (nonNullableType == typeof(Guid)) { - return value; + Guid convertedValue = Guid.Parse(stringValue); + return isNullableTypeRequested ? (Guid?)convertedValue : convertedValue; } - string? stringValue = value.ToString(); + if (nonNullableType == typeof(DateTime)) + { + DateTime convertedValue = DateTime.Parse(stringValue, null, DateTimeStyles.RoundtripKind); + return isNullableTypeRequested ? (DateTime?)convertedValue : convertedValue; + } - if (string.IsNullOrEmpty(stringValue)) + if (nonNullableType == typeof(DateTimeOffset)) { - return GetDefaultValue(type); + DateTimeOffset convertedValue = DateTimeOffset.Parse(stringValue, null, DateTimeStyles.RoundtripKind); + return isNullableTypeRequested ? (DateTimeOffset?)convertedValue : convertedValue; } - bool isNullableTypeRequested = type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>); - Type nonNullableType = Nullable.GetUnderlyingType(type) ?? type; + if (nonNullableType == typeof(TimeSpan)) + { + TimeSpan convertedValue = TimeSpan.Parse(stringValue); + return isNullableTypeRequested ? (TimeSpan?)convertedValue : convertedValue; + } - try + if (nonNullableType.IsEnum) { - if (nonNullableType == typeof(Guid)) - { - Guid convertedValue = Guid.Parse(stringValue); - return isNullableTypeRequested ? (Guid?)convertedValue : convertedValue; - } - - if (nonNullableType == typeof(DateTimeOffset)) - { - DateTimeOffset convertedValue = DateTimeOffset.Parse(stringValue); - return isNullableTypeRequested ? (DateTimeOffset?)convertedValue : convertedValue; - } - - if (nonNullableType == typeof(TimeSpan)) - { - TimeSpan convertedValue = TimeSpan.Parse(stringValue); - return isNullableTypeRequested ? (TimeSpan?)convertedValue : convertedValue; - } - - if (nonNullableType.IsEnum) - { - object convertedValue = Enum.Parse(nonNullableType, stringValue); - - // https://bradwilson.typepad.com/blog/2008/07/creating-nullab.html - return convertedValue; - } + object convertedValue = Enum.Parse(nonNullableType, stringValue); // https://bradwilson.typepad.com/blog/2008/07/creating-nullab.html - return Convert.ChangeType(stringValue, nonNullableType); + return convertedValue; } - catch (Exception exception) when (exception is FormatException or OverflowException or InvalidCastException or ArgumentException) - { - string runtimeTypeName = runtimeType.GetFriendlyTypeName(); - string targetTypeName = type.GetFriendlyTypeName(); - throw new FormatException($"Failed to convert '{value}' of type '{runtimeTypeName}' to type '{targetTypeName}'.", exception); - } + // https://bradwilson.typepad.com/blog/2008/07/creating-nullab.html + return Convert.ChangeType(stringValue, nonNullableType); } - - public static bool CanContainNull(Type type) + catch (Exception exception) when (exception is FormatException or OverflowException or InvalidCastException or ArgumentException) { - return !type.IsValueType || Nullable.GetUnderlyingType(type) != null; - } + string runtimeTypeName = runtimeType.GetFriendlyTypeName(); + string targetTypeName = type.GetFriendlyTypeName(); - public static object? GetDefaultValue(Type type) - { - return type.IsValueType ? Activator.CreateInstance(type) : null; + throw new FormatException($"Failed to convert '{value}' of type '{runtimeTypeName}' to type '{targetTypeName}'.", exception); } } + + public static bool CanContainNull(Type type) + { + return !type.IsValueType || Nullable.GetUnderlyingType(type) != null; + } + + public static object? GetDefaultValue(Type type) + { + return type.IsValueType ? Activator.CreateInstance(type) : null; + } } diff --git a/src/JsonApiDotNetCore/Resources/JsonApiResourceDefinition.cs b/src/JsonApiDotNetCore/Resources/JsonApiResourceDefinition.cs index da1d76b395..99f575a293 100644 --- a/src/JsonApiDotNetCore/Resources/JsonApiResourceDefinition.cs +++ b/src/JsonApiDotNetCore/Resources/JsonApiResourceDefinition.cs @@ -1,173 +1,167 @@ -using System; -using System.Collections.Generic; using System.Collections.Immutable; using System.ComponentModel; -using System.Linq; using System.Linq.Expressions; -using System.Threading; -using System.Threading.Tasks; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Resources -{ - /// - [PublicAPI] - public class JsonApiResourceDefinition : IResourceDefinition - where TResource : class, IIdentifiable - { - protected IResourceGraph ResourceGraph { get; } +namespace JsonApiDotNetCore.Resources; - /// - /// Provides metadata for the resource type . - /// - protected ResourceType ResourceType { get; } +/// +[PublicAPI] +public class JsonApiResourceDefinition : IResourceDefinition + where TResource : class, IIdentifiable +{ + protected IResourceGraph ResourceGraph { get; } - public JsonApiResourceDefinition(IResourceGraph resourceGraph) - { - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); + /// + /// Provides metadata for the resource type . + /// + protected ResourceType ResourceType { get; } - ResourceGraph = resourceGraph; - ResourceType = resourceGraph.GetResourceType(); - } + public JsonApiResourceDefinition(IResourceGraph resourceGraph) + { + ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); - /// - public virtual IImmutableSet OnApplyIncludes(IImmutableSet existingIncludes) - { - return existingIncludes; - } + ResourceGraph = resourceGraph; + ResourceType = resourceGraph.GetResourceType(); + } - /// - public virtual FilterExpression? OnApplyFilter(FilterExpression? existingFilter) - { - return existingFilter; - } + /// + public virtual IImmutableSet OnApplyIncludes(IImmutableSet existingIncludes) + { + return existingIncludes; + } - /// - public virtual SortExpression? OnApplySort(SortExpression? existingSort) - { - return existingSort; - } + /// + public virtual FilterExpression? OnApplyFilter(FilterExpression? existingFilter) + { + return existingFilter; + } - /// - /// Creates a from a lambda expression. - /// - /// - /// model.CreatedAt, ListSortDirection.Ascending), - /// (model => model.Password, ListSortDirection.Descending) - /// }); - /// ]]> - /// - protected SortExpression CreateSortExpressionFromLambda(PropertySortOrder keySelectors) - { - ArgumentGuard.NotNullNorEmpty(keySelectors, nameof(keySelectors)); + /// + public virtual SortExpression? OnApplySort(SortExpression? existingSort) + { + return existingSort; + } - ImmutableArray.Builder elementsBuilder = ImmutableArray.CreateBuilder(keySelectors.Count); + /// + /// Creates a from a lambda expression. + /// + /// + /// model.CreatedAt, ListSortDirection.Ascending), + /// (model => model.Password, ListSortDirection.Descending) + /// }); + /// ]]> + /// + protected SortExpression CreateSortExpressionFromLambda(PropertySortOrder keySelectors) + { + ArgumentGuard.NotNullNorEmpty(keySelectors, nameof(keySelectors)); - foreach ((Expression> keySelector, ListSortDirection sortDirection) in keySelectors) - { - bool isAscending = sortDirection == ListSortDirection.Ascending; - AttrAttribute attribute = ResourceGraph.GetAttributes(keySelector).Single(); + ImmutableArray.Builder elementsBuilder = ImmutableArray.CreateBuilder(keySelectors.Count); - var sortElement = new SortElementExpression(new ResourceFieldChainExpression(attribute), isAscending); - elementsBuilder.Add(sortElement); - } + foreach ((Expression> keySelector, ListSortDirection sortDirection) in keySelectors) + { + bool isAscending = sortDirection == ListSortDirection.Ascending; + AttrAttribute attribute = ResourceGraph.GetAttributes(keySelector).Single(); - return new SortExpression(elementsBuilder.ToImmutable()); + var sortElement = new SortElementExpression(new ResourceFieldChainExpression(attribute), isAscending); + elementsBuilder.Add(sortElement); } - /// - public virtual PaginationExpression? OnApplyPagination(PaginationExpression? existingPagination) - { - return existingPagination; - } + return new SortExpression(elementsBuilder.ToImmutable()); + } - /// - public virtual SparseFieldSetExpression? OnApplySparseFieldSet(SparseFieldSetExpression? existingSparseFieldSet) - { - return existingSparseFieldSet; - } + /// + public virtual PaginationExpression? OnApplyPagination(PaginationExpression? existingPagination) + { + return existingPagination; + } - /// - public virtual QueryStringParameterHandlers? OnRegisterQueryableHandlersForQueryStringParameters() - { - return null; - } + /// + public virtual SparseFieldSetExpression? OnApplySparseFieldSet(SparseFieldSetExpression? existingSparseFieldSet) + { + return existingSparseFieldSet; + } - /// - public virtual IDictionary? GetMeta(TResource resource) - { - return null; - } + /// + public virtual QueryStringParameterHandlers? OnRegisterQueryableHandlersForQueryStringParameters() + { + return null; + } - /// - public virtual Task OnPrepareWriteAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) - { - return Task.CompletedTask; - } + /// + public virtual IDictionary? GetMeta(TResource resource) + { + return null; + } - /// - public virtual Task OnSetToOneRelationshipAsync(TResource leftResource, HasOneAttribute hasOneRelationship, - IIdentifiable? rightResourceId, WriteOperationKind writeOperation, CancellationToken cancellationToken) - { - return Task.FromResult(rightResourceId); - } + /// + public virtual Task OnPrepareWriteAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } - /// - public virtual Task OnSetToManyRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, - WriteOperationKind writeOperation, CancellationToken cancellationToken) - { - return Task.CompletedTask; - } + /// + public virtual Task OnSetToOneRelationshipAsync(TResource leftResource, HasOneAttribute hasOneRelationship, IIdentifiable? rightResourceId, + WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + return Task.FromResult(rightResourceId); + } - /// - public virtual Task OnAddToRelationshipAsync(TId leftResourceId, HasManyAttribute hasManyRelationship, ISet rightResourceIds, - CancellationToken cancellationToken) - { - return Task.CompletedTask; - } + /// + public virtual Task OnSetToManyRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } - /// - public virtual Task OnRemoveFromRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, - CancellationToken cancellationToken) - { - return Task.CompletedTask; - } + /// + public virtual Task OnAddToRelationshipAsync(TId leftResourceId, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + CancellationToken cancellationToken) + { + return Task.CompletedTask; + } - /// - public virtual Task OnWritingAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) - { - return Task.CompletedTask; - } + /// + public virtual Task OnRemoveFromRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + CancellationToken cancellationToken) + { + return Task.CompletedTask; + } - /// - public virtual Task OnWriteSucceededAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) - { - return Task.CompletedTask; - } + /// + public virtual Task OnWritingAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } - /// - public virtual void OnDeserialize(TResource resource) - { - } + /// + public virtual Task OnWriteSucceededAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } - /// - public virtual void OnSerialize(TResource resource) - { - } + /// + public virtual void OnDeserialize(TResource resource) + { + } - /// - /// 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)> - { - } + /// + public virtual void OnSerialize(TResource resource) + { + } + + /// + /// 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)> + { } } diff --git a/src/JsonApiDotNetCore/Resources/OperationContainer.cs b/src/JsonApiDotNetCore/Resources/OperationContainer.cs index 4336fef3f3..91f2553fd2 100644 --- a/src/JsonApiDotNetCore/Resources/OperationContainer.cs +++ b/src/JsonApiDotNetCore/Resources/OperationContainer.cs @@ -1,63 +1,61 @@ -using System.Collections.Generic; using JetBrains.Annotations; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Resources -{ - /// - /// Represents a write operation on a JSON:API resource. - /// - [PublicAPI] - public sealed class OperationContainer - { - private static readonly CollectionConverter CollectionConverter = new(); +namespace JsonApiDotNetCore.Resources; - public IIdentifiable Resource { get; } - public ITargetedFields TargetedFields { get; } - public IJsonApiRequest Request { get; } +/// +/// Represents a write operation on a JSON:API resource. +/// +[PublicAPI] +public sealed class OperationContainer +{ + private static readonly CollectionConverter CollectionConverter = new(); - public OperationContainer(IIdentifiable resource, ITargetedFields targetedFields, IJsonApiRequest request) - { - ArgumentGuard.NotNull(resource, nameof(resource)); - ArgumentGuard.NotNull(targetedFields, nameof(targetedFields)); - ArgumentGuard.NotNull(request, nameof(request)); + public IIdentifiable Resource { get; } + public ITargetedFields TargetedFields { get; } + public IJsonApiRequest Request { get; } - Resource = resource; - TargetedFields = targetedFields; - Request = request; - } + public OperationContainer(IIdentifiable resource, ITargetedFields targetedFields, IJsonApiRequest request) + { + ArgumentGuard.NotNull(resource, nameof(resource)); + ArgumentGuard.NotNull(targetedFields, nameof(targetedFields)); + ArgumentGuard.NotNull(request, nameof(request)); - public void SetTransactionId(string transactionId) - { - ((JsonApiRequest)Request).TransactionId = transactionId; - } + Resource = resource; + TargetedFields = targetedFields; + Request = request; + } - public OperationContainer WithResource(IIdentifiable resource) - { - ArgumentGuard.NotNull(resource, nameof(resource)); + public void SetTransactionId(string transactionId) + { + ((JsonApiRequest)Request).TransactionId = transactionId; + } - return new OperationContainer(resource, TargetedFields, Request); - } + public OperationContainer WithResource(IIdentifiable resource) + { + ArgumentGuard.NotNull(resource, nameof(resource)); - public ISet GetSecondaryResources() - { - var secondaryResources = new HashSet(IdentifiableComparer.Instance); + return new OperationContainer(resource, TargetedFields, Request); + } - foreach (RelationshipAttribute relationship in TargetedFields.Relationships) - { - AddSecondaryResources(relationship, secondaryResources); - } + public ISet GetSecondaryResources() + { + var secondaryResources = new HashSet(IdentifiableComparer.Instance); - return secondaryResources; + foreach (RelationshipAttribute relationship in TargetedFields.Relationships) + { + AddSecondaryResources(relationship, secondaryResources); } - private void AddSecondaryResources(RelationshipAttribute relationship, HashSet secondaryResources) - { - object? rightValue = relationship.GetValue(Resource); - ICollection rightResources = CollectionConverter.ExtractResources(rightValue); + return secondaryResources; + } - secondaryResources.AddRange(rightResources); - } + private void AddSecondaryResources(RelationshipAttribute relationship, HashSet secondaryResources) + { + object? rightValue = relationship.GetValue(Resource); + ICollection rightResources = CollectionConverter.ExtractResources(rightValue); + + secondaryResources.AddRange(rightResources); } } diff --git a/src/JsonApiDotNetCore/Resources/QueryStringParameterHandlers.cs b/src/JsonApiDotNetCore/Resources/QueryStringParameterHandlers.cs index 1f5d412252..52c9f7d71a 100644 --- a/src/JsonApiDotNetCore/Resources/QueryStringParameterHandlers.cs +++ b/src/JsonApiDotNetCore/Resources/QueryStringParameterHandlers.cs @@ -1,15 +1,11 @@ -using System; -using System.Collections.Generic; -using System.Linq; using Microsoft.Extensions.Primitives; -namespace JsonApiDotNetCore.Resources +namespace JsonApiDotNetCore.Resources; + +/// +/// This is an alias type intended to simplify the implementation's method signature. See +/// for usage details. +/// +public sealed class QueryStringParameterHandlers : Dictionary, StringValues, IQueryable>> { - /// - /// 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/ResourceChangeTracker.cs b/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs index 92d797e14e..7ccd381456 100644 --- a/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs +++ b/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs @@ -1,100 +1,98 @@ -using System.Collections.Generic; using System.Text.Json; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Resources -{ - /// - [PublicAPI] - public sealed class ResourceChangeTracker : IResourceChangeTracker - where TResource : class, IIdentifiable - { - private readonly ResourceType _resourceType; - private readonly ITargetedFields _targetedFields; +namespace JsonApiDotNetCore.Resources; - private IDictionary? _initiallyStoredAttributeValues; - private IDictionary? _requestAttributeValues; - private IDictionary? _finallyStoredAttributeValues; +/// +[PublicAPI] +public sealed class ResourceChangeTracker : IResourceChangeTracker + where TResource : class, IIdentifiable +{ + private readonly ResourceType _resourceType; + private readonly ITargetedFields _targetedFields; - public ResourceChangeTracker(IResourceGraph resourceGraph, ITargetedFields targetedFields) - { - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); - ArgumentGuard.NotNull(targetedFields, nameof(targetedFields)); + private IDictionary? _initiallyStoredAttributeValues; + private IDictionary? _requestAttributeValues; + private IDictionary? _finallyStoredAttributeValues; - _resourceType = resourceGraph.GetResourceType(); - _targetedFields = targetedFields; - } + public ResourceChangeTracker(IResourceGraph resourceGraph, ITargetedFields targetedFields) + { + ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); + ArgumentGuard.NotNull(targetedFields, nameof(targetedFields)); - /// - public void SetInitiallyStoredAttributeValues(TResource resource) - { - ArgumentGuard.NotNull(resource, nameof(resource)); + _resourceType = resourceGraph.GetResourceType(); + _targetedFields = targetedFields; + } - _initiallyStoredAttributeValues = CreateAttributeDictionary(resource, _resourceType.Attributes); - } + /// + public void SetInitiallyStoredAttributeValues(TResource resource) + { + ArgumentGuard.NotNull(resource, nameof(resource)); - /// - public void SetRequestAttributeValues(TResource resource) - { - ArgumentGuard.NotNull(resource, nameof(resource)); + _initiallyStoredAttributeValues = CreateAttributeDictionary(resource, _resourceType.Attributes); + } - _requestAttributeValues = CreateAttributeDictionary(resource, _targetedFields.Attributes); - } + /// + public void SetRequestAttributeValues(TResource resource) + { + ArgumentGuard.NotNull(resource, nameof(resource)); - /// - public void SetFinallyStoredAttributeValues(TResource resource) - { - ArgumentGuard.NotNull(resource, nameof(resource)); + _requestAttributeValues = CreateAttributeDictionary(resource, _targetedFields.Attributes); + } - _finallyStoredAttributeValues = CreateAttributeDictionary(resource, _resourceType.Attributes); - } + /// + public void SetFinallyStoredAttributeValues(TResource resource) + { + ArgumentGuard.NotNull(resource, nameof(resource)); - private IDictionary CreateAttributeDictionary(TResource resource, IEnumerable attributes) - { - var result = new Dictionary(); + _finallyStoredAttributeValues = CreateAttributeDictionary(resource, _resourceType.Attributes); + } - foreach (AttrAttribute attribute in attributes) - { - object? value = attribute.GetValue(resource); - string json = JsonSerializer.Serialize(value); - result.Add(attribute.PublicName, json); - } + private IDictionary CreateAttributeDictionary(TResource resource, IEnumerable attributes) + { + var result = new Dictionary(); - return result; + foreach (AttrAttribute attribute in attributes) + { + object? value = attribute.GetValue(resource); + string json = JsonSerializer.Serialize(value); + result.Add(attribute.PublicName, json); } - /// - public bool HasImplicitChanges() + return result; + } + + /// + public bool HasImplicitChanges() + { + if (_initiallyStoredAttributeValues != null && _requestAttributeValues != null && _finallyStoredAttributeValues != null) { - if (_initiallyStoredAttributeValues != null && _requestAttributeValues != null && _finallyStoredAttributeValues != null) + foreach (string key in _initiallyStoredAttributeValues.Keys) { - foreach (string key in _initiallyStoredAttributeValues.Keys) + if (_requestAttributeValues.TryGetValue(key, out string? requestValue)) { - if (_requestAttributeValues.TryGetValue(key, out string? requestValue)) - { - string actualValue = _finallyStoredAttributeValues[key]; + string actualValue = _finallyStoredAttributeValues[key]; - if (requestValue != actualValue) - { - return true; - } - } - else + if (requestValue != actualValue) { - string initiallyStoredValue = _initiallyStoredAttributeValues[key]; - string finallyStoredValue = _finallyStoredAttributeValues[key]; + return true; + } + } + else + { + string initiallyStoredValue = _initiallyStoredAttributeValues[key]; + string finallyStoredValue = _finallyStoredAttributeValues[key]; - if (initiallyStoredValue != finallyStoredValue) - { - return true; - } + if (initiallyStoredValue != finallyStoredValue) + { + return true; } } } - - return false; } + + return false; } } diff --git a/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs b/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs index 1622a32bd2..a16b04cfea 100644 --- a/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs +++ b/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Generic; using System.Collections.Immutable; -using System.Threading; -using System.Threading.Tasks; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; @@ -10,206 +6,205 @@ using JsonApiDotNetCore.Resources.Annotations; using Microsoft.Extensions.DependencyInjection; -namespace JsonApiDotNetCore.Resources +namespace JsonApiDotNetCore.Resources; + +/// +[PublicAPI] +public class ResourceDefinitionAccessor : IResourceDefinitionAccessor { - /// - [PublicAPI] - public class ResourceDefinitionAccessor : IResourceDefinitionAccessor - { - private readonly IResourceGraph _resourceGraph; - private readonly IServiceProvider _serviceProvider; + private readonly IResourceGraph _resourceGraph; + private readonly IServiceProvider _serviceProvider; - public ResourceDefinitionAccessor(IResourceGraph resourceGraph, IServiceProvider serviceProvider) - { - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); - ArgumentGuard.NotNull(serviceProvider, nameof(serviceProvider)); + public ResourceDefinitionAccessor(IResourceGraph resourceGraph, IServiceProvider serviceProvider) + { + ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); + ArgumentGuard.NotNull(serviceProvider, nameof(serviceProvider)); - _resourceGraph = resourceGraph; - _serviceProvider = serviceProvider; - } + _resourceGraph = resourceGraph; + _serviceProvider = serviceProvider; + } - /// - public IImmutableSet OnApplyIncludes(ResourceType resourceType, IImmutableSet existingIncludes) - { - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + /// + public IImmutableSet OnApplyIncludes(ResourceType resourceType, IImmutableSet existingIncludes) + { + ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - dynamic resourceDefinition = ResolveResourceDefinition(resourceType); - return resourceDefinition.OnApplyIncludes(existingIncludes); - } + dynamic resourceDefinition = ResolveResourceDefinition(resourceType); + return resourceDefinition.OnApplyIncludes(existingIncludes); + } - /// - public FilterExpression? OnApplyFilter(ResourceType resourceType, FilterExpression? existingFilter) - { - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + /// + public FilterExpression? OnApplyFilter(ResourceType resourceType, FilterExpression? existingFilter) + { + ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - dynamic resourceDefinition = ResolveResourceDefinition(resourceType); - return resourceDefinition.OnApplyFilter(existingFilter); - } + dynamic resourceDefinition = ResolveResourceDefinition(resourceType); + return resourceDefinition.OnApplyFilter(existingFilter); + } - /// - public SortExpression? OnApplySort(ResourceType resourceType, SortExpression? existingSort) - { - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + /// + public SortExpression? OnApplySort(ResourceType resourceType, SortExpression? existingSort) + { + ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - dynamic resourceDefinition = ResolveResourceDefinition(resourceType); - return resourceDefinition.OnApplySort(existingSort); - } + dynamic resourceDefinition = ResolveResourceDefinition(resourceType); + return resourceDefinition.OnApplySort(existingSort); + } - /// - public PaginationExpression? OnApplyPagination(ResourceType resourceType, PaginationExpression? existingPagination) - { - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + /// + public PaginationExpression? OnApplyPagination(ResourceType resourceType, PaginationExpression? existingPagination) + { + ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - dynamic resourceDefinition = ResolveResourceDefinition(resourceType); - return resourceDefinition.OnApplyPagination(existingPagination); - } + dynamic resourceDefinition = ResolveResourceDefinition(resourceType); + return resourceDefinition.OnApplyPagination(existingPagination); + } - /// - public SparseFieldSetExpression? OnApplySparseFieldSet(ResourceType resourceType, SparseFieldSetExpression? existingSparseFieldSet) - { - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + /// + public SparseFieldSetExpression? OnApplySparseFieldSet(ResourceType resourceType, SparseFieldSetExpression? existingSparseFieldSet) + { + ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - dynamic resourceDefinition = ResolveResourceDefinition(resourceType); - return resourceDefinition.OnApplySparseFieldSet(existingSparseFieldSet); - } + dynamic resourceDefinition = ResolveResourceDefinition(resourceType); + return resourceDefinition.OnApplySparseFieldSet(existingSparseFieldSet); + } - /// - public object? GetQueryableHandlerForQueryStringParameter(Type resourceClrType, string parameterName) - { - ArgumentGuard.NotNull(resourceClrType, nameof(resourceClrType)); - ArgumentGuard.NotNullNorEmpty(parameterName, nameof(parameterName)); + /// + public object? GetQueryableHandlerForQueryStringParameter(Type resourceClrType, string parameterName) + { + ArgumentGuard.NotNull(resourceClrType, nameof(resourceClrType)); + ArgumentGuard.NotNullNorEmpty(parameterName, nameof(parameterName)); - dynamic resourceDefinition = ResolveResourceDefinition(resourceClrType); - dynamic handlers = resourceDefinition.OnRegisterQueryableHandlersForQueryStringParameters(); + dynamic resourceDefinition = ResolveResourceDefinition(resourceClrType); + dynamic handlers = resourceDefinition.OnRegisterQueryableHandlersForQueryStringParameters(); - if (handlers != null) + if (handlers != null) + { + if (handlers.ContainsKey(parameterName)) { - if (handlers.ContainsKey(parameterName)) - { - return handlers[parameterName]; - } + return handlers[parameterName]; } - - return null; } - /// - public IDictionary? GetMeta(ResourceType resourceType, IIdentifiable resourceInstance) - { - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + return null; + } - dynamic resourceDefinition = ResolveResourceDefinition(resourceType); - return resourceDefinition.GetMeta((dynamic)resourceInstance); - } + /// + public IDictionary? GetMeta(ResourceType resourceType, IIdentifiable resourceInstance) + { + ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - /// - public async Task OnPrepareWriteAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { - ArgumentGuard.NotNull(resource, nameof(resource)); + dynamic resourceDefinition = ResolveResourceDefinition(resourceType); + return resourceDefinition.GetMeta((dynamic)resourceInstance); + } - dynamic resourceDefinition = ResolveResourceDefinition(typeof(TResource)); - await resourceDefinition.OnPrepareWriteAsync(resource, writeOperation, cancellationToken); - } + /// + public async Task OnPrepareWriteAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + ArgumentGuard.NotNull(resource, nameof(resource)); - /// - public async Task OnSetToOneRelationshipAsync(TResource leftResource, HasOneAttribute hasOneRelationship, - IIdentifiable? rightResourceId, WriteOperationKind writeOperation, CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { - ArgumentGuard.NotNull(leftResource, nameof(leftResource)); - ArgumentGuard.NotNull(hasOneRelationship, nameof(hasOneRelationship)); + dynamic resourceDefinition = ResolveResourceDefinition(typeof(TResource)); + await resourceDefinition.OnPrepareWriteAsync(resource, writeOperation, cancellationToken); + } - dynamic resourceDefinition = ResolveResourceDefinition(typeof(TResource)); - return await resourceDefinition.OnSetToOneRelationshipAsync(leftResource, hasOneRelationship, rightResourceId, writeOperation, cancellationToken); - } + /// + public async Task OnSetToOneRelationshipAsync(TResource leftResource, HasOneAttribute hasOneRelationship, + IIdentifiable? rightResourceId, WriteOperationKind writeOperation, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + ArgumentGuard.NotNull(leftResource, nameof(leftResource)); + ArgumentGuard.NotNull(hasOneRelationship, nameof(hasOneRelationship)); - /// - public async Task OnSetToManyRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, - ISet rightResourceIds, WriteOperationKind writeOperation, CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { - ArgumentGuard.NotNull(leftResource, nameof(leftResource)); - ArgumentGuard.NotNull(hasManyRelationship, nameof(hasManyRelationship)); - ArgumentGuard.NotNull(rightResourceIds, nameof(rightResourceIds)); + dynamic resourceDefinition = ResolveResourceDefinition(typeof(TResource)); + return await resourceDefinition.OnSetToOneRelationshipAsync(leftResource, hasOneRelationship, rightResourceId, writeOperation, cancellationToken); + } - dynamic resourceDefinition = ResolveResourceDefinition(typeof(TResource)); - await resourceDefinition.OnSetToManyRelationshipAsync(leftResource, hasManyRelationship, rightResourceIds, writeOperation, cancellationToken); - } + /// + public async Task OnSetToManyRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, + ISet rightResourceIds, WriteOperationKind writeOperation, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + ArgumentGuard.NotNull(leftResource, nameof(leftResource)); + ArgumentGuard.NotNull(hasManyRelationship, nameof(hasManyRelationship)); + ArgumentGuard.NotNull(rightResourceIds, nameof(rightResourceIds)); - /// - public async Task OnAddToRelationshipAsync(TId leftResourceId, HasManyAttribute hasManyRelationship, - ISet rightResourceIds, CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { - ArgumentGuard.NotNull(hasManyRelationship, nameof(hasManyRelationship)); - ArgumentGuard.NotNull(rightResourceIds, nameof(rightResourceIds)); + dynamic resourceDefinition = ResolveResourceDefinition(typeof(TResource)); + await resourceDefinition.OnSetToManyRelationshipAsync(leftResource, hasManyRelationship, rightResourceIds, writeOperation, cancellationToken); + } - dynamic resourceDefinition = ResolveResourceDefinition(typeof(TResource)); - await resourceDefinition.OnAddToRelationshipAsync(leftResourceId, hasManyRelationship, rightResourceIds, cancellationToken); - } + /// + public async Task OnAddToRelationshipAsync(TId leftResourceId, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + ArgumentGuard.NotNull(hasManyRelationship, nameof(hasManyRelationship)); + ArgumentGuard.NotNull(rightResourceIds, nameof(rightResourceIds)); - /// - public async Task OnRemoveFromRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, - ISet rightResourceIds, CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { - ArgumentGuard.NotNull(leftResource, nameof(leftResource)); - ArgumentGuard.NotNull(hasManyRelationship, nameof(hasManyRelationship)); - ArgumentGuard.NotNull(rightResourceIds, nameof(rightResourceIds)); + dynamic resourceDefinition = ResolveResourceDefinition(typeof(TResource)); + await resourceDefinition.OnAddToRelationshipAsync(leftResourceId, hasManyRelationship, rightResourceIds, cancellationToken); + } - dynamic resourceDefinition = ResolveResourceDefinition(typeof(TResource)); - await resourceDefinition.OnRemoveFromRelationshipAsync(leftResource, hasManyRelationship, rightResourceIds, cancellationToken); - } + /// + public async Task OnRemoveFromRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, + ISet rightResourceIds, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + ArgumentGuard.NotNull(leftResource, nameof(leftResource)); + ArgumentGuard.NotNull(hasManyRelationship, nameof(hasManyRelationship)); + ArgumentGuard.NotNull(rightResourceIds, nameof(rightResourceIds)); - /// - public async Task OnWritingAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { - ArgumentGuard.NotNull(resource, nameof(resource)); + dynamic resourceDefinition = ResolveResourceDefinition(typeof(TResource)); + await resourceDefinition.OnRemoveFromRelationshipAsync(leftResource, hasManyRelationship, rightResourceIds, cancellationToken); + } - dynamic resourceDefinition = ResolveResourceDefinition(typeof(TResource)); - await resourceDefinition.OnWritingAsync(resource, writeOperation, cancellationToken); - } + /// + public async Task OnWritingAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + ArgumentGuard.NotNull(resource, nameof(resource)); - /// - public async Task OnWriteSucceededAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { - ArgumentGuard.NotNull(resource, nameof(resource)); + dynamic resourceDefinition = ResolveResourceDefinition(typeof(TResource)); + await resourceDefinition.OnWritingAsync(resource, writeOperation, cancellationToken); + } - dynamic resourceDefinition = ResolveResourceDefinition(typeof(TResource)); - await resourceDefinition.OnWriteSucceededAsync(resource, writeOperation, cancellationToken); - } + /// + public async Task OnWriteSucceededAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + ArgumentGuard.NotNull(resource, nameof(resource)); - /// - public void OnDeserialize(IIdentifiable resource) - { - ArgumentGuard.NotNull(resource, nameof(resource)); + dynamic resourceDefinition = ResolveResourceDefinition(typeof(TResource)); + await resourceDefinition.OnWriteSucceededAsync(resource, writeOperation, cancellationToken); + } - dynamic resourceDefinition = ResolveResourceDefinition(resource.GetType()); - resourceDefinition.OnDeserialize((dynamic)resource); - } + /// + public void OnDeserialize(IIdentifiable resource) + { + ArgumentGuard.NotNull(resource, nameof(resource)); - /// - public void OnSerialize(IIdentifiable resource) - { - ArgumentGuard.NotNull(resource, nameof(resource)); + dynamic resourceDefinition = ResolveResourceDefinition(resource.GetType()); + resourceDefinition.OnDeserialize((dynamic)resource); + } - dynamic resourceDefinition = ResolveResourceDefinition(resource.GetType()); - resourceDefinition.OnSerialize((dynamic)resource); - } + /// + public void OnSerialize(IIdentifiable resource) + { + ArgumentGuard.NotNull(resource, nameof(resource)); - protected object ResolveResourceDefinition(Type resourceClrType) - { - ResourceType resourceType = _resourceGraph.GetResourceType(resourceClrType); - return ResolveResourceDefinition(resourceType); - } + dynamic resourceDefinition = ResolveResourceDefinition(resource.GetType()); + resourceDefinition.OnSerialize((dynamic)resource); + } - protected virtual object ResolveResourceDefinition(ResourceType resourceType) - { - Type resourceDefinitionType = typeof(IResourceDefinition<,>).MakeGenericType(resourceType.ClrType, resourceType.IdentityClrType); - return _serviceProvider.GetRequiredService(resourceDefinitionType); - } + protected object ResolveResourceDefinition(Type resourceClrType) + { + ResourceType resourceType = _resourceGraph.GetResourceType(resourceClrType); + return ResolveResourceDefinition(resourceType); + } + + protected virtual object ResolveResourceDefinition(ResourceType resourceType) + { + Type resourceDefinitionType = typeof(IResourceDefinition<,>).MakeGenericType(resourceType.ClrType, resourceType.IdentityClrType); + return _serviceProvider.GetRequiredService(resourceDefinitionType); } } diff --git a/src/JsonApiDotNetCore/Resources/ResourceFactory.cs b/src/JsonApiDotNetCore/Resources/ResourceFactory.cs index f5e305a29f..fa3e571c7c 100644 --- a/src/JsonApiDotNetCore/Resources/ResourceFactory.cs +++ b/src/JsonApiDotNetCore/Resources/ResourceFactory.cs @@ -1,142 +1,124 @@ -using System; -using System.Collections.Generic; -using System.Linq; using System.Linq.Expressions; using System.Reflection; +using JsonApiDotNetCore.Queries.Internal; using Microsoft.Extensions.DependencyInjection; -namespace JsonApiDotNetCore.Resources +namespace JsonApiDotNetCore.Resources; + +/// +internal sealed class ResourceFactory : IResourceFactory { + private readonly IServiceProvider _serviceProvider; + + public ResourceFactory(IServiceProvider serviceProvider) + { + ArgumentGuard.NotNull(serviceProvider, nameof(serviceProvider)); + + _serviceProvider = serviceProvider; + } + /// - internal sealed class ResourceFactory : IResourceFactory + public IIdentifiable CreateInstance(Type resourceClrType) { - private readonly IServiceProvider _serviceProvider; + ArgumentGuard.NotNull(resourceClrType, nameof(resourceClrType)); - public ResourceFactory(IServiceProvider serviceProvider) - { - ArgumentGuard.NotNull(serviceProvider, nameof(serviceProvider)); + return InnerCreateInstance(resourceClrType, _serviceProvider); + } - _serviceProvider = serviceProvider; - } + /// + public TResource CreateInstance() + where TResource : IIdentifiable + { + return (TResource)InnerCreateInstance(typeof(TResource), _serviceProvider); + } - /// - public IIdentifiable CreateInstance(Type resourceClrType) - { - ArgumentGuard.NotNull(resourceClrType, nameof(resourceClrType)); + private static IIdentifiable InnerCreateInstance(Type type, IServiceProvider serviceProvider) + { + bool hasSingleConstructorWithoutParameters = HasSingleConstructorWithoutParameters(type); - return InnerCreateInstance(resourceClrType, _serviceProvider); + try + { + return hasSingleConstructorWithoutParameters + ? (IIdentifiable)Activator.CreateInstance(type)! + : (IIdentifiable)ActivatorUtilities.CreateInstance(serviceProvider, type); } - - /// - public TResource CreateInstance() - where TResource : IIdentifiable +#pragma warning disable AV1210 // Catch a specific exception instead of Exception, SystemException or ApplicationException + catch (Exception exception) +#pragma warning restore AV1210 // Catch a specific exception instead of Exception, SystemException or ApplicationException { - return (TResource)InnerCreateInstance(typeof(TResource), _serviceProvider); + 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 resourceClrType) + { + ArgumentGuard.NotNull(resourceClrType, nameof(resourceClrType)); - private static IIdentifiable InnerCreateInstance(Type type, IServiceProvider serviceProvider) + if (HasSingleConstructorWithoutParameters(resourceClrType)) { - bool hasSingleConstructorWithoutParameters = HasSingleConstructorWithoutParameters(type); + return Expression.New(resourceClrType); + } + + var constructorArguments = new List(); + + ConstructorInfo longestConstructor = GetLongestConstructor(resourceClrType); + foreach (ParameterInfo constructorParameter in longestConstructor.GetParameters()) + { try { - return hasSingleConstructorWithoutParameters - ? (IIdentifiable)Activator.CreateInstance(type)! - : (IIdentifiable)ActivatorUtilities.CreateInstance(serviceProvider, type); + object constructorArgument = ActivatorUtilities.GetServiceOrCreateInstance(_serviceProvider, constructorParameter.ParameterType); + + Expression argumentExpression = constructorArgument.CreateTupleAccessExpressionForConstant(constructorArgument.GetType()); + constructorArguments.Add(argumentExpression); } #pragma warning disable AV1210 // Catch a specific exception instead of Exception, SystemException or ApplicationException catch (Exception exception) #pragma warning restore AV1210 // Catch a specific exception instead of Exception, SystemException or ApplicationException { 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); + $"Failed to create an instance of '{resourceClrType.FullName}': Parameter '{constructorParameter.Name}' could not be resolved.", exception); } } - /// - public NewExpression CreateNewExpression(Type resourceClrType) - { - ArgumentGuard.NotNull(resourceClrType, nameof(resourceClrType)); - - if (HasSingleConstructorWithoutParameters(resourceClrType)) - { - return Expression.New(resourceClrType); - } - - var constructorArguments = new List(); - - ConstructorInfo longestConstructor = GetLongestConstructor(resourceClrType); - - foreach (ParameterInfo constructorParameter in longestConstructor.GetParameters()) - { - try - { - object constructorArgument = ActivatorUtilities.GetServiceOrCreateInstance(_serviceProvider, constructorParameter.ParameterType); + return Expression.New(longestConstructor, constructorArguments); + } - Expression argumentExpression = CreateTupleAccessExpressionForConstant(constructorArgument, constructorArgument.GetType()); + private static bool HasSingleConstructorWithoutParameters(Type type) + { + ConstructorInfo[] constructors = type.GetConstructors().Where(constructor => !constructor.IsStatic).ToArray(); - constructorArguments.Add(argumentExpression); - } -#pragma warning disable AV1210 // Catch a specific exception instead of Exception, SystemException or ApplicationException - catch (Exception exception) -#pragma warning restore AV1210 // Catch a specific exception instead of Exception, SystemException or ApplicationException - { - throw new InvalidOperationException( - $"Failed to create an instance of '{resourceClrType.FullName}': Parameter '{constructorParameter.Name}' could not be resolved.", - exception); - } - } + return constructors.Length == 1 && constructors[0].GetParameters().Length == 0; + } - return Expression.New(longestConstructor, constructorArguments); - } + private static ConstructorInfo GetLongestConstructor(Type type) + { + ConstructorInfo[] constructors = type.GetConstructors().Where(constructor => !constructor.IsStatic).ToArray(); - private static Expression CreateTupleAccessExpressionForConstant(object value, Type type) + if (constructors.Length == 0) { - MethodInfo tupleCreateMethod = typeof(Tuple).GetMethods() - .Single(method => method.Name == "Create" && method.IsGenericMethod && method.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"); + throw new InvalidOperationException($"No public constructor was found for '{type.FullName}'."); } - private static bool HasSingleConstructorWithoutParameters(Type type) - { - ConstructorInfo[] constructors = type.GetConstructors().Where(constructor => !constructor.IsStatic).ToArray(); - - return constructors.Length == 1 && constructors[0].GetParameters().Length == 0; - } + ConstructorInfo bestMatch = constructors[0]; + int maxParameterLength = constructors[0].GetParameters().Length; - private static ConstructorInfo GetLongestConstructor(Type type) + for (int index = 1; index < constructors.Length; index++) { - ConstructorInfo[] constructors = type.GetConstructors().Where(constructor => !constructor.IsStatic).ToArray(); + ConstructorInfo constructor = constructors[index]; + int length = constructor.GetParameters().Length; - if (constructors.Length == 0) + if (length > maxParameterLength) { - throw new InvalidOperationException($"No public constructor was found for '{type.FullName}'."); + bestMatch = constructor; + maxParameterLength = length; } - - ConstructorInfo bestMatch = constructors[0]; - int maxParameterLength = constructors[0].GetParameters().Length; - - for (int index = 1; index < constructors.Length; index++) - { - ConstructorInfo constructor = constructors[index]; - int length = constructor.GetParameters().Length; - - if (length > maxParameterLength) - { - bestMatch = constructor; - maxParameterLength = length; - } - } - - return bestMatch; } + + return bestMatch; } } diff --git a/src/JsonApiDotNetCore/Resources/TargetedFields.cs b/src/JsonApiDotNetCore/Resources/TargetedFields.cs index 4e2d571e5c..fe4701c61e 100644 --- a/src/JsonApiDotNetCore/Resources/TargetedFields.cs +++ b/src/JsonApiDotNetCore/Resources/TargetedFields.cs @@ -1,32 +1,30 @@ -using System.Collections.Generic; using JetBrains.Annotations; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Resources +namespace JsonApiDotNetCore.Resources; + +/// +[PublicAPI] +public sealed class TargetedFields : ITargetedFields { - /// - [PublicAPI] - public sealed class TargetedFields : ITargetedFields - { - IReadOnlySet ITargetedFields.Attributes => Attributes; - IReadOnlySet ITargetedFields.Relationships => Relationships; + IReadOnlySet ITargetedFields.Attributes => Attributes; + IReadOnlySet ITargetedFields.Relationships => Relationships; - public HashSet Attributes { get; } = new(); - public HashSet Relationships { get; } = new(); + public HashSet Attributes { get; } = new(); + public HashSet Relationships { get; } = new(); - /// - public void CopyFrom(ITargetedFields other) - { - Clear(); + /// + public void CopyFrom(ITargetedFields other) + { + Clear(); - Attributes.AddRange(other.Attributes); - Relationships.AddRange(other.Relationships); - } + Attributes.AddRange(other.Attributes); + Relationships.AddRange(other.Relationships); + } - public void Clear() - { - Attributes.Clear(); - Relationships.Clear(); - } + public void Clear() + { + Attributes.Clear(); + Relationships.Clear(); } } diff --git a/src/JsonApiDotNetCore/Serialization/JsonConverters/JsonObjectConverter.cs b/src/JsonApiDotNetCore/Serialization/JsonConverters/JsonObjectConverter.cs index f77f21cdab..32e4351e12 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonConverters/JsonObjectConverter.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonConverters/JsonObjectConverter.cs @@ -1,35 +1,34 @@ using System.Text.Json; using System.Text.Json.Serialization; -namespace JsonApiDotNetCore.Serialization.JsonConverters +namespace JsonApiDotNetCore.Serialization.JsonConverters; + +public abstract class JsonObjectConverter : JsonConverter { - public abstract class JsonObjectConverter : JsonConverter + protected static TValue? ReadSubTree(ref Utf8JsonReader reader, JsonSerializerOptions options) { - protected static TValue? ReadSubTree(ref Utf8JsonReader reader, JsonSerializerOptions options) + if (typeof(TValue) != typeof(object) && options.GetConverter(typeof(TValue)) is JsonConverter converter) { - if (typeof(TValue) != typeof(object) && options.GetConverter(typeof(TValue)) is JsonConverter converter) - { - return converter.Read(ref reader, typeof(TValue), options); - } - - return JsonSerializer.Deserialize(ref reader, options); + return converter.Read(ref reader, typeof(TValue), options); } - protected static void WriteSubTree(Utf8JsonWriter writer, TValue value, JsonSerializerOptions options) + return JsonSerializer.Deserialize(ref reader, options); + } + + protected static void WriteSubTree(Utf8JsonWriter writer, TValue value, JsonSerializerOptions options) + { + if (typeof(TValue) != typeof(object) && options.GetConverter(typeof(TValue)) is JsonConverter converter) { - if (typeof(TValue) != typeof(object) && options.GetConverter(typeof(TValue)) is JsonConverter converter) - { - converter.Write(writer, value, options); - } - else - { - JsonSerializer.Serialize(writer, value, options); - } + converter.Write(writer, value, options); } - - protected static JsonException GetEndOfStreamError() + else { - return new JsonException("Unexpected end of JSON stream."); + JsonSerializer.Serialize(writer, value, options); } } + + protected static JsonException GetEndOfStreamError() + { + return new JsonException("Unexpected end of JSON stream."); + } } diff --git a/src/JsonApiDotNetCore/Serialization/JsonConverters/ResourceObjectConverter.cs b/src/JsonApiDotNetCore/Serialization/JsonConverters/ResourceObjectConverter.cs index 9fb5e4c607..005e423add 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonConverters/ResourceObjectConverter.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonConverters/ResourceObjectConverter.cs @@ -1,5 +1,3 @@ -using System; -using System.Collections.Generic; using System.Reflection; using System.Text.Json; using JetBrains.Annotations; @@ -9,262 +7,261 @@ using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCore.Serialization.Request; -namespace JsonApiDotNetCore.Serialization.JsonConverters +namespace JsonApiDotNetCore.Serialization.JsonConverters; + +/// +/// Converts to/from JSON. +/// +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class ResourceObjectConverter : JsonObjectConverter { + private static readonly JsonEncodedText TypeText = JsonEncodedText.Encode("type"); + private static readonly JsonEncodedText IdText = JsonEncodedText.Encode("id"); + private static readonly JsonEncodedText LidText = JsonEncodedText.Encode("lid"); + private static readonly JsonEncodedText MetaText = JsonEncodedText.Encode("meta"); + private static readonly JsonEncodedText AttributesText = JsonEncodedText.Encode("attributes"); + private static readonly JsonEncodedText RelationshipsText = JsonEncodedText.Encode("relationships"); + private static readonly JsonEncodedText LinksText = JsonEncodedText.Encode("links"); + + private readonly IResourceGraph _resourceGraph; + + public ResourceObjectConverter(IResourceGraph resourceGraph) + { + ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); + + _resourceGraph = resourceGraph; + } + /// - /// Converts to/from JSON. + /// Resolves the resource type and attributes against the resource graph. Because attribute values in are typed as + /// , we must lookup and supply the target type to the serializer. /// - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class ResourceObjectConverter : JsonObjectConverter + public override ResourceObject Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - private static readonly JsonEncodedText TypeText = JsonEncodedText.Encode("type"); - private static readonly JsonEncodedText IdText = JsonEncodedText.Encode("id"); - private static readonly JsonEncodedText LidText = JsonEncodedText.Encode("lid"); - private static readonly JsonEncodedText MetaText = JsonEncodedText.Encode("meta"); - private static readonly JsonEncodedText AttributesText = JsonEncodedText.Encode("attributes"); - private static readonly JsonEncodedText RelationshipsText = JsonEncodedText.Encode("relationships"); - private static readonly JsonEncodedText LinksText = JsonEncodedText.Encode("links"); + // Inside a JsonConverter there is no way to know where in the JSON object tree we are. And the serializer is unable to provide + // the correct position either. So we avoid an exception on missing/invalid 'type' element and postpone producing an error response + // to the post-processing phase. - private readonly IResourceGraph _resourceGraph; - - public ResourceObjectConverter(IResourceGraph resourceGraph) + var resourceObject = new ResourceObject { - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); + // The 'attributes' element may occur before 'type', but we need to know the resource type before we can deserialize attributes + // into their corresponding CLR types. + Type = PeekType(ref reader) + }; - _resourceGraph = resourceGraph; - } + ResourceType? resourceType = resourceObject.Type != null ? _resourceGraph.FindResourceType(resourceObject.Type) : null; - /// - /// Resolves the resource type and attributes against the resource graph. Because attribute values in are typed as - /// , we must lookup and supply the target type to the serializer. - /// - public override ResourceObject Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + while (reader.Read()) { - // Inside a JsonConverter there is no way to know where in the JSON object tree we are. And the serializer is unable to provide - // the correct position either. So we avoid an exception on missing/invalid 'type' element and postpone producing an error response - // to the post-processing phase. - - var resourceObject = new ResourceObject - { - // The 'attributes' element may occur before 'type', but we need to know the resource type before we can deserialize attributes - // into their corresponding CLR types. - Type = PeekType(ref reader) - }; - - ResourceType? resourceType = resourceObject.Type != null ? _resourceGraph.FindResourceType(resourceObject.Type) : null; - - while (reader.Read()) + switch (reader.TokenType) { - switch (reader.TokenType) + case JsonTokenType.EndObject: { - case JsonTokenType.EndObject: - { - return resourceObject; - } - case JsonTokenType.PropertyName: - { - string? propertyName = reader.GetString(); - reader.Read(); + return resourceObject; + } + case JsonTokenType.PropertyName: + { + string? propertyName = reader.GetString(); + reader.Read(); - switch (propertyName) + switch (propertyName) + { + case "id": { - case "id": - { - if (reader.TokenType != JsonTokenType.String) - { - // Newtonsoft.Json used to auto-convert number to strings, while System.Text.Json does not. This is so likely - // to hit users during upgrade that we special-case for this and produce a helpful error message. - var jsonElement = ReadSubTree(ref reader, options); - throw new JsonException($"Failed to convert ID '{jsonElement}' of type '{jsonElement.ValueKind}' to type 'String'."); - } - - resourceObject.Id = reader.GetString(); - break; - } - case "lid": - { - resourceObject.Lid = reader.GetString(); - break; - } - case "attributes": - { - if (resourceType != null) - { - resourceObject.Attributes = ReadAttributes(ref reader, options, resourceType); - } - else - { - reader.Skip(); - } - - break; - } - case "relationships": - { - resourceObject.Relationships = ReadSubTree>(ref reader, options); - break; - } - case "links": + if (reader.TokenType != JsonTokenType.String) { - resourceObject.Links = ReadSubTree(ref reader, options); - break; + // Newtonsoft.Json used to auto-convert number to strings, while System.Text.Json does not. This is so likely + // to hit users during upgrade that we special-case for this and produce a helpful error message. + var jsonElement = ReadSubTree(ref reader, options); + throw new JsonException($"Failed to convert ID '{jsonElement}' of type '{jsonElement.ValueKind}' to type 'String'."); } - case "meta": + + resourceObject.Id = reader.GetString(); + break; + } + case "lid": + { + resourceObject.Lid = reader.GetString(); + break; + } + case "attributes": + { + if (resourceType != null) { - resourceObject.Meta = ReadSubTree>(ref reader, options); - break; + resourceObject.Attributes = ReadAttributes(ref reader, options, resourceType); } - default: + else { reader.Skip(); - break; } - } - - break; - } - } - } - throw GetEndOfStreamError(); - } - - private static string? PeekType(ref Utf8JsonReader reader) - { - // https://docs.microsoft.com/en-us/dotnet/standard/serialization/system-text-json-converters-how-to?pivots=dotnet-5-0#an-alternative-way-to-do-polymorphic-deserialization - Utf8JsonReader readerClone = reader; - - while (readerClone.Read()) - { - if (readerClone.TokenType == JsonTokenType.PropertyName) - { - string? propertyName = readerClone.GetString(); - readerClone.Read(); - - switch (propertyName) - { - case "type": + break; + } + case "relationships": + { + resourceObject.Relationships = ReadSubTree>(ref reader, options); + break; + } + case "links": + { + resourceObject.Links = ReadSubTree(ref reader, options); + break; + } + case "meta": { - return readerClone.GetString(); + resourceObject.Meta = ReadSubTree>(ref reader, options); + break; } default: { - readerClone.Skip(); + reader.Skip(); break; } } + + break; } } - - return null; } - private static IDictionary ReadAttributes(ref Utf8JsonReader reader, JsonSerializerOptions options, ResourceType resourceType) - { - var attributes = new Dictionary(); + throw GetEndOfStreamError(); + } - while (reader.Read()) + private static string? PeekType(ref Utf8JsonReader reader) + { + // https://docs.microsoft.com/en-us/dotnet/standard/serialization/system-text-json-converters-how-to?pivots=dotnet-5-0#an-alternative-way-to-do-polymorphic-deserialization + Utf8JsonReader readerClone = reader; + + while (readerClone.Read()) + { + if (readerClone.TokenType == JsonTokenType.PropertyName) { - switch (reader.TokenType) + string? propertyName = readerClone.GetString(); + readerClone.Read(); + + switch (propertyName) { - case JsonTokenType.EndObject: + case "type": { - return attributes; + return readerClone.GetString(); } - case JsonTokenType.PropertyName: + default: { - string attributeName = reader.GetString() ?? string.Empty; - reader.Read(); + readerClone.Skip(); + break; + } + } + } + } - AttrAttribute? attribute = resourceType.FindAttributeByPublicName(attributeName); - PropertyInfo? property = attribute?.Property; + return null; + } - if (property != null) - { - object? attributeValue; + private static IDictionary ReadAttributes(ref Utf8JsonReader reader, JsonSerializerOptions options, ResourceType resourceType) + { + var attributes = new Dictionary(); - if (property.Name == nameof(Identifiable.Id)) - { - attributeValue = JsonInvalidAttributeInfo.Id; - } - else - { - try - { - attributeValue = JsonSerializer.Deserialize(ref reader, property.PropertyType, options); - } - catch (JsonException) - { - // Inside a JsonConverter there is no way to know where in the JSON object tree we are. And the serializer - // is unable to provide the correct position either. So we avoid an exception and postpone producing an error - // response to the post-processing phase, by setting a sentinel value. - var jsonElement = ReadSubTree(ref reader, options); - - attributeValue = new JsonInvalidAttributeInfo(attributeName, property.PropertyType, jsonElement.ToString(), - jsonElement.ValueKind); - } - } + while (reader.Read()) + { + switch (reader.TokenType) + { + case JsonTokenType.EndObject: + { + return attributes; + } + case JsonTokenType.PropertyName: + { + string attributeName = reader.GetString() ?? string.Empty; + reader.Read(); - attributes.Add(attributeName, attributeValue); + AttrAttribute? attribute = resourceType.FindAttributeByPublicName(attributeName); + PropertyInfo? property = attribute?.Property; + + if (property != null) + { + object? attributeValue; + + if (property.Name == nameof(Identifiable.Id)) + { + attributeValue = JsonInvalidAttributeInfo.Id; } else { - attributes.Add(attributeName, null); - reader.Skip(); + try + { + attributeValue = JsonSerializer.Deserialize(ref reader, property.PropertyType, options); + } + catch (JsonException) + { + // Inside a JsonConverter there is no way to know where in the JSON object tree we are. And the serializer + // is unable to provide the correct position either. So we avoid an exception and postpone producing an error + // response to the post-processing phase, by setting a sentinel value. + var jsonElement = ReadSubTree(ref reader, options); + + attributeValue = new JsonInvalidAttributeInfo(attributeName, property.PropertyType, jsonElement.ToString(), + jsonElement.ValueKind); + } } - break; + attributes.Add(attributeName, attributeValue); } + else + { + attributes.Add(attributeName, null); + reader.Skip(); + } + + break; } } - - throw GetEndOfStreamError(); } - /// - /// Ensures that attribute values are not wrapped in s. - /// - public override void Write(Utf8JsonWriter writer, ResourceObject value, JsonSerializerOptions options) - { - writer.WriteStartObject(); + throw GetEndOfStreamError(); + } - writer.WriteString(TypeText, value.Type); + /// + /// Ensures that attribute values are not wrapped in s. + /// + public override void Write(Utf8JsonWriter writer, ResourceObject value, JsonSerializerOptions options) + { + writer.WriteStartObject(); - if (value.Id != null) - { - writer.WriteString(IdText, value.Id); - } + writer.WriteString(TypeText, value.Type); - if (value.Lid != null) - { - writer.WriteString(LidText, value.Lid); - } + if (value.Id != null) + { + writer.WriteString(IdText, value.Id); + } - if (!value.Attributes.IsNullOrEmpty()) - { - writer.WritePropertyName(AttributesText); - WriteSubTree(writer, value.Attributes, options); - } + if (value.Lid != null) + { + writer.WriteString(LidText, value.Lid); + } - if (!value.Relationships.IsNullOrEmpty()) - { - writer.WritePropertyName(RelationshipsText); - WriteSubTree(writer, value.Relationships, options); - } + if (!value.Attributes.IsNullOrEmpty()) + { + writer.WritePropertyName(AttributesText); + WriteSubTree(writer, value.Attributes, options); + } - if (value.Links != null && value.Links.HasValue()) - { - writer.WritePropertyName(LinksText); - WriteSubTree(writer, value.Links, options); - } + if (!value.Relationships.IsNullOrEmpty()) + { + writer.WritePropertyName(RelationshipsText); + WriteSubTree(writer, value.Relationships, options); + } - if (!value.Meta.IsNullOrEmpty()) - { - writer.WritePropertyName(MetaText); - WriteSubTree(writer, value.Meta, options); - } + if (value.Links != null && value.Links.HasValue()) + { + writer.WritePropertyName(LinksText); + WriteSubTree(writer, value.Links, options); + } - writer.WriteEndObject(); + if (!value.Meta.IsNullOrEmpty()) + { + writer.WritePropertyName(MetaText); + WriteSubTree(writer, value.Meta, options); } + + writer.WriteEndObject(); } } diff --git a/src/JsonApiDotNetCore/Serialization/JsonConverters/SingleOrManyDataConverterFactory.cs b/src/JsonApiDotNetCore/Serialization/JsonConverters/SingleOrManyDataConverterFactory.cs index 2ad8ad6e06..25e497c2c1 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonConverters/SingleOrManyDataConverterFactory.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonConverters/SingleOrManyDataConverterFactory.cs @@ -1,83 +1,79 @@ -using System; -using System.Collections.Generic; -using System.Linq; using System.Reflection; using System.Text.Json; using System.Text.Json.Serialization; using JetBrains.Annotations; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Serialization.JsonConverters +namespace JsonApiDotNetCore.Serialization.JsonConverters; + +/// +/// Converts to/from JSON. +/// +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class SingleOrManyDataConverterFactory : JsonConverterFactory { - /// - /// Converts to/from JSON. - /// - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class SingleOrManyDataConverterFactory : JsonConverterFactory + public override bool CanConvert(Type typeToConvert) { - public override bool CanConvert(Type typeToConvert) - { - return typeToConvert.IsGenericType && typeToConvert.GetGenericTypeDefinition() == typeof(SingleOrManyData<>); - } + return typeToConvert.IsGenericType && typeToConvert.GetGenericTypeDefinition() == typeof(SingleOrManyData<>); + } - public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) - { - Type objectType = typeToConvert.GetGenericArguments()[0]; - Type converterType = typeof(SingleOrManyDataConverter<>).MakeGenericType(objectType); + public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) + { + Type objectType = typeToConvert.GetGenericArguments()[0]; + Type converterType = typeof(SingleOrManyDataConverter<>).MakeGenericType(objectType); - return (JsonConverter)Activator.CreateInstance(converterType, BindingFlags.Instance | BindingFlags.Public, null, null, null)!; - } + return (JsonConverter)Activator.CreateInstance(converterType, BindingFlags.Instance | BindingFlags.Public, null, null, null)!; + } - private sealed class SingleOrManyDataConverter : JsonObjectConverter> - where T : class, IResourceIdentity, new() + private sealed class SingleOrManyDataConverter : JsonObjectConverter> + where T : class, IResourceIdentity, new() + { + public override SingleOrManyData Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions serializerOptions) { - public override SingleOrManyData Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions serializerOptions) - { - var objects = new List(); - bool isManyData = false; - bool hasCompletedToMany = false; + var objects = new List(); + bool isManyData = false; + bool hasCompletedToMany = false; - do + do + { + switch (reader.TokenType) { - switch (reader.TokenType) + case JsonTokenType.EndArray: { - case JsonTokenType.EndArray: + hasCompletedToMany = true; + break; + } + case JsonTokenType.Null: + { + if (isManyData) { - hasCompletedToMany = true; - break; + objects.Add(new T()); } - case JsonTokenType.Null: - { - if (isManyData) - { - objects.Add(new T()); - } - break; - } - case JsonTokenType.StartObject: - { - var resourceObject = ReadSubTree(ref reader, serializerOptions); - objects.Add(resourceObject); - break; - } - case JsonTokenType.StartArray: - { - isManyData = true; - break; - } + break; + } + case JsonTokenType.StartObject: + { + var resourceObject = ReadSubTree(ref reader, serializerOptions); + objects.Add(resourceObject); + break; + } + case JsonTokenType.StartArray: + { + isManyData = true; + break; } } - while (isManyData && !hasCompletedToMany && reader.Read()); - - object? data = isManyData ? objects : objects.FirstOrDefault(); - return new SingleOrManyData(data); } + while (isManyData && !hasCompletedToMany && reader.Read()); - public override void Write(Utf8JsonWriter writer, SingleOrManyData value, JsonSerializerOptions options) - { - WriteSubTree(writer, value.Value, options); - } + object? data = isManyData ? objects : objects.FirstOrDefault(); + return new SingleOrManyData(data); + } + + public override void Write(Utf8JsonWriter writer, SingleOrManyData value, JsonSerializerOptions options) + { + WriteSubTree(writer, value.Value, options); } } } diff --git a/src/JsonApiDotNetCore/Serialization/JsonConverters/WriteOnlyDocumentConverter.cs b/src/JsonApiDotNetCore/Serialization/JsonConverters/WriteOnlyDocumentConverter.cs index 65d8f36d12..623857f5ff 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonConverters/WriteOnlyDocumentConverter.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonConverters/WriteOnlyDocumentConverter.cs @@ -1,86 +1,84 @@ -using System; using System.Text.Json; using JetBrains.Annotations; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Serialization.JsonConverters +namespace JsonApiDotNetCore.Serialization.JsonConverters; + +/// +/// Converts to JSON. +/// +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class WriteOnlyDocumentConverter : JsonObjectConverter { + private static readonly JsonEncodedText JsonApiText = JsonEncodedText.Encode("jsonapi"); + private static readonly JsonEncodedText LinksText = JsonEncodedText.Encode("links"); + private static readonly JsonEncodedText DataText = JsonEncodedText.Encode("data"); + private static readonly JsonEncodedText AtomicOperationsText = JsonEncodedText.Encode("atomic:operations"); + private static readonly JsonEncodedText AtomicResultsText = JsonEncodedText.Encode("atomic:results"); + private static readonly JsonEncodedText ErrorsText = JsonEncodedText.Encode("errors"); + private static readonly JsonEncodedText IncludedText = JsonEncodedText.Encode("included"); + private static readonly JsonEncodedText MetaText = JsonEncodedText.Encode("meta"); + + public override Document Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + throw new NotSupportedException("This converter cannot be used for reading JSON."); + } + /// - /// Converts to JSON. + /// Conditionally writes "data": null or omits it, depending on . /// - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class WriteOnlyDocumentConverter : JsonObjectConverter + public override void Write(Utf8JsonWriter writer, Document value, JsonSerializerOptions options) { - private static readonly JsonEncodedText JsonApiText = JsonEncodedText.Encode("jsonapi"); - private static readonly JsonEncodedText LinksText = JsonEncodedText.Encode("links"); - private static readonly JsonEncodedText DataText = JsonEncodedText.Encode("data"); - private static readonly JsonEncodedText AtomicOperationsText = JsonEncodedText.Encode("atomic:operations"); - private static readonly JsonEncodedText AtomicResultsText = JsonEncodedText.Encode("atomic:results"); - private static readonly JsonEncodedText ErrorsText = JsonEncodedText.Encode("errors"); - private static readonly JsonEncodedText IncludedText = JsonEncodedText.Encode("included"); - private static readonly JsonEncodedText MetaText = JsonEncodedText.Encode("meta"); + writer.WriteStartObject(); - public override Document Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + if (value.JsonApi != null) { - throw new NotSupportedException("This converter cannot be used for reading JSON."); + writer.WritePropertyName(JsonApiText); + WriteSubTree(writer, value.JsonApi, options); } - /// - /// Conditionally writes "data": null or omits it, depending on . - /// - public override void Write(Utf8JsonWriter writer, Document value, JsonSerializerOptions options) + if (value.Links != null && value.Links.HasValue()) { - writer.WriteStartObject(); - - if (value.JsonApi != null) - { - writer.WritePropertyName(JsonApiText); - WriteSubTree(writer, value.JsonApi, options); - } - - if (value.Links != null && value.Links.HasValue()) - { - writer.WritePropertyName(LinksText); - WriteSubTree(writer, value.Links, options); - } - - if (value.Data.IsAssigned) - { - writer.WritePropertyName(DataText); - WriteSubTree(writer, value.Data, options); - } + writer.WritePropertyName(LinksText); + WriteSubTree(writer, value.Links, options); + } - if (!value.Operations.IsNullOrEmpty()) - { - writer.WritePropertyName(AtomicOperationsText); - WriteSubTree(writer, value.Operations, options); - } + if (value.Data.IsAssigned) + { + writer.WritePropertyName(DataText); + WriteSubTree(writer, value.Data, options); + } - if (!value.Results.IsNullOrEmpty()) - { - writer.WritePropertyName(AtomicResultsText); - WriteSubTree(writer, value.Results, options); - } + if (!value.Operations.IsNullOrEmpty()) + { + writer.WritePropertyName(AtomicOperationsText); + WriteSubTree(writer, value.Operations, options); + } - if (!value.Errors.IsNullOrEmpty()) - { - writer.WritePropertyName(ErrorsText); - WriteSubTree(writer, value.Errors, options); - } + if (!value.Results.IsNullOrEmpty()) + { + writer.WritePropertyName(AtomicResultsText); + WriteSubTree(writer, value.Results, options); + } - if (value.Included != null) - { - writer.WritePropertyName(IncludedText); - WriteSubTree(writer, value.Included, options); - } + if (!value.Errors.IsNullOrEmpty()) + { + writer.WritePropertyName(ErrorsText); + WriteSubTree(writer, value.Errors, options); + } - if (!value.Meta.IsNullOrEmpty()) - { - writer.WritePropertyName(MetaText); - WriteSubTree(writer, value.Meta, options); - } + if (value.Included != null) + { + writer.WritePropertyName(IncludedText); + WriteSubTree(writer, value.Included, options); + } - writer.WriteEndObject(); + if (!value.Meta.IsNullOrEmpty()) + { + writer.WritePropertyName(MetaText); + WriteSubTree(writer, value.Meta, options); } + + writer.WriteEndObject(); } } diff --git a/src/JsonApiDotNetCore/Serialization/JsonConverters/WriteOnlyRelationshipObjectConverter.cs b/src/JsonApiDotNetCore/Serialization/JsonConverters/WriteOnlyRelationshipObjectConverter.cs index d80fcee5bd..047e0737c5 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonConverters/WriteOnlyRelationshipObjectConverter.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonConverters/WriteOnlyRelationshipObjectConverter.cs @@ -1,51 +1,49 @@ -using System; using System.Text.Json; using JetBrains.Annotations; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Serialization.JsonConverters +namespace JsonApiDotNetCore.Serialization.JsonConverters; + +/// +/// Converts to JSON. +/// +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class WriteOnlyRelationshipObjectConverter : JsonObjectConverter { + private static readonly JsonEncodedText DataText = JsonEncodedText.Encode("data"); + private static readonly JsonEncodedText LinksText = JsonEncodedText.Encode("links"); + private static readonly JsonEncodedText MetaText = JsonEncodedText.Encode("meta"); + + public override RelationshipObject Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + throw new NotSupportedException("This converter cannot be used for reading JSON."); + } + /// - /// Converts to JSON. + /// Conditionally writes "data": null or omits it, depending on . /// - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class WriteOnlyRelationshipObjectConverter : JsonObjectConverter + public override void Write(Utf8JsonWriter writer, RelationshipObject value, JsonSerializerOptions options) { - private static readonly JsonEncodedText DataText = JsonEncodedText.Encode("data"); - private static readonly JsonEncodedText LinksText = JsonEncodedText.Encode("links"); - private static readonly JsonEncodedText MetaText = JsonEncodedText.Encode("meta"); + writer.WriteStartObject(); - public override RelationshipObject Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + if (value.Links != null && value.Links.HasValue()) { - throw new NotSupportedException("This converter cannot be used for reading JSON."); + writer.WritePropertyName(LinksText); + WriteSubTree(writer, value.Links, options); } - /// - /// Conditionally writes "data": null or omits it, depending on . - /// - public override void Write(Utf8JsonWriter writer, RelationshipObject value, JsonSerializerOptions options) + if (value.Data.IsAssigned) { - writer.WriteStartObject(); - - if (value.Links != null && value.Links.HasValue()) - { - writer.WritePropertyName(LinksText); - WriteSubTree(writer, value.Links, options); - } - - if (value.Data.IsAssigned) - { - writer.WritePropertyName(DataText); - WriteSubTree(writer, value.Data, options); - } - - if (!value.Meta.IsNullOrEmpty()) - { - writer.WritePropertyName(MetaText); - WriteSubTree(writer, value.Meta, options); - } - - writer.WriteEndObject(); + writer.WritePropertyName(DataText); + WriteSubTree(writer, value.Data, options); } + + if (!value.Meta.IsNullOrEmpty()) + { + writer.WritePropertyName(MetaText); + WriteSubTree(writer, value.Meta, options); + } + + writer.WriteEndObject(); } } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationCode.cs b/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationCode.cs index c016299024..7fa05cac3d 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationCode.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationCode.cs @@ -1,15 +1,14 @@ using System.Text.Json.Serialization; -namespace JsonApiDotNetCore.Serialization.Objects +namespace JsonApiDotNetCore.Serialization.Objects; + +/// +/// See "op" in https://jsonapi.org/ext/atomic/#operation-objects. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum AtomicOperationCode { - /// - /// See "op" in https://jsonapi.org/ext/atomic/#operation-objects. - /// - [JsonConverter(typeof(JsonStringEnumConverter))] - public enum AtomicOperationCode - { - Add, - Update, - Remove - } + Add, + Update, + Remove } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationObject.cs b/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationObject.cs index b4cddf1aa7..b9233e0ed8 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationObject.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationObject.cs @@ -1,33 +1,31 @@ -using System.Collections.Generic; using System.Text.Json.Serialization; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Serialization.Objects +namespace JsonApiDotNetCore.Serialization.Objects; + +/// +/// See https://jsonapi.org/ext/atomic/#operation-objects. +/// +[PublicAPI] +public sealed class AtomicOperationObject { - /// - /// See https://jsonapi.org/ext/atomic/#operation-objects. - /// - [PublicAPI] - public sealed class AtomicOperationObject - { - [JsonPropertyName("data")] - [JsonIgnore(Condition = JsonIgnoreCondition.Never)] - public SingleOrManyData Data { get; set; } + [JsonPropertyName("data")] + [JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public SingleOrManyData Data { get; set; } - [JsonPropertyName("op")] - [JsonIgnore(Condition = JsonIgnoreCondition.Never)] - public AtomicOperationCode Code { get; set; } + [JsonPropertyName("op")] + [JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public AtomicOperationCode Code { get; set; } - [JsonPropertyName("ref")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public AtomicReference? Ref { get; set; } + [JsonPropertyName("ref")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public AtomicReference? Ref { get; set; } - [JsonPropertyName("href")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Href { get; set; } + [JsonPropertyName("href")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Href { get; set; } - [JsonPropertyName("meta")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IDictionary? Meta { get; set; } - } + [JsonPropertyName("meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IDictionary? Meta { get; set; } } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/AtomicReference.cs b/src/JsonApiDotNetCore/Serialization/Objects/AtomicReference.cs index 7c4f93caa9..01693d1db6 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/AtomicReference.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/AtomicReference.cs @@ -1,28 +1,27 @@ using System.Text.Json.Serialization; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Serialization.Objects +namespace JsonApiDotNetCore.Serialization.Objects; + +/// +/// See "ref" in https://jsonapi.org/ext/atomic/#operation-objects. +/// +[PublicAPI] +public sealed class AtomicReference : IResourceIdentity { - /// - /// See "ref" in https://jsonapi.org/ext/atomic/#operation-objects. - /// - [PublicAPI] - public sealed class AtomicReference : IResourceIdentity - { - [JsonPropertyName("type")] - [JsonIgnore(Condition = JsonIgnoreCondition.Never)] - public string? Type { get; set; } + [JsonPropertyName("type")] + [JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public string? Type { get; set; } - [JsonPropertyName("id")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Id { get; set; } + [JsonPropertyName("id")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Id { get; set; } - [JsonPropertyName("lid")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Lid { get; set; } + [JsonPropertyName("lid")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Lid { get; set; } - [JsonPropertyName("relationship")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Relationship { get; set; } - } + [JsonPropertyName("relationship")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Relationship { get; set; } } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/AtomicResultObject.cs b/src/JsonApiDotNetCore/Serialization/Objects/AtomicResultObject.cs index 0d55c65a3c..90a4ee9345 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/AtomicResultObject.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/AtomicResultObject.cs @@ -1,21 +1,19 @@ -using System.Collections.Generic; using System.Text.Json.Serialization; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Serialization.Objects +namespace JsonApiDotNetCore.Serialization.Objects; + +/// +/// See https://jsonapi.org/ext/atomic/#result-objects. +/// +[PublicAPI] +public sealed class AtomicResultObject { - /// - /// See https://jsonapi.org/ext/atomic/#result-objects. - /// - [PublicAPI] - public sealed class AtomicResultObject - { - [JsonPropertyName("data")] - [JsonIgnore(Condition = JsonIgnoreCondition.Never)] - public SingleOrManyData Data { get; set; } + [JsonPropertyName("data")] + [JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public SingleOrManyData Data { get; set; } - [JsonPropertyName("meta")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IDictionary? Meta { get; set; } - } + [JsonPropertyName("meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IDictionary? Meta { get; set; } } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/Document.cs b/src/JsonApiDotNetCore/Serialization/Objects/Document.cs index 9242398d34..2f40aeb27b 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/Document.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/Document.cs @@ -1,43 +1,41 @@ -using System.Collections.Generic; using System.Text.Json.Serialization; -namespace JsonApiDotNetCore.Serialization.Objects +namespace JsonApiDotNetCore.Serialization.Objects; + +/// +/// See https://jsonapi.org/format/1.1/#document-top-level and https://jsonapi.org/ext/atomic/#document-structure. +/// +public sealed class Document { - /// - /// See https://jsonapi.org/format/1.1/#document-top-level and https://jsonapi.org/ext/atomic/#document-structure. - /// - public sealed class Document - { - [JsonPropertyName("jsonapi")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public JsonApiObject? JsonApi { get; set; } - - [JsonPropertyName("links")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public TopLevelLinks? Links { get; set; } - - [JsonPropertyName("data")] - // JsonIgnoreCondition is determined at runtime by WriteOnlyDocumentConverter. - public SingleOrManyData Data { get; set; } - - [JsonPropertyName("atomic:operations")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IList? Operations { get; set; } - - [JsonPropertyName("atomic:results")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IList? Results { get; set; } - - [JsonPropertyName("errors")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IList? Errors { get; set; } - - [JsonPropertyName("included")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IList? Included { get; set; } - - [JsonPropertyName("meta")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IDictionary? Meta { get; set; } - } + [JsonPropertyName("jsonapi")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonApiObject? JsonApi { get; set; } + + [JsonPropertyName("links")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public TopLevelLinks? Links { get; set; } + + [JsonPropertyName("data")] + // JsonIgnoreCondition is determined at runtime by WriteOnlyDocumentConverter. + public SingleOrManyData Data { get; set; } + + [JsonPropertyName("atomic:operations")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IList? Operations { get; set; } + + [JsonPropertyName("atomic:results")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IList? Results { get; set; } + + [JsonPropertyName("errors")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IList? Errors { get; set; } + + [JsonPropertyName("included")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IList? Included { get; set; } + + [JsonPropertyName("meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IDictionary? Meta { get; set; } } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/ErrorLinks.cs b/src/JsonApiDotNetCore/Serialization/Objects/ErrorLinks.cs index e45dc22a2f..4b8d4de528 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/ErrorLinks.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/ErrorLinks.cs @@ -1,20 +1,19 @@ using System.Text.Json.Serialization; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Serialization.Objects +namespace JsonApiDotNetCore.Serialization.Objects; + +/// +/// See "links" in https://jsonapi.org/format/1.1/#error-objects. +/// +[PublicAPI] +public sealed class ErrorLinks { - /// - /// See "links" in https://jsonapi.org/format/1.1/#error-objects. - /// - [PublicAPI] - public sealed class ErrorLinks - { - [JsonPropertyName("about")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? About { get; set; } + [JsonPropertyName("about")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? About { get; set; } - [JsonPropertyName("type")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Type { get; set; } - } + [JsonPropertyName("type")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Type { get; set; } } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/ErrorObject.cs b/src/JsonApiDotNetCore/Serialization/Objects/ErrorObject.cs index 9fb0eb6f85..87ad1ebefe 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/ErrorObject.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/ErrorObject.cs @@ -1,78 +1,74 @@ -using System; -using System.Collections.Generic; -using System.Linq; using System.Net; using System.Text.Json.Serialization; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Serialization.Objects +namespace JsonApiDotNetCore.Serialization.Objects; + +/// +/// See https://jsonapi.org/format/1.1/#error-objects. +/// +[PublicAPI] +public sealed class ErrorObject { - /// - /// See https://jsonapi.org/format/1.1/#error-objects. - /// - [PublicAPI] - public sealed class ErrorObject - { - [JsonPropertyName("id")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Id { get; set; } = Guid.NewGuid().ToString(); + [JsonPropertyName("id")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Id { get; set; } = Guid.NewGuid().ToString(); - [JsonPropertyName("links")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public ErrorLinks? Links { get; set; } + [JsonPropertyName("links")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ErrorLinks? Links { get; set; } - [JsonIgnore] - public HttpStatusCode StatusCode { get; set; } + [JsonIgnore] + public HttpStatusCode StatusCode { get; set; } - [JsonPropertyName("status")] - [JsonIgnore(Condition = JsonIgnoreCondition.Never)] - public string Status - { - get => StatusCode.ToString("d"); - set => StatusCode = (HttpStatusCode)int.Parse(value); - } + [JsonPropertyName("status")] + [JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public string Status + { + get => StatusCode.ToString("d"); + set => StatusCode = (HttpStatusCode)int.Parse(value); + } - [JsonPropertyName("code")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Code { get; set; } + [JsonPropertyName("code")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Code { get; set; } - [JsonPropertyName("title")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Title { get; set; } + [JsonPropertyName("title")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Title { get; set; } - [JsonPropertyName("detail")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Detail { get; set; } + [JsonPropertyName("detail")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Detail { get; set; } - [JsonPropertyName("source")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public ErrorSource? Source { get; set; } + [JsonPropertyName("source")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ErrorSource? Source { get; set; } - [JsonPropertyName("meta")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IDictionary? Meta { get; set; } + [JsonPropertyName("meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IDictionary? Meta { get; set; } - public ErrorObject(HttpStatusCode statusCode) - { - StatusCode = statusCode; - } + public ErrorObject(HttpStatusCode statusCode) + { + StatusCode = statusCode; + } - public static HttpStatusCode GetResponseStatusCode(IReadOnlyList errorObjects) + public static HttpStatusCode GetResponseStatusCode(IReadOnlyList errorObjects) + { + if (errorObjects.IsNullOrEmpty()) { - if (errorObjects.IsNullOrEmpty()) - { - return HttpStatusCode.InternalServerError; - } - - int[] statusCodes = errorObjects.Select(error => (int)error.StatusCode).Distinct().ToArray(); + return HttpStatusCode.InternalServerError; + } - if (statusCodes.Length == 1) - { - return (HttpStatusCode)statusCodes[0]; - } + int[] statusCodes = errorObjects.Select(error => (int)error.StatusCode).Distinct().ToArray(); - int statusCode = int.Parse($"{statusCodes.Max().ToString()[0]}00"); - return (HttpStatusCode)statusCode; + if (statusCodes.Length == 1) + { + return (HttpStatusCode)statusCodes[0]; } + + int statusCode = int.Parse($"{statusCodes.Max().ToString()[0]}00"); + return (HttpStatusCode)statusCode; } } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/ErrorSource.cs b/src/JsonApiDotNetCore/Serialization/Objects/ErrorSource.cs index ec363c2f8d..156f734aa2 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/ErrorSource.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/ErrorSource.cs @@ -1,24 +1,23 @@ using System.Text.Json.Serialization; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Serialization.Objects +namespace JsonApiDotNetCore.Serialization.Objects; + +/// +/// See "source" in https://jsonapi.org/format/1.1/#error-objects. +/// +[PublicAPI] +public sealed class ErrorSource { - /// - /// See "source" in https://jsonapi.org/format/1.1/#error-objects. - /// - [PublicAPI] - public sealed class ErrorSource - { - [JsonPropertyName("pointer")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Pointer { get; set; } + [JsonPropertyName("pointer")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Pointer { get; set; } - [JsonPropertyName("parameter")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Parameter { get; set; } + [JsonPropertyName("parameter")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Parameter { get; set; } - [JsonPropertyName("header")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Header { get; set; } - } + [JsonPropertyName("header")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Header { get; set; } } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/IResourceIdentity.cs b/src/JsonApiDotNetCore/Serialization/Objects/IResourceIdentity.cs index 9b683c6922..c4b57f535f 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/IResourceIdentity.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/IResourceIdentity.cs @@ -1,9 +1,8 @@ -namespace JsonApiDotNetCore.Serialization.Objects +namespace JsonApiDotNetCore.Serialization.Objects; + +public interface IResourceIdentity { - public interface IResourceIdentity - { - public string? Type { get; } - public string? Id { get; } - public string? Lid { get; } - } + public string? Type { get; } + public string? Id { get; } + public string? Lid { get; } } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/JsonApiObject.cs b/src/JsonApiDotNetCore/Serialization/Objects/JsonApiObject.cs index d0a385e404..4a48f90099 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/JsonApiObject.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/JsonApiObject.cs @@ -1,29 +1,27 @@ -using System.Collections.Generic; using System.Text.Json.Serialization; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Serialization.Objects +namespace JsonApiDotNetCore.Serialization.Objects; + +/// +/// See https://jsonapi.org/format/1.1/#document-jsonapi-object. +/// +[PublicAPI] +public sealed class JsonApiObject { - /// - /// See https://jsonapi.org/format/1.1/#document-jsonapi-object. - /// - [PublicAPI] - public sealed class JsonApiObject - { - [JsonPropertyName("version")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Version { get; set; } + [JsonPropertyName("version")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Version { get; set; } - [JsonPropertyName("ext")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IList? Ext { get; set; } + [JsonPropertyName("ext")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IList? Ext { get; set; } - [JsonPropertyName("profile")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IList? Profile { get; set; } + [JsonPropertyName("profile")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IList? Profile { get; set; } - [JsonPropertyName("meta")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IDictionary? Meta { get; set; } - } + [JsonPropertyName("meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IDictionary? Meta { get; set; } } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/RelationshipLinks.cs b/src/JsonApiDotNetCore/Serialization/Objects/RelationshipLinks.cs index 944b811605..f3f6c2bf02 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/RelationshipLinks.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/RelationshipLinks.cs @@ -1,25 +1,24 @@ using System.Text.Json.Serialization; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Serialization.Objects +namespace JsonApiDotNetCore.Serialization.Objects; + +/// +/// See "links" in https://jsonapi.org/format/1.1/#document-resource-object-relationships. +/// +[PublicAPI] +public sealed class RelationshipLinks { - /// - /// See "links" in https://jsonapi.org/format/1.1/#document-resource-object-relationships. - /// - [PublicAPI] - public sealed class RelationshipLinks - { - [JsonPropertyName("self")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Self { get; set; } + [JsonPropertyName("self")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Self { get; set; } - [JsonPropertyName("related")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Related { get; set; } + [JsonPropertyName("related")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Related { get; set; } - internal bool HasValue() - { - return !string.IsNullOrEmpty(Self) || !string.IsNullOrEmpty(Related); - } + internal bool HasValue() + { + return !string.IsNullOrEmpty(Self) || !string.IsNullOrEmpty(Related); } } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/RelationshipObject.cs b/src/JsonApiDotNetCore/Serialization/Objects/RelationshipObject.cs index 96f5414eea..9411ecf83a 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/RelationshipObject.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/RelationshipObject.cs @@ -1,25 +1,23 @@ -using System.Collections.Generic; using System.Text.Json.Serialization; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Serialization.Objects +namespace JsonApiDotNetCore.Serialization.Objects; + +/// +/// See https://jsonapi.org/format/1.1/#document-resource-object-relationships. +/// +[PublicAPI] +public sealed class RelationshipObject { - /// - /// See https://jsonapi.org/format/1.1/#document-resource-object-relationships. - /// - [PublicAPI] - public sealed class RelationshipObject - { - [JsonPropertyName("links")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public RelationshipLinks? Links { get; set; } + [JsonPropertyName("links")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public RelationshipLinks? Links { get; set; } - [JsonPropertyName("data")] - // JsonIgnoreCondition is determined at runtime by WriteOnlyRelationshipObjectConverter. - public SingleOrManyData Data { get; set; } + [JsonPropertyName("data")] + // JsonIgnoreCondition is determined at runtime by WriteOnlyRelationshipObjectConverter. + public SingleOrManyData Data { get; set; } - [JsonPropertyName("meta")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IDictionary? Meta { get; set; } - } + [JsonPropertyName("meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IDictionary? Meta { get; set; } } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/ResourceIdentifierObject.cs b/src/JsonApiDotNetCore/Serialization/Objects/ResourceIdentifierObject.cs index 9b9de3afb8..a1b8271cf7 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/ResourceIdentifierObject.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/ResourceIdentifierObject.cs @@ -1,29 +1,27 @@ -using System.Collections.Generic; using System.Text.Json.Serialization; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Serialization.Objects +namespace JsonApiDotNetCore.Serialization.Objects; + +/// +/// See https://jsonapi.org/format/1.1/#document-resource-identifier-objects. +/// +[PublicAPI] +public sealed class ResourceIdentifierObject : IResourceIdentity { - /// - /// See https://jsonapi.org/format/1.1/#document-resource-identifier-objects. - /// - [PublicAPI] - public sealed class ResourceIdentifierObject : IResourceIdentity - { - [JsonPropertyName("type")] - [JsonIgnore(Condition = JsonIgnoreCondition.Never)] - public string? Type { get; set; } + [JsonPropertyName("type")] + [JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public string? Type { get; set; } - [JsonPropertyName("id")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Id { get; set; } + [JsonPropertyName("id")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Id { get; set; } - [JsonPropertyName("lid")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Lid { get; set; } + [JsonPropertyName("lid")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Lid { get; set; } - [JsonPropertyName("meta")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IDictionary? Meta { get; set; } - } + [JsonPropertyName("meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IDictionary? Meta { get; set; } } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/ResourceLinks.cs b/src/JsonApiDotNetCore/Serialization/Objects/ResourceLinks.cs index ddee80c85d..22c396082d 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/ResourceLinks.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/ResourceLinks.cs @@ -1,21 +1,20 @@ using System.Text.Json.Serialization; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Serialization.Objects +namespace JsonApiDotNetCore.Serialization.Objects; + +/// +/// See https://jsonapi.org/format/1.1/#document-resource-object-links. +/// +[PublicAPI] +public sealed class ResourceLinks { - /// - /// See https://jsonapi.org/format/1.1/#document-resource-object-links. - /// - [PublicAPI] - public sealed class ResourceLinks - { - [JsonPropertyName("self")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Self { get; set; } + [JsonPropertyName("self")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Self { get; set; } - internal bool HasValue() - { - return !string.IsNullOrEmpty(Self); - } + internal bool HasValue() + { + return !string.IsNullOrEmpty(Self); } } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/ResourceObject.cs b/src/JsonApiDotNetCore/Serialization/Objects/ResourceObject.cs index 85f340075d..43b3b9616a 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/ResourceObject.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/ResourceObject.cs @@ -1,41 +1,39 @@ -using System.Collections.Generic; using System.Text.Json.Serialization; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Serialization.Objects +namespace JsonApiDotNetCore.Serialization.Objects; + +/// +/// See https://jsonapi.org/format/1.1/#document-resource-objects. +/// +[PublicAPI] +public sealed class ResourceObject : IResourceIdentity { - /// - /// See https://jsonapi.org/format/1.1/#document-resource-objects. - /// - [PublicAPI] - public sealed class ResourceObject : IResourceIdentity - { - [JsonPropertyName("type")] - [JsonIgnore(Condition = JsonIgnoreCondition.Never)] - public string? Type { get; set; } - - [JsonPropertyName("id")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Id { get; set; } - - [JsonPropertyName("lid")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Lid { get; set; } - - [JsonPropertyName("attributes")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IDictionary? Attributes { get; set; } - - [JsonPropertyName("relationships")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IDictionary? Relationships { get; set; } - - [JsonPropertyName("links")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public ResourceLinks? Links { get; set; } - - [JsonPropertyName("meta")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IDictionary? Meta { get; set; } - } + [JsonPropertyName("type")] + [JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public string? Type { get; set; } + + [JsonPropertyName("id")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Id { get; set; } + + [JsonPropertyName("lid")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Lid { get; set; } + + [JsonPropertyName("attributes")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IDictionary? Attributes { get; set; } + + [JsonPropertyName("relationships")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IDictionary? Relationships { get; set; } + + [JsonPropertyName("links")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ResourceLinks? Links { get; set; } + + [JsonPropertyName("meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IDictionary? Meta { get; set; } } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/SingleOrManyData.cs b/src/JsonApiDotNetCore/Serialization/Objects/SingleOrManyData.cs index 548dde07e9..1d2f99e126 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/SingleOrManyData.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/SingleOrManyData.cs @@ -1,49 +1,44 @@ -using System.Collections.Generic; -using System.Linq; using System.Text.Json; using System.Text.Json.Serialization; using JetBrains.Annotations; using JsonApiDotNetCore.Serialization.JsonConverters; -namespace JsonApiDotNetCore.Serialization.Objects +namespace JsonApiDotNetCore.Serialization.Objects; + +/// +/// Represents the value of the "data" element, which is either null, a single object or an array of objects. Add +/// to to properly roundtrip. +/// +[PublicAPI] +public readonly struct SingleOrManyData + // The "new()" constraint exists for parity with SingleOrManyDataConverterFactory, which creates empty instances + // to ensure ManyValue never contains null items. + where T : class, IResourceIdentity, new() { - /// - /// Represents the value of the "data" element, which is either null, a single object or an array of objects. Add - /// to to properly roundtrip. - /// - [PublicAPI] - public readonly struct SingleOrManyData - // The "new()" constraint exists for parity with SingleOrManyDataConverterFactory, which creates empty instances - // to ensure ManyValue never contains null items. - where T : class, IResourceIdentity, new() - { - // ReSharper disable once MergeConditionalExpression - // Justification: ReSharper reporting this is a bug, which is fixed in v2021.2.1. This condition cannot be merged. - public object? Value => ManyValue != null ? ManyValue : SingleValue; + public object? Value => ManyValue != null ? ManyValue : SingleValue; - [JsonIgnore] - public bool IsAssigned { get; } + [JsonIgnore] + public bool IsAssigned { get; } - [JsonIgnore] - public T? SingleValue { get; } + [JsonIgnore] + public T? SingleValue { get; } - [JsonIgnore] - public IList? ManyValue { get; } + [JsonIgnore] + public IList? ManyValue { get; } - public SingleOrManyData(object? value) - { - IsAssigned = true; + public SingleOrManyData(object? value) + { + IsAssigned = true; - if (value is IEnumerable manyData) - { - ManyValue = manyData.ToList(); - SingleValue = null; - } - else - { - ManyValue = null; - SingleValue = (T?)value; - } + if (value is IEnumerable manyData) + { + ManyValue = manyData.ToList(); + SingleValue = null; + } + else + { + ManyValue = null; + SingleValue = (T?)value; } } } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/TopLevelLinks.cs b/src/JsonApiDotNetCore/Serialization/Objects/TopLevelLinks.cs index abb8a365e9..14578253c2 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/TopLevelLinks.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/TopLevelLinks.cs @@ -1,46 +1,45 @@ using System.Text.Json.Serialization; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Serialization.Objects +namespace JsonApiDotNetCore.Serialization.Objects; + +/// +/// See "links" in https://jsonapi.org/format/1.1/#document-top-level. +/// +[PublicAPI] +public sealed class TopLevelLinks { - /// - /// See "links" in https://jsonapi.org/format/1.1/#document-top-level. - /// - [PublicAPI] - public sealed class TopLevelLinks + [JsonPropertyName("self")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Self { get; set; } + + [JsonPropertyName("related")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Related { get; set; } + + [JsonPropertyName("describedby")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? DescribedBy { get; set; } + + [JsonPropertyName("first")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? First { get; set; } + + [JsonPropertyName("last")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Last { get; set; } + + [JsonPropertyName("prev")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Prev { get; set; } + + [JsonPropertyName("next")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Next { get; set; } + + internal bool HasValue() { - [JsonPropertyName("self")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Self { get; set; } - - [JsonPropertyName("related")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Related { get; set; } - - [JsonPropertyName("describedby")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? DescribedBy { get; set; } - - [JsonPropertyName("first")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? First { get; set; } - - [JsonPropertyName("last")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Last { get; set; } - - [JsonPropertyName("prev")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Prev { get; set; } - - [JsonPropertyName("next")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Next { get; set; } - - internal bool HasValue() - { - return !string.IsNullOrEmpty(Self) || !string.IsNullOrEmpty(Related) || !string.IsNullOrEmpty(DescribedBy) || !string.IsNullOrEmpty(First) || - !string.IsNullOrEmpty(Last) || !string.IsNullOrEmpty(Prev) || !string.IsNullOrEmpty(Next); - } + return !string.IsNullOrEmpty(Self) || !string.IsNullOrEmpty(Related) || !string.IsNullOrEmpty(DescribedBy) || !string.IsNullOrEmpty(First) || + !string.IsNullOrEmpty(Last) || !string.IsNullOrEmpty(Prev) || !string.IsNullOrEmpty(Next); } } diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicOperationObjectAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicOperationObjectAdapter.cs index ea157e917d..061bd0f920 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicOperationObjectAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicOperationObjectAdapter.cs @@ -1,164 +1,159 @@ -using System; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Serialization.Request.Adapters -{ - /// - public sealed class AtomicOperationObjectAdapter : IAtomicOperationObjectAdapter - { - private readonly IResourceDataInOperationsRequestAdapter _resourceDataInOperationsRequestAdapter; - private readonly IAtomicReferenceAdapter _atomicReferenceAdapter; - private readonly IRelationshipDataAdapter _relationshipDataAdapter; - private readonly IJsonApiOptions _options; +namespace JsonApiDotNetCore.Serialization.Request.Adapters; - public AtomicOperationObjectAdapter(IJsonApiOptions options, IAtomicReferenceAdapter atomicReferenceAdapter, - IResourceDataInOperationsRequestAdapter resourceDataInOperationsRequestAdapter, IRelationshipDataAdapter relationshipDataAdapter) - { - ArgumentGuard.NotNull(options, nameof(options)); - ArgumentGuard.NotNull(atomicReferenceAdapter, nameof(atomicReferenceAdapter)); - ArgumentGuard.NotNull(resourceDataInOperationsRequestAdapter, nameof(resourceDataInOperationsRequestAdapter)); - ArgumentGuard.NotNull(relationshipDataAdapter, nameof(relationshipDataAdapter)); - - _options = options; - _atomicReferenceAdapter = atomicReferenceAdapter; - _resourceDataInOperationsRequestAdapter = resourceDataInOperationsRequestAdapter; - _relationshipDataAdapter = relationshipDataAdapter; - } +/// +public sealed class AtomicOperationObjectAdapter : IAtomicOperationObjectAdapter +{ + private readonly IResourceDataInOperationsRequestAdapter _resourceDataInOperationsRequestAdapter; + private readonly IAtomicReferenceAdapter _atomicReferenceAdapter; + private readonly IRelationshipDataAdapter _relationshipDataAdapter; + private readonly IJsonApiOptions _options; - /// - public OperationContainer Convert(AtomicOperationObject atomicOperationObject, RequestAdapterState state) - { - AssertNoHref(atomicOperationObject, state); + public AtomicOperationObjectAdapter(IJsonApiOptions options, IAtomicReferenceAdapter atomicReferenceAdapter, + IResourceDataInOperationsRequestAdapter resourceDataInOperationsRequestAdapter, IRelationshipDataAdapter relationshipDataAdapter) + { + ArgumentGuard.NotNull(options, nameof(options)); + ArgumentGuard.NotNull(atomicReferenceAdapter, nameof(atomicReferenceAdapter)); + ArgumentGuard.NotNull(resourceDataInOperationsRequestAdapter, nameof(resourceDataInOperationsRequestAdapter)); + ArgumentGuard.NotNull(relationshipDataAdapter, nameof(relationshipDataAdapter)); + + _options = options; + _atomicReferenceAdapter = atomicReferenceAdapter; + _resourceDataInOperationsRequestAdapter = resourceDataInOperationsRequestAdapter; + _relationshipDataAdapter = relationshipDataAdapter; + } - WriteOperationKind writeOperation = ConvertOperationCode(atomicOperationObject, state); + /// + public OperationContainer Convert(AtomicOperationObject atomicOperationObject, RequestAdapterState state) + { + AssertNoHref(atomicOperationObject, state); - state.WritableTargetedFields = new TargetedFields(); + WriteOperationKind writeOperation = ConvertOperationCode(atomicOperationObject, state); - state.WritableRequest = new JsonApiRequest - { - Kind = EndpointKind.AtomicOperations, - WriteOperation = writeOperation - }; + state.WritableTargetedFields = new TargetedFields(); - (ResourceIdentityRequirements requirements, IIdentifiable? primaryResource) = ConvertRef(atomicOperationObject, state); + state.WritableRequest = new JsonApiRequest + { + Kind = EndpointKind.AtomicOperations, + WriteOperation = writeOperation + }; - if (writeOperation is WriteOperationKind.CreateResource or WriteOperationKind.UpdateResource) - { - primaryResource = _resourceDataInOperationsRequestAdapter.Convert(atomicOperationObject.Data, requirements, state); - } + (ResourceIdentityRequirements requirements, IIdentifiable? primaryResource) = ConvertRef(atomicOperationObject, state); - return new OperationContainer(primaryResource!, state.WritableTargetedFields, state.Request); + if (writeOperation is WriteOperationKind.CreateResource or WriteOperationKind.UpdateResource) + { + primaryResource = _resourceDataInOperationsRequestAdapter.Convert(atomicOperationObject.Data, requirements, state); } - private static void AssertNoHref(AtomicOperationObject atomicOperationObject, RequestAdapterState state) + return new OperationContainer(primaryResource!, state.WritableTargetedFields, state.Request); + } + + private static void AssertNoHref(AtomicOperationObject atomicOperationObject, RequestAdapterState state) + { + if (atomicOperationObject.Href != null) { - if (atomicOperationObject.Href != null) - { - using IDisposable _ = state.Position.PushElement("href"); - throw new ModelConversionException(state.Position, "The 'href' element is not supported.", null); - } + using IDisposable _ = state.Position.PushElement("href"); + throw new ModelConversionException(state.Position, "The 'href' element is not supported.", null); } + } - private WriteOperationKind ConvertOperationCode(AtomicOperationObject atomicOperationObject, RequestAdapterState state) + private WriteOperationKind ConvertOperationCode(AtomicOperationObject atomicOperationObject, RequestAdapterState state) + { + switch (atomicOperationObject.Code) { - switch (atomicOperationObject.Code) + case AtomicOperationCode.Add: { - case AtomicOperationCode.Add: + if (atomicOperationObject.Ref is { Relationship: null }) { - // ReSharper disable once MergeIntoPattern - // Justification: Merging this into a pattern crashes the command-line versions of CleanupCode/InspectCode. - // Tracked at: https://youtrack.jetbrains.com/issue/RSRP-486717 - if (atomicOperationObject.Ref != null && atomicOperationObject.Ref.Relationship == null) - { - using IDisposable _ = state.Position.PushElement("ref"); - throw new ModelConversionException(state.Position, "The 'relationship' element is required.", null); - } - - return atomicOperationObject.Ref == null ? WriteOperationKind.CreateResource : WriteOperationKind.AddToRelationship; + using IDisposable _ = state.Position.PushElement("ref"); + throw new ModelConversionException(state.Position, "The 'relationship' element is required.", null); } - case AtomicOperationCode.Update: + + return atomicOperationObject.Ref == null ? WriteOperationKind.CreateResource : WriteOperationKind.AddToRelationship; + } + case AtomicOperationCode.Update: + { + return atomicOperationObject.Ref?.Relationship != null ? WriteOperationKind.SetRelationship : WriteOperationKind.UpdateResource; + } + case AtomicOperationCode.Remove: + { + if (atomicOperationObject.Ref == null) { - return atomicOperationObject.Ref?.Relationship != null ? WriteOperationKind.SetRelationship : WriteOperationKind.UpdateResource; + throw new ModelConversionException(state.Position, "The 'ref' element is required.", null); } - case AtomicOperationCode.Remove: - { - if (atomicOperationObject.Ref == null) - { - throw new ModelConversionException(state.Position, "The 'ref' element is required.", null); - } - return atomicOperationObject.Ref.Relationship != null ? WriteOperationKind.RemoveFromRelationship : WriteOperationKind.DeleteResource; - } + return atomicOperationObject.Ref.Relationship != null ? WriteOperationKind.RemoveFromRelationship : WriteOperationKind.DeleteResource; } - - throw new NotSupportedException($"Unknown operation code '{atomicOperationObject.Code}'."); } - private (ResourceIdentityRequirements requirements, IIdentifiable? primaryResource) ConvertRef(AtomicOperationObject atomicOperationObject, - RequestAdapterState state) - { - ResourceIdentityRequirements requirements = CreateRefRequirements(state); - IIdentifiable? primaryResource = null; + throw new NotSupportedException($"Unknown operation code '{atomicOperationObject.Code}'."); + } - AtomicReferenceResult? refResult = atomicOperationObject.Ref != null - ? _atomicReferenceAdapter.Convert(atomicOperationObject.Ref, requirements, state) - : null; + private (ResourceIdentityRequirements requirements, IIdentifiable? primaryResource) ConvertRef(AtomicOperationObject atomicOperationObject, + RequestAdapterState state) + { + ResourceIdentityRequirements requirements = CreateRefRequirements(state); + IIdentifiable? primaryResource = null; - if (refResult != null) - { - state.WritableRequest!.PrimaryId = refResult.Resource.StringId; - state.WritableRequest.PrimaryResourceType = refResult.ResourceType; - state.WritableRequest.Relationship = refResult.Relationship; - state.WritableRequest.IsCollection = refResult.Relationship is HasManyAttribute; + AtomicReferenceResult? refResult = atomicOperationObject.Ref != null + ? _atomicReferenceAdapter.Convert(atomicOperationObject.Ref, requirements, state) + : null; - ConvertRefRelationship(atomicOperationObject.Data, refResult, state); + if (refResult != null) + { + state.WritableRequest!.PrimaryId = refResult.Resource.StringId; + state.WritableRequest.PrimaryResourceType = refResult.ResourceType; + state.WritableRequest.Relationship = refResult.Relationship; + state.WritableRequest.IsCollection = refResult.Relationship is HasManyAttribute; - requirements = CreateDataRequirements(refResult, requirements); - primaryResource = refResult.Resource; - } + ConvertRefRelationship(atomicOperationObject.Data, refResult, state); - return (requirements, primaryResource); + requirements = CreateDataRequirements(refResult, requirements); + primaryResource = refResult.Resource; } - private ResourceIdentityRequirements CreateRefRequirements(RequestAdapterState state) - { - JsonElementConstraint? idConstraint = state.Request.WriteOperation == WriteOperationKind.CreateResource - ? _options.AllowClientGeneratedIds ? null : JsonElementConstraint.Forbidden - : JsonElementConstraint.Required; + return (requirements, primaryResource); + } - return new ResourceIdentityRequirements - { - IdConstraint = idConstraint - }; - } + private ResourceIdentityRequirements CreateRefRequirements(RequestAdapterState state) + { + JsonElementConstraint? idConstraint = state.Request.WriteOperation == WriteOperationKind.CreateResource + ? _options.AllowClientGeneratedIds ? null : JsonElementConstraint.Forbidden + : JsonElementConstraint.Required; - private static ResourceIdentityRequirements CreateDataRequirements(AtomicReferenceResult refResult, ResourceIdentityRequirements refRequirements) + return new ResourceIdentityRequirements { - return new ResourceIdentityRequirements - { - ResourceType = refResult.ResourceType, - IdConstraint = refRequirements.IdConstraint, - IdValue = refResult.Resource.StringId, - LidValue = refResult.Resource.LocalId, - RelationshipName = refResult.Relationship?.PublicName - }; - } + IdConstraint = idConstraint + }; + } - private void ConvertRefRelationship(SingleOrManyData relationshipData, AtomicReferenceResult refResult, RequestAdapterState state) + private static ResourceIdentityRequirements CreateDataRequirements(AtomicReferenceResult refResult, ResourceIdentityRequirements refRequirements) + { + return new ResourceIdentityRequirements { - if (refResult.Relationship != null) - { - state.WritableRequest!.SecondaryResourceType = refResult.Relationship.RightType; + ResourceType = refResult.ResourceType, + IdConstraint = refRequirements.IdConstraint, + IdValue = refResult.Resource.StringId, + LidValue = refResult.Resource.LocalId, + RelationshipName = refResult.Relationship?.PublicName + }; + } + + private void ConvertRefRelationship(SingleOrManyData relationshipData, AtomicReferenceResult refResult, RequestAdapterState state) + { + if (refResult.Relationship != null) + { + state.WritableRequest!.SecondaryResourceType = refResult.Relationship.RightType; - state.WritableTargetedFields!.Relationships.Add(refResult.Relationship); + state.WritableTargetedFields!.Relationships.Add(refResult.Relationship); - object? rightValue = _relationshipDataAdapter.Convert(relationshipData, refResult.Relationship, true, state); - refResult.Relationship.SetValue(refResult.Resource, rightValue); - } + object? rightValue = _relationshipDataAdapter.Convert(relationshipData, refResult.Relationship, true, state); + refResult.Relationship.SetValue(refResult.Resource, rightValue); } } } diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicReferenceAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicReferenceAdapter.cs index b17c94edb6..e1aec5641b 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicReferenceAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicReferenceAdapter.cs @@ -1,47 +1,45 @@ -using System; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Serialization.Request.Adapters +namespace JsonApiDotNetCore.Serialization.Request.Adapters; + +/// +[PublicAPI] +public sealed class AtomicReferenceAdapter : ResourceIdentityAdapter, IAtomicReferenceAdapter { - /// - [PublicAPI] - public sealed class AtomicReferenceAdapter : ResourceIdentityAdapter, IAtomicReferenceAdapter + public AtomicReferenceAdapter(IResourceGraph resourceGraph, IResourceFactory resourceFactory) + : base(resourceGraph, resourceFactory) + { + } + + /// + public AtomicReferenceResult Convert(AtomicReference atomicReference, ResourceIdentityRequirements requirements, RequestAdapterState state) + { + ArgumentGuard.NotNull(atomicReference, nameof(atomicReference)); + ArgumentGuard.NotNull(requirements, nameof(requirements)); + ArgumentGuard.NotNull(state, nameof(state)); + + using IDisposable _ = state.Position.PushElement("ref"); + (IIdentifiable resource, ResourceType resourceType) = ConvertResourceIdentity(atomicReference, requirements, state); + + RelationshipAttribute? relationship = atomicReference.Relationship != null + ? ConvertRelationship(atomicReference.Relationship, resourceType, state) + : null; + + return new AtomicReferenceResult(resource, resourceType, relationship); + } + + private RelationshipAttribute ConvertRelationship(string relationshipName, ResourceType resourceType, RequestAdapterState state) { - public AtomicReferenceAdapter(IResourceGraph resourceGraph, IResourceFactory resourceFactory) - : base(resourceGraph, resourceFactory) - { - } - - /// - public AtomicReferenceResult Convert(AtomicReference atomicReference, ResourceIdentityRequirements requirements, RequestAdapterState state) - { - ArgumentGuard.NotNull(atomicReference, nameof(atomicReference)); - ArgumentGuard.NotNull(requirements, nameof(requirements)); - ArgumentGuard.NotNull(state, nameof(state)); - - using IDisposable _ = state.Position.PushElement("ref"); - (IIdentifiable resource, ResourceType resourceType) = ConvertResourceIdentity(atomicReference, requirements, state); - - RelationshipAttribute? relationship = atomicReference.Relationship != null - ? ConvertRelationship(atomicReference.Relationship, resourceType, state) - : null; - - return new AtomicReferenceResult(resource, resourceType, relationship); - } - - private RelationshipAttribute ConvertRelationship(string relationshipName, ResourceType resourceType, RequestAdapterState state) - { - using IDisposable _ = state.Position.PushElement("relationship"); - RelationshipAttribute? relationship = resourceType.FindRelationshipByPublicName(relationshipName); - - AssertIsKnownRelationship(relationship, relationshipName, resourceType, state); - AssertToManyInAddOrRemoveRelationship(relationship, state); - - return relationship; - } + using IDisposable _ = state.Position.PushElement("relationship"); + RelationshipAttribute? relationship = resourceType.FindRelationshipByPublicName(relationshipName); + + AssertIsKnownRelationship(relationship, relationshipName, resourceType, state); + AssertToManyInAddOrRemoveRelationship(relationship, state); + + return relationship; } } diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicReferenceResult.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicReferenceResult.cs index 724a5da96c..9e6d982e21 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicReferenceResult.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicReferenceResult.cs @@ -3,26 +3,25 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Serialization.Request.Adapters +namespace JsonApiDotNetCore.Serialization.Request.Adapters; + +/// +/// The result of validating and converting "ref" in an entry of an atomic:operations request. +/// +[PublicAPI] +public sealed class AtomicReferenceResult { - /// - /// The result of validating and converting "ref" in an entry of an atomic:operations request. - /// - [PublicAPI] - public sealed class AtomicReferenceResult - { - public IIdentifiable Resource { get; } - public ResourceType ResourceType { get; } - public RelationshipAttribute? Relationship { get; } + public IIdentifiable Resource { get; } + public ResourceType ResourceType { get; } + public RelationshipAttribute? Relationship { get; } - public AtomicReferenceResult(IIdentifiable resource, ResourceType resourceType, RelationshipAttribute? relationship) - { - ArgumentGuard.NotNull(resource, nameof(resource)); - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + public AtomicReferenceResult(IIdentifiable resource, ResourceType resourceType, RelationshipAttribute? relationship) + { + ArgumentGuard.NotNull(resource, nameof(resource)); + ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - Resource = resource; - ResourceType = resourceType; - Relationship = relationship; - } + Resource = resource; + ResourceType = resourceType; + Relationship = relationship; } } diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/BaseAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/BaseAdapter.cs index 72c4d12b1e..64e2f6d53b 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/BaseAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/BaseAdapter.cs @@ -2,64 +2,63 @@ using JsonApiDotNetCore.Serialization.Objects; using SysNotNull = System.Diagnostics.CodeAnalysis.NotNullAttribute; -namespace JsonApiDotNetCore.Serialization.Request.Adapters +namespace JsonApiDotNetCore.Serialization.Request.Adapters; + +/// +/// Contains shared assertions for derived types. +/// +public abstract class BaseAdapter { - /// - /// Contains shared assertions for derived types. - /// - public abstract class BaseAdapter + [AssertionMethod] + protected static void AssertHasData(SingleOrManyData data, RequestAdapterState state) + where T : class, IResourceIdentity, new() { - [AssertionMethod] - protected static void AssertHasData(SingleOrManyData data, RequestAdapterState state) - where T : class, IResourceIdentity, new() + if (!data.IsAssigned) { - if (!data.IsAssigned) - { - throw new ModelConversionException(state.Position, "The 'data' element is required.", null); - } + throw new ModelConversionException(state.Position, "The 'data' element is required.", null); } + } - [AssertionMethod] - protected static void AssertDataHasSingleValue(SingleOrManyData data, bool allowNull, RequestAdapterState state) - where T : class, IResourceIdentity, new() + [AssertionMethod] + protected static void AssertDataHasSingleValue(SingleOrManyData data, bool allowNull, RequestAdapterState state) + where T : class, IResourceIdentity, new() + { + if (data.SingleValue == null) { - if (data.SingleValue == null) + if (!allowNull) { - if (!allowNull) + if (data.ManyValue == null) { - if (data.ManyValue == null) - { - AssertObjectIsNotNull(data.SingleValue, state); - } - - throw new ModelConversionException(state.Position, "Expected an object, instead of an array.", null); + AssertObjectIsNotNull(data.SingleValue, state); } - if (data.ManyValue != null) - { - throw new ModelConversionException(state.Position, "Expected an object or 'null', instead of an array.", null); - } + throw new ModelConversionException(state.Position, "Expected an object, instead of an array.", null); } - } - [AssertionMethod] - protected static void AssertDataHasManyValue(SingleOrManyData data, RequestAdapterState state) - where T : class, IResourceIdentity, new() - { - if (data.ManyValue == null) + if (data.ManyValue != null) { - throw new ModelConversionException(state.Position, - data.SingleValue == null ? "Expected an array, instead of 'null'." : "Expected an array, instead of an object.", null); + throw new ModelConversionException(state.Position, "Expected an object or 'null', instead of an array.", null); } } + } - protected static void AssertObjectIsNotNull([SysNotNull] T? value, RequestAdapterState state) - where T : class + [AssertionMethod] + protected static void AssertDataHasManyValue(SingleOrManyData data, RequestAdapterState state) + where T : class, IResourceIdentity, new() + { + if (data.ManyValue == null) { - if (value is null) - { - throw new ModelConversionException(state.Position, "Expected an object, instead of 'null'.", null); - } + throw new ModelConversionException(state.Position, + data.SingleValue == null ? "Expected an array, instead of 'null'." : "Expected an array, instead of an object.", null); + } + } + + protected static void AssertObjectIsNotNull([SysNotNull] T? value, RequestAdapterState state) + where T : class + { + if (value is null) + { + throw new ModelConversionException(state.Position, "Expected an object, instead of 'null'.", null); } } } diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentAdapter.cs index 46bcc1ca25..369f9076d2 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentAdapter.cs @@ -2,41 +2,40 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Serialization.Request.Adapters +namespace JsonApiDotNetCore.Serialization.Request.Adapters; + +/// +public sealed class DocumentAdapter : IDocumentAdapter { - /// - public sealed class DocumentAdapter : IDocumentAdapter - { - private readonly IJsonApiRequest _request; - private readonly ITargetedFields _targetedFields; - private readonly IDocumentInResourceOrRelationshipRequestAdapter _documentInResourceOrRelationshipRequestAdapter; - private readonly IDocumentInOperationsRequestAdapter _documentInOperationsRequestAdapter; + private readonly IJsonApiRequest _request; + private readonly ITargetedFields _targetedFields; + private readonly IDocumentInResourceOrRelationshipRequestAdapter _documentInResourceOrRelationshipRequestAdapter; + private readonly IDocumentInOperationsRequestAdapter _documentInOperationsRequestAdapter; - public DocumentAdapter(IJsonApiRequest request, ITargetedFields targetedFields, - IDocumentInResourceOrRelationshipRequestAdapter documentInResourceOrRelationshipRequestAdapter, - IDocumentInOperationsRequestAdapter documentInOperationsRequestAdapter) - { - ArgumentGuard.NotNull(request, nameof(request)); - ArgumentGuard.NotNull(targetedFields, nameof(targetedFields)); - ArgumentGuard.NotNull(documentInResourceOrRelationshipRequestAdapter, nameof(documentInResourceOrRelationshipRequestAdapter)); - ArgumentGuard.NotNull(documentInOperationsRequestAdapter, nameof(documentInOperationsRequestAdapter)); + public DocumentAdapter(IJsonApiRequest request, ITargetedFields targetedFields, + IDocumentInResourceOrRelationshipRequestAdapter documentInResourceOrRelationshipRequestAdapter, + IDocumentInOperationsRequestAdapter documentInOperationsRequestAdapter) + { + ArgumentGuard.NotNull(request, nameof(request)); + ArgumentGuard.NotNull(targetedFields, nameof(targetedFields)); + ArgumentGuard.NotNull(documentInResourceOrRelationshipRequestAdapter, nameof(documentInResourceOrRelationshipRequestAdapter)); + ArgumentGuard.NotNull(documentInOperationsRequestAdapter, nameof(documentInOperationsRequestAdapter)); - _request = request; - _targetedFields = targetedFields; - _documentInResourceOrRelationshipRequestAdapter = documentInResourceOrRelationshipRequestAdapter; - _documentInOperationsRequestAdapter = documentInOperationsRequestAdapter; - } + _request = request; + _targetedFields = targetedFields; + _documentInResourceOrRelationshipRequestAdapter = documentInResourceOrRelationshipRequestAdapter; + _documentInOperationsRequestAdapter = documentInOperationsRequestAdapter; + } - /// - public object? Convert(Document document) - { - ArgumentGuard.NotNull(document, nameof(document)); + /// + public object? Convert(Document document) + { + ArgumentGuard.NotNull(document, nameof(document)); - using var adapterState = new RequestAdapterState(_request, _targetedFields); + using var adapterState = new RequestAdapterState(_request, _targetedFields); - return adapterState.Request.Kind == EndpointKind.AtomicOperations - ? _documentInOperationsRequestAdapter.Convert(document, adapterState) - : _documentInResourceOrRelationshipRequestAdapter.Convert(document, adapterState); - } + return adapterState.Request.Kind == EndpointKind.AtomicOperations + ? _documentInOperationsRequestAdapter.Convert(document, adapterState) + : _documentInResourceOrRelationshipRequestAdapter.Convert(document, adapterState); } } diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentInOperationsRequestAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentInOperationsRequestAdapter.cs index a5088cf1af..6d26125666 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentInOperationsRequestAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentInOperationsRequestAdapter.cs @@ -1,74 +1,71 @@ -using System; -using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Serialization.Request.Adapters +namespace JsonApiDotNetCore.Serialization.Request.Adapters; + +/// +public sealed class DocumentInOperationsRequestAdapter : BaseAdapter, IDocumentInOperationsRequestAdapter { - /// - public sealed class DocumentInOperationsRequestAdapter : BaseAdapter, IDocumentInOperationsRequestAdapter - { - private readonly IJsonApiOptions _options; - private readonly IAtomicOperationObjectAdapter _atomicOperationObjectAdapter; + private readonly IJsonApiOptions _options; + private readonly IAtomicOperationObjectAdapter _atomicOperationObjectAdapter; - public DocumentInOperationsRequestAdapter(IJsonApiOptions options, IAtomicOperationObjectAdapter atomicOperationObjectAdapter) - { - ArgumentGuard.NotNull(options, nameof(options)); - ArgumentGuard.NotNull(atomicOperationObjectAdapter, nameof(atomicOperationObjectAdapter)); + public DocumentInOperationsRequestAdapter(IJsonApiOptions options, IAtomicOperationObjectAdapter atomicOperationObjectAdapter) + { + ArgumentGuard.NotNull(options, nameof(options)); + ArgumentGuard.NotNull(atomicOperationObjectAdapter, nameof(atomicOperationObjectAdapter)); - _options = options; - _atomicOperationObjectAdapter = atomicOperationObjectAdapter; - } + _options = options; + _atomicOperationObjectAdapter = atomicOperationObjectAdapter; + } - /// - public IList Convert(Document document, RequestAdapterState state) - { - ArgumentGuard.NotNull(state, nameof(state)); - AssertHasOperations(document.Operations, state); + /// + public IList Convert(Document document, RequestAdapterState state) + { + ArgumentGuard.NotNull(state, nameof(state)); + AssertHasOperations(document.Operations, state); - using IDisposable _ = state.Position.PushElement("atomic:operations"); - AssertMaxOperationsNotExceeded(document.Operations, state); + using IDisposable _ = state.Position.PushElement("atomic:operations"); + AssertMaxOperationsNotExceeded(document.Operations, state); - return ConvertOperations(document.Operations, state); - } + return ConvertOperations(document.Operations, state); + } - private static void AssertHasOperations([NotNull] IEnumerable? atomicOperationObjects, RequestAdapterState state) + private static void AssertHasOperations([NotNull] IEnumerable? atomicOperationObjects, RequestAdapterState state) + { + if (atomicOperationObjects.IsNullOrEmpty()) { - if (atomicOperationObjects.IsNullOrEmpty()) - { - throw new ModelConversionException(state.Position, "No operations found.", null); - } + throw new ModelConversionException(state.Position, "No operations found.", null); } + } - private void AssertMaxOperationsNotExceeded(ICollection atomicOperationObjects, RequestAdapterState state) + private void AssertMaxOperationsNotExceeded(ICollection atomicOperationObjects, RequestAdapterState state) + { + if (atomicOperationObjects.Count > _options.MaximumOperationsPerRequest) { - if (atomicOperationObjects.Count > _options.MaximumOperationsPerRequest) - { - throw new ModelConversionException(state.Position, "Too many operations in request.", - $"The number of operations in this request ({atomicOperationObjects.Count}) is higher " + - $"than the maximum of {_options.MaximumOperationsPerRequest}."); - } + throw new ModelConversionException(state.Position, "Too many operations in request.", + $"The number of operations in this request ({atomicOperationObjects.Count}) is higher " + + $"than the maximum of {_options.MaximumOperationsPerRequest}."); } + } - private IList ConvertOperations(IEnumerable atomicOperationObjects, RequestAdapterState state) - { - var operations = new List(); - int operationIndex = 0; - - foreach (AtomicOperationObject? atomicOperationObject in atomicOperationObjects) - { - using IDisposable _ = state.Position.PushArrayIndex(operationIndex); - AssertObjectIsNotNull(atomicOperationObject, state); + private IList ConvertOperations(IEnumerable atomicOperationObjects, RequestAdapterState state) + { + var operations = new List(); + int operationIndex = 0; - OperationContainer operation = _atomicOperationObjectAdapter.Convert(atomicOperationObject, state); - operations.Add(operation); + foreach (AtomicOperationObject? atomicOperationObject in atomicOperationObjects) + { + using IDisposable _ = state.Position.PushArrayIndex(operationIndex); + AssertObjectIsNotNull(atomicOperationObject, state); - operationIndex++; - } + OperationContainer operation = _atomicOperationObjectAdapter.Convert(atomicOperationObject, state); + operations.Add(operation); - return operations; + operationIndex++; } + + return operations; } } diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentInResourceOrRelationshipRequestAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentInResourceOrRelationshipRequestAdapter.cs index 1a127b49ed..aaf5b813c8 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentInResourceOrRelationshipRequestAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentInResourceOrRelationshipRequestAdapter.cs @@ -1,77 +1,75 @@ -using System.Collections.Generic; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Serialization.Request.Adapters +namespace JsonApiDotNetCore.Serialization.Request.Adapters; + +/// +public sealed class DocumentInResourceOrRelationshipRequestAdapter : IDocumentInResourceOrRelationshipRequestAdapter { - /// - public sealed class DocumentInResourceOrRelationshipRequestAdapter : IDocumentInResourceOrRelationshipRequestAdapter + private readonly IJsonApiOptions _options; + private readonly IResourceDataAdapter _resourceDataAdapter; + private readonly IRelationshipDataAdapter _relationshipDataAdapter; + + public DocumentInResourceOrRelationshipRequestAdapter(IJsonApiOptions options, IResourceDataAdapter resourceDataAdapter, + IRelationshipDataAdapter relationshipDataAdapter) { - private readonly IJsonApiOptions _options; - private readonly IResourceDataAdapter _resourceDataAdapter; - private readonly IRelationshipDataAdapter _relationshipDataAdapter; + ArgumentGuard.NotNull(options, nameof(options)); + ArgumentGuard.NotNull(resourceDataAdapter, nameof(resourceDataAdapter)); + ArgumentGuard.NotNull(relationshipDataAdapter, nameof(relationshipDataAdapter)); - public DocumentInResourceOrRelationshipRequestAdapter(IJsonApiOptions options, IResourceDataAdapter resourceDataAdapter, - IRelationshipDataAdapter relationshipDataAdapter) - { - ArgumentGuard.NotNull(options, nameof(options)); - ArgumentGuard.NotNull(resourceDataAdapter, nameof(resourceDataAdapter)); - ArgumentGuard.NotNull(relationshipDataAdapter, nameof(relationshipDataAdapter)); + _options = options; + _resourceDataAdapter = resourceDataAdapter; + _relationshipDataAdapter = relationshipDataAdapter; + } - _options = options; - _resourceDataAdapter = resourceDataAdapter; - _relationshipDataAdapter = relationshipDataAdapter; - } + /// + public object? Convert(Document document, RequestAdapterState state) + { + state.WritableTargetedFields = new TargetedFields(); - /// - public object? Convert(Document document, RequestAdapterState state) + switch (state.Request.WriteOperation) { - state.WritableTargetedFields = new TargetedFields(); - - switch (state.Request.WriteOperation) + case WriteOperationKind.CreateResource: + case WriteOperationKind.UpdateResource: + { + ResourceIdentityRequirements requirements = CreateIdentityRequirements(state); + return _resourceDataAdapter.Convert(document.Data, requirements, state); + } + case WriteOperationKind.SetRelationship: + case WriteOperationKind.AddToRelationship: + case WriteOperationKind.RemoveFromRelationship: { - case WriteOperationKind.CreateResource: - case WriteOperationKind.UpdateResource: + if (state.Request.Relationship == null) { - ResourceIdentityRequirements requirements = CreateIdentityRequirements(state); - return _resourceDataAdapter.Convert(document.Data, requirements, state); + // Let the controller throw for unknown relationship, because it knows the relationship name that was used. + return new HashSet(IdentifiableComparer.Instance); } - case WriteOperationKind.SetRelationship: - case WriteOperationKind.AddToRelationship: - case WriteOperationKind.RemoveFromRelationship: - { - if (state.Request.Relationship == null) - { - // Let the controller throw for unknown relationship, because it knows the relationship name that was used. - return new HashSet(IdentifiableComparer.Instance); - } - ResourceIdentityAdapter.AssertToManyInAddOrRemoveRelationship(state.Request.Relationship, state); + ResourceIdentityAdapter.AssertToManyInAddOrRemoveRelationship(state.Request.Relationship, state); - state.WritableTargetedFields.Relationships.Add(state.Request.Relationship); - return _relationshipDataAdapter.Convert(document.Data, state.Request.Relationship, false, state); - } + state.WritableTargetedFields.Relationships.Add(state.Request.Relationship); + return _relationshipDataAdapter.Convert(document.Data, state.Request.Relationship, false, state); } - - return null; } - private ResourceIdentityRequirements CreateIdentityRequirements(RequestAdapterState state) - { - JsonElementConstraint? idConstraint = state.Request.WriteOperation == WriteOperationKind.CreateResource - ? _options.AllowClientGeneratedIds ? null : JsonElementConstraint.Forbidden - : JsonElementConstraint.Required; + return null; + } - var requirements = new ResourceIdentityRequirements - { - ResourceType = state.Request.PrimaryResourceType, - IdConstraint = idConstraint, - IdValue = state.Request.PrimaryId - }; + private ResourceIdentityRequirements CreateIdentityRequirements(RequestAdapterState state) + { + JsonElementConstraint? idConstraint = state.Request.WriteOperation == WriteOperationKind.CreateResource + ? _options.AllowClientGeneratedIds ? null : JsonElementConstraint.Forbidden + : JsonElementConstraint.Required; - return requirements; - } + var requirements = new ResourceIdentityRequirements + { + ResourceType = state.Request.PrimaryResourceType, + IdConstraint = idConstraint, + IdValue = state.Request.PrimaryId + }; + + return requirements; } } diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/IAtomicOperationObjectAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IAtomicOperationObjectAdapter.cs index 5fb2c1d680..b734a96dad 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/IAtomicOperationObjectAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IAtomicOperationObjectAdapter.cs @@ -1,16 +1,15 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Serialization.Request.Adapters +namespace JsonApiDotNetCore.Serialization.Request.Adapters; + +/// +/// Validates and converts a single operation inside an atomic:operations request. +/// +public interface IAtomicOperationObjectAdapter { /// - /// Validates and converts a single operation inside an atomic:operations request. + /// Validates and converts the specified . /// - public interface IAtomicOperationObjectAdapter - { - /// - /// Validates and converts the specified . - /// - OperationContainer Convert(AtomicOperationObject atomicOperationObject, RequestAdapterState state); - } + OperationContainer Convert(AtomicOperationObject atomicOperationObject, RequestAdapterState state); } diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/IAtomicReferenceAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IAtomicReferenceAdapter.cs index bd4a12b2de..047ec78181 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/IAtomicReferenceAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IAtomicReferenceAdapter.cs @@ -1,16 +1,15 @@ using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Serialization.Request.Adapters +namespace JsonApiDotNetCore.Serialization.Request.Adapters; + +/// +/// Validates and converts a 'ref' element in an entry of an atomic:operations request. It appears in most kinds of operations and typically indicates +/// what would otherwise have been in the endpoint URL, if it were a resource request. +/// +public interface IAtomicReferenceAdapter { /// - /// Validates and converts a 'ref' element in an entry of an atomic:operations request. It appears in most kinds of operations and typically indicates - /// what would otherwise have been in the endpoint URL, if it were a resource request. + /// Validates and converts the specified . /// - public interface IAtomicReferenceAdapter - { - /// - /// Validates and converts the specified . - /// - AtomicReferenceResult Convert(AtomicReference atomicReference, ResourceIdentityRequirements requirements, RequestAdapterState state); - } + AtomicReferenceResult Convert(AtomicReference atomicReference, ResourceIdentityRequirements requirements, RequestAdapterState state); } diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/IDocumentAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IDocumentAdapter.cs index 78e3ed2f1a..794278fc73 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/IDocumentAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IDocumentAdapter.cs @@ -1,38 +1,37 @@ using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Serialization.Request.Adapters +namespace JsonApiDotNetCore.Serialization.Request.Adapters; + +/// +/// The entry point for validating and converting the deserialized from the request body into a model. The produced models are +/// used in ASP.NET Model Binding. +/// +public interface IDocumentAdapter { /// - /// The entry point for validating and converting the deserialized from the request body into a model. The produced models are - /// used in ASP.NET Model Binding. + /// Validates and converts the specified . Possible return values: + /// + /// + /// + /// ]]> (operations) + /// + /// + /// + /// + /// ]]> (to-many relationship, unknown relationship) + /// + /// + /// + /// + /// (resource, to-one relationship) + /// + /// + /// + /// + /// (to-one relationship) + /// + /// + /// /// - public interface IDocumentAdapter - { - /// - /// Validates and converts the specified . Possible return values: - /// - /// - /// - /// ]]> (operations) - /// - /// - /// - /// - /// ]]> (to-many relationship, unknown relationship) - /// - /// - /// - /// - /// (resource, to-one relationship) - /// - /// - /// - /// - /// (to-one relationship) - /// - /// - /// - /// - object? Convert(Document document); - } + object? Convert(Document document); } diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/IDocumentInOperationsRequestAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IDocumentInOperationsRequestAdapter.cs index de39fa6c91..228abbf3ce 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/IDocumentInOperationsRequestAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IDocumentInOperationsRequestAdapter.cs @@ -1,17 +1,15 @@ -using System.Collections.Generic; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Serialization.Request.Adapters +namespace JsonApiDotNetCore.Serialization.Request.Adapters; + +/// +/// Validates and converts a belonging to an atomic:operations request. +/// +public interface IDocumentInOperationsRequestAdapter { /// - /// Validates and converts a belonging to an atomic:operations request. + /// Validates and converts the specified . /// - public interface IDocumentInOperationsRequestAdapter - { - /// - /// Validates and converts the specified . - /// - IList Convert(Document document, RequestAdapterState state); - } + IList Convert(Document document, RequestAdapterState state); } diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/IDocumentInResourceOrRelationshipRequestAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IDocumentInResourceOrRelationshipRequestAdapter.cs index a1b8fc0585..5da648ce4e 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/IDocumentInResourceOrRelationshipRequestAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IDocumentInResourceOrRelationshipRequestAdapter.cs @@ -1,15 +1,14 @@ using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Serialization.Request.Adapters +namespace JsonApiDotNetCore.Serialization.Request.Adapters; + +/// +/// Validates and converts a belonging to a resource or relationship request. +/// +public interface IDocumentInResourceOrRelationshipRequestAdapter { /// - /// Validates and converts a belonging to a resource or relationship request. + /// Validates and converts the specified . /// - public interface IDocumentInResourceOrRelationshipRequestAdapter - { - /// - /// Validates and converts the specified . - /// - object? Convert(Document document, RequestAdapterState state); - } + object? Convert(Document document, RequestAdapterState state); } diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/IRelationshipDataAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IRelationshipDataAdapter.cs index 222642dc76..4a1b6d11a9 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/IRelationshipDataAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IRelationshipDataAdapter.cs @@ -1,24 +1,22 @@ using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Serialization.Request.Adapters +namespace JsonApiDotNetCore.Serialization.Request.Adapters; + +/// +/// Validates and converts the data from a relationship. It appears in a relationship request, in the relationships of a POST/PATCH resource request, in +/// an entry of an atomic:operations request that targets a relationship and in the relationships of an operations entry that creates or updates a +/// resource. +/// +public interface IRelationshipDataAdapter { /// - /// Validates and converts the data from a relationship. It appears in a relationship request, in the relationships of a POST/PATCH resource request, in - /// an entry of an atomic:operations request that targets a relationship and in the relationships of an operations entry that creates or updates a - /// resource. + /// Validates and converts the specified . /// - public interface IRelationshipDataAdapter - { - /// - /// Validates and converts the specified . - /// - object? Convert(SingleOrManyData data, RelationshipAttribute relationship, bool useToManyElementType, RequestAdapterState state); + object? Convert(SingleOrManyData data, RelationshipAttribute relationship, bool useToManyElementType, RequestAdapterState state); - /// - /// Validates and converts the specified . - /// - object? Convert(SingleOrManyData data, RelationshipAttribute relationship, bool useToManyElementType, - RequestAdapterState state); - } + /// + /// Validates and converts the specified . + /// + object? Convert(SingleOrManyData data, RelationshipAttribute relationship, bool useToManyElementType, RequestAdapterState state); } diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/IResourceDataAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IResourceDataAdapter.cs index e7dd737cfb..850e81d77c 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/IResourceDataAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IResourceDataAdapter.cs @@ -1,16 +1,15 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Serialization.Request.Adapters +namespace JsonApiDotNetCore.Serialization.Request.Adapters; + +/// +/// Validates and converts the data from a resource in a POST/PATCH resource request. +/// +public interface IResourceDataAdapter { /// - /// Validates and converts the data from a resource in a POST/PATCH resource request. + /// Validates and converts the specified . /// - public interface IResourceDataAdapter - { - /// - /// Validates and converts the specified . - /// - IIdentifiable Convert(SingleOrManyData data, ResourceIdentityRequirements requirements, RequestAdapterState state); - } + IIdentifiable Convert(SingleOrManyData data, ResourceIdentityRequirements requirements, RequestAdapterState state); } diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/IResourceDataInOperationsRequestAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IResourceDataInOperationsRequestAdapter.cs index f47e25dfa0..b2dd3da844 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/IResourceDataInOperationsRequestAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IResourceDataInOperationsRequestAdapter.cs @@ -2,17 +2,16 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Serialization.Request.Adapters +namespace JsonApiDotNetCore.Serialization.Request.Adapters; + +/// +/// Validates and converts the data from an entry in an atomic:operations request that creates or updates a resource. +/// +[PublicAPI] +public interface IResourceDataInOperationsRequestAdapter { /// - /// Validates and converts the data from an entry in an atomic:operations request that creates or updates a resource. + /// Validates and converts the specified . /// - [PublicAPI] - public interface IResourceDataInOperationsRequestAdapter - { - /// - /// Validates and converts the specified . - /// - IIdentifiable Convert(SingleOrManyData data, ResourceIdentityRequirements requirements, RequestAdapterState state); - } + IIdentifiable Convert(SingleOrManyData data, ResourceIdentityRequirements requirements, RequestAdapterState state); } diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/IResourceIdentifierObjectAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IResourceIdentifierObjectAdapter.cs index 3105143908..d1b233d4f0 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/IResourceIdentifierObjectAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IResourceIdentifierObjectAdapter.cs @@ -1,16 +1,15 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Serialization.Request.Adapters +namespace JsonApiDotNetCore.Serialization.Request.Adapters; + +/// +/// Validates and converts a . It appears in the data object(s) of a relationship. +/// +public interface IResourceIdentifierObjectAdapter { /// - /// Validates and converts a . It appears in the data object(s) of a relationship. + /// Validates and converts the specified . /// - public interface IResourceIdentifierObjectAdapter - { - /// - /// Validates and converts the specified . - /// - IIdentifiable Convert(ResourceIdentifierObject resourceIdentifierObject, ResourceIdentityRequirements requirements, RequestAdapterState state); - } + IIdentifiable Convert(ResourceIdentifierObject resourceIdentifierObject, ResourceIdentityRequirements requirements, RequestAdapterState state); } diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/IResourceObjectAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IResourceObjectAdapter.cs index 8245444e08..a8bec30bf6 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/IResourceObjectAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IResourceObjectAdapter.cs @@ -2,18 +2,17 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Serialization.Request.Adapters +namespace JsonApiDotNetCore.Serialization.Request.Adapters; + +/// +/// Validates and converts a . It appears in a POST/PATCH resource request and an entry in an atomic:operations request that +/// creates or updates a resource. +/// +public interface IResourceObjectAdapter { /// - /// Validates and converts a . It appears in a POST/PATCH resource request and an entry in an atomic:operations request that - /// creates or updates a resource. + /// Validates and converts the specified . /// - public interface IResourceObjectAdapter - { - /// - /// Validates and converts the specified . - /// - (IIdentifiable resource, ResourceType resourceType) Convert(ResourceObject resourceObject, ResourceIdentityRequirements requirements, - RequestAdapterState state); - } + (IIdentifiable resource, ResourceType resourceType) Convert(ResourceObject resourceObject, ResourceIdentityRequirements requirements, + RequestAdapterState state); } diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/JsonElementConstraint.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/JsonElementConstraint.cs index ebdd76945a..eaee05538c 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/JsonElementConstraint.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/JsonElementConstraint.cs @@ -1,21 +1,20 @@ using JetBrains.Annotations; -namespace JsonApiDotNetCore.Serialization.Request.Adapters +namespace JsonApiDotNetCore.Serialization.Request.Adapters; + +/// +/// Lists constraints for the presence or absence of a JSON element. +/// +[PublicAPI] +public enum JsonElementConstraint { /// - /// Lists constraints for the presence or absence of a JSON element. + /// A value for the element is not allowed. /// - [PublicAPI] - public enum JsonElementConstraint - { - /// - /// A value for the element is not allowed. - /// - Forbidden, + Forbidden, - /// - /// A value for the element is required. - /// - Required - } + /// + /// A value for the element is required. + /// + Required } diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/RelationshipDataAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/RelationshipDataAdapter.cs index 6cc42bacdd..89cf3caa18 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/RelationshipDataAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/RelationshipDataAdapter.cs @@ -1,121 +1,117 @@ -using System; using System.Collections; -using System.Collections.Generic; -using System.Linq; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Serialization.Request.Adapters +namespace JsonApiDotNetCore.Serialization.Request.Adapters; + +/// +public sealed class RelationshipDataAdapter : BaseAdapter, IRelationshipDataAdapter { - /// - public sealed class RelationshipDataAdapter : BaseAdapter, IRelationshipDataAdapter - { - private static readonly CollectionConverter CollectionConverter = new(); + private static readonly CollectionConverter CollectionConverter = new(); - private readonly IResourceIdentifierObjectAdapter _resourceIdentifierObjectAdapter; + private readonly IResourceIdentifierObjectAdapter _resourceIdentifierObjectAdapter; - public RelationshipDataAdapter(IResourceIdentifierObjectAdapter resourceIdentifierObjectAdapter) - { - ArgumentGuard.NotNull(resourceIdentifierObjectAdapter, nameof(resourceIdentifierObjectAdapter)); + public RelationshipDataAdapter(IResourceIdentifierObjectAdapter resourceIdentifierObjectAdapter) + { + ArgumentGuard.NotNull(resourceIdentifierObjectAdapter, nameof(resourceIdentifierObjectAdapter)); - _resourceIdentifierObjectAdapter = resourceIdentifierObjectAdapter; - } + _resourceIdentifierObjectAdapter = resourceIdentifierObjectAdapter; + } - /// - public object? Convert(SingleOrManyData data, RelationshipAttribute relationship, bool useToManyElementType, RequestAdapterState state) - { - SingleOrManyData identifierData = ToIdentifierData(data); - return Convert(identifierData, relationship, useToManyElementType, state); - } + /// + public object? Convert(SingleOrManyData data, RelationshipAttribute relationship, bool useToManyElementType, RequestAdapterState state) + { + SingleOrManyData identifierData = ToIdentifierData(data); + return Convert(identifierData, relationship, useToManyElementType, state); + } - private static SingleOrManyData ToIdentifierData(SingleOrManyData data) + private static SingleOrManyData ToIdentifierData(SingleOrManyData data) + { + if (!data.IsAssigned) { - if (!data.IsAssigned) - { - return default; - } + return default; + } - object? newValue = null; + object? newValue = null; - if (data.ManyValue != null) - { - newValue = data.ManyValue.Select(resourceObject => new ResourceIdentifierObject - { - Type = resourceObject.Type, - Id = resourceObject.Id, - Lid = resourceObject.Lid - }); - } - else if (data.SingleValue != null) + if (data.ManyValue != null) + { + newValue = data.ManyValue.Select(resourceObject => new ResourceIdentifierObject { - newValue = new ResourceIdentifierObject - { - Type = data.SingleValue.Type, - Id = data.SingleValue.Id, - Lid = data.SingleValue.Lid - }; - } - - return new SingleOrManyData(newValue); + Type = resourceObject.Type, + Id = resourceObject.Id, + Lid = resourceObject.Lid + }); } - - /// - public object? Convert(SingleOrManyData data, RelationshipAttribute relationship, bool useToManyElementType, - RequestAdapterState state) + else if (data.SingleValue != null) { - ArgumentGuard.NotNull(relationship, nameof(relationship)); - ArgumentGuard.NotNull(state, nameof(state)); - AssertHasData(data, state); - - using IDisposable _ = state.Position.PushElement("data"); - - var requirements = new ResourceIdentityRequirements + newValue = new ResourceIdentifierObject { - ResourceType = relationship.RightType, - IdConstraint = JsonElementConstraint.Required, - RelationshipName = relationship.PublicName + Type = data.SingleValue.Type, + Id = data.SingleValue.Id, + Lid = data.SingleValue.Lid }; - - return relationship is HasOneAttribute - ? ConvertToOneRelationshipData(data, requirements, state) - : ConvertToManyRelationshipData(data, relationship, requirements, useToManyElementType, state); } - private IIdentifiable? ConvertToOneRelationshipData(SingleOrManyData data, ResourceIdentityRequirements requirements, - RequestAdapterState state) - { - AssertDataHasSingleValue(data, true, state); + return new SingleOrManyData(newValue); + } - return data.SingleValue != null ? _resourceIdentifierObjectAdapter.Convert(data.SingleValue, requirements, state) : null; - } + /// + public object? Convert(SingleOrManyData data, RelationshipAttribute relationship, bool useToManyElementType, + RequestAdapterState state) + { + ArgumentGuard.NotNull(relationship, nameof(relationship)); + ArgumentGuard.NotNull(state, nameof(state)); + AssertHasData(data, state); - private IEnumerable ConvertToManyRelationshipData(SingleOrManyData data, RelationshipAttribute relationship, - ResourceIdentityRequirements requirements, bool useToManyElementType, RequestAdapterState state) + using IDisposable _ = state.Position.PushElement("data"); + + var requirements = new ResourceIdentityRequirements { - AssertDataHasManyValue(data, state); + ResourceType = relationship.RightType, + IdConstraint = JsonElementConstraint.Required, + RelationshipName = relationship.PublicName + }; + + return relationship is HasOneAttribute + ? ConvertToOneRelationshipData(data, requirements, state) + : ConvertToManyRelationshipData(data, relationship, requirements, useToManyElementType, state); + } - int arrayIndex = 0; - var rightResources = new List(); + private IIdentifiable? ConvertToOneRelationshipData(SingleOrManyData data, ResourceIdentityRequirements requirements, + RequestAdapterState state) + { + AssertDataHasSingleValue(data, true, state); - foreach (ResourceIdentifierObject resourceIdentifierObject in data.ManyValue!) - { - using IDisposable _ = state.Position.PushArrayIndex(arrayIndex); + return data.SingleValue != null ? _resourceIdentifierObjectAdapter.Convert(data.SingleValue, requirements, state) : null; + } - IIdentifiable rightResource = _resourceIdentifierObjectAdapter.Convert(resourceIdentifierObject, requirements, state); - rightResources.Add(rightResource); + private IEnumerable ConvertToManyRelationshipData(SingleOrManyData data, RelationshipAttribute relationship, + ResourceIdentityRequirements requirements, bool useToManyElementType, RequestAdapterState state) + { + AssertDataHasManyValue(data, state); - arrayIndex++; - } + int arrayIndex = 0; + var rightResources = new List(); - if (useToManyElementType) - { - return CollectionConverter.CopyToTypedCollection(rightResources, relationship.Property.PropertyType); - } + foreach (ResourceIdentifierObject resourceIdentifierObject in data.ManyValue!) + { + using IDisposable _ = state.Position.PushArrayIndex(arrayIndex); + + IIdentifiable rightResource = _resourceIdentifierObjectAdapter.Convert(resourceIdentifierObject, requirements, state); + rightResources.Add(rightResource); - var resourceSet = new HashSet(IdentifiableComparer.Instance); - resourceSet.AddRange(rightResources); - return resourceSet; + arrayIndex++; } + + if (useToManyElementType) + { + return CollectionConverter.CopyToTypedCollection(rightResources, relationship.Property.PropertyType); + } + + var resourceSet = new HashSet(IdentifiableComparer.Instance); + resourceSet.AddRange(rightResources); + return resourceSet; } } diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/RequestAdapterPosition.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/RequestAdapterPosition.cs index 252c0fe2a6..3ae7caa6af 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/RequestAdapterPosition.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/RequestAdapterPosition.cs @@ -1,76 +1,72 @@ -using System; -using System.Collections.Generic; -using System.Linq; using System.Text; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Serialization.Request.Adapters +namespace JsonApiDotNetCore.Serialization.Request.Adapters; + +/// +/// Tracks the location within an object tree when validating and converting a request body. +/// +[PublicAPI] +public sealed class RequestAdapterPosition { - /// - /// Tracks the location within an object tree when validating and converting a request body. - /// - [PublicAPI] - public sealed class RequestAdapterPosition + private readonly Stack _stack = new(); + private readonly IDisposable _disposable; + + public RequestAdapterPosition() { - private readonly Stack _stack = new(); - private readonly IDisposable _disposable; + _disposable = new PopStackOnDispose(this); + } - public RequestAdapterPosition() - { - _disposable = new PopStackOnDispose(this); - } + public IDisposable PushElement(string name) + { + ArgumentGuard.NotNullNorEmpty(name, nameof(name)); - public IDisposable PushElement(string name) - { - ArgumentGuard.NotNullNorEmpty(name, nameof(name)); + _stack.Push($"/{name}"); + return _disposable; + } - _stack.Push($"/{name}"); - return _disposable; - } + public IDisposable PushArrayIndex(int index) + { + _stack.Push($"[{index}]"); + return _disposable; + } - public IDisposable PushArrayIndex(int index) + public string? ToSourcePointer() + { + if (!_stack.Any()) { - _stack.Push($"[{index}]"); - return _disposable; + return null; } - public string? ToSourcePointer() + var builder = new StringBuilder(); + var clone = new Stack(_stack); + + while (clone.Any()) { - if (!_stack.Any()) - { - return null; - } + string element = clone.Pop(); + builder.Append(element); + } - var builder = new StringBuilder(); - var clone = new Stack(_stack); + return builder.ToString(); + } - while (clone.Any()) - { - string element = clone.Pop(); - builder.Append(element); - } + public override string ToString() + { + return ToSourcePointer() ?? string.Empty; + } - return builder.ToString(); - } + private sealed class PopStackOnDispose : IDisposable + { + private readonly RequestAdapterPosition _owner; - public override string ToString() + public PopStackOnDispose(RequestAdapterPosition owner) { - return ToSourcePointer() ?? string.Empty; + _owner = owner; } - private sealed class PopStackOnDispose : IDisposable + public void Dispose() { - private readonly RequestAdapterPosition _owner; - - public PopStackOnDispose(RequestAdapterPosition owner) - { - _owner = owner; - } - - public void Dispose() - { - _owner._stack.Pop(); - } + _owner._stack.Pop(); } } } diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/RequestAdapterState.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/RequestAdapterState.cs index b333b61140..88cf686f51 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/RequestAdapterState.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/RequestAdapterState.cs @@ -1,68 +1,66 @@ -using System; using JetBrains.Annotations; using JsonApiDotNetCore.AtomicOperations; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources; -namespace JsonApiDotNetCore.Serialization.Request.Adapters +namespace JsonApiDotNetCore.Serialization.Request.Adapters; + +/// +/// Tracks state while adapting objects from into the shape that controller actions accept. +/// +[PublicAPI] +public sealed class RequestAdapterState : IDisposable { - /// - /// Tracks state while adapting objects from into the shape that controller actions accept. - /// - [PublicAPI] - public sealed class RequestAdapterState : IDisposable - { - private readonly IDisposable? _backupRequestState; + private readonly IDisposable? _backupRequestState; - public IJsonApiRequest InjectableRequest { get; } - public ITargetedFields InjectableTargetedFields { get; } + public IJsonApiRequest InjectableRequest { get; } + public ITargetedFields InjectableTargetedFields { get; } - public JsonApiRequest? WritableRequest { get; set; } - public TargetedFields? WritableTargetedFields { get; set; } + public JsonApiRequest? WritableRequest { get; set; } + public TargetedFields? WritableTargetedFields { get; set; } - public RequestAdapterPosition Position { get; } = new(); - public IJsonApiRequest Request => WritableRequest ?? InjectableRequest; + public RequestAdapterPosition Position { get; } = new(); + public IJsonApiRequest Request => WritableRequest ?? InjectableRequest; - public RequestAdapterState(IJsonApiRequest request, ITargetedFields targetedFields) - { - ArgumentGuard.NotNull(request, nameof(request)); - ArgumentGuard.NotNull(targetedFields, nameof(targetedFields)); + public RequestAdapterState(IJsonApiRequest request, ITargetedFields targetedFields) + { + ArgumentGuard.NotNull(request, nameof(request)); + ArgumentGuard.NotNull(targetedFields, nameof(targetedFields)); - InjectableRequest = request; - InjectableTargetedFields = targetedFields; + InjectableRequest = request; + InjectableTargetedFields = targetedFields; - if (request.Kind == EndpointKind.AtomicOperations) - { - _backupRequestState = new RevertRequestStateOnDispose(request, targetedFields); - } + if (request.Kind == EndpointKind.AtomicOperations) + { + _backupRequestState = new RevertRequestStateOnDispose(request, targetedFields); } + } - public void RefreshInjectables() + public void RefreshInjectables() + { + if (WritableRequest != null) { - if (WritableRequest != null) - { - InjectableRequest.CopyFrom(WritableRequest); - } - - if (WritableTargetedFields != null) - { - InjectableTargetedFields.CopyFrom(WritableTargetedFields); - } + InjectableRequest.CopyFrom(WritableRequest); } - public void Dispose() + if (WritableTargetedFields != null) { - // For resource requests, we'd like the injected state to become the final state. - // But for operations, it makes more sense to reset than to reflect the last operation. + InjectableTargetedFields.CopyFrom(WritableTargetedFields); + } + } + + public void Dispose() + { + // For resource requests, we'd like the injected state to become the final state. + // But for operations, it makes more sense to reset than to reflect the last operation. - if (_backupRequestState != null) - { - _backupRequestState.Dispose(); - } - else - { - RefreshInjectables(); - } + if (_backupRequestState != null) + { + _backupRequestState.Dispose(); + } + else + { + RefreshInjectables(); } } } diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceDataAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceDataAdapter.cs index 6e1d72fc17..dc84fbad3d 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceDataAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceDataAdapter.cs @@ -1,49 +1,47 @@ -using System; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Serialization.Request.Adapters +namespace JsonApiDotNetCore.Serialization.Request.Adapters; + +/// +public class ResourceDataAdapter : BaseAdapter, IResourceDataAdapter { - /// - public class ResourceDataAdapter : BaseAdapter, IResourceDataAdapter - { - private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; - private readonly IResourceObjectAdapter _resourceObjectAdapter; + private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; + private readonly IResourceObjectAdapter _resourceObjectAdapter; - public ResourceDataAdapter(IResourceDefinitionAccessor resourceDefinitionAccessor, IResourceObjectAdapter resourceObjectAdapter) - { - ArgumentGuard.NotNull(resourceDefinitionAccessor, nameof(resourceDefinitionAccessor)); - ArgumentGuard.NotNull(resourceObjectAdapter, nameof(resourceObjectAdapter)); + public ResourceDataAdapter(IResourceDefinitionAccessor resourceDefinitionAccessor, IResourceObjectAdapter resourceObjectAdapter) + { + ArgumentGuard.NotNull(resourceDefinitionAccessor, nameof(resourceDefinitionAccessor)); + ArgumentGuard.NotNull(resourceObjectAdapter, nameof(resourceObjectAdapter)); - _resourceDefinitionAccessor = resourceDefinitionAccessor; - _resourceObjectAdapter = resourceObjectAdapter; - } + _resourceDefinitionAccessor = resourceDefinitionAccessor; + _resourceObjectAdapter = resourceObjectAdapter; + } - /// - public IIdentifiable Convert(SingleOrManyData data, ResourceIdentityRequirements requirements, RequestAdapterState state) - { - ArgumentGuard.NotNull(requirements, nameof(requirements)); - ArgumentGuard.NotNull(state, nameof(state)); + /// + public IIdentifiable Convert(SingleOrManyData data, ResourceIdentityRequirements requirements, RequestAdapterState state) + { + ArgumentGuard.NotNull(requirements, nameof(requirements)); + ArgumentGuard.NotNull(state, nameof(state)); - AssertHasData(data, state); + AssertHasData(data, state); - using IDisposable _ = state.Position.PushElement("data"); - AssertDataHasSingleValue(data, false, state); + using IDisposable _ = state.Position.PushElement("data"); + AssertDataHasSingleValue(data, false, state); - (IIdentifiable resource, ResourceType _) = ConvertResourceObject(data, requirements, state); + (IIdentifiable resource, ResourceType _) = ConvertResourceObject(data, requirements, state); - // Ensure that IResourceDefinition extensibility point sees the current operation, in case it injects IJsonApiRequest. - state.RefreshInjectables(); + // Ensure that IResourceDefinition extensibility point sees the current operation, in case it injects IJsonApiRequest. + state.RefreshInjectables(); - _resourceDefinitionAccessor.OnDeserialize(resource); - return resource; - } + _resourceDefinitionAccessor.OnDeserialize(resource); + return resource; + } - protected virtual (IIdentifiable resource, ResourceType resourceType) ConvertResourceObject(SingleOrManyData data, - ResourceIdentityRequirements requirements, RequestAdapterState state) - { - return _resourceObjectAdapter.Convert(data.SingleValue!, requirements, state); - } + protected virtual (IIdentifiable resource, ResourceType resourceType) ConvertResourceObject(SingleOrManyData data, + ResourceIdentityRequirements requirements, RequestAdapterState state) + { + return _resourceObjectAdapter.Convert(data.SingleValue!, requirements, state); } } diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceDataInOperationsRequestAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceDataInOperationsRequestAdapter.cs index 5ebdb6f3cd..afccb303b5 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceDataInOperationsRequestAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceDataInOperationsRequestAdapter.cs @@ -2,27 +2,26 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Serialization.Request.Adapters +namespace JsonApiDotNetCore.Serialization.Request.Adapters; + +/// +public sealed class ResourceDataInOperationsRequestAdapter : ResourceDataAdapter, IResourceDataInOperationsRequestAdapter { - /// - public sealed class ResourceDataInOperationsRequestAdapter : ResourceDataAdapter, IResourceDataInOperationsRequestAdapter + public ResourceDataInOperationsRequestAdapter(IResourceDefinitionAccessor resourceDefinitionAccessor, IResourceObjectAdapter resourceObjectAdapter) + : base(resourceDefinitionAccessor, resourceObjectAdapter) { - public ResourceDataInOperationsRequestAdapter(IResourceDefinitionAccessor resourceDefinitionAccessor, IResourceObjectAdapter resourceObjectAdapter) - : base(resourceDefinitionAccessor, resourceObjectAdapter) - { - } + } - protected override (IIdentifiable resource, ResourceType resourceType) ConvertResourceObject(SingleOrManyData data, - ResourceIdentityRequirements requirements, RequestAdapterState state) - { - // This override ensures that we enrich IJsonApiRequest before calling into IResourceDefinition, so it is ready for consumption there. + protected override (IIdentifiable resource, ResourceType resourceType) ConvertResourceObject(SingleOrManyData data, + ResourceIdentityRequirements requirements, RequestAdapterState state) + { + // This override ensures that we enrich IJsonApiRequest before calling into IResourceDefinition, so it is ready for consumption there. - (IIdentifiable resource, ResourceType resourceType) = base.ConvertResourceObject(data, requirements, state); + (IIdentifiable resource, ResourceType resourceType) = base.ConvertResourceObject(data, requirements, state); - state.WritableRequest!.PrimaryResourceType = resourceType; - state.WritableRequest.PrimaryId = resource.StringId; + state.WritableRequest!.PrimaryResourceType = resourceType; + state.WritableRequest.PrimaryId = resource.StringId; - return (resource, resourceType); - } + return (resource, resourceType); } } diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentifierObjectAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentifierObjectAdapter.cs index fc5cbfc3e4..d0e1b54856 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentifierObjectAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentifierObjectAdapter.cs @@ -2,25 +2,24 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Serialization.Request.Adapters +namespace JsonApiDotNetCore.Serialization.Request.Adapters; + +/// +public sealed class ResourceIdentifierObjectAdapter : ResourceIdentityAdapter, IResourceIdentifierObjectAdapter { - /// - public sealed class ResourceIdentifierObjectAdapter : ResourceIdentityAdapter, IResourceIdentifierObjectAdapter + public ResourceIdentifierObjectAdapter(IResourceGraph resourceGraph, IResourceFactory resourceFactory) + : base(resourceGraph, resourceFactory) { - public ResourceIdentifierObjectAdapter(IResourceGraph resourceGraph, IResourceFactory resourceFactory) - : base(resourceGraph, resourceFactory) - { - } + } - /// - public IIdentifiable Convert(ResourceIdentifierObject resourceIdentifierObject, ResourceIdentityRequirements requirements, RequestAdapterState state) - { - ArgumentGuard.NotNull(resourceIdentifierObject, nameof(resourceIdentifierObject)); - ArgumentGuard.NotNull(requirements, nameof(requirements)); - ArgumentGuard.NotNull(state, nameof(state)); + /// + public IIdentifiable Convert(ResourceIdentifierObject resourceIdentifierObject, ResourceIdentityRequirements requirements, RequestAdapterState state) + { + ArgumentGuard.NotNull(resourceIdentifierObject, nameof(resourceIdentifierObject)); + ArgumentGuard.NotNull(requirements, nameof(requirements)); + ArgumentGuard.NotNull(state, nameof(state)); - (IIdentifiable resource, _) = ConvertResourceIdentity(resourceIdentifierObject, requirements, state); - return resource; - } + (IIdentifiable resource, _) = ConvertResourceIdentity(resourceIdentifierObject, requirements, state); + return resource; } } diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs index 80d927c1b0..f453b8dd21 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs @@ -1,4 +1,3 @@ -using System; using System.Diagnostics.CodeAnalysis; using System.Net; using JsonApiDotNetCore.Configuration; @@ -7,217 +6,215 @@ using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Serialization.Request.Adapters +namespace JsonApiDotNetCore.Serialization.Request.Adapters; + +/// +/// Base class for validating and converting objects that represent an identity. +/// +public abstract class ResourceIdentityAdapter : BaseAdapter { - /// - /// Base class for validating and converting objects that represent an identity. - /// - public abstract class ResourceIdentityAdapter : BaseAdapter - { - private readonly IResourceGraph _resourceGraph; - private readonly IResourceFactory _resourceFactory; + private readonly IResourceGraph _resourceGraph; + private readonly IResourceFactory _resourceFactory; - protected ResourceIdentityAdapter(IResourceGraph resourceGraph, IResourceFactory resourceFactory) - { - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); - ArgumentGuard.NotNull(resourceFactory, nameof(resourceFactory)); + protected ResourceIdentityAdapter(IResourceGraph resourceGraph, IResourceFactory resourceFactory) + { + ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); + ArgumentGuard.NotNull(resourceFactory, nameof(resourceFactory)); - _resourceGraph = resourceGraph; - _resourceFactory = resourceFactory; - } + _resourceGraph = resourceGraph; + _resourceFactory = resourceFactory; + } - protected (IIdentifiable resource, ResourceType resourceType) ConvertResourceIdentity(IResourceIdentity identity, - ResourceIdentityRequirements requirements, RequestAdapterState state) - { - ArgumentGuard.NotNull(identity, nameof(identity)); - ArgumentGuard.NotNull(requirements, nameof(requirements)); - ArgumentGuard.NotNull(state, nameof(state)); + protected (IIdentifiable resource, ResourceType resourceType) ConvertResourceIdentity(IResourceIdentity identity, ResourceIdentityRequirements requirements, + RequestAdapterState state) + { + ArgumentGuard.NotNull(identity, nameof(identity)); + ArgumentGuard.NotNull(requirements, nameof(requirements)); + ArgumentGuard.NotNull(state, nameof(state)); - ResourceType resourceType = ResolveType(identity, requirements, state); - IIdentifiable resource = CreateResource(identity, requirements, resourceType.ClrType, state); + ResourceType resourceType = ResolveType(identity, requirements, state); + IIdentifiable resource = CreateResource(identity, requirements, resourceType.ClrType, state); - return (resource, resourceType); - } + return (resource, resourceType); + } - private ResourceType ResolveType(IResourceIdentity identity, ResourceIdentityRequirements requirements, RequestAdapterState state) - { - AssertHasType(identity.Type, state); + private ResourceType ResolveType(IResourceIdentity identity, ResourceIdentityRequirements requirements, RequestAdapterState state) + { + AssertHasType(identity.Type, state); - using IDisposable _ = state.Position.PushElement("type"); - ResourceType? resourceType = _resourceGraph.FindResourceType(identity.Type); + using IDisposable _ = state.Position.PushElement("type"); + ResourceType? resourceType = _resourceGraph.FindResourceType(identity.Type); - AssertIsKnownResourceType(resourceType, identity.Type, state); - AssertIsCompatibleResourceType(resourceType, requirements.ResourceType, requirements.RelationshipName, state); + AssertIsKnownResourceType(resourceType, identity.Type, state); + AssertIsCompatibleResourceType(resourceType, requirements.ResourceType, requirements.RelationshipName, state); - return resourceType; - } + return resourceType; + } - private static void AssertHasType([NotNull] string? identityType, RequestAdapterState state) + private static void AssertHasType([NotNull] string? identityType, RequestAdapterState state) + { + if (identityType == null) { - if (identityType == null) - { - throw new ModelConversionException(state.Position, "The 'type' element is required.", null); - } + throw new ModelConversionException(state.Position, "The 'type' element is required.", null); } + } - private static void AssertIsKnownResourceType([NotNull] ResourceType? resourceType, string typeName, RequestAdapterState state) + private static void AssertIsKnownResourceType([NotNull] ResourceType? resourceType, string typeName, RequestAdapterState state) + { + if (resourceType == null) { - if (resourceType == null) - { - throw new ModelConversionException(state.Position, "Unknown resource type found.", $"Resource type '{typeName}' does not exist."); - } + throw new ModelConversionException(state.Position, "Unknown resource type found.", $"Resource type '{typeName}' does not exist."); } + } - private static void AssertIsCompatibleResourceType(ResourceType actual, ResourceType? expected, string? relationshipName, RequestAdapterState state) + private static void AssertIsCompatibleResourceType(ResourceType actual, ResourceType? expected, string? relationshipName, RequestAdapterState state) + { + if (expected != null && !expected.ClrType.IsAssignableFrom(actual.ClrType)) { - if (expected != null && !expected.ClrType.IsAssignableFrom(actual.ClrType)) - { - string message = relationshipName != null - ? $"Type '{actual.PublicName}' is incompatible with type '{expected.PublicName}' of relationship '{relationshipName}'." - : $"Type '{actual.PublicName}' is incompatible with type '{expected.PublicName}'."; + string message = relationshipName != null + ? $"Type '{actual.PublicName}' is incompatible with type '{expected.PublicName}' of relationship '{relationshipName}'." + : $"Type '{actual.PublicName}' is incompatible with type '{expected.PublicName}'."; - throw new ModelConversionException(state.Position, "Incompatible resource type found.", message, HttpStatusCode.Conflict); - } + throw new ModelConversionException(state.Position, "Incompatible resource type found.", message, HttpStatusCode.Conflict); } + } - private IIdentifiable CreateResource(IResourceIdentity identity, ResourceIdentityRequirements requirements, Type resourceClrType, - RequestAdapterState state) + private IIdentifiable CreateResource(IResourceIdentity identity, ResourceIdentityRequirements requirements, Type resourceClrType, RequestAdapterState state) + { + if (state.Request.Kind != EndpointKind.AtomicOperations) { - if (state.Request.Kind != EndpointKind.AtomicOperations) - { - AssertHasNoLid(identity, state); - } + AssertHasNoLid(identity, state); + } - AssertNoIdWithLid(identity, state); + AssertNoIdWithLid(identity, state); - if (requirements.IdConstraint == JsonElementConstraint.Required) - { - AssertHasIdOrLid(identity, requirements, state); - } - else if (requirements.IdConstraint == JsonElementConstraint.Forbidden) - { - AssertHasNoId(identity, state); - } + if (requirements.IdConstraint == JsonElementConstraint.Required) + { + AssertHasIdOrLid(identity, requirements, state); + } + else if (requirements.IdConstraint == JsonElementConstraint.Forbidden) + { + AssertHasNoId(identity, state); + } - AssertSameIdValue(identity, requirements.IdValue, state); - AssertSameLidValue(identity, requirements.LidValue, state); + AssertSameIdValue(identity, requirements.IdValue, state); + AssertSameLidValue(identity, requirements.LidValue, state); - IIdentifiable resource = _resourceFactory.CreateInstance(resourceClrType); - AssignStringId(identity, resource, state); - resource.LocalId = identity.Lid; - return resource; - } + IIdentifiable resource = _resourceFactory.CreateInstance(resourceClrType); + AssignStringId(identity, resource, state); + resource.LocalId = identity.Lid; + return resource; + } - private static void AssertHasNoLid(IResourceIdentity identity, RequestAdapterState state) + private static void AssertHasNoLid(IResourceIdentity identity, RequestAdapterState state) + { + if (identity.Lid != null) { - if (identity.Lid != null) - { - using IDisposable _ = state.Position.PushElement("lid"); - throw new ModelConversionException(state.Position, "The 'lid' element is not supported at this endpoint.", null); - } + using IDisposable _ = state.Position.PushElement("lid"); + throw new ModelConversionException(state.Position, "The 'lid' element is not supported at this endpoint.", null); } + } - private static void AssertNoIdWithLid(IResourceIdentity identity, RequestAdapterState state) + private static void AssertNoIdWithLid(IResourceIdentity identity, RequestAdapterState state) + { + if (identity.Id != null && identity.Lid != null) { - if (identity.Id != null && identity.Lid != null) - { - throw new ModelConversionException(state.Position, "The 'id' and 'lid' element are mutually exclusive.", null); - } + throw new ModelConversionException(state.Position, "The 'id' and 'lid' element are mutually exclusive.", null); } + } - private static void AssertHasIdOrLid(IResourceIdentity identity, ResourceIdentityRequirements requirements, RequestAdapterState state) - { - string? message = null; + private static void AssertHasIdOrLid(IResourceIdentity identity, ResourceIdentityRequirements requirements, RequestAdapterState state) + { + string? message = null; - if (requirements.IdValue != null && identity.Id == null) - { - message = "The 'id' element is required."; - } - else if (requirements.LidValue != null && identity.Lid == null) - { - message = "The 'lid' element is required."; - } - else if (identity.Id == null && identity.Lid == null) - { - message = state.Request.Kind == EndpointKind.AtomicOperations ? "The 'id' or 'lid' element is required." : "The 'id' element is required."; - } + if (requirements.IdValue != null && identity.Id == null) + { + message = "The 'id' element is required."; + } + else if (requirements.LidValue != null && identity.Lid == null) + { + message = "The 'lid' element is required."; + } + else if (identity.Id == null && identity.Lid == null) + { + message = state.Request.Kind == EndpointKind.AtomicOperations ? "The 'id' or 'lid' element is required." : "The 'id' element is required."; + } - if (message != null) - { - throw new ModelConversionException(state.Position, message, null); - } + if (message != null) + { + throw new ModelConversionException(state.Position, message, null); } + } - private static void AssertHasNoId(IResourceIdentity identity, RequestAdapterState state) + private static void AssertHasNoId(IResourceIdentity identity, RequestAdapterState state) + { + if (identity.Id != null) { - if (identity.Id != null) - { - using IDisposable _ = state.Position.PushElement("id"); - throw new ModelConversionException(state.Position, "The use of client-generated IDs is disabled.", null, HttpStatusCode.Forbidden); - } + using IDisposable _ = state.Position.PushElement("id"); + throw new ModelConversionException(state.Position, "The use of client-generated IDs is disabled.", null, HttpStatusCode.Forbidden); } + } - private static void AssertSameIdValue(IResourceIdentity identity, string? expected, RequestAdapterState state) + private static void AssertSameIdValue(IResourceIdentity identity, string? expected, RequestAdapterState state) + { + if (expected != null && identity.Id != expected) { - if (expected != null && identity.Id != expected) - { - using IDisposable _ = state.Position.PushElement("id"); + using IDisposable _ = state.Position.PushElement("id"); - throw new ModelConversionException(state.Position, "Conflicting 'id' values found.", $"Expected '{expected}' instead of '{identity.Id}'.", - HttpStatusCode.Conflict); - } + throw new ModelConversionException(state.Position, "Conflicting 'id' values found.", $"Expected '{expected}' instead of '{identity.Id}'.", + HttpStatusCode.Conflict); } + } - private static void AssertSameLidValue(IResourceIdentity identity, string? expected, RequestAdapterState state) + private static void AssertSameLidValue(IResourceIdentity identity, string? expected, RequestAdapterState state) + { + if (expected != null && identity.Lid != expected) { - if (expected != null && identity.Lid != expected) - { - using IDisposable _ = state.Position.PushElement("lid"); + using IDisposable _ = state.Position.PushElement("lid"); - throw new ModelConversionException(state.Position, "Conflicting 'lid' values found.", $"Expected '{expected}' instead of '{identity.Lid}'.", - HttpStatusCode.Conflict); - } + throw new ModelConversionException(state.Position, "Conflicting 'lid' values found.", $"Expected '{expected}' instead of '{identity.Lid}'.", + HttpStatusCode.Conflict); } + } - private void AssignStringId(IResourceIdentity identity, IIdentifiable resource, RequestAdapterState state) + private void AssignStringId(IResourceIdentity identity, IIdentifiable resource, RequestAdapterState state) + { + if (identity.Id != null) { - if (identity.Id != null) + try { - try - { - resource.StringId = identity.Id; - } - catch (FormatException exception) - { - using IDisposable _ = state.Position.PushElement("id"); - throw new ModelConversionException(state.Position, "Incompatible 'id' value found.", exception.Message); - } + resource.StringId = identity.Id; } - } - - protected static void AssertIsKnownRelationship([NotNull] RelationshipAttribute? relationship, string relationshipName, ResourceType resourceType, - RequestAdapterState state) - { - if (relationship == null) + catch (FormatException exception) { - throw new ModelConversionException(state.Position, "Unknown relationship found.", - $"Relationship '{relationshipName}' does not exist on resource type '{resourceType.PublicName}'."); + using IDisposable _ = state.Position.PushElement("id"); + throw new ModelConversionException(state.Position, "Incompatible 'id' value found.", exception.Message); } } + } - protected internal static void AssertToManyInAddOrRemoveRelationship(RelationshipAttribute relationship, RequestAdapterState state) + protected static void AssertIsKnownRelationship([NotNull] RelationshipAttribute? relationship, string relationshipName, ResourceType resourceType, + RequestAdapterState state) + { + if (relationship == null) { - bool requireToManyRelationship = state.Request.WriteOperation is WriteOperationKind.AddToRelationship or WriteOperationKind.RemoveFromRelationship; + throw new ModelConversionException(state.Position, "Unknown relationship found.", + $"Relationship '{relationshipName}' does not exist on resource type '{resourceType.PublicName}'."); + } + } - if (requireToManyRelationship && relationship is not HasManyAttribute) - { - string message = state.Request.Kind == EndpointKind.AtomicOperations - ? "Only to-many relationships can be targeted through this operation." - : "Only to-many relationships can be targeted through this endpoint."; + protected internal static void AssertToManyInAddOrRemoveRelationship(RelationshipAttribute relationship, RequestAdapterState state) + { + bool requireToManyRelationship = state.Request.WriteOperation is WriteOperationKind.AddToRelationship or WriteOperationKind.RemoveFromRelationship; - throw new ModelConversionException(state.Position, message, $"Relationship '{relationship.PublicName}' is not a to-many relationship.", - HttpStatusCode.Forbidden); - } + if (requireToManyRelationship && relationship is not HasManyAttribute) + { + string message = state.Request.Kind == EndpointKind.AtomicOperations + ? "Only to-many relationships can be targeted through this operation." + : "Only to-many relationships can be targeted through this endpoint."; + + throw new ModelConversionException(state.Position, message, $"Relationship '{relationship.PublicName}' is not a to-many relationship.", + HttpStatusCode.Forbidden); } } } diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityRequirements.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityRequirements.cs index 0483723abd..11db5e8ee3 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityRequirements.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityRequirements.cs @@ -2,37 +2,36 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Serialization.Request.Adapters +namespace JsonApiDotNetCore.Serialization.Request.Adapters; + +/// +/// Defines requirements to validate an instance against. +/// +[PublicAPI] +public sealed class ResourceIdentityRequirements { /// - /// Defines requirements to validate an instance against. + /// When not null, indicates that the "type" element must be compatible with the specified resource type. /// - [PublicAPI] - public sealed class ResourceIdentityRequirements - { - /// - /// When not null, indicates that the "type" element must be compatible with the specified resource type. - /// - public ResourceType? ResourceType { get; init; } + public ResourceType? ResourceType { get; init; } - /// - /// When not null, indicates the presence or absence of the "id" element. - /// - public JsonElementConstraint? IdConstraint { get; init; } + /// + /// When not null, indicates the presence or absence of the "id" element. + /// + public JsonElementConstraint? IdConstraint { get; init; } - /// - /// When not null, indicates what the value of the "id" element must be. - /// - public string? IdValue { get; init; } + /// + /// When not null, indicates what the value of the "id" element must be. + /// + public string? IdValue { get; init; } - /// - /// When not null, indicates what the value of the "lid" element must be. - /// - public string? LidValue { get; init; } + /// + /// When not null, indicates what the value of the "lid" element must be. + /// + public string? LidValue { get; init; } - /// - /// When not null, indicates the name of the relationship to use in error messages. - /// - public string? RelationshipName { get; init; } - } + /// + /// When not null, indicates the name of the relationship to use in error messages. + /// + public string? RelationshipName { get; init; } } diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceObjectAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceObjectAdapter.cs index 5e1ebb5311..2199782a3a 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceObjectAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceObjectAdapter.cs @@ -1,5 +1,3 @@ -using System; -using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; @@ -7,155 +5,153 @@ using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Serialization.Request.Adapters +namespace JsonApiDotNetCore.Serialization.Request.Adapters; + +/// +public sealed class ResourceObjectAdapter : ResourceIdentityAdapter, IResourceObjectAdapter { - /// - public sealed class ResourceObjectAdapter : ResourceIdentityAdapter, IResourceObjectAdapter + private readonly IJsonApiOptions _options; + private readonly IRelationshipDataAdapter _relationshipDataAdapter; + + public ResourceObjectAdapter(IResourceGraph resourceGraph, IResourceFactory resourceFactory, IJsonApiOptions options, + IRelationshipDataAdapter relationshipDataAdapter) + : base(resourceGraph, resourceFactory) { - private readonly IJsonApiOptions _options; - private readonly IRelationshipDataAdapter _relationshipDataAdapter; + ArgumentGuard.NotNull(options, nameof(options)); + ArgumentGuard.NotNull(relationshipDataAdapter, nameof(relationshipDataAdapter)); - public ResourceObjectAdapter(IResourceGraph resourceGraph, IResourceFactory resourceFactory, IJsonApiOptions options, - IRelationshipDataAdapter relationshipDataAdapter) - : base(resourceGraph, resourceFactory) - { - ArgumentGuard.NotNull(options, nameof(options)); - ArgumentGuard.NotNull(relationshipDataAdapter, nameof(relationshipDataAdapter)); + _options = options; + _relationshipDataAdapter = relationshipDataAdapter; + } - _options = options; - _relationshipDataAdapter = relationshipDataAdapter; - } + /// + public (IIdentifiable resource, ResourceType resourceType) Convert(ResourceObject resourceObject, ResourceIdentityRequirements requirements, + RequestAdapterState state) + { + ArgumentGuard.NotNull(resourceObject, nameof(resourceObject)); + ArgumentGuard.NotNull(requirements, nameof(requirements)); + ArgumentGuard.NotNull(state, nameof(state)); - /// - public (IIdentifiable resource, ResourceType resourceType) Convert(ResourceObject resourceObject, ResourceIdentityRequirements requirements, - RequestAdapterState state) - { - ArgumentGuard.NotNull(resourceObject, nameof(resourceObject)); - ArgumentGuard.NotNull(requirements, nameof(requirements)); - ArgumentGuard.NotNull(state, nameof(state)); + (IIdentifiable resource, ResourceType resourceType) = ConvertResourceIdentity(resourceObject, requirements, state); - (IIdentifiable resource, ResourceType resourceType) = ConvertResourceIdentity(resourceObject, requirements, state); + ConvertAttributes(resourceObject.Attributes, resource, resourceType, state); + ConvertRelationships(resourceObject.Relationships, resource, resourceType, state); - ConvertAttributes(resourceObject.Attributes, resource, resourceType, state); - ConvertRelationships(resourceObject.Relationships, resource, resourceType, state); + return (resource, resourceType); + } - return (resource, resourceType); - } + private void ConvertAttributes(IDictionary? resourceObjectAttributes, IIdentifiable resource, ResourceType resourceType, + RequestAdapterState state) + { + using IDisposable _ = state.Position.PushElement("attributes"); - private void ConvertAttributes(IDictionary? resourceObjectAttributes, IIdentifiable resource, ResourceType resourceType, - RequestAdapterState state) + foreach ((string attributeName, object? attributeValue) in resourceObjectAttributes.EmptyIfNull()) { - using IDisposable _ = state.Position.PushElement("attributes"); - - foreach ((string attributeName, object? attributeValue) in resourceObjectAttributes.EmptyIfNull()) - { - ConvertAttribute(resource, attributeName, attributeValue, resourceType, state); - } + ConvertAttribute(resource, attributeName, attributeValue, resourceType, state); } + } - private void ConvertAttribute(IIdentifiable resource, string attributeName, object? attributeValue, ResourceType resourceType, - RequestAdapterState state) - { - using IDisposable _ = state.Position.PushElement(attributeName); - AttrAttribute? attr = resourceType.FindAttributeByPublicName(attributeName); + private void ConvertAttribute(IIdentifiable resource, string attributeName, object? attributeValue, ResourceType resourceType, RequestAdapterState state) + { + using IDisposable _ = state.Position.PushElement(attributeName); + AttrAttribute? attr = resourceType.FindAttributeByPublicName(attributeName); - if (attr == null && _options.AllowUnknownFieldsInRequestBody) - { - return; - } + if (attr == null && _options.AllowUnknownFieldsInRequestBody) + { + return; + } - AssertIsKnownAttribute(attr, attributeName, resourceType, state); - AssertNoInvalidAttribute(attributeValue, state); - AssertNoBlockedCreate(attr, resourceType, state); - AssertNoBlockedChange(attr, resourceType, state); - AssertNotReadOnly(attr, resourceType, state); + AssertIsKnownAttribute(attr, attributeName, resourceType, state); + AssertNoInvalidAttribute(attributeValue, state); + AssertNoBlockedCreate(attr, resourceType, state); + AssertNoBlockedChange(attr, resourceType, state); + AssertNotReadOnly(attr, resourceType, state); - attr.SetValue(resource, attributeValue); - state.WritableTargetedFields!.Attributes.Add(attr); - } + attr.SetValue(resource, attributeValue); + state.WritableTargetedFields!.Attributes.Add(attr); + } - private static void AssertIsKnownAttribute([NotNull] AttrAttribute? attr, string attributeName, ResourceType resourceType, RequestAdapterState state) + private static void AssertIsKnownAttribute([NotNull] AttrAttribute? attr, string attributeName, ResourceType resourceType, RequestAdapterState state) + { + if (attr == null) { - if (attr == null) - { - throw new ModelConversionException(state.Position, "Unknown attribute found.", - $"Attribute '{attributeName}' does not exist on resource type '{resourceType.PublicName}'."); - } + throw new ModelConversionException(state.Position, "Unknown attribute found.", + $"Attribute '{attributeName}' does not exist on resource type '{resourceType.PublicName}'."); } + } - private static void AssertNoInvalidAttribute(object? attributeValue, RequestAdapterState state) + private static void AssertNoInvalidAttribute(object? attributeValue, RequestAdapterState state) + { + if (attributeValue is JsonInvalidAttributeInfo info) { - if (attributeValue is JsonInvalidAttributeInfo info) + if (info == JsonInvalidAttributeInfo.Id) { - if (info == JsonInvalidAttributeInfo.Id) - { - throw new ModelConversionException(state.Position, "Resource ID is read-only.", null); - } + throw new ModelConversionException(state.Position, "Resource ID is read-only.", null); + } - string typeName = info.AttributeType.GetFriendlyTypeName(); + string typeName = info.AttributeType.GetFriendlyTypeName(); - throw new ModelConversionException(state.Position, "Incompatible attribute value found.", - $"Failed to convert attribute '{info.AttributeName}' with value '{info.JsonValue}' of type '{info.JsonType}' to type '{typeName}'."); - } + throw new ModelConversionException(state.Position, "Incompatible attribute value found.", + $"Failed to convert attribute '{info.AttributeName}' with value '{info.JsonValue}' of type '{info.JsonType}' to type '{typeName}'."); } + } - private static void AssertNoBlockedCreate(AttrAttribute attr, ResourceType resourceType, RequestAdapterState state) + private static void AssertNoBlockedCreate(AttrAttribute attr, ResourceType resourceType, RequestAdapterState state) + { + if (state.Request.WriteOperation == WriteOperationKind.CreateResource && !attr.Capabilities.HasFlag(AttrCapabilities.AllowCreate)) { - if (state.Request.WriteOperation == WriteOperationKind.CreateResource && !attr.Capabilities.HasFlag(AttrCapabilities.AllowCreate)) - { - throw new ModelConversionException(state.Position, "Attribute value cannot be assigned when creating resource.", - $"The attribute '{attr.PublicName}' on resource type '{resourceType.PublicName}' cannot be assigned to."); - } + throw new ModelConversionException(state.Position, "Attribute value cannot be assigned when creating resource.", + $"The attribute '{attr.PublicName}' on resource type '{resourceType.PublicName}' cannot be assigned to."); } + } - private static void AssertNoBlockedChange(AttrAttribute attr, ResourceType resourceType, RequestAdapterState state) + private static void AssertNoBlockedChange(AttrAttribute attr, ResourceType resourceType, RequestAdapterState state) + { + if (state.Request.WriteOperation == WriteOperationKind.UpdateResource && !attr.Capabilities.HasFlag(AttrCapabilities.AllowChange)) { - if (state.Request.WriteOperation == WriteOperationKind.UpdateResource && !attr.Capabilities.HasFlag(AttrCapabilities.AllowChange)) - { - throw new ModelConversionException(state.Position, "Attribute value cannot be assigned when updating resource.", - $"The attribute '{attr.PublicName}' on resource type '{resourceType.PublicName}' cannot be assigned to."); - } + throw new ModelConversionException(state.Position, "Attribute value cannot be assigned when updating resource.", + $"The attribute '{attr.PublicName}' on resource type '{resourceType.PublicName}' cannot be assigned to."); } + } - private static void AssertNotReadOnly(AttrAttribute attr, ResourceType resourceType, RequestAdapterState state) + private static void AssertNotReadOnly(AttrAttribute attr, ResourceType resourceType, RequestAdapterState state) + { + if (attr.Property.SetMethod == null) { - if (attr.Property.SetMethod == null) - { - throw new ModelConversionException(state.Position, "Attribute is read-only.", - $"Attribute '{attr.PublicName}' on resource type '{resourceType.PublicName}' is read-only."); - } + throw new ModelConversionException(state.Position, "Attribute is read-only.", + $"Attribute '{attr.PublicName}' on resource type '{resourceType.PublicName}' is read-only."); } + } - private void ConvertRelationships(IDictionary? resourceObjectRelationships, IIdentifiable resource, - ResourceType resourceType, RequestAdapterState state) - { - using IDisposable _ = state.Position.PushElement("relationships"); + private void ConvertRelationships(IDictionary? resourceObjectRelationships, IIdentifiable resource, ResourceType resourceType, + RequestAdapterState state) + { + using IDisposable _ = state.Position.PushElement("relationships"); - foreach ((string relationshipName, RelationshipObject? relationshipObject) in resourceObjectRelationships.EmptyIfNull()) - { - ConvertRelationship(relationshipName, relationshipObject, resource, resourceType, state); - } + foreach ((string relationshipName, RelationshipObject? relationshipObject) in resourceObjectRelationships.EmptyIfNull()) + { + ConvertRelationship(relationshipName, relationshipObject, resource, resourceType, state); } + } - private void ConvertRelationship(string relationshipName, RelationshipObject? relationshipObject, IIdentifiable resource, ResourceType resourceType, - RequestAdapterState state) - { - using IDisposable _ = state.Position.PushElement(relationshipName); - AssertObjectIsNotNull(relationshipObject, state); + private void ConvertRelationship(string relationshipName, RelationshipObject? relationshipObject, IIdentifiable resource, ResourceType resourceType, + RequestAdapterState state) + { + using IDisposable _ = state.Position.PushElement(relationshipName); + AssertObjectIsNotNull(relationshipObject, state); - RelationshipAttribute? relationship = resourceType.FindRelationshipByPublicName(relationshipName); + RelationshipAttribute? relationship = resourceType.FindRelationshipByPublicName(relationshipName); - if (relationship == null && _options.AllowUnknownFieldsInRequestBody) - { - return; - } + if (relationship == null && _options.AllowUnknownFieldsInRequestBody) + { + return; + } - AssertIsKnownRelationship(relationship, relationshipName, resourceType, state); + AssertIsKnownRelationship(relationship, relationshipName, resourceType, state); - object? rightValue = _relationshipDataAdapter.Convert(relationshipObject.Data, relationship, true, state); + object? rightValue = _relationshipDataAdapter.Convert(relationshipObject.Data, relationship, true, state); - relationship.SetValue(resource, rightValue); - state.WritableTargetedFields!.Relationships.Add(relationship); - } + relationship.SetValue(resource, rightValue); + state.WritableTargetedFields!.Relationships.Add(relationship); } } diff --git a/src/JsonApiDotNetCore/Serialization/Request/IJsonApiReader.cs b/src/JsonApiDotNetCore/Serialization/Request/IJsonApiReader.cs index 7d4a915f62..be91910c75 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/IJsonApiReader.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/IJsonApiReader.cs @@ -1,19 +1,17 @@ -using System.Threading.Tasks; using JetBrains.Annotations; using Microsoft.AspNetCore.Http; -namespace JsonApiDotNetCore.Serialization.Request +namespace JsonApiDotNetCore.Serialization.Request; + +/// +/// Deserializes the incoming JSON:API request body and converts it to models, which are passed to controller actions by ASP.NET on `FromBody` +/// parameters. +/// +[PublicAPI] +public interface IJsonApiReader { /// - /// Deserializes the incoming JSON:API request body and converts it to models, which are passed to controller actions by ASP.NET on `FromBody` - /// parameters. + /// Reads an object from the request body. /// - [PublicAPI] - public interface IJsonApiReader - { - /// - /// Reads an object from the request body. - /// - Task ReadAsync(HttpRequest httpRequest); - } + Task ReadAsync(HttpRequest httpRequest); } diff --git a/src/JsonApiDotNetCore/Serialization/Request/JsonApiReader.cs b/src/JsonApiDotNetCore/Serialization/Request/JsonApiReader.cs index 312c11b0b4..0942683487 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/JsonApiReader.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/JsonApiReader.cs @@ -1,8 +1,6 @@ -using System; using System.Net; using System.Text; using System.Text.Json; -using System.Threading.Tasks; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Diagnostics; @@ -16,107 +14,106 @@ using Microsoft.Extensions.Logging; using SysNotNull = System.Diagnostics.CodeAnalysis.NotNullAttribute; -namespace JsonApiDotNetCore.Serialization.Request +namespace JsonApiDotNetCore.Serialization.Request; + +/// +public sealed class JsonApiReader : IJsonApiReader { + private readonly IJsonApiOptions _options; + private readonly IDocumentAdapter _documentAdapter; + private readonly TraceLogWriter _traceWriter; + + public JsonApiReader(IJsonApiOptions options, IDocumentAdapter documentAdapter, ILoggerFactory loggerFactory) + { + ArgumentGuard.NotNull(options, nameof(options)); + ArgumentGuard.NotNull(documentAdapter, nameof(documentAdapter)); + ArgumentGuard.NotNull(loggerFactory, nameof(loggerFactory)); + + _options = options; + _documentAdapter = documentAdapter; + _traceWriter = new TraceLogWriter(loggerFactory); + } + /// - public sealed class JsonApiReader : IJsonApiReader + public async Task ReadAsync(HttpRequest httpRequest) { - private readonly IJsonApiOptions _options; - private readonly IDocumentAdapter _documentAdapter; - private readonly TraceLogWriter _traceWriter; + ArgumentGuard.NotNull(httpRequest, nameof(httpRequest)); - public JsonApiReader(IJsonApiOptions options, IDocumentAdapter documentAdapter, ILoggerFactory loggerFactory) - { - ArgumentGuard.NotNull(options, nameof(options)); - ArgumentGuard.NotNull(documentAdapter, nameof(documentAdapter)); - ArgumentGuard.NotNull(loggerFactory, nameof(loggerFactory)); + string requestBody = await ReceiveRequestBodyAsync(httpRequest); - _options = options; - _documentAdapter = documentAdapter; - _traceWriter = new TraceLogWriter(loggerFactory); - } + _traceWriter.LogMessage(() => $"Received {httpRequest.Method} request at '{httpRequest.GetEncodedUrl()}' with body: <<{requestBody}>>"); - /// - public async Task ReadAsync(HttpRequest httpRequest) - { - ArgumentGuard.NotNull(httpRequest, nameof(httpRequest)); + return GetModel(requestBody); + } - string requestBody = await ReceiveRequestBodyAsync(httpRequest); + private static async Task ReceiveRequestBodyAsync(HttpRequest httpRequest) + { + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Receive request body"); - _traceWriter.LogMessage(() => $"Received {httpRequest.Method} request at '{httpRequest.GetEncodedUrl()}' with body: <<{requestBody}>>"); + using var reader = new HttpRequestStreamReader(httpRequest.Body, Encoding.UTF8); + return await reader.ReadToEndAsync(); + } - return GetModel(requestBody); - } + private object? GetModel(string requestBody) + { + AssertHasRequestBody(requestBody); - private static async Task ReceiveRequestBodyAsync(HttpRequest httpRequest) - { - using IDisposable _ = CodeTimingSessionManager.Current.Measure("Receive request body"); + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Read request body"); + + Document document = DeserializeDocument(requestBody); + return ConvertDocumentToModel(document, requestBody); + } - using var reader = new HttpRequestStreamReader(httpRequest.Body, Encoding.UTF8); - return await reader.ReadToEndAsync(); + [AssertionMethod] + private static void AssertHasRequestBody(string requestBody) + { + if (string.IsNullOrEmpty(requestBody)) + { + throw new InvalidRequestBodyException(null, "Missing request body.", null, null, HttpStatusCode.BadRequest); } + } - private object? GetModel(string requestBody) + private Document DeserializeDocument(string requestBody) + { + try { - AssertHasRequestBody(requestBody); + using IDisposable _ = + CodeTimingSessionManager.Current.Measure("JsonSerializer.Deserialize", MeasurementSettings.ExcludeJsonSerializationInPercentages); - using IDisposable _ = CodeTimingSessionManager.Current.Measure("Read request body"); + var document = JsonSerializer.Deserialize(requestBody, _options.SerializerReadOptions); - Document document = DeserializeDocument(requestBody); - return ConvertDocumentToModel(document, requestBody); - } + AssertHasDocument(document, requestBody); - [AssertionMethod] - private static void AssertHasRequestBody(string requestBody) + return document; + } + catch (JsonException exception) { - if (string.IsNullOrEmpty(requestBody)) - { - throw new InvalidRequestBodyException(null, "Missing request body.", null, null, HttpStatusCode.BadRequest); - } + // JsonException.Path looks great for setting error.source.pointer, but unfortunately it is wrong in most cases. + // This is due to the use of custom converters, which are unable to interact with internal position tracking. + // https://github.com/dotnet/runtime/issues/50205#issuecomment-808401245 + throw new InvalidRequestBodyException(_options.IncludeRequestBodyInErrors ? requestBody : null, null, exception.Message, null, null, exception); } + } - private Document DeserializeDocument(string requestBody) + private void AssertHasDocument([SysNotNull] Document? document, string requestBody) + { + if (document == null) { - try - { - using IDisposable _ = - CodeTimingSessionManager.Current.Measure("JsonSerializer.Deserialize", MeasurementSettings.ExcludeJsonSerializationInPercentages); - - var document = JsonSerializer.Deserialize(requestBody, _options.SerializerReadOptions); - - AssertHasDocument(document, requestBody); - - return document; - } - catch (JsonException exception) - { - // JsonException.Path looks great for setting error.source.pointer, but unfortunately it is wrong in most cases. - // This is due to the use of custom converters, which are unable to interact with internal position tracking. - // https://github.com/dotnet/runtime/issues/50205#issuecomment-808401245 - throw new InvalidRequestBodyException(_options.IncludeRequestBodyInErrors ? requestBody : null, null, exception.Message, null, null, exception); - } + throw new InvalidRequestBodyException(_options.IncludeRequestBodyInErrors ? requestBody : null, "Expected an object, instead of 'null'.", null, + null); } + } - private void AssertHasDocument([SysNotNull] Document? document, string requestBody) + private object? ConvertDocumentToModel(Document document, string requestBody) + { + try { - if (document == null) - { - throw new InvalidRequestBodyException(_options.IncludeRequestBodyInErrors ? requestBody : null, "Expected an object, instead of 'null'.", null, - null); - } + return _documentAdapter.Convert(document); } - - private object? ConvertDocumentToModel(Document document, string requestBody) + catch (ModelConversionException exception) { - try - { - return _documentAdapter.Convert(document); - } - catch (ModelConversionException exception) - { - throw new InvalidRequestBodyException(_options.IncludeRequestBodyInErrors ? requestBody : null, exception.GenericMessage, - exception.SpecificMessage, exception.SourcePointer, exception.StatusCode, exception); - } + throw new InvalidRequestBodyException(_options.IncludeRequestBodyInErrors ? requestBody : null, exception.GenericMessage, exception.SpecificMessage, + exception.SourcePointer, exception.StatusCode, exception); } } } diff --git a/src/JsonApiDotNetCore/Serialization/Request/JsonInvalidAttributeInfo.cs b/src/JsonApiDotNetCore/Serialization/Request/JsonInvalidAttributeInfo.cs index 7080da1c9d..02ef36aa53 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/JsonInvalidAttributeInfo.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/JsonInvalidAttributeInfo.cs @@ -1,29 +1,27 @@ -using System; using System.Text.Json; -namespace JsonApiDotNetCore.Serialization.Request +namespace JsonApiDotNetCore.Serialization.Request; + +/// +/// A sentinel value that is temporarily stored in the attributes dictionary to postpone producing an error. +/// +internal sealed class JsonInvalidAttributeInfo { - /// - /// A sentinel value that is temporarily stored in the attributes dictionary to postpone producing an error. - /// - internal sealed class JsonInvalidAttributeInfo - { - public static readonly JsonInvalidAttributeInfo Id = new("id", typeof(string), "-", JsonValueKind.Undefined); + public static readonly JsonInvalidAttributeInfo Id = new("id", typeof(string), "-", JsonValueKind.Undefined); - public string AttributeName { get; } - public Type AttributeType { get; } - public string? JsonValue { get; } - public JsonValueKind JsonType { get; } + public string AttributeName { get; } + public Type AttributeType { get; } + public string? JsonValue { get; } + public JsonValueKind JsonType { get; } - public JsonInvalidAttributeInfo(string attributeName, Type attributeType, string? jsonValue, JsonValueKind jsonType) - { - ArgumentGuard.NotNullNorEmpty(attributeName, nameof(attributeName)); - ArgumentGuard.NotNull(attributeType, nameof(attributeType)); + public JsonInvalidAttributeInfo(string attributeName, Type attributeType, string? jsonValue, JsonValueKind jsonType) + { + ArgumentGuard.NotNullNorEmpty(attributeName, nameof(attributeName)); + ArgumentGuard.NotNull(attributeType, nameof(attributeType)); - AttributeName = attributeName; - AttributeType = attributeType; - JsonValue = jsonValue; - JsonType = jsonType; - } + AttributeName = attributeName; + AttributeType = attributeType; + JsonValue = jsonValue; + JsonType = jsonType; } } diff --git a/src/JsonApiDotNetCore/Serialization/Request/ModelConversionException.cs b/src/JsonApiDotNetCore/Serialization/Request/ModelConversionException.cs index 70be3f7366..b43af538e5 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/ModelConversionException.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/ModelConversionException.cs @@ -1,30 +1,28 @@ -using System; using System.Net; using JetBrains.Annotations; using JsonApiDotNetCore.Serialization.Request.Adapters; -namespace JsonApiDotNetCore.Serialization.Request +namespace JsonApiDotNetCore.Serialization.Request; + +/// +/// The error that is thrown when unable to convert a deserialized request body to an ASP.NET model. +/// +[PublicAPI] +public sealed class ModelConversionException : Exception { - /// - /// The error that is thrown when unable to convert a deserialized request body to an ASP.NET model. - /// - [PublicAPI] - public sealed class ModelConversionException : Exception - { - public string? GenericMessage { get; } - public string? SpecificMessage { get; } - public HttpStatusCode? StatusCode { get; } - public string? SourcePointer { get; } + public string? GenericMessage { get; } + public string? SpecificMessage { get; } + public HttpStatusCode? StatusCode { get; } + public string? SourcePointer { get; } - public ModelConversionException(RequestAdapterPosition position, string? genericMessage, string? specificMessage, HttpStatusCode? statusCode = null) - : base(genericMessage) - { - ArgumentGuard.NotNull(position, nameof(position)); + public ModelConversionException(RequestAdapterPosition position, string? genericMessage, string? specificMessage, HttpStatusCode? statusCode = null) + : base(genericMessage) + { + ArgumentGuard.NotNull(position, nameof(position)); - GenericMessage = genericMessage; - SpecificMessage = specificMessage; - StatusCode = statusCode; - SourcePointer = position.ToSourcePointer(); - } + GenericMessage = genericMessage; + SpecificMessage = specificMessage; + StatusCode = statusCode; + SourcePointer = position.ToSourcePointer(); } } diff --git a/src/JsonApiDotNetCore/Serialization/Response/ETagGenerator.cs b/src/JsonApiDotNetCore/Serialization/Response/ETagGenerator.cs index f2a78adb65..1352317575 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/ETagGenerator.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/ETagGenerator.cs @@ -1,26 +1,25 @@ using Microsoft.Net.Http.Headers; -namespace JsonApiDotNetCore.Serialization.Response +namespace JsonApiDotNetCore.Serialization.Response; + +/// +internal sealed class ETagGenerator : IETagGenerator { - /// - internal sealed class ETagGenerator : IETagGenerator - { - private readonly IFingerprintGenerator _fingerprintGenerator; + private readonly IFingerprintGenerator _fingerprintGenerator; - public ETagGenerator(IFingerprintGenerator fingerprintGenerator) - { - ArgumentGuard.NotNull(fingerprintGenerator, nameof(fingerprintGenerator)); + public ETagGenerator(IFingerprintGenerator fingerprintGenerator) + { + ArgumentGuard.NotNull(fingerprintGenerator, nameof(fingerprintGenerator)); - _fingerprintGenerator = fingerprintGenerator; - } + _fingerprintGenerator = fingerprintGenerator; + } - /// - public EntityTagHeaderValue Generate(string requestUrl, string responseBody) - { - string fingerprint = _fingerprintGenerator.Generate(ArrayFactory.Create(requestUrl, responseBody)); - string eTagValue = $"\"{fingerprint}\""; + /// + public EntityTagHeaderValue Generate(string requestUrl, string responseBody) + { + string fingerprint = _fingerprintGenerator.Generate(ArrayFactory.Create(requestUrl, responseBody)); + string eTagValue = $"\"{fingerprint}\""; - return EntityTagHeaderValue.Parse(eTagValue); - } + return EntityTagHeaderValue.Parse(eTagValue); } } diff --git a/src/JsonApiDotNetCore/Serialization/Response/EmptyResponseMeta.cs b/src/JsonApiDotNetCore/Serialization/Response/EmptyResponseMeta.cs index a787901494..77b2058796 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/EmptyResponseMeta.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/EmptyResponseMeta.cs @@ -1,14 +1,11 @@ -using System.Collections.Generic; +namespace JsonApiDotNetCore.Serialization.Response; -namespace JsonApiDotNetCore.Serialization.Response +/// +public sealed class EmptyResponseMeta : IResponseMeta { /// - public sealed class EmptyResponseMeta : IResponseMeta + public IReadOnlyDictionary? GetMeta() { - /// - public IReadOnlyDictionary? GetMeta() - { - return null; - } + return null; } } diff --git a/src/JsonApiDotNetCore/Serialization/Response/FingerprintGenerator.cs b/src/JsonApiDotNetCore/Serialization/Response/FingerprintGenerator.cs index 9835bf5de3..0eaee430c3 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/FingerprintGenerator.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/FingerprintGenerator.cs @@ -1,54 +1,51 @@ -using System.Collections.Generic; -using System.Linq; using System.Security.Cryptography; using System.Text; -namespace JsonApiDotNetCore.Serialization.Response +namespace JsonApiDotNetCore.Serialization.Response; + +/// +internal sealed class FingerprintGenerator : IFingerprintGenerator { + private static readonly byte[] Separator = Encoding.UTF8.GetBytes("|"); + private static readonly uint[] LookupTable = Enumerable.Range(0, 256).Select(ToLookupEntry).ToArray(); + + private static uint ToLookupEntry(int index) + { + string hex = index.ToString("X2"); + return hex[0] + ((uint)hex[1] << 16); + } + /// - internal sealed class FingerprintGenerator : IFingerprintGenerator + public string Generate(IEnumerable elements) { - private static readonly byte[] Separator = Encoding.UTF8.GetBytes("|"); - private static readonly uint[] LookupTable = Enumerable.Range(0, 256).Select(ToLookupEntry).ToArray(); + ArgumentGuard.NotNull(elements, nameof(elements)); - private static uint ToLookupEntry(int index) - { - string hex = index.ToString("X2"); - return hex[0] + ((uint)hex[1] << 16); - } + using var hasher = IncrementalHash.CreateHash(HashAlgorithmName.MD5); - /// - public string Generate(IEnumerable elements) + foreach (string element in elements) { - ArgumentGuard.NotNull(elements, nameof(elements)); - - using var hasher = IncrementalHash.CreateHash(HashAlgorithmName.MD5); - - foreach (string element in elements) - { - byte[] buffer = Encoding.UTF8.GetBytes(element); - hasher.AppendData(buffer); - hasher.AppendData(Separator); - } - - byte[] hash = hasher.GetHashAndReset(); - return ByteArrayToHex(hash); + byte[] buffer = Encoding.UTF8.GetBytes(element); + hasher.AppendData(buffer); + hasher.AppendData(Separator); } - private static string ByteArrayToHex(byte[] bytes) - { - // https://stackoverflow.com/questions/311165/how-do-you-convert-a-byte-array-to-a-hexadecimal-string-and-vice-versa + byte[] hash = hasher.GetHashAndReset(); + return ByteArrayToHex(hash); + } - char[] buffer = new char[bytes.Length * 2]; + private static string ByteArrayToHex(byte[] bytes) + { + // https://stackoverflow.com/questions/311165/how-do-you-convert-a-byte-array-to-a-hexadecimal-string-and-vice-versa - for (int index = 0; index < bytes.Length; index++) - { - uint value = LookupTable[bytes[index]]; - buffer[2 * index] = (char)value; - buffer[2 * index + 1] = (char)(value >> 16); - } + char[] buffer = new char[bytes.Length * 2]; - return new string(buffer); + for (int index = 0; index < bytes.Length; index++) + { + uint value = LookupTable[bytes[index]]; + buffer[2 * index] = (char)value; + buffer[2 * index + 1] = (char)(value >> 16); } + + return new string(buffer); } } diff --git a/src/JsonApiDotNetCore/Serialization/Response/IETagGenerator.cs b/src/JsonApiDotNetCore/Serialization/Response/IETagGenerator.cs index 5fc5070129..1adcbb8515 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/IETagGenerator.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/IETagGenerator.cs @@ -1,24 +1,23 @@ using Microsoft.Net.Http.Headers; -namespace JsonApiDotNetCore.Serialization.Response +namespace JsonApiDotNetCore.Serialization.Response; + +/// +/// Provides generation of an ETag HTTP response header. +/// +public interface IETagGenerator { /// - /// Provides generation of an ETag HTTP response header. + /// Generates an ETag HTTP response header value for the response to an incoming request. /// - public interface IETagGenerator - { - /// - /// Generates an ETag HTTP response header value for the response to an incoming request. - /// - /// - /// The incoming request URL, including query string. - /// - /// - /// The produced response body. - /// - /// - /// The ETag, or null to disable saving bandwidth. - /// - public EntityTagHeaderValue Generate(string requestUrl, string responseBody); - } + /// + /// The incoming request URL, including query string. + /// + /// + /// The produced response body. + /// + /// + /// The ETag, or null to disable saving bandwidth. + /// + public EntityTagHeaderValue Generate(string requestUrl, string responseBody); } diff --git a/src/JsonApiDotNetCore/Serialization/Response/IFingerprintGenerator.cs b/src/JsonApiDotNetCore/Serialization/Response/IFingerprintGenerator.cs index 7b18e7db19..d1189c4076 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/IFingerprintGenerator.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/IFingerprintGenerator.cs @@ -1,17 +1,15 @@ -using System.Collections.Generic; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Serialization.Response +namespace JsonApiDotNetCore.Serialization.Response; + +/// +/// Provides a method to generate a fingerprint for a collection of string values. +/// +[PublicAPI] +public interface IFingerprintGenerator { /// - /// Provides a method to generate a fingerprint for a collection of string values. + /// Generates a fingerprint for the specified elements. /// - [PublicAPI] - public interface IFingerprintGenerator - { - /// - /// Generates a fingerprint for the specified elements. - /// - public string Generate(IEnumerable elements); - } + public string Generate(IEnumerable elements); } diff --git a/src/JsonApiDotNetCore/Serialization/Response/IJsonApiWriter.cs b/src/JsonApiDotNetCore/Serialization/Response/IJsonApiWriter.cs index 8c904cf032..62087d2adb 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/IJsonApiWriter.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/IJsonApiWriter.cs @@ -1,18 +1,16 @@ -using System.Threading.Tasks; using JetBrains.Annotations; using Microsoft.AspNetCore.Http; -namespace JsonApiDotNetCore.Serialization.Response +namespace JsonApiDotNetCore.Serialization.Response; + +/// +/// Serializes ASP.NET models into the outgoing JSON:API response body. +/// +[PublicAPI] +public interface IJsonApiWriter { /// - /// Serializes ASP.NET models into the outgoing JSON:API response body. + /// Writes an object to the response body. /// - [PublicAPI] - public interface IJsonApiWriter - { - /// - /// Writes an object to the response body. - /// - Task WriteAsync(object? model, HttpContext httpContext); - } + Task WriteAsync(object? model, HttpContext httpContext); } diff --git a/src/JsonApiDotNetCore/Serialization/Response/ILinkBuilder.cs b/src/JsonApiDotNetCore/Serialization/Response/ILinkBuilder.cs index 891556c7af..e8c9870b85 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/ILinkBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/ILinkBuilder.cs @@ -3,26 +3,25 @@ using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Serialization.Response +namespace JsonApiDotNetCore.Serialization.Response; + +/// +/// Builds resource object links and relationship object links. +/// +public interface ILinkBuilder { /// - /// Builds resource object links and relationship object links. + /// Builds the links object that is included in the top-level of the document. /// - public interface ILinkBuilder - { - /// - /// Builds the links object that is included in the top-level of the document. - /// - TopLevelLinks? GetTopLevelLinks(); + TopLevelLinks? GetTopLevelLinks(); - /// - /// Builds the links object for a returned resource (primary or included). - /// - ResourceLinks? GetResourceLinks(ResourceType resourceType, IIdentifiable resource); + /// + /// Builds the links object for a returned resource (primary or included). + /// + ResourceLinks? GetResourceLinks(ResourceType resourceType, IIdentifiable resource); - /// - /// Builds the links object for a relationship inside a returned resource. - /// - RelationshipLinks? GetRelationshipLinks(RelationshipAttribute relationship, IIdentifiable leftResource); - } + /// + /// Builds the links object for a relationship inside a returned resource. + /// + RelationshipLinks? GetRelationshipLinks(RelationshipAttribute relationship, IIdentifiable leftResource); } diff --git a/src/JsonApiDotNetCore/Serialization/Response/IMetaBuilder.cs b/src/JsonApiDotNetCore/Serialization/Response/IMetaBuilder.cs index 285f0df550..e804179505 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/IMetaBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/IMetaBuilder.cs @@ -1,23 +1,21 @@ -using System.Collections.Generic; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Serialization.Response +namespace JsonApiDotNetCore.Serialization.Response; + +/// +/// Builds the top-level meta object. +/// +[PublicAPI] +public interface IMetaBuilder { /// - /// Builds the top-level meta object. + /// Merges the specified dictionary with existing key/value pairs. In the event of a key collision, the value from the specified dictionary will + /// overwrite the existing one. /// - [PublicAPI] - public interface IMetaBuilder - { - /// - /// Merges the specified dictionary with existing key/value pairs. In the event of a key collision, the value from the specified dictionary will - /// overwrite the existing one. - /// - void Add(IReadOnlyDictionary values); + void Add(IReadOnlyDictionary values); - /// - /// Builds the top-level meta data object. - /// - IDictionary? Build(); - } + /// + /// Builds the top-level meta data object. + /// + IDictionary? Build(); } diff --git a/src/JsonApiDotNetCore/Serialization/Response/IResponseMeta.cs b/src/JsonApiDotNetCore/Serialization/Response/IResponseMeta.cs index 83596f9146..f3035cfa4c 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/IResponseMeta.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/IResponseMeta.cs @@ -1,18 +1,16 @@ -using System.Collections.Generic; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Serialization.Response +namespace JsonApiDotNetCore.Serialization.Response; + +/// +/// Provides a method to obtain global JSON:API meta, which is added at top-level to a response . Use +/// to specify nested metadata per individual resource. +/// +public interface IResponseMeta { /// - /// Provides a method to obtain global JSON:API meta, which is added at top-level to a response . Use - /// to specify nested metadata per individual resource. + /// Gets the global top-level JSON:API meta information to add to the response. /// - public interface IResponseMeta - { - /// - /// Gets the global top-level JSON:API meta information to add to the response. - /// - IReadOnlyDictionary? GetMeta(); - } + IReadOnlyDictionary? GetMeta(); } diff --git a/src/JsonApiDotNetCore/Serialization/Response/IResponseModelAdapter.cs b/src/JsonApiDotNetCore/Serialization/Response/IResponseModelAdapter.cs index 153e993b0d..f458edf33a 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/IResponseModelAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/IResponseModelAdapter.cs @@ -1,47 +1,46 @@ using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Serialization.Response +namespace JsonApiDotNetCore.Serialization.Response; + +/// +/// Converts the produced model from an ASP.NET controller action into a , ready to be serialized as the response body. +/// +public interface IResponseModelAdapter { /// - /// Converts the produced model from an ASP.NET controller action into a , ready to be serialized as the response body. + /// Validates and converts the specified . Supported model types: + /// + /// + /// + /// ]]> + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// ]]> + /// + /// + /// + /// + /// ]]> + /// + /// + /// + /// + /// + /// + /// + /// /// - public interface IResponseModelAdapter - { - /// - /// Validates and converts the specified . Supported model types: - /// - /// - /// - /// ]]> - /// - /// - /// - /// - /// - /// - /// - /// - /// - /// - /// - /// - /// - /// - /// ]]> - /// - /// - /// - /// - /// ]]> - /// - /// - /// - /// - /// - /// - /// - /// - /// - Document Convert(object? model); - } + Document Convert(object? model); } diff --git a/src/JsonApiDotNetCore/Serialization/Response/JsonApiWriter.cs b/src/JsonApiDotNetCore/Serialization/Response/JsonApiWriter.cs index 793a720c3a..20f4ad242b 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/JsonApiWriter.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/JsonApiWriter.cs @@ -1,11 +1,6 @@ -using System; -using System.Collections.Generic; -using System.IO; using System.Net; -using System.Net.Http; using System.Text; using System.Text.Json; -using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Diagnostics; using JsonApiDotNetCore.Errors; @@ -18,169 +13,166 @@ using Microsoft.Extensions.Logging; using Microsoft.Net.Http.Headers; -namespace JsonApiDotNetCore.Serialization.Response +namespace JsonApiDotNetCore.Serialization.Response; + +/// +public sealed class JsonApiWriter : IJsonApiWriter { + private readonly IJsonApiRequest _request; + private readonly IJsonApiOptions _options; + private readonly IResponseModelAdapter _responseModelAdapter; + private readonly IExceptionHandler _exceptionHandler; + private readonly IETagGenerator _eTagGenerator; + private readonly TraceLogWriter _traceWriter; + + public JsonApiWriter(IJsonApiRequest request, IJsonApiOptions options, IResponseModelAdapter responseModelAdapter, IExceptionHandler exceptionHandler, + IETagGenerator eTagGenerator, ILoggerFactory loggerFactory) + { + ArgumentGuard.NotNull(request, nameof(request)); + ArgumentGuard.NotNull(responseModelAdapter, nameof(responseModelAdapter)); + ArgumentGuard.NotNull(exceptionHandler, nameof(exceptionHandler)); + ArgumentGuard.NotNull(eTagGenerator, nameof(eTagGenerator)); + ArgumentGuard.NotNull(options, nameof(options)); + ArgumentGuard.NotNull(loggerFactory, nameof(loggerFactory)); + + _request = request; + _options = options; + _responseModelAdapter = responseModelAdapter; + _exceptionHandler = exceptionHandler; + _eTagGenerator = eTagGenerator; + _traceWriter = new TraceLogWriter(loggerFactory); + } + /// - public sealed class JsonApiWriter : IJsonApiWriter + public async Task WriteAsync(object? model, HttpContext httpContext) { - private readonly IJsonApiRequest _request; - private readonly IJsonApiOptions _options; - private readonly IResponseModelAdapter _responseModelAdapter; - private readonly IExceptionHandler _exceptionHandler; - private readonly IETagGenerator _eTagGenerator; - private readonly TraceLogWriter _traceWriter; - - public JsonApiWriter(IJsonApiRequest request, IJsonApiOptions options, IResponseModelAdapter responseModelAdapter, IExceptionHandler exceptionHandler, - IETagGenerator eTagGenerator, ILoggerFactory loggerFactory) - { - ArgumentGuard.NotNull(request, nameof(request)); - ArgumentGuard.NotNull(responseModelAdapter, nameof(responseModelAdapter)); - ArgumentGuard.NotNull(exceptionHandler, nameof(exceptionHandler)); - ArgumentGuard.NotNull(eTagGenerator, nameof(eTagGenerator)); - ArgumentGuard.NotNull(options, nameof(options)); - ArgumentGuard.NotNull(loggerFactory, nameof(loggerFactory)); - - _request = request; - _options = options; - _responseModelAdapter = responseModelAdapter; - _exceptionHandler = exceptionHandler; - _eTagGenerator = eTagGenerator; - _traceWriter = new TraceLogWriter(loggerFactory); - } + ArgumentGuard.NotNull(httpContext, nameof(httpContext)); - /// - public async Task WriteAsync(object? model, HttpContext httpContext) + if (model == null && !CanWriteBody((HttpStatusCode)httpContext.Response.StatusCode)) { - ArgumentGuard.NotNull(httpContext, nameof(httpContext)); + // Prevent exception from Kestrel server, caused by writing data:null json response. + return; + } - if (model == null && !CanWriteBody((HttpStatusCode)httpContext.Response.StatusCode)) - { - // Prevent exception from Kestrel server, caused by writing data:null json response. - return; - } + string? responseBody = GetResponseBody(model, httpContext); - string? responseBody = GetResponseBody(model, httpContext); + if (httpContext.Request.Method == HttpMethod.Head.Method) + { + httpContext.Response.GetTypedHeaders().ContentLength = responseBody == null ? 0 : Encoding.UTF8.GetByteCount(responseBody); + return; + } - if (httpContext.Request.Method == HttpMethod.Head.Method) - { - httpContext.Response.GetTypedHeaders().ContentLength = responseBody == null ? 0 : Encoding.UTF8.GetByteCount(responseBody); - return; - } + _traceWriter.LogMessage(() => + $"Sending {httpContext.Response.StatusCode} response for {httpContext.Request.Method} request at '{httpContext.Request.GetEncodedUrl()}' with body: <<{responseBody}>>"); - _traceWriter.LogMessage(() => - $"Sending {httpContext.Response.StatusCode} response for {httpContext.Request.Method} request at '{httpContext.Request.GetEncodedUrl()}' with body: <<{responseBody}>>"); + await SendResponseBodyAsync(httpContext.Response, responseBody); + } - await SendResponseBodyAsync(httpContext.Response, responseBody); - } + private static bool CanWriteBody(HttpStatusCode statusCode) + { + return statusCode is not HttpStatusCode.NoContent and not HttpStatusCode.ResetContent and not HttpStatusCode.NotModified; + } - private static bool CanWriteBody(HttpStatusCode statusCode) - { - return statusCode is not HttpStatusCode.NoContent and not HttpStatusCode.ResetContent and not HttpStatusCode.NotModified; - } + private string? GetResponseBody(object? model, HttpContext httpContext) + { + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Write response body"); - private string? GetResponseBody(object? model, HttpContext httpContext) + try { - using IDisposable _ = CodeTimingSessionManager.Current.Measure("Write response body"); - - try + if (model is ProblemDetails problemDetails) { - if (model is ProblemDetails problemDetails) - { - throw new UnsuccessfulActionResultException(problemDetails); - } - - if (model == null && !IsSuccessStatusCode((HttpStatusCode)httpContext.Response.StatusCode)) - { - throw new UnsuccessfulActionResultException((HttpStatusCode)httpContext.Response.StatusCode); - } + throw new UnsuccessfulActionResultException(problemDetails); + } - string responseBody = RenderModel(model); + if (model == null && !IsSuccessStatusCode((HttpStatusCode)httpContext.Response.StatusCode)) + { + throw new UnsuccessfulActionResultException((HttpStatusCode)httpContext.Response.StatusCode); + } - if (SetETagResponseHeader(httpContext.Request, httpContext.Response, responseBody)) - { - httpContext.Response.StatusCode = (int)HttpStatusCode.NotModified; - return null; - } + string responseBody = RenderModel(model); - return responseBody; - } -#pragma warning disable AV1210 // Catch a specific exception instead of Exception, SystemException or ApplicationException - catch (Exception exception) -#pragma warning restore AV1210 // Catch a specific exception instead of Exception, SystemException or ApplicationException + if (SetETagResponseHeader(httpContext.Request, httpContext.Response, responseBody)) { - IReadOnlyList errors = _exceptionHandler.HandleException(exception); - httpContext.Response.StatusCode = (int)ErrorObject.GetResponseStatusCode(errors); - - return RenderModel(errors); + httpContext.Response.StatusCode = (int)HttpStatusCode.NotModified; + return null; } - } - private static bool IsSuccessStatusCode(HttpStatusCode statusCode) - { - return new HttpResponseMessage(statusCode).IsSuccessStatusCode; + return responseBody; } - - private string RenderModel(object? model) +#pragma warning disable AV1210 // Catch a specific exception instead of Exception, SystemException or ApplicationException + catch (Exception exception) +#pragma warning restore AV1210 // Catch a specific exception instead of Exception, SystemException or ApplicationException { - Document document = _responseModelAdapter.Convert(model); - return SerializeDocument(document); + IReadOnlyList errors = _exceptionHandler.HandleException(exception); + httpContext.Response.StatusCode = (int)ErrorObject.GetResponseStatusCode(errors); + + return RenderModel(errors); } + } - private string SerializeDocument(Document document) - { - using IDisposable _ = - CodeTimingSessionManager.Current.Measure("JsonSerializer.Serialize", MeasurementSettings.ExcludeJsonSerializationInPercentages); + private static bool IsSuccessStatusCode(HttpStatusCode statusCode) + { + return new HttpResponseMessage(statusCode).IsSuccessStatusCode; + } - return JsonSerializer.Serialize(document, _options.SerializerWriteOptions); - } + private string RenderModel(object? model) + { + Document document = _responseModelAdapter.Convert(model); + return SerializeDocument(document); + } - private bool SetETagResponseHeader(HttpRequest request, HttpResponse response, string responseContent) - { - bool isReadOnly = request.Method == HttpMethod.Get.Method || request.Method == HttpMethod.Head.Method; + private string SerializeDocument(Document document) + { + using IDisposable _ = CodeTimingSessionManager.Current.Measure("JsonSerializer.Serialize", MeasurementSettings.ExcludeJsonSerializationInPercentages); - if (isReadOnly && response.StatusCode == (int)HttpStatusCode.OK) - { - string url = request.GetEncodedUrl(); - EntityTagHeaderValue responseETag = _eTagGenerator.Generate(url, responseContent); + return JsonSerializer.Serialize(document, _options.SerializerWriteOptions); + } + + private bool SetETagResponseHeader(HttpRequest request, HttpResponse response, string responseContent) + { + bool isReadOnly = request.Method == HttpMethod.Get.Method || request.Method == HttpMethod.Head.Method; - response.Headers.Add(HeaderNames.ETag, responseETag.ToString()); + if (isReadOnly && response.StatusCode == (int)HttpStatusCode.OK) + { + string url = request.GetEncodedUrl(); + EntityTagHeaderValue responseETag = _eTagGenerator.Generate(url, responseContent); - return RequestContainsMatchingETag(request.Headers, responseETag); - } + response.Headers.Add(HeaderNames.ETag, responseETag.ToString()); - return false; + return RequestContainsMatchingETag(request.Headers, responseETag); } - private static bool RequestContainsMatchingETag(IHeaderDictionary requestHeaders, EntityTagHeaderValue responseETag) + return false; + } + + private static bool RequestContainsMatchingETag(IHeaderDictionary requestHeaders, EntityTagHeaderValue responseETag) + { + if (requestHeaders.Keys.Contains(HeaderNames.IfNoneMatch) && + EntityTagHeaderValue.TryParseList(requestHeaders[HeaderNames.IfNoneMatch], out IList? requestETags)) { - if (requestHeaders.Keys.Contains(HeaderNames.IfNoneMatch) && - EntityTagHeaderValue.TryParseList(requestHeaders[HeaderNames.IfNoneMatch], out IList? requestETags)) + foreach (EntityTagHeaderValue requestETag in requestETags) { - foreach (EntityTagHeaderValue requestETag in requestETags) + if (responseETag.Equals(requestETag)) { - if (responseETag.Equals(requestETag)) - { - return true; - } + return true; } } - - return false; } - private async Task SendResponseBodyAsync(HttpResponse httpResponse, string? responseBody) + return false; + } + + private async Task SendResponseBodyAsync(HttpResponse httpResponse, string? responseBody) + { + if (!string.IsNullOrEmpty(responseBody)) { - if (!string.IsNullOrEmpty(responseBody)) - { - httpResponse.ContentType = - _request.Kind == EndpointKind.AtomicOperations ? HeaderConstants.AtomicOperationsMediaType : HeaderConstants.MediaType; + httpResponse.ContentType = _request.Kind == EndpointKind.AtomicOperations ? HeaderConstants.AtomicOperationsMediaType : HeaderConstants.MediaType; - using IDisposable _ = CodeTimingSessionManager.Current.Measure("Send response body"); + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Send response body"); - await using TextWriter writer = new HttpResponseStreamWriter(httpResponse.Body, Encoding.UTF8); - await writer.WriteAsync(responseBody); - await writer.FlushAsync(); - } + await using TextWriter writer = new HttpResponseStreamWriter(httpResponse.Body, Encoding.UTF8); + await writer.WriteAsync(responseBody); + await writer.FlushAsync(); } } } diff --git a/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs b/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs index 1b95266000..38e63ea9cb 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; using System.Collections.Immutable; -using System.Linq; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; @@ -16,330 +13,327 @@ using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Routing; -namespace JsonApiDotNetCore.Serialization.Response -{ - [PublicAPI] - public class LinkBuilder : ILinkBuilder - { - private const string PageSizeParameterName = "page[size]"; - private const string PageNumberParameterName = "page[number]"; +namespace JsonApiDotNetCore.Serialization.Response; - private static readonly string GetPrimaryControllerActionName = NoAsyncSuffix(nameof(BaseJsonApiController, int>.GetAsync)); +[PublicAPI] +public class LinkBuilder : ILinkBuilder +{ + private const string PageSizeParameterName = "page[size]"; + private const string PageNumberParameterName = "page[number]"; - private static readonly string GetSecondaryControllerActionName = - NoAsyncSuffix(nameof(BaseJsonApiController, int>.GetSecondaryAsync)); + private static readonly string GetPrimaryControllerActionName = NoAsyncSuffix(nameof(BaseJsonApiController, int>.GetAsync)); + private static readonly string GetSecondaryControllerActionName = NoAsyncSuffix(nameof(BaseJsonApiController, int>.GetSecondaryAsync)); - private static readonly string GetRelationshipControllerActionName = - NoAsyncSuffix(nameof(BaseJsonApiController, int>.GetRelationshipAsync)); + private static readonly string GetRelationshipControllerActionName = + NoAsyncSuffix(nameof(BaseJsonApiController, int>.GetRelationshipAsync)); - private readonly IJsonApiOptions _options; - private readonly IJsonApiRequest _request; - private readonly IPaginationContext _paginationContext; - private readonly IHttpContextAccessor _httpContextAccessor; - private readonly LinkGenerator _linkGenerator; - private readonly IControllerResourceMapping _controllerResourceMapping; + private readonly IJsonApiOptions _options; + private readonly IJsonApiRequest _request; + private readonly IPaginationContext _paginationContext; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly LinkGenerator _linkGenerator; + private readonly IControllerResourceMapping _controllerResourceMapping; - private HttpContext HttpContext + private HttpContext HttpContext + { + get { - get + if (_httpContextAccessor.HttpContext == null) { - if (_httpContextAccessor.HttpContext == null) - { - throw new InvalidOperationException("An active HTTP request is required."); - } - - return _httpContextAccessor.HttpContext; + throw new InvalidOperationException("An active HTTP request is required."); } - } - public LinkBuilder(IJsonApiOptions options, IJsonApiRequest request, IPaginationContext paginationContext, IHttpContextAccessor httpContextAccessor, - LinkGenerator linkGenerator, IControllerResourceMapping controllerResourceMapping) - { - ArgumentGuard.NotNull(options, nameof(options)); - ArgumentGuard.NotNull(request, nameof(request)); - ArgumentGuard.NotNull(paginationContext, nameof(paginationContext)); - ArgumentGuard.NotNull(linkGenerator, nameof(linkGenerator)); - ArgumentGuard.NotNull(controllerResourceMapping, nameof(controllerResourceMapping)); - - _options = options; - _request = request; - _paginationContext = paginationContext; - _httpContextAccessor = httpContextAccessor; - _linkGenerator = linkGenerator; - _controllerResourceMapping = controllerResourceMapping; + return _httpContextAccessor.HttpContext; } + } - private static string NoAsyncSuffix(string actionName) - { - return actionName.EndsWith("Async", StringComparison.Ordinal) ? actionName[..^"Async".Length] : actionName; - } - - /// - public TopLevelLinks? GetTopLevelLinks() - { - var links = new TopLevelLinks(); - ResourceType? resourceType = _request.SecondaryResourceType ?? _request.PrimaryResourceType; - - if (ShouldIncludeTopLevelLink(LinkTypes.Self, resourceType)) - { - links.Self = GetLinkForTopLevelSelf(); - } + public LinkBuilder(IJsonApiOptions options, IJsonApiRequest request, IPaginationContext paginationContext, IHttpContextAccessor httpContextAccessor, + LinkGenerator linkGenerator, IControllerResourceMapping controllerResourceMapping) + { + ArgumentGuard.NotNull(options, nameof(options)); + ArgumentGuard.NotNull(request, nameof(request)); + ArgumentGuard.NotNull(paginationContext, nameof(paginationContext)); + ArgumentGuard.NotNull(linkGenerator, nameof(linkGenerator)); + ArgumentGuard.NotNull(controllerResourceMapping, nameof(controllerResourceMapping)); + + _options = options; + _request = request; + _paginationContext = paginationContext; + _httpContextAccessor = httpContextAccessor; + _linkGenerator = linkGenerator; + _controllerResourceMapping = controllerResourceMapping; + } - if (_request.Kind == EndpointKind.Relationship && _request.Relationship != null && ShouldIncludeTopLevelLink(LinkTypes.Related, resourceType)) - { - links.Related = GetLinkForRelationshipRelated(_request.PrimaryId!, _request.Relationship); - } + private static string NoAsyncSuffix(string actionName) + { + return actionName.EndsWith("Async", StringComparison.Ordinal) ? actionName[..^"Async".Length] : actionName; + } - if (_request.IsCollection && _paginationContext.PageSize != null && ShouldIncludeTopLevelLink(LinkTypes.Paging, resourceType)) - { - SetPaginationInTopLevelLinks(resourceType!, links); - } + /// + public TopLevelLinks? GetTopLevelLinks() + { + var links = new TopLevelLinks(); + ResourceType? resourceType = _request.SecondaryResourceType ?? _request.PrimaryResourceType; - return links.HasValue() ? links : null; + if (ShouldIncludeTopLevelLink(LinkTypes.Self, resourceType)) + { + links.Self = GetLinkForTopLevelSelf(); } - /// - /// 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 . - /// - private bool ShouldIncludeTopLevelLink(LinkTypes linkType, ResourceType? resourceType) + if (_request.Kind == EndpointKind.Relationship && _request.Relationship != null && ShouldIncludeTopLevelLink(LinkTypes.Related, resourceType)) { - if (resourceType != null && resourceType.TopLevelLinks != LinkTypes.NotConfigured) - { - return resourceType.TopLevelLinks.HasFlag(linkType); - } - - return _options.TopLevelLinks.HasFlag(linkType); + links.Related = GetLinkForRelationshipRelated(_request.PrimaryId!, _request.Relationship); } - private string GetLinkForTopLevelSelf() + if (_request.IsCollection && _paginationContext.PageSize != null && ShouldIncludeTopLevelLink(LinkTypes.Paging, resourceType)) { - // Note: in tests, this does not properly escape special characters due to WebApplicationFactory short-circuiting. - return _options.UseRelativeLinks ? HttpContext.Request.GetEncodedPathAndQuery() : HttpContext.Request.GetEncodedUrl(); + SetPaginationInTopLevelLinks(resourceType!, links); } - private void SetPaginationInTopLevelLinks(ResourceType resourceType, TopLevelLinks links) + return links.HasValue() ? links : null; + } + + /// + /// 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 . + /// + private bool ShouldIncludeTopLevelLink(LinkTypes linkType, ResourceType? resourceType) + { + if (resourceType != null && resourceType.TopLevelLinks != LinkTypes.NotConfigured) { - string? pageSizeValue = CalculatePageSizeValue(_paginationContext.PageSize, resourceType); + return resourceType.TopLevelLinks.HasFlag(linkType); + } - links.First = GetLinkForPagination(1, pageSizeValue); + return _options.TopLevelLinks.HasFlag(linkType); + } - if (_paginationContext.TotalPageCount > 0) - { - links.Last = GetLinkForPagination(_paginationContext.TotalPageCount.Value, pageSizeValue); - } + private string GetLinkForTopLevelSelf() + { + // Note: in tests, this does not properly escape special characters due to WebApplicationFactory short-circuiting. + return _options.UseRelativeLinks ? HttpContext.Request.GetEncodedPathAndQuery() : HttpContext.Request.GetEncodedUrl(); + } - if (_paginationContext.PageNumber.OneBasedValue > 1) - { - links.Prev = GetLinkForPagination(_paginationContext.PageNumber.OneBasedValue - 1, pageSizeValue); - } + private void SetPaginationInTopLevelLinks(ResourceType resourceType, TopLevelLinks links) + { + string? pageSizeValue = CalculatePageSizeValue(_paginationContext.PageSize, resourceType); - bool hasNextPage = _paginationContext.PageNumber.OneBasedValue < _paginationContext.TotalPageCount; - bool possiblyHasNextPage = _paginationContext.TotalPageCount == null && _paginationContext.IsPageFull; + links.First = GetLinkForPagination(1, pageSizeValue); - if (hasNextPage || possiblyHasNextPage) - { - links.Next = GetLinkForPagination(_paginationContext.PageNumber.OneBasedValue + 1, pageSizeValue); - } + if (_paginationContext.TotalPageCount > 0) + { + links.Last = GetLinkForPagination(_paginationContext.TotalPageCount.Value, pageSizeValue); } - private string? CalculatePageSizeValue(PageSize? topPageSize, ResourceType resourceType) + if (_paginationContext.PageNumber.OneBasedValue > 1) { - string pageSizeParameterValue = HttpContext.Request.Query[PageSizeParameterName]; - - PageSize? newTopPageSize = Equals(topPageSize, _options.DefaultPageSize) ? null : topPageSize; - return ChangeTopPageSize(pageSizeParameterValue, newTopPageSize, resourceType); + links.Prev = GetLinkForPagination(_paginationContext.PageNumber.OneBasedValue - 1, pageSizeValue); } - private string? ChangeTopPageSize(string pageSizeParameterValue, PageSize? topPageSize, ResourceType resourceType) + bool hasNextPage = _paginationContext.PageNumber.OneBasedValue < _paginationContext.TotalPageCount; + bool possiblyHasNextPage = _paginationContext.TotalPageCount == null && _paginationContext.IsPageFull; + + if (hasNextPage || possiblyHasNextPage) { - IImmutableList elements = ParsePageSizeExpression(pageSizeParameterValue, resourceType); - int elementInTopScopeIndex = elements.FindIndex(expression => expression.Scope == null); + links.Next = GetLinkForPagination(_paginationContext.PageNumber.OneBasedValue + 1, pageSizeValue); + } + } - if (topPageSize != null) - { - var topPageSizeElement = new PaginationElementQueryStringValueExpression(null, topPageSize.Value); + private string? CalculatePageSizeValue(PageSize? topPageSize, ResourceType resourceType) + { + string pageSizeParameterValue = HttpContext.Request.Query[PageSizeParameterName]; - elements = elementInTopScopeIndex != -1 ? elements.SetItem(elementInTopScopeIndex, topPageSizeElement) : elements.Insert(0, topPageSizeElement); - } - else - { - if (elementInTopScopeIndex != -1) - { - elements = elements.RemoveAt(elementInTopScopeIndex); - } - } + PageSize? newTopPageSize = Equals(topPageSize, _options.DefaultPageSize) ? null : topPageSize; + return ChangeTopPageSize(pageSizeParameterValue, newTopPageSize, resourceType); + } + + private string? ChangeTopPageSize(string pageSizeParameterValue, PageSize? topPageSize, ResourceType resourceType) + { + IImmutableList elements = ParsePageSizeExpression(pageSizeParameterValue, resourceType); + int elementInTopScopeIndex = elements.FindIndex(expression => expression.Scope == null); - string parameterValue = string.Join(',', - elements.Select(expression => expression.Scope == null ? expression.Value.ToString() : $"{expression.Scope}:{expression.Value}")); + if (topPageSize != null) + { + var topPageSizeElement = new PaginationElementQueryStringValueExpression(null, topPageSize.Value); - return parameterValue == string.Empty ? null : parameterValue; + elements = elementInTopScopeIndex != -1 ? elements.SetItem(elementInTopScopeIndex, topPageSizeElement) : elements.Insert(0, topPageSizeElement); } - - private IImmutableList ParsePageSizeExpression(string? pageSizeParameterValue, ResourceType resourceType) + else { - if (pageSizeParameterValue == null) + if (elementInTopScopeIndex != -1) { - return ImmutableArray.Empty; + elements = elements.RemoveAt(elementInTopScopeIndex); } + } - var parser = new PaginationParser(); - PaginationQueryStringValueExpression paginationExpression = parser.Parse(pageSizeParameterValue, resourceType); + string parameterValue = string.Join(',', + elements.Select(expression => expression.Scope == null ? expression.Value.ToString() : $"{expression.Scope}:{expression.Value}")); - return paginationExpression.Elements; - } + return parameterValue == string.Empty ? null : parameterValue; + } - private string GetLinkForPagination(int pageOffset, string? pageSizeValue) + private IImmutableList ParsePageSizeExpression(string? pageSizeParameterValue, ResourceType resourceType) + { + if (pageSizeParameterValue == null) { - string queryStringValue = GetQueryStringInPaginationLink(pageOffset, pageSizeValue); + return ImmutableArray.Empty; + } - var builder = new UriBuilder(HttpContext.Request.GetEncodedUrl()) - { - Query = queryStringValue - }; + var parser = new PaginationParser(); + PaginationQueryStringValueExpression paginationExpression = parser.Parse(pageSizeParameterValue, resourceType); - UriComponents components = _options.UseRelativeLinks ? UriComponents.PathAndQuery : UriComponents.AbsoluteUri; - return builder.Uri.GetComponents(components, UriFormat.SafeUnescaped); - } + return paginationExpression.Elements; + } + + private string GetLinkForPagination(int pageOffset, string? pageSizeValue) + { + string queryStringValue = GetQueryStringInPaginationLink(pageOffset, pageSizeValue); - private string GetQueryStringInPaginationLink(int pageOffset, string? pageSizeValue) + var builder = new UriBuilder(HttpContext.Request.GetEncodedUrl()) { - IDictionary parameters = HttpContext.Request.Query.ToDictionary(pair => pair.Key, pair => (string?)pair.Value.ToString()); + Query = queryStringValue + }; - if (pageSizeValue == null) - { - parameters.Remove(PageSizeParameterName); - } - else - { - parameters[PageSizeParameterName] = pageSizeValue; - } + UriComponents components = _options.UseRelativeLinks ? UriComponents.PathAndQuery : UriComponents.AbsoluteUri; + return builder.Uri.GetComponents(components, UriFormat.SafeUnescaped); + } - if (pageOffset == 1) - { - parameters.Remove(PageNumberParameterName); - } - else - { - parameters[PageNumberParameterName] = pageOffset.ToString(); - } + private string GetQueryStringInPaginationLink(int pageOffset, string? pageSizeValue) + { + IDictionary parameters = HttpContext.Request.Query.ToDictionary(pair => pair.Key, pair => (string?)pair.Value.ToString()); - return QueryString.Create(parameters).Value ?? string.Empty; + if (pageSizeValue == null) + { + parameters.Remove(PageSizeParameterName); + } + else + { + parameters[PageSizeParameterName] = pageSizeValue; } - /// - public ResourceLinks? GetResourceLinks(ResourceType resourceType, IIdentifiable resource) + if (pageOffset == 1) + { + parameters.Remove(PageNumberParameterName); + } + else { - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - ArgumentGuard.NotNull(resource, nameof(resource)); + parameters[PageNumberParameterName] = pageOffset.ToString(); + } - var links = new ResourceLinks(); + return QueryString.Create(parameters).Value ?? string.Empty; + } - if (ShouldIncludeResourceLink(LinkTypes.Self, resourceType)) - { - links.Self = GetLinkForResourceSelf(resourceType, resource); - } + /// + public ResourceLinks? GetResourceLinks(ResourceType resourceType, IIdentifiable resource) + { + ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + ArgumentGuard.NotNull(resource, nameof(resource)); - return links.HasValue() ? links : null; - } + var links = new ResourceLinks(); - /// - /// 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 . - /// - private bool ShouldIncludeResourceLink(LinkTypes linkType, ResourceType resourceType) + if (ShouldIncludeResourceLink(LinkTypes.Self, resourceType)) { - if (resourceType.ResourceLinks != LinkTypes.NotConfigured) - { - return resourceType.ResourceLinks.HasFlag(linkType); - } - - return _options.ResourceLinks.HasFlag(linkType); + links.Self = GetLinkForResourceSelf(resourceType, resource); } - private string? GetLinkForResourceSelf(ResourceType resourceType, IIdentifiable resource) - { - string? controllerName = _controllerResourceMapping.GetControllerNameForResourceType(resourceType); - IDictionary routeValues = GetRouteValues(resource.StringId!, null); + return links.HasValue() ? links : null; + } - return RenderLinkForAction(controllerName, GetPrimaryControllerActionName, routeValues); + /// + /// 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 . + /// + private bool ShouldIncludeResourceLink(LinkTypes linkType, ResourceType resourceType) + { + if (resourceType.ResourceLinks != LinkTypes.NotConfigured) + { + return resourceType.ResourceLinks.HasFlag(linkType); } - /// - public RelationshipLinks? GetRelationshipLinks(RelationshipAttribute relationship, IIdentifiable leftResource) - { - ArgumentGuard.NotNull(relationship, nameof(relationship)); - ArgumentGuard.NotNull(leftResource, nameof(leftResource)); + return _options.ResourceLinks.HasFlag(linkType); + } - var links = new RelationshipLinks(); + private string? GetLinkForResourceSelf(ResourceType resourceType, IIdentifiable resource) + { + string? controllerName = _controllerResourceMapping.GetControllerNameForResourceType(resourceType); + IDictionary routeValues = GetRouteValues(resource.StringId!, null); - if (ShouldIncludeRelationshipLink(LinkTypes.Self, relationship)) - { - links.Self = GetLinkForRelationshipSelf(leftResource.StringId!, relationship); - } + return RenderLinkForAction(controllerName, GetPrimaryControllerActionName, routeValues); + } - if (ShouldIncludeRelationshipLink(LinkTypes.Related, relationship)) - { - links.Related = GetLinkForRelationshipRelated(leftResource.StringId!, relationship); - } + /// + public RelationshipLinks? GetRelationshipLinks(RelationshipAttribute relationship, IIdentifiable leftResource) + { + ArgumentGuard.NotNull(relationship, nameof(relationship)); + ArgumentGuard.NotNull(leftResource, nameof(leftResource)); - return links.HasValue() ? links : null; - } + var links = new RelationshipLinks(); - private string? GetLinkForRelationshipSelf(string leftId, RelationshipAttribute relationship) + if (ShouldIncludeRelationshipLink(LinkTypes.Self, relationship)) { - string? controllerName = _controllerResourceMapping.GetControllerNameForResourceType(relationship.LeftType); - IDictionary routeValues = GetRouteValues(leftId, relationship.PublicName); - - return RenderLinkForAction(controllerName, GetRelationshipControllerActionName, routeValues); + links.Self = GetLinkForRelationshipSelf(leftResource.StringId!, relationship); } - private string? GetLinkForRelationshipRelated(string leftId, RelationshipAttribute relationship) + if (ShouldIncludeRelationshipLink(LinkTypes.Related, relationship)) { - string? controllerName = _controllerResourceMapping.GetControllerNameForResourceType(relationship.LeftType); - IDictionary routeValues = GetRouteValues(leftId, relationship.PublicName); - - return RenderLinkForAction(controllerName, GetSecondaryControllerActionName, routeValues); + links.Related = GetLinkForRelationshipRelated(leftResource.StringId!, relationship); } - private IDictionary GetRouteValues(string primaryId, string? relationshipName) - { - // By default, we copy all route parameters from the *current* endpoint, which helps in case all endpoints have the same - // set of non-standard parameters. There is no way we can know which non-standard parameters a *different* endpoint needs, - // so users must override RenderLinkForAction to supply them, if applicable. - RouteValueDictionary routeValues = HttpContext.Request.RouteValues; + return links.HasValue() ? links : null; + } - routeValues["id"] = primaryId; - routeValues["relationshipName"] = relationshipName; + private string? GetLinkForRelationshipSelf(string leftId, RelationshipAttribute relationship) + { + string? controllerName = _controllerResourceMapping.GetControllerNameForResourceType(relationship.LeftType); + IDictionary routeValues = GetRouteValues(leftId, relationship.PublicName); - return routeValues; - } + return RenderLinkForAction(controllerName, GetRelationshipControllerActionName, routeValues); + } + + private string? GetLinkForRelationshipRelated(string leftId, RelationshipAttribute relationship) + { + string? controllerName = _controllerResourceMapping.GetControllerNameForResourceType(relationship.LeftType); + IDictionary routeValues = GetRouteValues(leftId, relationship.PublicName); + + return RenderLinkForAction(controllerName, GetSecondaryControllerActionName, routeValues); + } - protected virtual string? RenderLinkForAction(string? controllerName, string actionName, IDictionary routeValues) + private IDictionary GetRouteValues(string primaryId, string? relationshipName) + { + // By default, we copy all route parameters from the *current* endpoint, which helps in case all endpoints have the same + // set of non-standard parameters. There is no way we can know which non-standard parameters a *different* endpoint needs, + // so users must override RenderLinkForAction to supply them, if applicable. + RouteValueDictionary routeValues = HttpContext.Request.RouteValues; + + routeValues["id"] = primaryId; + routeValues["relationshipName"] = relationshipName; + + return routeValues; + } + + protected virtual string? RenderLinkForAction(string? controllerName, string actionName, IDictionary routeValues) + { + return _options.UseRelativeLinks + ? _linkGenerator.GetPathByAction(HttpContext, actionName, controllerName, routeValues) + : _linkGenerator.GetUriByAction(HttpContext, actionName, controllerName, routeValues); + } + + /// + /// Checks if the relationship object level should be added by first checking configuration on the + /// attribute, if not configured by checking on the resource + /// type that contains this relationship, and if not configured by checking with the global configuration in . + /// + private bool ShouldIncludeRelationshipLink(LinkTypes linkType, RelationshipAttribute relationship) + { + if (relationship.Links != LinkTypes.NotConfigured) { - return _options.UseRelativeLinks - ? _linkGenerator.GetPathByAction(HttpContext, actionName, controllerName, routeValues) - : _linkGenerator.GetUriByAction(HttpContext, actionName, controllerName, routeValues); + return relationship.Links.HasFlag(linkType); } - /// - /// Checks if the relationship object level should be added by first checking configuration on the - /// attribute, if not configured by checking on the resource - /// type that contains this relationship, and if not configured by checking with the global configuration in . - /// - private bool ShouldIncludeRelationshipLink(LinkTypes linkType, RelationshipAttribute relationship) + if (relationship.LeftType.RelationshipLinks != LinkTypes.NotConfigured) { - if (relationship.Links != LinkTypes.NotConfigured) - { - return relationship.Links.HasFlag(linkType); - } - - if (relationship.LeftType.RelationshipLinks != LinkTypes.NotConfigured) - { - return relationship.LeftType.RelationshipLinks.HasFlag(linkType); - } - - return _options.RelationshipLinks.HasFlag(linkType); + return relationship.LeftType.RelationshipLinks.HasFlag(linkType); } + + return _options.RelationshipLinks.HasFlag(linkType); } } diff --git a/src/JsonApiDotNetCore/Serialization/Response/MetaBuilder.cs b/src/JsonApiDotNetCore/Serialization/Response/MetaBuilder.cs index ef75f6472a..d250501d60 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/MetaBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/MetaBuilder.cs @@ -1,62 +1,55 @@ -using System.Collections.Generic; -using System.Linq; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries; -namespace JsonApiDotNetCore.Serialization.Response +namespace JsonApiDotNetCore.Serialization.Response; + +/// +[PublicAPI] +public sealed class MetaBuilder : IMetaBuilder { - /// - [PublicAPI] - public sealed class MetaBuilder : IMetaBuilder - { - private readonly IPaginationContext _paginationContext; - private readonly IJsonApiOptions _options; - private readonly IResponseMeta _responseMeta; + private readonly IPaginationContext _paginationContext; + private readonly IJsonApiOptions _options; + private readonly IResponseMeta _responseMeta; - private Dictionary _meta = new(); + private Dictionary _meta = new(); - public MetaBuilder(IPaginationContext paginationContext, IJsonApiOptions options, IResponseMeta responseMeta) - { - ArgumentGuard.NotNull(paginationContext, nameof(paginationContext)); - ArgumentGuard.NotNull(options, nameof(options)); - ArgumentGuard.NotNull(responseMeta, nameof(responseMeta)); + public MetaBuilder(IPaginationContext paginationContext, IJsonApiOptions options, IResponseMeta responseMeta) + { + ArgumentGuard.NotNull(paginationContext, nameof(paginationContext)); + ArgumentGuard.NotNull(options, nameof(options)); + ArgumentGuard.NotNull(responseMeta, nameof(responseMeta)); - _paginationContext = paginationContext; - _options = options; - _responseMeta = responseMeta; - } + _paginationContext = paginationContext; + _options = options; + _responseMeta = responseMeta; + } - /// - public void Add(IReadOnlyDictionary values) - { - ArgumentGuard.NotNull(values, nameof(values)); + /// + public void Add(IReadOnlyDictionary values) + { + ArgumentGuard.NotNull(values, nameof(values)); - _meta = values.Keys.Union(_meta.Keys).ToDictionary(key => key, key => values.ContainsKey(key) ? values[key] : _meta[key]); - } + _meta = values.Keys.Union(_meta.Keys).ToDictionary(key => key, key => values.ContainsKey(key) ? values[key] : _meta[key]); + } - /// - public IDictionary? Build() + /// + public IDictionary? Build() + { + if (_paginationContext.TotalResourceCount != null) { - if (_paginationContext.TotalResourceCount != null) - { - const string keyName = "Total"; - - string key = _options.SerializerOptions.DictionaryKeyPolicy == null - ? keyName - : _options.SerializerOptions.DictionaryKeyPolicy.ConvertName(keyName); - - _meta.Add(key, _paginationContext.TotalResourceCount); - } - - IReadOnlyDictionary? extraMeta = _responseMeta.GetMeta(); + const string keyName = "Total"; + string key = _options.SerializerOptions.DictionaryKeyPolicy == null ? keyName : _options.SerializerOptions.DictionaryKeyPolicy.ConvertName(keyName); + _meta.Add(key, _paginationContext.TotalResourceCount); + } - if (extraMeta != null) - { - Add(extraMeta); - } + IReadOnlyDictionary? extraMeta = _responseMeta.GetMeta(); - return _meta.Any() ? _meta : null; + if (extraMeta != null) + { + Add(extraMeta); } + + return _meta.Any() ? _meta : null; } } diff --git a/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs b/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs index f33f566249..df75fcaa66 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs @@ -1,280 +1,274 @@ -using System; -using System.Collections.Generic; -using System.Linq; using System.Text; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Serialization.Response +namespace JsonApiDotNetCore.Serialization.Response; + +/// +/// Represents a dependency tree of resource objects. It provides the values for 'data' and 'included' in the response body. The tree is built by +/// recursively walking the resource relationships from the inclusion chains. Note that a subsequent chain may add additional relationships to a resource +/// object that was produced by an earlier chain. Afterwards, this tree is used to fill relationship objects in the resource objects (depending on sparse +/// fieldsets) and to emit all entries in relationship declaration order. +/// +internal sealed class ResourceObjectTreeNode : IEquatable { - /// - /// Represents a dependency tree of resource objects. It provides the values for 'data' and 'included' in the response body. The tree is built by - /// recursively walking the resource relationships from the inclusion chains. Note that a subsequent chain may add additional relationships to a resource - /// object that was produced by an earlier chain. Afterwards, this tree is used to fill relationship objects in the resource objects (depending on sparse - /// fieldsets) and to emit all entries in relationship declaration order. - /// - internal sealed class ResourceObjectTreeNode : IEquatable - { - // Placeholder root node for the tree, which is never emitted itself. - private static readonly ResourceType RootType = new("(root)", typeof(object), typeof(object)); - private static readonly IIdentifiable RootResource = new EmptyResource(); + // Placeholder root node for the tree, which is never emitted itself. + private static readonly ResourceType RootType = new("(root)", typeof(object), typeof(object)); + private static readonly IIdentifiable RootResource = new EmptyResource(); - // Direct children from root. These are emitted in 'data'. - private List? _directChildren; + // Direct children from root. These are emitted in 'data'. + private List? _directChildren; - // Related resource objects per relationship. These are emitted in 'included'. - private Dictionary>? _childrenByRelationship; + // Related resource objects per relationship. These are emitted in 'included'. + private Dictionary>? _childrenByRelationship; - private bool IsTreeRoot => RootType.Equals(ResourceType); + private bool IsTreeRoot => RootType.Equals(ResourceType); - // The resource this node was built for. We only store it for the LinkBuilder. - public IIdentifiable Resource { get; } + // The resource this node was built for. We only store it for the LinkBuilder. + public IIdentifiable Resource { get; } - // The resource type. We use its relationships to maintain order. - public ResourceType ResourceType { get; } + // The resource type. We use its relationships to maintain order. + public ResourceType ResourceType { get; } - // The produced resource object from Resource. For each resource, at most one ResourceObject and one tree node must exist. - public ResourceObject ResourceObject { get; } + // The produced resource object from Resource. For each resource, at most one ResourceObject and one tree node must exist. + public ResourceObject ResourceObject { get; } - public ResourceObjectTreeNode(IIdentifiable resource, ResourceType resourceType, ResourceObject resourceObject) - { - ArgumentGuard.NotNull(resource, nameof(resource)); - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - ArgumentGuard.NotNull(resourceObject, nameof(resourceObject)); + public ResourceObjectTreeNode(IIdentifiable resource, ResourceType resourceType, ResourceObject resourceObject) + { + ArgumentGuard.NotNull(resource, nameof(resource)); + ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + ArgumentGuard.NotNull(resourceObject, nameof(resourceObject)); - Resource = resource; - ResourceType = resourceType; - ResourceObject = resourceObject; - } + Resource = resource; + ResourceType = resourceType; + ResourceObject = resourceObject; + } - public static ResourceObjectTreeNode CreateRoot() - { - return new ResourceObjectTreeNode(RootResource, RootType, new ResourceObject()); - } + public static ResourceObjectTreeNode CreateRoot() + { + return new ResourceObjectTreeNode(RootResource, RootType, new ResourceObject()); + } - public void AttachDirectChild(ResourceObjectTreeNode treeNode) - { - ArgumentGuard.NotNull(treeNode, nameof(treeNode)); + public void AttachDirectChild(ResourceObjectTreeNode treeNode) + { + ArgumentGuard.NotNull(treeNode, nameof(treeNode)); - _directChildren ??= new List(); - _directChildren.Add(treeNode); - } + _directChildren ??= new List(); + _directChildren.Add(treeNode); + } - public void EnsureHasRelationship(RelationshipAttribute relationship) - { - ArgumentGuard.NotNull(relationship, nameof(relationship)); + public void EnsureHasRelationship(RelationshipAttribute relationship) + { + ArgumentGuard.NotNull(relationship, nameof(relationship)); - _childrenByRelationship ??= new Dictionary>(); + _childrenByRelationship ??= new Dictionary>(); - if (!_childrenByRelationship.ContainsKey(relationship)) - { - _childrenByRelationship[relationship] = new HashSet(); - } + if (!_childrenByRelationship.ContainsKey(relationship)) + { + _childrenByRelationship[relationship] = new HashSet(); } + } + + public void AttachRelationshipChild(RelationshipAttribute relationship, ResourceObjectTreeNode rightNode) + { + ArgumentGuard.NotNull(relationship, nameof(relationship)); + ArgumentGuard.NotNull(rightNode, nameof(rightNode)); - public void AttachRelationshipChild(RelationshipAttribute relationship, ResourceObjectTreeNode rightNode) + if (_childrenByRelationship == null) { - ArgumentGuard.NotNull(relationship, nameof(relationship)); - ArgumentGuard.NotNull(rightNode, nameof(rightNode)); + throw new InvalidOperationException("Call EnsureHasRelationship() first."); + } - if (_childrenByRelationship == null) - { - throw new InvalidOperationException("Call EnsureHasRelationship() first."); - } + HashSet rightNodes = _childrenByRelationship[relationship]; + rightNodes.Add(rightNode); + } - HashSet rightNodes = _childrenByRelationship[relationship]; - rightNodes.Add(rightNode); - } + /// + /// Recursively walks the tree and returns the set of unique nodes. Uses relationship declaration order. + /// + public ISet GetUniqueNodes() + { + AssertIsTreeRoot(); - /// - /// Recursively walks the tree and returns the set of unique nodes. Uses relationship declaration order. - /// - public ISet GetUniqueNodes() - { - AssertIsTreeRoot(); + var visited = new HashSet(); - var visited = new HashSet(); + VisitSubtree(this, visited); - VisitSubtree(this, visited); + return visited; + } - return visited; + private static void VisitSubtree(ResourceObjectTreeNode treeNode, ISet visited) + { + if (visited.Contains(treeNode)) + { + return; } - private static void VisitSubtree(ResourceObjectTreeNode treeNode, ISet visited) + if (!treeNode.IsTreeRoot) { - if (visited.Contains(treeNode)) - { - return; - } - - if (!treeNode.IsTreeRoot) - { - visited.Add(treeNode); - } - - VisitDirectChildrenInSubtree(treeNode, visited); - VisitRelationshipChildrenInSubtree(treeNode, visited); + visited.Add(treeNode); } - private static void VisitDirectChildrenInSubtree(ResourceObjectTreeNode treeNode, ISet visited) + VisitDirectChildrenInSubtree(treeNode, visited); + VisitRelationshipChildrenInSubtree(treeNode, visited); + } + + private static void VisitDirectChildrenInSubtree(ResourceObjectTreeNode treeNode, ISet visited) + { + if (treeNode._directChildren != null) { - if (treeNode._directChildren != null) + foreach (ResourceObjectTreeNode child in treeNode._directChildren) { - foreach (ResourceObjectTreeNode child in treeNode._directChildren) - { - VisitSubtree(child, visited); - } + VisitSubtree(child, visited); } } + } - private static void VisitRelationshipChildrenInSubtree(ResourceObjectTreeNode treeNode, ISet visited) + private static void VisitRelationshipChildrenInSubtree(ResourceObjectTreeNode treeNode, ISet visited) + { + if (treeNode._childrenByRelationship != null) { - if (treeNode._childrenByRelationship != null) + foreach (RelationshipAttribute relationship in treeNode.ResourceType.Relationships) { - foreach (RelationshipAttribute relationship in treeNode.ResourceType.Relationships) + if (treeNode._childrenByRelationship.TryGetValue(relationship, out HashSet? rightNodes)) { - if (treeNode._childrenByRelationship.TryGetValue(relationship, out HashSet? rightNodes)) - { - VisitRelationshipChildInSubtree(rightNodes, visited); - } + VisitRelationshipChildInSubtree(rightNodes, visited); } } } + } - private static void VisitRelationshipChildInSubtree(HashSet rightNodes, ISet visited) + private static void VisitRelationshipChildInSubtree(HashSet rightNodes, ISet visited) + { + foreach (ResourceObjectTreeNode rightNode in rightNodes) { - foreach (ResourceObjectTreeNode rightNode in rightNodes) - { - VisitSubtree(rightNode, visited); - } + VisitSubtree(rightNode, visited); } + } - public ISet? GetRightNodesInRelationship(RelationshipAttribute relationship) - { - return _childrenByRelationship != null && _childrenByRelationship.TryGetValue(relationship, out HashSet? rightNodes) - ? rightNodes - : null; - } + public ISet? GetRightNodesInRelationship(RelationshipAttribute relationship) + { + return _childrenByRelationship != null && _childrenByRelationship.TryGetValue(relationship, out HashSet? rightNodes) + ? rightNodes + : null; + } - /// - /// Provides the value for 'data' in the response body. Uses relationship declaration order. - /// - public IList GetResponseData() - { - AssertIsTreeRoot(); + /// + /// Provides the value for 'data' in the response body. Uses relationship declaration order. + /// + public IList GetResponseData() + { + AssertIsTreeRoot(); - return GetDirectChildren().Select(child => child.ResourceObject).ToArray(); - } + return GetDirectChildren().Select(child => child.ResourceObject).ToArray(); + } + + /// + /// Provides the value for 'included' in the response body. Uses relationship declaration order. + /// + public IList GetResponseIncluded() + { + AssertIsTreeRoot(); + + var visited = new HashSet(); - /// - /// Provides the value for 'included' in the response body. Uses relationship declaration order. - /// - public IList GetResponseIncluded() + foreach (ResourceObjectTreeNode child in GetDirectChildren()) { - AssertIsTreeRoot(); + VisitRelationshipChildrenInSubtree(child, visited); + } - var visited = new HashSet(); + return visited.Select(node => node.ResourceObject).ToArray(); + } - foreach (ResourceObjectTreeNode child in GetDirectChildren()) - { - VisitRelationshipChildrenInSubtree(child, visited); - } + private IList GetDirectChildren() + { + return _directChildren == null ? Array.Empty() : _directChildren; + } - return visited.Select(node => node.ResourceObject).ToArray(); + private void AssertIsTreeRoot() + { + if (!IsTreeRoot) + { + throw new InvalidOperationException("Internal error: this method should only be called from the root of the tree."); } + } - private IList GetDirectChildren() + public bool Equals(ResourceObjectTreeNode? other) + { + if (ReferenceEquals(null, other)) { - // ReSharper disable once MergeConditionalExpression - // Justification: ReSharper reporting this is a bug, which is fixed in v2021.2.1. This condition cannot be merged. - return _directChildren == null ? Array.Empty() : _directChildren; + return false; } - private void AssertIsTreeRoot() + if (ReferenceEquals(this, other)) { - if (!IsTreeRoot) - { - throw new InvalidOperationException("Internal error: this method should only be called from the root of the tree."); - } + return true; } - public bool Equals(ResourceObjectTreeNode? other) - { - if (ReferenceEquals(null, other)) - { - return false; - } + return ResourceObjectComparer.Instance.Equals(ResourceObject, other.ResourceObject); + } - if (ReferenceEquals(this, other)) - { - return true; - } + public override bool Equals(object? other) + { + return Equals(other as ResourceObjectTreeNode); + } - return ResourceObjectComparer.Instance.Equals(ResourceObject, other.ResourceObject); - } + public override int GetHashCode() + { + return ResourceObject.GetHashCode(); + } + + public override string ToString() + { + var builder = new StringBuilder(); + builder.Append(IsTreeRoot ? ResourceType.PublicName : $"{ResourceObject.Type}:{ResourceObject.Id}"); - public override bool Equals(object? other) + if (_directChildren != null) { - return Equals(other as ResourceObjectTreeNode); + builder.Append($", children: {_directChildren.Count}"); } - - public override int GetHashCode() + else if (_childrenByRelationship != null) { - return ResourceObject.GetHashCode(); + builder.Append($", children: {string.Join(',', _childrenByRelationship.Select(pair => $"{pair.Key.PublicName} ({pair.Value.Count})"))}"); } - public override string ToString() - { - var builder = new StringBuilder(); - builder.Append(IsTreeRoot ? ResourceType.PublicName : $"{ResourceObject.Type}:{ResourceObject.Id}"); + return builder.ToString(); + } - if (_directChildren != null) - { - builder.Append($", children: {_directChildren.Count}"); - } - else if (_childrenByRelationship != null) - { - builder.Append($", children: {string.Join(',', _childrenByRelationship.Select(pair => $"{pair.Key.PublicName} ({pair.Value.Count})"))}"); - } + private sealed class EmptyResource : IIdentifiable + { + public string? StringId { get; set; } + public string? LocalId { get; set; } + } - return builder.ToString(); - } + private sealed class ResourceObjectComparer : IEqualityComparer + { + public static readonly ResourceObjectComparer Instance = new(); - private sealed class EmptyResource : IIdentifiable + private ResourceObjectComparer() { - public string? StringId { get; set; } - public string? LocalId { get; set; } } - private sealed class ResourceObjectComparer : IEqualityComparer + public bool Equals(ResourceObject? x, ResourceObject? y) { - public static readonly ResourceObjectComparer Instance = new(); - - private ResourceObjectComparer() + if (ReferenceEquals(x, y)) { + return true; } - public bool Equals(ResourceObject? x, ResourceObject? y) + if (x is null || y is null || x.GetType() != y.GetType()) { - if (ReferenceEquals(x, y)) - { - return true; - } - - if (x is null || y is null || x.GetType() != y.GetType()) - { - return false; - } - - return x.Type == y.Type && x.Id == y.Id && x.Lid == y.Lid; + return false; } - public int GetHashCode(ResourceObject obj) - { - return HashCode.Combine(obj.Type, obj.Id, obj.Lid); - } + return x.Type == y.Type && x.Id == y.Id && x.Lid == y.Lid; + } + + public int GetHashCode(ResourceObject obj) + { + return HashCode.Combine(obj.Type, obj.Id, obj.Lid); } } } diff --git a/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs b/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs index 1d20316c1e..d881668c9f 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; using System.Collections.Immutable; -using System.Linq; using System.Text.Json.Serialization; using JetBrains.Annotations; using JsonApiDotNetCore.AtomicOperations; @@ -15,351 +12,350 @@ using JsonApiDotNetCore.Resources.Internal; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Serialization.Response +namespace JsonApiDotNetCore.Serialization.Response; + +/// +[PublicAPI] +public class ResponseModelAdapter : IResponseModelAdapter { - /// - [PublicAPI] - public class ResponseModelAdapter : IResponseModelAdapter + private static readonly CollectionConverter CollectionConverter = new(); + + private readonly IJsonApiRequest _request; + private readonly IJsonApiOptions _options; + private readonly ILinkBuilder _linkBuilder; + private readonly IMetaBuilder _metaBuilder; + private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; + private readonly IEvaluatedIncludeCache _evaluatedIncludeCache; + private readonly IRequestQueryStringAccessor _requestQueryStringAccessor; + private readonly ISparseFieldSetCache _sparseFieldSetCache; + + // Ensures that at most one ResourceObject (and one tree node) is produced per resource instance. + private readonly Dictionary _resourceToTreeNodeCache = new(IdentifiableComparer.Instance); + + public ResponseModelAdapter(IJsonApiRequest request, IJsonApiOptions options, ILinkBuilder linkBuilder, IMetaBuilder metaBuilder, + IResourceDefinitionAccessor resourceDefinitionAccessor, IEvaluatedIncludeCache evaluatedIncludeCache, ISparseFieldSetCache sparseFieldSetCache, + IRequestQueryStringAccessor requestQueryStringAccessor) { - private static readonly CollectionConverter CollectionConverter = new(); - - private readonly IJsonApiRequest _request; - private readonly IJsonApiOptions _options; - private readonly ILinkBuilder _linkBuilder; - private readonly IMetaBuilder _metaBuilder; - private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; - private readonly IEvaluatedIncludeCache _evaluatedIncludeCache; - private readonly IRequestQueryStringAccessor _requestQueryStringAccessor; - private readonly ISparseFieldSetCache _sparseFieldSetCache; - - // Ensures that at most one ResourceObject (and one tree node) is produced per resource instance. - private readonly Dictionary _resourceToTreeNodeCache = new(IdentifiableComparer.Instance); - - public ResponseModelAdapter(IJsonApiRequest request, IJsonApiOptions options, ILinkBuilder linkBuilder, IMetaBuilder metaBuilder, - IResourceDefinitionAccessor resourceDefinitionAccessor, IEvaluatedIncludeCache evaluatedIncludeCache, ISparseFieldSetCache sparseFieldSetCache, - IRequestQueryStringAccessor requestQueryStringAccessor) - { - ArgumentGuard.NotNull(request, nameof(request)); - ArgumentGuard.NotNull(options, nameof(options)); - ArgumentGuard.NotNull(linkBuilder, nameof(linkBuilder)); - ArgumentGuard.NotNull(metaBuilder, nameof(metaBuilder)); - ArgumentGuard.NotNull(resourceDefinitionAccessor, nameof(resourceDefinitionAccessor)); - ArgumentGuard.NotNull(evaluatedIncludeCache, nameof(evaluatedIncludeCache)); - ArgumentGuard.NotNull(sparseFieldSetCache, nameof(sparseFieldSetCache)); - ArgumentGuard.NotNull(requestQueryStringAccessor, nameof(requestQueryStringAccessor)); - - _request = request; - _options = options; - _linkBuilder = linkBuilder; - _metaBuilder = metaBuilder; - _resourceDefinitionAccessor = resourceDefinitionAccessor; - _evaluatedIncludeCache = evaluatedIncludeCache; - _sparseFieldSetCache = sparseFieldSetCache; - _requestQueryStringAccessor = requestQueryStringAccessor; - } - - /// - public Document Convert(object? model) - { - _sparseFieldSetCache.Reset(); - _resourceToTreeNodeCache.Clear(); - - var document = new Document(); + ArgumentGuard.NotNull(request, nameof(request)); + ArgumentGuard.NotNull(options, nameof(options)); + ArgumentGuard.NotNull(linkBuilder, nameof(linkBuilder)); + ArgumentGuard.NotNull(metaBuilder, nameof(metaBuilder)); + ArgumentGuard.NotNull(resourceDefinitionAccessor, nameof(resourceDefinitionAccessor)); + ArgumentGuard.NotNull(evaluatedIncludeCache, nameof(evaluatedIncludeCache)); + ArgumentGuard.NotNull(sparseFieldSetCache, nameof(sparseFieldSetCache)); + ArgumentGuard.NotNull(requestQueryStringAccessor, nameof(requestQueryStringAccessor)); + + _request = request; + _options = options; + _linkBuilder = linkBuilder; + _metaBuilder = metaBuilder; + _resourceDefinitionAccessor = resourceDefinitionAccessor; + _evaluatedIncludeCache = evaluatedIncludeCache; + _sparseFieldSetCache = sparseFieldSetCache; + _requestQueryStringAccessor = requestQueryStringAccessor; + } - IncludeExpression? include = _evaluatedIncludeCache.Get(); - IImmutableSet includeElements = include?.Elements ?? ImmutableHashSet.Empty; + /// + public Document Convert(object? model) + { + _sparseFieldSetCache.Reset(); + _resourceToTreeNodeCache.Clear(); - var rootNode = ResourceObjectTreeNode.CreateRoot(); + var document = new Document(); - if (model is IEnumerable resources) - { - ResourceType resourceType = (_request.SecondaryResourceType ?? _request.PrimaryResourceType)!; + IncludeExpression? include = _evaluatedIncludeCache.Get(); + IImmutableSet includeElements = include?.Elements ?? ImmutableHashSet.Empty; - foreach (IIdentifiable resource in resources) - { - TraverseResource(resource, resourceType, _request.Kind, includeElements, rootNode, null); - } + var rootNode = ResourceObjectTreeNode.CreateRoot(); - PopulateRelationshipsInTree(rootNode, _request.Kind); + if (model is IEnumerable resources) + { + ResourceType resourceType = (_request.SecondaryResourceType ?? _request.PrimaryResourceType)!; - IEnumerable resourceObjects = rootNode.GetResponseData(); - document.Data = new SingleOrManyData(resourceObjects); - } - else if (model is IIdentifiable resource) + foreach (IIdentifiable resource in resources) { - ResourceType resourceType = (_request.SecondaryResourceType ?? _request.PrimaryResourceType)!; - TraverseResource(resource, resourceType, _request.Kind, includeElements, rootNode, null); - PopulateRelationshipsInTree(rootNode, _request.Kind); - - ResourceObject resourceObject = rootNode.GetResponseData().Single(); - document.Data = new SingleOrManyData(resourceObject); - } - else if (model == null) - { - document.Data = new SingleOrManyData(null); - } - else if (model is IEnumerable operations) - { - using var _ = new RevertRequestStateOnDispose(_request, null); - document.Results = operations.Select(operation => ConvertOperation(operation, includeElements)).ToList(); - } - else if (model is IEnumerable errorObjects) - { - document.Errors = errorObjects.ToArray(); - } - else if (model is ErrorObject errorObject) - { - document.Errors = errorObject.AsArray(); - } - else - { - throw new InvalidOperationException("Data being returned must be resources, operations, errors or null."); } - document.JsonApi = GetApiObject(); - document.Links = _linkBuilder.GetTopLevelLinks(); - document.Meta = _metaBuilder.Build(); - document.Included = GetIncluded(rootNode); + PopulateRelationshipsInTree(rootNode, _request.Kind); - return document; + IEnumerable resourceObjects = rootNode.GetResponseData(); + document.Data = new SingleOrManyData(resourceObjects); } - - protected virtual AtomicResultObject ConvertOperation(OperationContainer? operation, IImmutableSet includeElements) + else if (model is IIdentifiable resource) { - ResourceObject? resourceObject = null; - - if (operation != null) - { - _request.CopyFrom(operation.Request); + ResourceType resourceType = (_request.SecondaryResourceType ?? _request.PrimaryResourceType)!; - ResourceType resourceType = (operation.Request.SecondaryResourceType ?? operation.Request.PrimaryResourceType)!; - var rootNode = ResourceObjectTreeNode.CreateRoot(); + TraverseResource(resource, resourceType, _request.Kind, includeElements, rootNode, null); + PopulateRelationshipsInTree(rootNode, _request.Kind); - TraverseResource(operation.Resource, resourceType, operation.Request.Kind, includeElements, rootNode, null); - PopulateRelationshipsInTree(rootNode, operation.Request.Kind); + ResourceObject resourceObject = rootNode.GetResponseData().Single(); + document.Data = new SingleOrManyData(resourceObject); + } + else if (model == null) + { + document.Data = new SingleOrManyData(null); + } + else if (model is IEnumerable operations) + { + using var _ = new RevertRequestStateOnDispose(_request, null); + document.Results = operations.Select(operation => ConvertOperation(operation, includeElements)).ToList(); + } + else if (model is IEnumerable errorObjects) + { + document.Errors = errorObjects.ToArray(); + } + else if (model is ErrorObject errorObject) + { + document.Errors = errorObject.AsArray(); + } + else + { + throw new InvalidOperationException("Data being returned must be resources, operations, errors or null."); + } - resourceObject = rootNode.GetResponseData().Single(); + document.JsonApi = GetApiObject(); + document.Links = _linkBuilder.GetTopLevelLinks(); + document.Meta = _metaBuilder.Build(); + document.Included = GetIncluded(rootNode); - _sparseFieldSetCache.Reset(); - _resourceToTreeNodeCache.Clear(); - } + return document; + } - return new AtomicResultObject - { - Data = resourceObject == null ? default : new SingleOrManyData(resourceObject) - }; - } + protected virtual AtomicResultObject ConvertOperation(OperationContainer? operation, IImmutableSet includeElements) + { + ResourceObject? resourceObject = null; - private void TraverseResource(IIdentifiable resource, ResourceType resourceType, EndpointKind kind, - IImmutableSet includeElements, ResourceObjectTreeNode parentTreeNode, RelationshipAttribute? parentRelationship) + if (operation != null) { - ResourceObjectTreeNode treeNode = GetOrCreateTreeNode(resource, resourceType, kind); + _request.CopyFrom(operation.Request); - if (parentRelationship != null) - { - parentTreeNode.AttachRelationshipChild(parentRelationship, treeNode); - } - else - { - parentTreeNode.AttachDirectChild(treeNode); - } + ResourceType resourceType = (operation.Request.SecondaryResourceType ?? operation.Request.PrimaryResourceType)!; + var rootNode = ResourceObjectTreeNode.CreateRoot(); - if (kind != EndpointKind.Relationship) - { - TraverseRelationships(resource, treeNode, includeElements, kind); - } + TraverseResource(operation.Resource, resourceType, operation.Request.Kind, includeElements, rootNode, null); + PopulateRelationshipsInTree(rootNode, operation.Request.Kind); + + resourceObject = rootNode.GetResponseData().Single(); + + _sparseFieldSetCache.Reset(); + _resourceToTreeNodeCache.Clear(); } - private ResourceObjectTreeNode GetOrCreateTreeNode(IIdentifiable resource, ResourceType resourceType, EndpointKind kind) + return new AtomicResultObject { - if (!_resourceToTreeNodeCache.TryGetValue(resource, out ResourceObjectTreeNode? treeNode)) - { - ResourceObject resourceObject = ConvertResource(resource, resourceType, kind); - treeNode = new ResourceObjectTreeNode(resource, resourceType, resourceObject); + Data = resourceObject == null ? default : new SingleOrManyData(resourceObject) + }; + } - _resourceToTreeNodeCache.Add(resource, treeNode); - } + private void TraverseResource(IIdentifiable resource, ResourceType resourceType, EndpointKind kind, IImmutableSet includeElements, + ResourceObjectTreeNode parentTreeNode, RelationshipAttribute? parentRelationship) + { + ResourceObjectTreeNode treeNode = GetOrCreateTreeNode(resource, resourceType, kind); - return treeNode; + if (parentRelationship != null) + { + parentTreeNode.AttachRelationshipChild(parentRelationship, treeNode); + } + else + { + parentTreeNode.AttachDirectChild(treeNode); } - protected virtual ResourceObject ConvertResource(IIdentifiable resource, ResourceType resourceType, EndpointKind kind) + if (kind != EndpointKind.Relationship) { - bool isRelationship = kind == EndpointKind.Relationship; + TraverseRelationships(resource, treeNode, includeElements, kind); + } + } - if (!isRelationship) - { - _resourceDefinitionAccessor.OnSerialize(resource); - } + private ResourceObjectTreeNode GetOrCreateTreeNode(IIdentifiable resource, ResourceType resourceType, EndpointKind kind) + { + if (!_resourceToTreeNodeCache.TryGetValue(resource, out ResourceObjectTreeNode? treeNode)) + { + ResourceObject resourceObject = ConvertResource(resource, resourceType, kind); + treeNode = new ResourceObjectTreeNode(resource, resourceType, resourceObject); - var resourceObject = new ResourceObject - { - Type = resourceType.PublicName, - Id = resource.StringId - }; + _resourceToTreeNodeCache.Add(resource, treeNode); + } - if (!isRelationship) - { - IImmutableSet fieldSet = _sparseFieldSetCache.GetSparseFieldSetForSerializer(resourceType); + return treeNode; + } - resourceObject.Attributes = ConvertAttributes(resource, resourceType, fieldSet); - resourceObject.Links = _linkBuilder.GetResourceLinks(resourceType, resource); - resourceObject.Meta = _resourceDefinitionAccessor.GetMeta(resourceType, resource); - } + protected virtual ResourceObject ConvertResource(IIdentifiable resource, ResourceType resourceType, EndpointKind kind) + { + bool isRelationship = kind == EndpointKind.Relationship; - return resourceObject; + if (!isRelationship) + { + _resourceDefinitionAccessor.OnSerialize(resource); } - protected virtual IDictionary? ConvertAttributes(IIdentifiable resource, ResourceType resourceType, - IImmutableSet fieldSet) + var resourceObject = new ResourceObject { - var attrMap = new Dictionary(resourceType.Attributes.Count); - - foreach (AttrAttribute attr in resourceType.Attributes) - { - if (!fieldSet.Contains(attr) || attr.Property.Name == nameof(Identifiable.Id)) - { - continue; - } + Type = resourceType.PublicName, + Id = resource.StringId + }; - object? value = attr.GetValue(resource); - - if (_options.SerializerOptions.DefaultIgnoreCondition == JsonIgnoreCondition.WhenWritingNull && value == null) - { - continue; - } + if (!isRelationship) + { + IImmutableSet fieldSet = _sparseFieldSetCache.GetSparseFieldSetForSerializer(resourceType); - if (_options.SerializerOptions.DefaultIgnoreCondition == JsonIgnoreCondition.WhenWritingDefault && - Equals(value, RuntimeTypeConverter.GetDefaultValue(attr.Property.PropertyType))) - { - continue; - } + resourceObject.Attributes = ConvertAttributes(resource, resourceType, fieldSet); + resourceObject.Links = _linkBuilder.GetResourceLinks(resourceType, resource); + resourceObject.Meta = _resourceDefinitionAccessor.GetMeta(resourceType, resource); + } - attrMap.Add(attr.PublicName, value); - } + return resourceObject; + } - return attrMap.Any() ? attrMap : null; - } + protected virtual IDictionary? ConvertAttributes(IIdentifiable resource, ResourceType resourceType, + IImmutableSet fieldSet) + { + var attrMap = new Dictionary(resourceType.Attributes.Count); - private void TraverseRelationships(IIdentifiable leftResource, ResourceObjectTreeNode leftTreeNode, - IImmutableSet includeElements, EndpointKind kind) + foreach (AttrAttribute attr in resourceType.Attributes) { - foreach (IncludeElementExpression includeElement in includeElements) + if (!fieldSet.Contains(attr) || attr.Property.Name == nameof(Identifiable.Id)) { - TraverseRelationship(includeElement.Relationship, leftResource, leftTreeNode, includeElement, kind); + continue; } - } - private void TraverseRelationship(RelationshipAttribute relationship, IIdentifiable leftResource, ResourceObjectTreeNode leftTreeNode, - IncludeElementExpression includeElement, EndpointKind kind) - { - object? rightValue = relationship.GetValue(leftResource); - ICollection rightResources = CollectionConverter.ExtractResources(rightValue); + object? value = attr.GetValue(resource); - leftTreeNode.EnsureHasRelationship(relationship); + if (_options.SerializerOptions.DefaultIgnoreCondition == JsonIgnoreCondition.WhenWritingNull && value == null) + { + continue; + } - foreach (IIdentifiable rightResource in rightResources) + if (_options.SerializerOptions.DefaultIgnoreCondition == JsonIgnoreCondition.WhenWritingDefault && + Equals(value, RuntimeTypeConverter.GetDefaultValue(attr.Property.PropertyType))) { - TraverseResource(rightResource, relationship.RightType, kind, includeElement.Children, leftTreeNode, relationship); + continue; } + + attrMap.Add(attr.PublicName, value); } - private void PopulateRelationshipsInTree(ResourceObjectTreeNode rootNode, EndpointKind kind) + return attrMap.Any() ? attrMap : null; + } + + private void TraverseRelationships(IIdentifiable leftResource, ResourceObjectTreeNode leftTreeNode, IImmutableSet includeElements, + EndpointKind kind) + { + foreach (IncludeElementExpression includeElement in includeElements) { - if (kind != EndpointKind.Relationship) - { - foreach (ResourceObjectTreeNode treeNode in rootNode.GetUniqueNodes()) - { - PopulateRelationshipsInResourceObject(treeNode); - } - } + TraverseRelationship(includeElement.Relationship, leftResource, leftTreeNode, includeElement, kind); } + } + + private void TraverseRelationship(RelationshipAttribute relationship, IIdentifiable leftResource, ResourceObjectTreeNode leftTreeNode, + IncludeElementExpression includeElement, EndpointKind kind) + { + object? rightValue = relationship.GetValue(leftResource); + ICollection rightResources = CollectionConverter.ExtractResources(rightValue); + + leftTreeNode.EnsureHasRelationship(relationship); - private void PopulateRelationshipsInResourceObject(ResourceObjectTreeNode treeNode) + foreach (IIdentifiable rightResource in rightResources) { - IImmutableSet fieldSet = _sparseFieldSetCache.GetSparseFieldSetForSerializer(treeNode.ResourceType); + TraverseResource(rightResource, relationship.RightType, kind, includeElement.Children, leftTreeNode, relationship); + } + } - foreach (RelationshipAttribute relationship in treeNode.ResourceType.Relationships) + private void PopulateRelationshipsInTree(ResourceObjectTreeNode rootNode, EndpointKind kind) + { + if (kind != EndpointKind.Relationship) + { + foreach (ResourceObjectTreeNode treeNode in rootNode.GetUniqueNodes()) { - if (fieldSet.Contains(relationship)) - { - PopulateRelationshipInResourceObject(treeNode, relationship); - } + PopulateRelationshipsInResourceObject(treeNode); } } + } - private void PopulateRelationshipInResourceObject(ResourceObjectTreeNode treeNode, RelationshipAttribute relationship) - { - SingleOrManyData data = GetRelationshipData(treeNode, relationship); - RelationshipLinks? links = _linkBuilder.GetRelationshipLinks(relationship, treeNode.Resource); + private void PopulateRelationshipsInResourceObject(ResourceObjectTreeNode treeNode) + { + IImmutableSet fieldSet = _sparseFieldSetCache.GetSparseFieldSetForSerializer(treeNode.ResourceType); - if (links != null || data.IsAssigned) + foreach (RelationshipAttribute relationship in treeNode.ResourceType.Relationships) + { + if (fieldSet.Contains(relationship)) { - var relationshipObject = new RelationshipObject - { - Links = links, - Data = data - }; - - treeNode.ResourceObject.Relationships ??= new Dictionary(); - treeNode.ResourceObject.Relationships.Add(relationship.PublicName, relationshipObject); + PopulateRelationshipInResourceObject(treeNode, relationship); } } + } - private static SingleOrManyData GetRelationshipData(ResourceObjectTreeNode treeNode, RelationshipAttribute relationship) - { - ISet? rightNodes = treeNode.GetRightNodesInRelationship(relationship); + private void PopulateRelationshipInResourceObject(ResourceObjectTreeNode treeNode, RelationshipAttribute relationship) + { + SingleOrManyData data = GetRelationshipData(treeNode, relationship); + RelationshipLinks? links = _linkBuilder.GetRelationshipLinks(relationship, treeNode.Resource); - if (rightNodes != null) + if (links != null || data.IsAssigned) + { + var relationshipObject = new RelationshipObject { - IEnumerable resourceIdentifierObjects = rightNodes.Select(rightNode => new ResourceIdentifierObject - { - Type = rightNode.ResourceType.PublicName, - Id = rightNode.ResourceObject.Id - }); - - return relationship is HasOneAttribute - ? new SingleOrManyData(resourceIdentifierObjects.SingleOrDefault()) - : new SingleOrManyData(resourceIdentifierObjects); - } + Links = links, + Data = data + }; - return default; + treeNode.ResourceObject.Relationships ??= new Dictionary(); + treeNode.ResourceObject.Relationships.Add(relationship.PublicName, relationshipObject); } + } - protected virtual JsonApiObject? GetApiObject() + private static SingleOrManyData GetRelationshipData(ResourceObjectTreeNode treeNode, RelationshipAttribute relationship) + { + ISet? rightNodes = treeNode.GetRightNodesInRelationship(relationship); + + if (rightNodes != null) { - if (!_options.IncludeJsonApiVersion) + IEnumerable resourceIdentifierObjects = rightNodes.Select(rightNode => new ResourceIdentifierObject { - return null; - } + Type = rightNode.ResourceType.PublicName, + Id = rightNode.ResourceObject.Id + }); - var jsonApiObject = new JsonApiObject - { - Version = "1.1" - }; + return relationship is HasOneAttribute + ? new SingleOrManyData(resourceIdentifierObjects.SingleOrDefault()) + : new SingleOrManyData(resourceIdentifierObjects); + } - if (_request.Kind == EndpointKind.AtomicOperations) - { - jsonApiObject.Ext = new List - { - "https://jsonapi.org/ext/atomic" - }; - } + return default; + } - return jsonApiObject; + protected virtual JsonApiObject? GetApiObject() + { + if (!_options.IncludeJsonApiVersion) + { + return null; } - private IList? GetIncluded(ResourceObjectTreeNode rootNode) + var jsonApiObject = new JsonApiObject { - IList resourceObjects = rootNode.GetResponseIncluded(); + Version = "1.1" + }; - if (resourceObjects.Any()) + if (_request.Kind == EndpointKind.AtomicOperations) + { + jsonApiObject.Ext = new List { - return resourceObjects; - } + "https://jsonapi.org/ext/atomic" + }; + } + + return jsonApiObject; + } - return _requestQueryStringAccessor.Query.ContainsKey("include") ? Array.Empty() : null; + private IList? GetIncluded(ResourceObjectTreeNode rootNode) + { + IList resourceObjects = rootNode.GetResponseIncluded(); + + if (resourceObjects.Any()) + { + return resourceObjects; } + + return _requestQueryStringAccessor.Query.ContainsKey("include") ? Array.Empty() : null; } } diff --git a/src/JsonApiDotNetCore/Services/AsyncCollectionExtensions.cs b/src/JsonApiDotNetCore/Services/AsyncCollectionExtensions.cs index e93b294a22..3924999f63 100644 --- a/src/JsonApiDotNetCore/Services/AsyncCollectionExtensions.cs +++ b/src/JsonApiDotNetCore/Services/AsyncCollectionExtensions.cs @@ -1,36 +1,32 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Services +namespace JsonApiDotNetCore.Services; + +[PublicAPI] +public static class AsyncCollectionExtensions { - [PublicAPI] - public static class AsyncCollectionExtensions + public static async Task AddRangeAsync(this ICollection source, IAsyncEnumerable elementsToAdd, CancellationToken cancellationToken = default) { - public static async Task AddRangeAsync(this ICollection source, IAsyncEnumerable elementsToAdd, CancellationToken cancellationToken = default) - { - ArgumentGuard.NotNull(source, nameof(source)); - ArgumentGuard.NotNull(elementsToAdd, nameof(elementsToAdd)); + ArgumentGuard.NotNull(source, nameof(source)); + ArgumentGuard.NotNull(elementsToAdd, nameof(elementsToAdd)); - await foreach (T missingResource in elementsToAdd.WithCancellation(cancellationToken)) - { - source.Add(missingResource); - } - } - - public static async Task> ToListAsync(this IAsyncEnumerable source, CancellationToken cancellationToken = default) + await foreach (T missingResource in elementsToAdd.WithCancellation(cancellationToken)) { - ArgumentGuard.NotNull(source, nameof(source)); + source.Add(missingResource); + } + } - var list = new List(); + public static async Task> ToListAsync(this IAsyncEnumerable source, CancellationToken cancellationToken = default) + { + ArgumentGuard.NotNull(source, nameof(source)); - await foreach (T element in source.WithCancellation(cancellationToken)) - { - list.Add(element); - } + var list = new List(); - return list; + await foreach (T element in source.WithCancellation(cancellationToken)) + { + list.Add(element); } + + return list; } } diff --git a/src/JsonApiDotNetCore/Services/IAddToRelationshipService.cs b/src/JsonApiDotNetCore/Services/IAddToRelationshipService.cs index bc0ae069ff..58fb122a50 100644 --- a/src/JsonApiDotNetCore/Services/IAddToRelationshipService.cs +++ b/src/JsonApiDotNetCore/Services/IAddToRelationshipService.cs @@ -1,33 +1,29 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; // ReSharper disable UnusedTypeParameter -namespace JsonApiDotNetCore.Services +namespace JsonApiDotNetCore.Services; + +/// +[PublicAPI] +public interface IAddToRelationshipService + where TResource : class, IIdentifiable { - /// - [PublicAPI] - public interface IAddToRelationshipService - where TResource : class, IIdentifiable - { - /// - /// Handles a JSON:API request to add resources to a to-many relationship. - /// - /// - /// Identifies the left side of the relationship. - /// - /// - /// The relationship to add resources to. - /// - /// - /// The set of resources to add to the relationship. - /// - /// - /// Propagates notification that request handling should be canceled. - /// - Task AddToToManyRelationshipAsync(TId leftId, string relationshipName, ISet rightResourceIds, CancellationToken cancellationToken); - } + /// + /// Handles a JSON:API request to add resources to a to-many relationship. + /// + /// + /// Identifies the left side of the relationship. + /// + /// + /// The relationship to add resources to. + /// + /// + /// The set of resources to add to the relationship. + /// + /// + /// Propagates notification that request handling should be canceled. + /// + Task AddToToManyRelationshipAsync(TId leftId, string relationshipName, ISet rightResourceIds, CancellationToken cancellationToken); } diff --git a/src/JsonApiDotNetCore/Services/ICreateService.cs b/src/JsonApiDotNetCore/Services/ICreateService.cs index 56286f9fe7..7a75d6d4af 100644 --- a/src/JsonApiDotNetCore/Services/ICreateService.cs +++ b/src/JsonApiDotNetCore/Services/ICreateService.cs @@ -1,16 +1,13 @@ -using System.Threading; -using System.Threading.Tasks; using JsonApiDotNetCore.Resources; -namespace JsonApiDotNetCore.Services +namespace JsonApiDotNetCore.Services; + +/// +public interface ICreateService + where TResource : class, IIdentifiable { - /// - public interface ICreateService - where TResource : class, IIdentifiable - { - /// - /// Handles a JSON:API request to create a new resource with attributes, relationships or both. - /// - Task CreateAsync(TResource resource, CancellationToken cancellationToken); - } + /// + /// Handles a JSON:API request to create a new resource with attributes, relationships or both. + /// + Task CreateAsync(TResource resource, CancellationToken cancellationToken); } diff --git a/src/JsonApiDotNetCore/Services/IDeleteService.cs b/src/JsonApiDotNetCore/Services/IDeleteService.cs index a509f10caa..9bdfcd143b 100644 --- a/src/JsonApiDotNetCore/Services/IDeleteService.cs +++ b/src/JsonApiDotNetCore/Services/IDeleteService.cs @@ -1,18 +1,15 @@ -using System.Threading; -using System.Threading.Tasks; using JsonApiDotNetCore.Resources; // ReSharper disable UnusedTypeParameter -namespace JsonApiDotNetCore.Services +namespace JsonApiDotNetCore.Services; + +/// +public interface 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, CancellationToken cancellationToken); - } + /// + /// Handles a JSON:API request to delete an existing resource. + /// + Task DeleteAsync(TId id, CancellationToken cancellationToken); } diff --git a/src/JsonApiDotNetCore/Services/IGetAllService.cs b/src/JsonApiDotNetCore/Services/IGetAllService.cs index a81caadd80..4c6b1d59c4 100644 --- a/src/JsonApiDotNetCore/Services/IGetAllService.cs +++ b/src/JsonApiDotNetCore/Services/IGetAllService.cs @@ -1,17 +1,13 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using JsonApiDotNetCore.Resources; -namespace JsonApiDotNetCore.Services +namespace JsonApiDotNetCore.Services; + +/// +public interface 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(CancellationToken cancellationToken); - } + /// + /// Handles a JSON:API request to retrieve a collection of resources for a primary endpoint. + /// + Task> GetAsync(CancellationToken cancellationToken); } diff --git a/src/JsonApiDotNetCore/Services/IGetByIdService.cs b/src/JsonApiDotNetCore/Services/IGetByIdService.cs index 3d942e15ce..4bf34788eb 100644 --- a/src/JsonApiDotNetCore/Services/IGetByIdService.cs +++ b/src/JsonApiDotNetCore/Services/IGetByIdService.cs @@ -1,16 +1,13 @@ -using System.Threading; -using System.Threading.Tasks; using JsonApiDotNetCore.Resources; -namespace JsonApiDotNetCore.Services +namespace JsonApiDotNetCore.Services; + +/// +public interface 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, CancellationToken cancellationToken); - } + /// + /// Handles a JSON:API request to retrieve a single resource for a primary endpoint. + /// + Task GetAsync(TId id, CancellationToken cancellationToken); } diff --git a/src/JsonApiDotNetCore/Services/IGetRelationshipService.cs b/src/JsonApiDotNetCore/Services/IGetRelationshipService.cs index d57962b72d..afd284a7ce 100644 --- a/src/JsonApiDotNetCore/Services/IGetRelationshipService.cs +++ b/src/JsonApiDotNetCore/Services/IGetRelationshipService.cs @@ -1,18 +1,15 @@ -using System.Threading; -using System.Threading.Tasks; using JsonApiDotNetCore.Resources; // ReSharper disable UnusedTypeParameter -namespace JsonApiDotNetCore.Services +namespace JsonApiDotNetCore.Services; + +/// +public interface 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, CancellationToken cancellationToken); - } + /// + /// Handles a JSON:API request to retrieve a single relationship. + /// + Task GetRelationshipAsync(TId id, string relationshipName, CancellationToken cancellationToken); } diff --git a/src/JsonApiDotNetCore/Services/IGetSecondaryService.cs b/src/JsonApiDotNetCore/Services/IGetSecondaryService.cs index 1820f435bd..9f8c528552 100644 --- a/src/JsonApiDotNetCore/Services/IGetSecondaryService.cs +++ b/src/JsonApiDotNetCore/Services/IGetSecondaryService.cs @@ -1,19 +1,16 @@ -using System.Threading; -using System.Threading.Tasks; using JsonApiDotNetCore.Resources; // ReSharper disable UnusedTypeParameter -namespace JsonApiDotNetCore.Services +namespace JsonApiDotNetCore.Services; + +/// +public interface 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, CancellationToken cancellationToken); - } + /// + /// 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, CancellationToken cancellationToken); } diff --git a/src/JsonApiDotNetCore/Services/IRemoveFromRelationshipService.cs b/src/JsonApiDotNetCore/Services/IRemoveFromRelationshipService.cs index 923753ba44..cb572801bb 100644 --- a/src/JsonApiDotNetCore/Services/IRemoveFromRelationshipService.cs +++ b/src/JsonApiDotNetCore/Services/IRemoveFromRelationshipService.cs @@ -1,31 +1,27 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using JsonApiDotNetCore.Resources; // ReSharper disable UnusedTypeParameter -namespace JsonApiDotNetCore.Services +namespace JsonApiDotNetCore.Services; + +/// +public interface IRemoveFromRelationshipService + where TResource : class, IIdentifiable { - /// - public interface IRemoveFromRelationshipService - where TResource : class, IIdentifiable - { - /// - /// Handles a JSON:API request to remove resources from a to-many relationship. - /// - /// - /// Identifies the left side of the relationship. - /// - /// - /// The relationship to remove resources from. - /// - /// - /// The set of resources to remove from the relationship. - /// - /// - /// Propagates notification that request handling should be canceled. - /// - Task RemoveFromToManyRelationshipAsync(TId leftId, string relationshipName, ISet rightResourceIds, CancellationToken cancellationToken); - } + /// + /// Handles a JSON:API request to remove resources from a to-many relationship. + /// + /// + /// Identifies the left side of the relationship. + /// + /// + /// The relationship to remove resources from. + /// + /// + /// The set of resources to remove from the relationship. + /// + /// + /// Propagates notification that request handling should be canceled. + /// + Task RemoveFromToManyRelationshipAsync(TId leftId, string relationshipName, ISet rightResourceIds, CancellationToken cancellationToken); } diff --git a/src/JsonApiDotNetCore/Services/IResourceCommandService.cs b/src/JsonApiDotNetCore/Services/IResourceCommandService.cs index 06ad5af8ca..6c6dc408c9 100644 --- a/src/JsonApiDotNetCore/Services/IResourceCommandService.cs +++ b/src/JsonApiDotNetCore/Services/IResourceCommandService.cs @@ -1,20 +1,19 @@ using JsonApiDotNetCore.Resources; -namespace JsonApiDotNetCore.Services +namespace JsonApiDotNetCore.Services; + +/// +/// Groups write operations. +/// +/// +/// The resource type. +/// +/// +/// The resource identifier type. +/// +public interface IResourceCommandService + : ICreateService, IAddToRelationshipService, IUpdateService, ISetRelationshipService, + IDeleteService, IRemoveFromRelationshipService + where TResource : class, IIdentifiable { - /// - /// Groups write operations. - /// - /// - /// The resource type. - /// - /// - /// The resource identifier type. - /// - public interface IResourceCommandService - : ICreateService, IAddToRelationshipService, IUpdateService, ISetRelationshipService, - IDeleteService, IRemoveFromRelationshipService - where TResource : class, IIdentifiable - { - } } diff --git a/src/JsonApiDotNetCore/Services/IResourceQueryService.cs b/src/JsonApiDotNetCore/Services/IResourceQueryService.cs index 55b210a7cc..7c9d32071f 100644 --- a/src/JsonApiDotNetCore/Services/IResourceQueryService.cs +++ b/src/JsonApiDotNetCore/Services/IResourceQueryService.cs @@ -1,19 +1,18 @@ using JsonApiDotNetCore.Resources; -namespace JsonApiDotNetCore.Services +namespace JsonApiDotNetCore.Services; + +/// +/// Groups read operations. +/// +/// +/// The resource type. +/// +/// +/// The resource identifier type. +/// +public interface IResourceQueryService + : IGetAllService, IGetByIdService, IGetRelationshipService, IGetSecondaryService + 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 index d6910c93b8..87637e53a2 100644 --- a/src/JsonApiDotNetCore/Services/IResourceService.cs +++ b/src/JsonApiDotNetCore/Services/IResourceService.cs @@ -1,18 +1,17 @@ using JsonApiDotNetCore.Resources; -namespace JsonApiDotNetCore.Services +namespace JsonApiDotNetCore.Services; + +/// +/// 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 { - /// - /// 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/ISetRelationshipService.cs b/src/JsonApiDotNetCore/Services/ISetRelationshipService.cs index 2f6f8aefad..3050394beb 100644 --- a/src/JsonApiDotNetCore/Services/ISetRelationshipService.cs +++ b/src/JsonApiDotNetCore/Services/ISetRelationshipService.cs @@ -1,30 +1,27 @@ -using System.Threading; -using System.Threading.Tasks; using JsonApiDotNetCore.Resources; // ReSharper disable UnusedTypeParameter -namespace JsonApiDotNetCore.Services +namespace JsonApiDotNetCore.Services; + +/// +public interface ISetRelationshipService + where TResource : class, IIdentifiable { - /// - public interface ISetRelationshipService - where TResource : class, IIdentifiable - { - /// - /// Handles a JSON:API request to perform a complete replacement of a relationship on an existing resource. - /// - /// - /// Identifies the left side of the relationship. - /// - /// - /// The relationship for which to perform a complete replacement. - /// - /// - /// The resource or set of resources to assign to the relationship. - /// - /// - /// Propagates notification that request handling should be canceled. - /// - Task SetRelationshipAsync(TId leftId, string relationshipName, object? rightValue, CancellationToken cancellationToken); - } + /// + /// Handles a JSON:API request to perform a complete replacement of a relationship on an existing resource. + /// + /// + /// Identifies the left side of the relationship. + /// + /// + /// The relationship for which to perform a complete replacement. + /// + /// + /// The resource or set of resources to assign to the relationship. + /// + /// + /// Propagates notification that request handling should be canceled. + /// + Task SetRelationshipAsync(TId leftId, string relationshipName, object? rightValue, CancellationToken cancellationToken); } diff --git a/src/JsonApiDotNetCore/Services/IUpdateService.cs b/src/JsonApiDotNetCore/Services/IUpdateService.cs index 93bb79bca3..f742a1fc2e 100644 --- a/src/JsonApiDotNetCore/Services/IUpdateService.cs +++ b/src/JsonApiDotNetCore/Services/IUpdateService.cs @@ -1,17 +1,14 @@ -using System.Threading; -using System.Threading.Tasks; using JsonApiDotNetCore.Resources; -namespace JsonApiDotNetCore.Services +namespace JsonApiDotNetCore.Services; + +/// +public interface IUpdateService + where TResource : class, IIdentifiable { - /// - public interface IUpdateService - where TResource : class, IIdentifiable - { - /// - /// Handles a JSON:API request to update the attributes and/or relationships of an existing resource. Only the values of sent attributes are replaced. - /// And only the values of sent relationships are replaced. - /// - Task UpdateAsync(TId id, TResource resource, CancellationToken cancellationToken); - } + /// + /// Handles a JSON:API request to update the attributes and/or relationships of an existing resource. Only the values of sent attributes are replaced. + /// And only the values of sent relationships are replaced. + /// + Task UpdateAsync(TId id, TResource resource, CancellationToken cancellationToken); } diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index dab5cd54f2..87046783c5 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -1,10 +1,5 @@ -using System; using System.Collections; -using System.Collections.Generic; -using System.Linq; using System.Runtime.CompilerServices; -using System.Threading; -using System.Threading.Tasks; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Diagnostics; @@ -18,548 +13,540 @@ using Microsoft.Extensions.Logging; using SysNotNull = System.Diagnostics.CodeAnalysis.NotNullAttribute; -namespace JsonApiDotNetCore.Services +namespace JsonApiDotNetCore.Services; + +/// +[PublicAPI] +public class JsonApiResourceService : IResourceService + where TResource : class, IIdentifiable { - /// - [PublicAPI] - public class JsonApiResourceService : IResourceService - where TResource : class, IIdentifiable + private readonly CollectionConverter _collectionConverter = new(); + private readonly IResourceRepositoryAccessor _repositoryAccessor; + 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 IResourceDefinitionAccessor _resourceDefinitionAccessor; + + public JsonApiResourceService(IResourceRepositoryAccessor repositoryAccessor, IQueryLayerComposer queryLayerComposer, IPaginationContext paginationContext, + IJsonApiOptions options, ILoggerFactory loggerFactory, IJsonApiRequest request, IResourceChangeTracker resourceChangeTracker, + IResourceDefinitionAccessor resourceDefinitionAccessor) { - private readonly CollectionConverter _collectionConverter = new(); - private readonly IResourceRepositoryAccessor _repositoryAccessor; - 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 IResourceDefinitionAccessor _resourceDefinitionAccessor; - - public JsonApiResourceService(IResourceRepositoryAccessor repositoryAccessor, IQueryLayerComposer queryLayerComposer, - IPaginationContext paginationContext, IJsonApiOptions options, ILoggerFactory loggerFactory, IJsonApiRequest request, - IResourceChangeTracker resourceChangeTracker, IResourceDefinitionAccessor resourceDefinitionAccessor) - { - ArgumentGuard.NotNull(repositoryAccessor, nameof(repositoryAccessor)); - ArgumentGuard.NotNull(queryLayerComposer, nameof(queryLayerComposer)); - ArgumentGuard.NotNull(paginationContext, nameof(paginationContext)); - ArgumentGuard.NotNull(options, nameof(options)); - ArgumentGuard.NotNull(loggerFactory, nameof(loggerFactory)); - ArgumentGuard.NotNull(request, nameof(request)); - ArgumentGuard.NotNull(resourceChangeTracker, nameof(resourceChangeTracker)); - ArgumentGuard.NotNull(resourceDefinitionAccessor, nameof(resourceDefinitionAccessor)); - - _repositoryAccessor = repositoryAccessor; - _queryLayerComposer = queryLayerComposer; - _paginationContext = paginationContext; - _options = options; - _request = request; - _resourceChangeTracker = resourceChangeTracker; - _resourceDefinitionAccessor = resourceDefinitionAccessor; - _traceWriter = new TraceLogWriter>(loggerFactory); - } - - /// - public virtual async Task> GetAsync(CancellationToken cancellationToken) - { - _traceWriter.LogMethodStart(); - - using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Get resources"); + ArgumentGuard.NotNull(repositoryAccessor, nameof(repositoryAccessor)); + ArgumentGuard.NotNull(queryLayerComposer, nameof(queryLayerComposer)); + ArgumentGuard.NotNull(paginationContext, nameof(paginationContext)); + ArgumentGuard.NotNull(options, nameof(options)); + ArgumentGuard.NotNull(loggerFactory, nameof(loggerFactory)); + ArgumentGuard.NotNull(request, nameof(request)); + ArgumentGuard.NotNull(resourceChangeTracker, nameof(resourceChangeTracker)); + ArgumentGuard.NotNull(resourceDefinitionAccessor, nameof(resourceDefinitionAccessor)); + + _repositoryAccessor = repositoryAccessor; + _queryLayerComposer = queryLayerComposer; + _paginationContext = paginationContext; + _options = options; + _request = request; + _resourceChangeTracker = resourceChangeTracker; + _resourceDefinitionAccessor = resourceDefinitionAccessor; + _traceWriter = new TraceLogWriter>(loggerFactory); + } - AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); + /// + public virtual async Task> GetAsync(CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(); - if (_options.IncludeTotalResourceCount) - { - FilterExpression? topFilter = _queryLayerComposer.GetPrimaryFilterFromConstraints(_request.PrimaryResourceType); - _paginationContext.TotalResourceCount = await _repositoryAccessor.CountAsync(_request.PrimaryResourceType, topFilter, cancellationToken); + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Get resources"); - if (_paginationContext.TotalResourceCount == 0) - { - return Array.Empty(); - } - } + AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); - QueryLayer queryLayer = _queryLayerComposer.ComposeFromConstraints(_request.PrimaryResourceType); - IReadOnlyCollection resources = await _repositoryAccessor.GetAsync(queryLayer, cancellationToken); + if (_options.IncludeTotalResourceCount) + { + FilterExpression? topFilter = _queryLayerComposer.GetPrimaryFilterFromConstraints(_request.PrimaryResourceType); + _paginationContext.TotalResourceCount = await _repositoryAccessor.CountAsync(_request.PrimaryResourceType, topFilter, cancellationToken); - if (queryLayer.Pagination?.PageSize?.Value == resources.Count) + if (_paginationContext.TotalResourceCount == 0) { - _paginationContext.IsPageFull = true; + return Array.Empty(); } - - return resources; } - /// - public virtual async Task GetAsync(TId id, CancellationToken cancellationToken) - { - _traceWriter.LogMethodStart(new - { - id - }); - - using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Get single resource"); + QueryLayer queryLayer = _queryLayerComposer.ComposeFromConstraints(_request.PrimaryResourceType); + IReadOnlyCollection resources = await _repositoryAccessor.GetAsync(queryLayer, cancellationToken); - return await GetPrimaryResourceByIdAsync(id, TopFieldSelection.PreserveExisting, cancellationToken); + if (queryLayer.Pagination?.PageSize?.Value == resources.Count) + { + _paginationContext.IsPageFull = true; } - /// - public virtual async Task GetSecondaryAsync(TId id, string relationshipName, CancellationToken cancellationToken) + return resources; + } + + /// + public virtual async Task GetAsync(TId id, CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new { - _traceWriter.LogMethodStart(new - { - id, - relationshipName - }); + id + }); - using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Get secondary resource(s)"); + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Get single resource"); - AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); - AssertHasRelationship(_request.Relationship, relationshipName); + return await GetPrimaryResourceByIdAsync(id, TopFieldSelection.PreserveExisting, cancellationToken); + } - if (_options.IncludeTotalResourceCount && _request.IsCollection) - { - await RetrieveResourceCountForNonPrimaryEndpointAsync(id, (HasManyAttribute)_request.Relationship, cancellationToken); + /// + public virtual async Task GetSecondaryAsync(TId id, string relationshipName, CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new + { + id, + relationshipName + }); - // We cannot return early when _paginationContext.TotalResourceCount == 0, because we don't know whether - // the parent resource exists. In case the parent does not exist, an error is produced below. - } + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Get secondary resource(s)"); - QueryLayer secondaryLayer = _queryLayerComposer.ComposeFromConstraints(_request.SecondaryResourceType!); + AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); + AssertHasRelationship(_request.Relationship, relationshipName); - QueryLayer primaryLayer = - _queryLayerComposer.WrapLayerForSecondaryEndpoint(secondaryLayer, _request.PrimaryResourceType, id, _request.Relationship); + if (_options.IncludeTotalResourceCount && _request.IsCollection) + { + await RetrieveResourceCountForNonPrimaryEndpointAsync(id, (HasManyAttribute)_request.Relationship, cancellationToken); - IReadOnlyCollection primaryResources = await _repositoryAccessor.GetAsync(primaryLayer, cancellationToken); + // We cannot return early when _paginationContext.TotalResourceCount == 0, because we don't know whether + // the parent resource exists. In case the parent does not exist, an error is produced below. + } - TResource? primaryResource = primaryResources.SingleOrDefault(); - AssertPrimaryResourceExists(primaryResource); + QueryLayer secondaryLayer = _queryLayerComposer.ComposeFromConstraints(_request.SecondaryResourceType!); + QueryLayer primaryLayer = _queryLayerComposer.WrapLayerForSecondaryEndpoint(secondaryLayer, _request.PrimaryResourceType, id, _request.Relationship); + IReadOnlyCollection primaryResources = await _repositoryAccessor.GetAsync(primaryLayer, cancellationToken); - object? rightValue = _request.Relationship.GetValue(primaryResource); + TResource? primaryResource = primaryResources.SingleOrDefault(); + AssertPrimaryResourceExists(primaryResource); - if (rightValue is ICollection rightResources && secondaryLayer.Pagination?.PageSize?.Value == rightResources.Count) - { - _paginationContext.IsPageFull = true; - } + object? rightValue = _request.Relationship.GetValue(primaryResource); - return rightValue; + if (rightValue is ICollection rightResources && secondaryLayer.Pagination?.PageSize?.Value == rightResources.Count) + { + _paginationContext.IsPageFull = true; } - /// - public virtual async Task GetRelationshipAsync(TId id, string relationshipName, CancellationToken cancellationToken) - { - _traceWriter.LogMethodStart(new - { - id, - relationshipName - }); + return rightValue; + } - ArgumentGuard.NotNullNorEmpty(relationshipName, nameof(relationshipName)); + /// + public virtual async Task GetRelationshipAsync(TId id, string relationshipName, CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new + { + id, + relationshipName + }); - using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Get relationship"); + ArgumentGuard.NotNullNorEmpty(relationshipName, nameof(relationshipName)); - AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); - AssertHasRelationship(_request.Relationship, relationshipName); + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Get relationship"); - if (_options.IncludeTotalResourceCount && _request.IsCollection) - { - await RetrieveResourceCountForNonPrimaryEndpointAsync(id, (HasManyAttribute)_request.Relationship, cancellationToken); + AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); + AssertHasRelationship(_request.Relationship, relationshipName); - // We cannot return early when _paginationContext.TotalResourceCount == 0, because we don't know whether - // the parent resource exists. In case the parent does not exist, an error is produced below. - } + if (_options.IncludeTotalResourceCount && _request.IsCollection) + { + await RetrieveResourceCountForNonPrimaryEndpointAsync(id, (HasManyAttribute)_request.Relationship, cancellationToken); - QueryLayer secondaryLayer = _queryLayerComposer.ComposeSecondaryLayerForRelationship(_request.SecondaryResourceType!); + // We cannot return early when _paginationContext.TotalResourceCount == 0, because we don't know whether + // the parent resource exists. In case the parent does not exist, an error is produced below. + } - QueryLayer primaryLayer = - _queryLayerComposer.WrapLayerForSecondaryEndpoint(secondaryLayer, _request.PrimaryResourceType, id, _request.Relationship); + QueryLayer secondaryLayer = _queryLayerComposer.ComposeSecondaryLayerForRelationship(_request.SecondaryResourceType!); + QueryLayer primaryLayer = _queryLayerComposer.WrapLayerForSecondaryEndpoint(secondaryLayer, _request.PrimaryResourceType, id, _request.Relationship); + IReadOnlyCollection primaryResources = await _repositoryAccessor.GetAsync(primaryLayer, cancellationToken); - IReadOnlyCollection primaryResources = await _repositoryAccessor.GetAsync(primaryLayer, cancellationToken); + TResource? primaryResource = primaryResources.SingleOrDefault(); + AssertPrimaryResourceExists(primaryResource); - TResource? primaryResource = primaryResources.SingleOrDefault(); - AssertPrimaryResourceExists(primaryResource); + object? rightValue = _request.Relationship.GetValue(primaryResource); - object? rightValue = _request.Relationship.GetValue(primaryResource); + if (rightValue is ICollection rightResources && secondaryLayer.Pagination?.PageSize?.Value == rightResources.Count) + { + _paginationContext.IsPageFull = true; + } - if (rightValue is ICollection rightResources && secondaryLayer.Pagination?.PageSize?.Value == rightResources.Count) - { - _paginationContext.IsPageFull = true; - } + return rightValue; + } - return rightValue; - } + private async Task RetrieveResourceCountForNonPrimaryEndpointAsync(TId id, HasManyAttribute relationship, CancellationToken cancellationToken) + { + FilterExpression? secondaryFilter = _queryLayerComposer.GetSecondaryFilterFromConstraints(id, relationship); - private async Task RetrieveResourceCountForNonPrimaryEndpointAsync(TId id, HasManyAttribute relationship, CancellationToken cancellationToken) + if (secondaryFilter != null) { - FilterExpression? secondaryFilter = _queryLayerComposer.GetSecondaryFilterFromConstraints(id, relationship); - - if (secondaryFilter != null) - { - _paginationContext.TotalResourceCount = await _repositoryAccessor.CountAsync(relationship.RightType, secondaryFilter, cancellationToken); - } + _paginationContext.TotalResourceCount = await _repositoryAccessor.CountAsync(relationship.RightType, secondaryFilter, cancellationToken); } + } - /// - public virtual async Task CreateAsync(TResource resource, CancellationToken cancellationToken) + /// + public virtual async Task CreateAsync(TResource resource, CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new { - _traceWriter.LogMethodStart(new - { - resource - }); + resource + }); - ArgumentGuard.NotNull(resource, nameof(resource)); - AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); + ArgumentGuard.NotNull(resource, nameof(resource)); + AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); - using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Create resource"); + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Create resource"); - TResource resourceFromRequest = resource; - _resourceChangeTracker.SetRequestAttributeValues(resourceFromRequest); + TResource resourceFromRequest = resource; + _resourceChangeTracker.SetRequestAttributeValues(resourceFromRequest); - TResource resourceForDatabase = await _repositoryAccessor.GetForCreateAsync(resource.Id, cancellationToken); + TResource resourceForDatabase = await _repositoryAccessor.GetForCreateAsync(resource.Id, cancellationToken); - _resourceChangeTracker.SetInitiallyStoredAttributeValues(resourceForDatabase); + _resourceChangeTracker.SetInitiallyStoredAttributeValues(resourceForDatabase); - await InitializeResourceAsync(resourceForDatabase, cancellationToken); + await InitializeResourceAsync(resourceForDatabase, cancellationToken); - try - { - await _repositoryAccessor.CreateAsync(resourceFromRequest, resourceForDatabase, cancellationToken); - } - catch (DataStoreUpdateException) - { - await AssertPrimaryResourceDoesNotExistAsync(resourceFromRequest, cancellationToken); - await AssertResourcesToAssignInRelationshipsExistAsync(resourceFromRequest, cancellationToken); - throw; - } + try + { + await _repositoryAccessor.CreateAsync(resourceFromRequest, resourceForDatabase, cancellationToken); + } + catch (DataStoreUpdateException) + { + await AssertPrimaryResourceDoesNotExistAsync(resourceFromRequest, cancellationToken); + await AssertResourcesToAssignInRelationshipsExistAsync(resourceFromRequest, cancellationToken); + throw; + } - TResource resourceFromDatabase = await GetPrimaryResourceByIdAsync(resourceForDatabase.Id, TopFieldSelection.WithAllAttributes, cancellationToken); + TResource resourceFromDatabase = await GetPrimaryResourceByIdAsync(resourceForDatabase.Id, TopFieldSelection.WithAllAttributes, cancellationToken); - _resourceChangeTracker.SetFinallyStoredAttributeValues(resourceFromDatabase); + _resourceChangeTracker.SetFinallyStoredAttributeValues(resourceFromDatabase); - bool hasImplicitChanges = _resourceChangeTracker.HasImplicitChanges(); - return hasImplicitChanges ? resourceFromDatabase : null; - } + bool hasImplicitChanges = _resourceChangeTracker.HasImplicitChanges(); + return hasImplicitChanges ? resourceFromDatabase : null; + } - protected async Task AssertPrimaryResourceDoesNotExistAsync(TResource resource, CancellationToken cancellationToken) + protected async Task AssertPrimaryResourceDoesNotExistAsync(TResource resource, CancellationToken cancellationToken) + { + if (!Equals(resource.Id, default(TId))) { - if (!Equals(resource.Id, default(TId))) - { - TResource? existingResource = await GetPrimaryResourceByIdOrDefaultAsync(resource.Id, TopFieldSelection.OnlyIdAttribute, cancellationToken); + TResource? existingResource = await GetPrimaryResourceByIdOrDefaultAsync(resource.Id, TopFieldSelection.OnlyIdAttribute, cancellationToken); - if (existingResource != null) - { - throw new ResourceAlreadyExistsException(resource.StringId!, _request.PrimaryResourceType!.PublicName); - } + if (existingResource != null) + { + throw new ResourceAlreadyExistsException(resource.StringId!, _request.PrimaryResourceType!.PublicName); } } + } - protected virtual async Task InitializeResourceAsync(TResource resourceForDatabase, CancellationToken cancellationToken) - { - await _resourceDefinitionAccessor.OnPrepareWriteAsync(resourceForDatabase, WriteOperationKind.CreateResource, cancellationToken); - } - - protected async Task AssertResourcesToAssignInRelationshipsExistAsync(TResource primaryResource, CancellationToken cancellationToken) - { - var missingResources = new List(); + protected virtual async Task InitializeResourceAsync(TResource resourceForDatabase, CancellationToken cancellationToken) + { + await _resourceDefinitionAccessor.OnPrepareWriteAsync(resourceForDatabase, WriteOperationKind.CreateResource, cancellationToken); + } - foreach ((QueryLayer queryLayer, RelationshipAttribute relationship) in _queryLayerComposer.ComposeForGetTargetedSecondaryResourceIds( - primaryResource)) - { - object? rightValue = relationship.GetValue(primaryResource); - ICollection rightResourceIds = _collectionConverter.ExtractResources(rightValue); + protected async Task AssertResourcesToAssignInRelationshipsExistAsync(TResource primaryResource, CancellationToken cancellationToken) + { + var missingResources = new List(); - IAsyncEnumerable missingResourcesInRelationship = - GetMissingRightResourcesAsync(queryLayer, relationship, rightResourceIds, cancellationToken); + foreach ((QueryLayer queryLayer, RelationshipAttribute relationship) in _queryLayerComposer.ComposeForGetTargetedSecondaryResourceIds(primaryResource)) + { + object? rightValue = relationship.GetValue(primaryResource); + ICollection rightResourceIds = _collectionConverter.ExtractResources(rightValue); - await missingResources.AddRangeAsync(missingResourcesInRelationship, cancellationToken); - } + IAsyncEnumerable missingResourcesInRelationship = + GetMissingRightResourcesAsync(queryLayer, relationship, rightResourceIds, cancellationToken); - if (missingResources.Any()) - { - throw new ResourcesInRelationshipsNotFoundException(missingResources); - } + await missingResources.AddRangeAsync(missingResourcesInRelationship, cancellationToken); } - private async IAsyncEnumerable GetMissingRightResourcesAsync(QueryLayer existingRightResourceIdsQueryLayer, - RelationshipAttribute relationship, ICollection rightResourceIds, [EnumeratorCancellation] CancellationToken cancellationToken) + if (missingResources.Any()) { - IReadOnlyCollection existingResources = await _repositoryAccessor.GetAsync(existingRightResourceIdsQueryLayer.ResourceType, - existingRightResourceIdsQueryLayer, cancellationToken); + throw new ResourcesInRelationshipsNotFoundException(missingResources); + } + } + + private async IAsyncEnumerable GetMissingRightResourcesAsync(QueryLayer existingRightResourceIdsQueryLayer, + RelationshipAttribute relationship, ICollection rightResourceIds, [EnumeratorCancellation] CancellationToken cancellationToken) + { + IReadOnlyCollection existingResources = await _repositoryAccessor.GetAsync(existingRightResourceIdsQueryLayer.ResourceType, + existingRightResourceIdsQueryLayer, cancellationToken); - string[] existingResourceIds = existingResources.Select(resource => resource.StringId!).ToArray(); + string[] existingResourceIds = existingResources.Select(resource => resource.StringId!).ToArray(); - foreach (IIdentifiable rightResourceId in rightResourceIds) + foreach (IIdentifiable rightResourceId in rightResourceIds) + { + if (!existingResourceIds.Contains(rightResourceId.StringId)) { - if (!existingResourceIds.Contains(rightResourceId.StringId)) - { - yield return new MissingResourceInRelationship(relationship.PublicName, existingRightResourceIdsQueryLayer.ResourceType.PublicName, - rightResourceId.StringId!); - } + yield return new MissingResourceInRelationship(relationship.PublicName, existingRightResourceIdsQueryLayer.ResourceType.PublicName, + rightResourceId.StringId!); } } + } - /// - public virtual async Task AddToToManyRelationshipAsync(TId leftId, string relationshipName, ISet rightResourceIds, - CancellationToken cancellationToken) + /// + public virtual async Task AddToToManyRelationshipAsync(TId leftId, string relationshipName, ISet rightResourceIds, + CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new { - _traceWriter.LogMethodStart(new - { - leftId, - rightResourceIds - }); + leftId, + rightResourceIds + }); - ArgumentGuard.NotNullNorEmpty(relationshipName, nameof(relationshipName)); - ArgumentGuard.NotNull(rightResourceIds, nameof(rightResourceIds)); + ArgumentGuard.NotNullNorEmpty(relationshipName, nameof(relationshipName)); + ArgumentGuard.NotNull(rightResourceIds, nameof(rightResourceIds)); - using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Add to to-many relationship"); + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Add to to-many relationship"); - AssertHasRelationship(_request.Relationship, relationshipName); - - if (rightResourceIds.Any() && _request.Relationship is HasManyAttribute { IsManyToMany: true } manyToManyRelationship) - { - // In the case of a many-to-many relationship, creating a duplicate entry in the join table results in a - // unique constraint violation. We avoid that by excluding already-existing entries from the set in advance. - await RemoveExistingIdsFromRelationshipRightSideAsync(manyToManyRelationship, leftId, rightResourceIds, cancellationToken); - } + AssertHasRelationship(_request.Relationship, relationshipName); - try - { - await _repositoryAccessor.AddToToManyRelationshipAsync(leftId, rightResourceIds, cancellationToken); - } - catch (DataStoreUpdateException) - { - await GetPrimaryResourceByIdAsync(leftId, TopFieldSelection.OnlyIdAttribute, cancellationToken); - await AssertRightResourcesExistAsync(rightResourceIds, cancellationToken); - throw; - } + if (rightResourceIds.Any() && _request.Relationship is HasManyAttribute { IsManyToMany: true } manyToManyRelationship) + { + // In the case of a many-to-many relationship, creating a duplicate entry in the join table results in a + // unique constraint violation. We avoid that by excluding already-existing entries from the set in advance. + await RemoveExistingIdsFromRelationshipRightSideAsync(manyToManyRelationship, leftId, rightResourceIds, cancellationToken); } - private async Task RemoveExistingIdsFromRelationshipRightSideAsync(HasManyAttribute hasManyRelationship, TId leftId, - ISet rightResourceIds, CancellationToken cancellationToken) + try + { + await _repositoryAccessor.AddToToManyRelationshipAsync(leftId, rightResourceIds, cancellationToken); + } + catch (DataStoreUpdateException) { - AssertRelationshipInJsonApiRequestIsNotNull(_request.Relationship); + await GetPrimaryResourceByIdAsync(leftId, TopFieldSelection.OnlyIdAttribute, cancellationToken); + await AssertRightResourcesExistAsync(rightResourceIds, cancellationToken); + throw; + } + } - TResource leftResource = await GetForHasManyUpdateAsync(hasManyRelationship, leftId, rightResourceIds, cancellationToken); + private async Task RemoveExistingIdsFromRelationshipRightSideAsync(HasManyAttribute hasManyRelationship, TId leftId, ISet rightResourceIds, + CancellationToken cancellationToken) + { + AssertRelationshipInJsonApiRequestIsNotNull(_request.Relationship); - object? rightValue = _request.Relationship.GetValue(leftResource); - ICollection existingRightResourceIds = _collectionConverter.ExtractResources(rightValue); + TResource leftResource = await GetForHasManyUpdateAsync(hasManyRelationship, leftId, rightResourceIds, cancellationToken); - rightResourceIds.ExceptWith(existingRightResourceIds); - } + object? rightValue = _request.Relationship.GetValue(leftResource); + ICollection existingRightResourceIds = _collectionConverter.ExtractResources(rightValue); - private async Task GetForHasManyUpdateAsync(HasManyAttribute hasManyRelationship, TId leftId, ISet rightResourceIds, - CancellationToken cancellationToken) - { - QueryLayer queryLayer = _queryLayerComposer.ComposeForHasMany(hasManyRelationship, leftId, rightResourceIds); - var leftResource = await _repositoryAccessor.GetForUpdateAsync(queryLayer, cancellationToken); - AssertPrimaryResourceExists(leftResource); + rightResourceIds.ExceptWith(existingRightResourceIds); + } - return leftResource; - } + private async Task GetForHasManyUpdateAsync(HasManyAttribute hasManyRelationship, TId leftId, ISet rightResourceIds, + CancellationToken cancellationToken) + { + QueryLayer queryLayer = _queryLayerComposer.ComposeForHasMany(hasManyRelationship, leftId, rightResourceIds); + var leftResource = await _repositoryAccessor.GetForUpdateAsync(queryLayer, cancellationToken); + AssertPrimaryResourceExists(leftResource); - protected async Task AssertRightResourcesExistAsync(object? rightValue, CancellationToken cancellationToken) - { - AssertRelationshipInJsonApiRequestIsNotNull(_request.Relationship); + return leftResource; + } - ICollection rightResourceIds = _collectionConverter.ExtractResources(rightValue); + protected async Task AssertRightResourcesExistAsync(object? rightValue, CancellationToken cancellationToken) + { + AssertRelationshipInJsonApiRequestIsNotNull(_request.Relationship); - if (rightResourceIds.Any()) - { - QueryLayer queryLayer = _queryLayerComposer.ComposeForGetRelationshipRightIds(_request.Relationship, rightResourceIds); + ICollection rightResourceIds = _collectionConverter.ExtractResources(rightValue); + + if (rightResourceIds.Any()) + { + QueryLayer queryLayer = _queryLayerComposer.ComposeForGetRelationshipRightIds(_request.Relationship, rightResourceIds); - List missingResources = - await GetMissingRightResourcesAsync(queryLayer, _request.Relationship, rightResourceIds, cancellationToken).ToListAsync(cancellationToken); + List missingResources = + await GetMissingRightResourcesAsync(queryLayer, _request.Relationship, rightResourceIds, cancellationToken).ToListAsync(cancellationToken); - if (missingResources.Any()) - { - throw new ResourcesInRelationshipsNotFoundException(missingResources); - } + if (missingResources.Any()) + { + throw new ResourcesInRelationshipsNotFoundException(missingResources); } } + } - /// - public virtual async Task UpdateAsync(TId id, TResource resource, CancellationToken cancellationToken) + /// + public virtual async Task UpdateAsync(TId id, TResource resource, CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new { - _traceWriter.LogMethodStart(new - { - id, - resource - }); + id, + resource + }); - ArgumentGuard.NotNull(resource, nameof(resource)); + ArgumentGuard.NotNull(resource, nameof(resource)); - using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Update resource"); + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Update resource"); - TResource resourceFromRequest = resource; - _resourceChangeTracker.SetRequestAttributeValues(resourceFromRequest); + TResource resourceFromRequest = resource; + _resourceChangeTracker.SetRequestAttributeValues(resourceFromRequest); - TResource resourceFromDatabase = await GetPrimaryResourceForUpdateAsync(id, cancellationToken); + TResource resourceFromDatabase = await GetPrimaryResourceForUpdateAsync(id, cancellationToken); - _resourceChangeTracker.SetInitiallyStoredAttributeValues(resourceFromDatabase); + _resourceChangeTracker.SetInitiallyStoredAttributeValues(resourceFromDatabase); - await _resourceDefinitionAccessor.OnPrepareWriteAsync(resourceFromDatabase, WriteOperationKind.UpdateResource, cancellationToken); + await _resourceDefinitionAccessor.OnPrepareWriteAsync(resourceFromDatabase, WriteOperationKind.UpdateResource, cancellationToken); - try - { - await _repositoryAccessor.UpdateAsync(resourceFromRequest, resourceFromDatabase, cancellationToken); - } - catch (DataStoreUpdateException) - { - await AssertResourcesToAssignInRelationshipsExistAsync(resourceFromRequest, cancellationToken); - throw; - } + try + { + await _repositoryAccessor.UpdateAsync(resourceFromRequest, resourceFromDatabase, cancellationToken); + } + catch (DataStoreUpdateException) + { + await AssertResourcesToAssignInRelationshipsExistAsync(resourceFromRequest, cancellationToken); + throw; + } - TResource afterResourceFromDatabase = await GetPrimaryResourceByIdAsync(id, TopFieldSelection.WithAllAttributes, cancellationToken); + TResource afterResourceFromDatabase = await GetPrimaryResourceByIdAsync(id, TopFieldSelection.WithAllAttributes, cancellationToken); - _resourceChangeTracker.SetFinallyStoredAttributeValues(afterResourceFromDatabase); + _resourceChangeTracker.SetFinallyStoredAttributeValues(afterResourceFromDatabase); - bool hasImplicitChanges = _resourceChangeTracker.HasImplicitChanges(); - return hasImplicitChanges ? afterResourceFromDatabase : null; - } + bool hasImplicitChanges = _resourceChangeTracker.HasImplicitChanges(); + return hasImplicitChanges ? afterResourceFromDatabase : null; + } - /// - public virtual async Task SetRelationshipAsync(TId leftId, string relationshipName, object? rightValue, CancellationToken cancellationToken) + /// + public virtual async Task SetRelationshipAsync(TId leftId, string relationshipName, object? rightValue, CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new { - _traceWriter.LogMethodStart(new - { - leftId, - relationshipName, - rightValue - }); + leftId, + relationshipName, + rightValue + }); - ArgumentGuard.NotNullNorEmpty(relationshipName, nameof(relationshipName)); + ArgumentGuard.NotNullNorEmpty(relationshipName, nameof(relationshipName)); - using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Set relationship"); + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Set relationship"); - AssertHasRelationship(_request.Relationship, relationshipName); + AssertHasRelationship(_request.Relationship, relationshipName); - TResource resourceFromDatabase = await GetPrimaryResourceForUpdateAsync(leftId, cancellationToken); + TResource resourceFromDatabase = await GetPrimaryResourceForUpdateAsync(leftId, cancellationToken); - await _resourceDefinitionAccessor.OnPrepareWriteAsync(resourceFromDatabase, WriteOperationKind.SetRelationship, cancellationToken); + await _resourceDefinitionAccessor.OnPrepareWriteAsync(resourceFromDatabase, WriteOperationKind.SetRelationship, cancellationToken); - try - { - await _repositoryAccessor.SetRelationshipAsync(resourceFromDatabase, rightValue, cancellationToken); - } - catch (DataStoreUpdateException) - { - await AssertRightResourcesExistAsync(rightValue, cancellationToken); - throw; - } + try + { + await _repositoryAccessor.SetRelationshipAsync(resourceFromDatabase, rightValue, cancellationToken); } + catch (DataStoreUpdateException) + { + await AssertRightResourcesExistAsync(rightValue, cancellationToken); + throw; + } + } - /// - public virtual async Task DeleteAsync(TId id, CancellationToken cancellationToken) + /// + public virtual async Task DeleteAsync(TId id, CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new { - _traceWriter.LogMethodStart(new - { - id - }); + id + }); - using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Delete resource"); + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Delete resource"); - try - { - await _repositoryAccessor.DeleteAsync(id, cancellationToken); - } - catch (DataStoreUpdateException) - { - await GetPrimaryResourceByIdAsync(id, TopFieldSelection.OnlyIdAttribute, cancellationToken); - throw; - } + try + { + await _repositoryAccessor.DeleteAsync(id, cancellationToken); + } + catch (DataStoreUpdateException) + { + await GetPrimaryResourceByIdAsync(id, TopFieldSelection.OnlyIdAttribute, cancellationToken); + throw; } + } - /// - public virtual async Task RemoveFromToManyRelationshipAsync(TId leftId, string relationshipName, ISet rightResourceIds, - CancellationToken cancellationToken) + /// + public virtual async Task RemoveFromToManyRelationshipAsync(TId leftId, string relationshipName, ISet rightResourceIds, + CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new { - _traceWriter.LogMethodStart(new - { - leftId, - relationshipName, - rightResourceIds - }); + leftId, + relationshipName, + rightResourceIds + }); - ArgumentGuard.NotNullNorEmpty(relationshipName, nameof(relationshipName)); - ArgumentGuard.NotNull(rightResourceIds, nameof(rightResourceIds)); + ArgumentGuard.NotNullNorEmpty(relationshipName, nameof(relationshipName)); + ArgumentGuard.NotNull(rightResourceIds, nameof(rightResourceIds)); - using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Remove from to-many relationship"); + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Remove from to-many relationship"); - AssertHasRelationship(_request.Relationship, relationshipName); - var hasManyRelationship = (HasManyAttribute)_request.Relationship; + AssertHasRelationship(_request.Relationship, relationshipName); + var hasManyRelationship = (HasManyAttribute)_request.Relationship; - TResource resourceFromDatabase = await GetForHasManyUpdateAsync(hasManyRelationship, leftId, rightResourceIds, cancellationToken); + TResource resourceFromDatabase = await GetForHasManyUpdateAsync(hasManyRelationship, leftId, rightResourceIds, cancellationToken); - await AssertRightResourcesExistAsync(rightResourceIds, cancellationToken); + await AssertRightResourcesExistAsync(rightResourceIds, cancellationToken); - await _repositoryAccessor.RemoveFromToManyRelationshipAsync(resourceFromDatabase, rightResourceIds, cancellationToken); - } + await _repositoryAccessor.RemoveFromToManyRelationshipAsync(resourceFromDatabase, rightResourceIds, cancellationToken); + } - protected async Task GetPrimaryResourceByIdAsync(TId id, TopFieldSelection fieldSelection, CancellationToken cancellationToken) - { - TResource? primaryResource = await GetPrimaryResourceByIdOrDefaultAsync(id, fieldSelection, cancellationToken); - AssertPrimaryResourceExists(primaryResource); + protected async Task GetPrimaryResourceByIdAsync(TId id, TopFieldSelection fieldSelection, CancellationToken cancellationToken) + { + TResource? primaryResource = await GetPrimaryResourceByIdOrDefaultAsync(id, fieldSelection, cancellationToken); + AssertPrimaryResourceExists(primaryResource); - return primaryResource; - } + return primaryResource; + } - private async Task GetPrimaryResourceByIdOrDefaultAsync(TId id, TopFieldSelection fieldSelection, CancellationToken cancellationToken) - { - AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); + private async Task GetPrimaryResourceByIdOrDefaultAsync(TId id, TopFieldSelection fieldSelection, CancellationToken cancellationToken) + { + AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); - QueryLayer primaryLayer = _queryLayerComposer.ComposeForGetById(id, _request.PrimaryResourceType, fieldSelection); + QueryLayer primaryLayer = _queryLayerComposer.ComposeForGetById(id, _request.PrimaryResourceType, fieldSelection); - IReadOnlyCollection primaryResources = await _repositoryAccessor.GetAsync(primaryLayer, cancellationToken); - return primaryResources.SingleOrDefault(); - } + IReadOnlyCollection primaryResources = await _repositoryAccessor.GetAsync(primaryLayer, cancellationToken); + return primaryResources.SingleOrDefault(); + } - protected async Task GetPrimaryResourceForUpdateAsync(TId id, CancellationToken cancellationToken) - { - AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); + protected async Task GetPrimaryResourceForUpdateAsync(TId id, CancellationToken cancellationToken) + { + AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); - QueryLayer queryLayer = _queryLayerComposer.ComposeForUpdate(id, _request.PrimaryResourceType); - var resource = await _repositoryAccessor.GetForUpdateAsync(queryLayer, cancellationToken); - AssertPrimaryResourceExists(resource); + QueryLayer queryLayer = _queryLayerComposer.ComposeForUpdate(id, _request.PrimaryResourceType); + var resource = await _repositoryAccessor.GetForUpdateAsync(queryLayer, cancellationToken); + AssertPrimaryResourceExists(resource); - return resource; - } + return resource; + } - [AssertionMethod] - private void AssertPrimaryResourceExists([SysNotNull] TResource? resource) - { - AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); + [AssertionMethod] + private void AssertPrimaryResourceExists([SysNotNull] TResource? resource) + { + AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); - if (resource == null) - { - throw new ResourceNotFoundException(_request.PrimaryId!, _request.PrimaryResourceType.PublicName); - } + if (resource == null) + { + throw new ResourceNotFoundException(_request.PrimaryId!, _request.PrimaryResourceType.PublicName); } + } - [AssertionMethod] - private void AssertHasRelationship([SysNotNull] RelationshipAttribute? relationship, string name) + [AssertionMethod] + private void AssertHasRelationship([SysNotNull] RelationshipAttribute? relationship, string name) + { + if (relationship == null) { - if (relationship == null) - { - throw new RelationshipNotFoundException(name, _request.PrimaryResourceType!.PublicName); - } + throw new RelationshipNotFoundException(name, _request.PrimaryResourceType!.PublicName); } + } - [AssertionMethod] - private void AssertPrimaryResourceTypeInJsonApiRequestIsNotNull([SysNotNull] ResourceType? resourceType) + [AssertionMethod] + private void AssertPrimaryResourceTypeInJsonApiRequestIsNotNull([SysNotNull] ResourceType? resourceType) + { + if (resourceType == null) { - if (resourceType == null) - { - throw new InvalidOperationException( - $"Expected {nameof(IJsonApiRequest)}.{nameof(IJsonApiRequest.PrimaryResourceType)} not to be null at this point."); - } + throw new InvalidOperationException( + $"Expected {nameof(IJsonApiRequest)}.{nameof(IJsonApiRequest.PrimaryResourceType)} not to be null at this point."); } + } - [AssertionMethod] - private void AssertRelationshipInJsonApiRequestIsNotNull([SysNotNull] RelationshipAttribute? relationship) + [AssertionMethod] + private void AssertRelationshipInJsonApiRequestIsNotNull([SysNotNull] RelationshipAttribute? relationship) + { + if (relationship == null) { - if (relationship == null) - { - throw new InvalidOperationException($"Expected {nameof(IJsonApiRequest)}.{nameof(IJsonApiRequest.Relationship)} not to be null at this point."); - } + throw new InvalidOperationException($"Expected {nameof(IJsonApiRequest)}.{nameof(IJsonApiRequest.Relationship)} not to be null at this point."); } } } diff --git a/src/JsonApiDotNetCore/TypeExtensions.cs b/src/JsonApiDotNetCore/TypeExtensions.cs index 18e6dc6967..c28ea84332 100644 --- a/src/JsonApiDotNetCore/TypeExtensions.cs +++ b/src/JsonApiDotNetCore/TypeExtensions.cs @@ -1,62 +1,56 @@ -using System; -using System.Linq; +namespace JsonApiDotNetCore; -namespace JsonApiDotNetCore +internal static class TypeExtensions { - internal static class TypeExtensions + /// + /// Whether the specified source type implements or equals the specified interface. + /// + public static bool IsOrImplementsInterface(this Type? source) { - /// - /// Whether the specified source type implements or equals the specified interface. - /// - public static bool IsOrImplementsInterface(this Type? source) - { - return IsOrImplementsInterface(source, typeof(TInterface)); - } - - /// - /// Whether the specified source type implements or equals the specified interface. This overload enables to test for an open generic interface. - /// - private static bool IsOrImplementsInterface(this Type? source, Type interfaceType) - { - ArgumentGuard.NotNull(interfaceType, nameof(interfaceType)); - - if (source == null) - { - return false; - } + return IsOrImplementsInterface(source, typeof(TInterface)); + } - return AreTypesEqual(interfaceType, source, interfaceType.IsGenericType) || - source.GetInterfaces().Any(type => AreTypesEqual(interfaceType, type, interfaceType.IsGenericType)); - } + /// + /// Whether the specified source type implements or equals the specified interface. This overload enables to test for an open generic interface. + /// + private static bool IsOrImplementsInterface(this Type? source, Type interfaceType) + { + ArgumentGuard.NotNull(interfaceType, nameof(interfaceType)); - private static bool AreTypesEqual(Type left, Type right, bool isLeftGeneric) + if (source == null) { - return isLeftGeneric ? right.IsGenericType && right.GetGenericTypeDefinition() == left : left == right; + return false; } - /// - /// Gets the name of a type, including the names of its generic type parameters. - /// - /// > - /// ]]> - /// - /// - public static string GetFriendlyTypeName(this Type type) - { - ArgumentGuard.NotNull(type, nameof(type)); + return AreTypesEqual(interfaceType, source, interfaceType.IsGenericType) || + source.GetInterfaces().Any(type => AreTypesEqual(interfaceType, type, interfaceType.IsGenericType)); + } - // Based on https://stackoverflow.com/questions/2581642/how-do-i-get-the-type-name-of-a-generic-type-argument. + private static bool AreTypesEqual(Type left, Type right, bool isLeftGeneric) + { + return isLeftGeneric ? right.IsGenericType && right.GetGenericTypeDefinition() == left : left == right; + } - if (type.IsGenericType) - { - string genericArguments = type.GetGenericArguments().Select(GetFriendlyTypeName) - .Aggregate((firstType, secondType) => $"{firstType}, {secondType}"); + /// + /// Gets the name of a type, including the names of its generic type arguments. + /// + /// > + /// ]]> + /// + /// + public static string GetFriendlyTypeName(this Type type) + { + ArgumentGuard.NotNull(type, nameof(type)); - return $"{type.Name[..type.Name.IndexOf("`", StringComparison.Ordinal)]}<{genericArguments}>"; - } + // Based on https://stackoverflow.com/questions/2581642/how-do-i-get-the-type-name-of-a-generic-type-argument. - return type.Name; + if (type.IsGenericType) + { + string typeArguments = type.GetGenericArguments().Select(GetFriendlyTypeName).Aggregate((firstType, secondType) => $"{firstType}, {secondType}"); + return $"{type.Name[..type.Name.IndexOf("`", StringComparison.Ordinal)]}<{typeArguments}>"; } + + return type.Name; } } diff --git a/test/DiscoveryTests/DiscoveryTests.csproj b/test/DiscoveryTests/DiscoveryTests.csproj index d9c503c784..2f1048de3f 100644 --- a/test/DiscoveryTests/DiscoveryTests.csproj +++ b/test/DiscoveryTests/DiscoveryTests.csproj @@ -1,6 +1,6 @@ - $(NetCoreAppVersion) + $(TargetFrameworkName) @@ -19,4 +19,4 @@ - \ No newline at end of file + diff --git a/test/DiscoveryTests/PrivateResource.cs b/test/DiscoveryTests/PrivateResource.cs index 065c63afbd..ed6dc23204 100644 --- a/test/DiscoveryTests/PrivateResource.cs +++ b/test/DiscoveryTests/PrivateResource.cs @@ -1,10 +1,9 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Resources; -namespace DiscoveryTests +namespace DiscoveryTests; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class PrivateResource : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class PrivateResource : Identifiable - { - } } diff --git a/test/DiscoveryTests/PrivateResourceDefinition.cs b/test/DiscoveryTests/PrivateResourceDefinition.cs index b3a33f556c..25ff719718 100644 --- a/test/DiscoveryTests/PrivateResourceDefinition.cs +++ b/test/DiscoveryTests/PrivateResourceDefinition.cs @@ -2,14 +2,13 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; -namespace DiscoveryTests +namespace DiscoveryTests; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class PrivateResourceDefinition : JsonApiResourceDefinition { - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class PrivateResourceDefinition : JsonApiResourceDefinition + public PrivateResourceDefinition(IResourceGraph resourceGraph) + : base(resourceGraph) { - public PrivateResourceDefinition(IResourceGraph resourceGraph) - : base(resourceGraph) - { - } } } diff --git a/test/DiscoveryTests/PrivateResourceRepository.cs b/test/DiscoveryTests/PrivateResourceRepository.cs index 1d5b4a4a4e..cb654ea724 100644 --- a/test/DiscoveryTests/PrivateResourceRepository.cs +++ b/test/DiscoveryTests/PrivateResourceRepository.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries; @@ -6,16 +5,15 @@ using JsonApiDotNetCore.Resources; using Microsoft.Extensions.Logging; -namespace DiscoveryTests +namespace DiscoveryTests; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class PrivateResourceRepository : EntityFrameworkCoreRepository { - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class PrivateResourceRepository : EntityFrameworkCoreRepository + public PrivateResourceRepository(ITargetedFields targetedFields, IDbContextResolver dbContextResolver, IResourceGraph resourceGraph, + IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory, + IResourceDefinitionAccessor resourceDefinitionAccessor) + : base(targetedFields, dbContextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor) { - public PrivateResourceRepository(ITargetedFields targetedFields, IDbContextResolver dbContextResolver, IResourceGraph resourceGraph, - IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory, - IResourceDefinitionAccessor resourceDefinitionAccessor) - : base(targetedFields, dbContextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor) - { - } } } diff --git a/test/DiscoveryTests/PrivateResourceService.cs b/test/DiscoveryTests/PrivateResourceService.cs index 47df356881..6d289eafb1 100644 --- a/test/DiscoveryTests/PrivateResourceService.cs +++ b/test/DiscoveryTests/PrivateResourceService.cs @@ -7,17 +7,15 @@ using JsonApiDotNetCore.Services; using Microsoft.Extensions.Logging; -namespace DiscoveryTests +namespace DiscoveryTests; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class PrivateResourceService : JsonApiResourceService { - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class PrivateResourceService : JsonApiResourceService + public PrivateResourceService(IResourceRepositoryAccessor repositoryAccessor, IQueryLayerComposer queryLayerComposer, IPaginationContext paginationContext, + IJsonApiOptions options, ILoggerFactory loggerFactory, IJsonApiRequest request, IResourceChangeTracker resourceChangeTracker, + IResourceDefinitionAccessor resourceDefinitionAccessor) + : base(repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, resourceChangeTracker, resourceDefinitionAccessor) { - public PrivateResourceService(IResourceRepositoryAccessor repositoryAccessor, IQueryLayerComposer queryLayerComposer, - IPaginationContext paginationContext, IJsonApiOptions options, ILoggerFactory loggerFactory, IJsonApiRequest request, - IResourceChangeTracker resourceChangeTracker, IResourceDefinitionAccessor resourceDefinitionAccessor) - : base(repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, resourceChangeTracker, - resourceDefinitionAccessor) - { - } } } diff --git a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs index 668cf7d66f..3396aed54b 100644 --- a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs +++ b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs @@ -14,123 +14,122 @@ using TestBuildingBlocks; using Xunit; -namespace DiscoveryTests +namespace DiscoveryTests; + +public sealed class ServiceDiscoveryFacadeTests { - public sealed class ServiceDiscoveryFacadeTests + private static readonly ILoggerFactory LoggerFactory = NullLoggerFactory.Instance; + private readonly IServiceCollection _services = new ServiceCollection(); + private readonly ResourceGraphBuilder _resourceGraphBuilder; + + public ServiceDiscoveryFacadeTests() + { + var dbResolverMock = new Mock(); + dbResolverMock.Setup(resolver => resolver.GetContext()).Returns(new Mock().Object); + _services.AddScoped(_ => dbResolverMock.Object); + + IJsonApiOptions options = new JsonApiOptions(); + + _services.AddSingleton(options); + _services.AddSingleton(LoggerFactory); + _services.AddScoped(_ => new Mock().Object); + _services.AddScoped(_ => new Mock().Object); + _services.AddScoped(_ => new Mock().Object); + _services.AddScoped(typeof(IResourceChangeTracker<>), typeof(ResourceChangeTracker<>)); + _services.AddScoped(_ => new Mock().Object); + _services.AddScoped(_ => new Mock().Object); + _services.AddScoped(_ => new Mock().Object); + _services.AddScoped(_ => new Mock().Object); + _services.AddScoped(_ => new Mock().Object); + + _resourceGraphBuilder = new ResourceGraphBuilder(options, LoggerFactory); + } + + [Fact] + public void Can_add_resources_from_assembly_to_graph() { - private static readonly ILoggerFactory LoggerFactory = NullLoggerFactory.Instance; - private readonly IServiceCollection _services = new ServiceCollection(); - private readonly ResourceGraphBuilder _resourceGraphBuilder; - - public ServiceDiscoveryFacadeTests() - { - var dbResolverMock = new Mock(); - dbResolverMock.Setup(resolver => resolver.GetContext()).Returns(new Mock().Object); - _services.AddScoped(_ => dbResolverMock.Object); - - IJsonApiOptions options = new JsonApiOptions(); - - _services.AddSingleton(options); - _services.AddSingleton(LoggerFactory); - _services.AddScoped(_ => new Mock().Object); - _services.AddScoped(_ => new Mock().Object); - _services.AddScoped(_ => new Mock().Object); - _services.AddScoped(typeof(IResourceChangeTracker<>), typeof(ResourceChangeTracker<>)); - _services.AddScoped(_ => new Mock().Object); - _services.AddScoped(_ => new Mock().Object); - _services.AddScoped(_ => new Mock().Object); - _services.AddScoped(_ => new Mock().Object); - _services.AddScoped(_ => new Mock().Object); - - _resourceGraphBuilder = new ResourceGraphBuilder(options, LoggerFactory); - } - - [Fact] - public void Can_add_resources_from_assembly_to_graph() - { - // Arrange - var facade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, LoggerFactory); - facade.AddAssembly(typeof(Person).Assembly); - - // Act - facade.DiscoverResources(); - - // Assert - IResourceGraph resourceGraph = _resourceGraphBuilder.Build(); - - ResourceType? personType = resourceGraph.FindResourceType(typeof(Person)); - personType.ShouldNotBeNull(); - - ResourceType? todoItemType = resourceGraph.FindResourceType(typeof(TodoItem)); - todoItemType.ShouldNotBeNull(); - } - - [Fact] - public void Can_add_resource_from_current_assembly_to_graph() - { - // Arrange - var facade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, LoggerFactory); - facade.AddCurrentAssembly(); - - // Act - facade.DiscoverResources(); - - // Assert - IResourceGraph resourceGraph = _resourceGraphBuilder.Build(); - - ResourceType? testResourceType = resourceGraph.FindResourceType(typeof(PrivateResource)); - testResourceType.ShouldNotBeNull(); - } - - [Fact] - public void Can_add_resource_service_from_current_assembly_to_container() - { - // Arrange - var facade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, LoggerFactory); - facade.AddCurrentAssembly(); - - // Act - facade.DiscoverInjectables(); - - // Assert - ServiceProvider services = _services.BuildServiceProvider(); - - var resourceService = services.GetRequiredService>(); - resourceService.Should().BeOfType(); - } - - [Fact] - public void Can_add_resource_repository_from_current_assembly_to_container() - { - // Arrange - var facade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, LoggerFactory); - facade.AddCurrentAssembly(); - - // Act - facade.DiscoverInjectables(); - - // Assert - ServiceProvider services = _services.BuildServiceProvider(); - - var resourceRepository = services.GetRequiredService>(); - resourceRepository.Should().BeOfType(); - } - - [Fact] - public void Can_add_resource_definition_from_current_assembly_to_container() - { - // Arrange - var facade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, LoggerFactory); - facade.AddCurrentAssembly(); - - // Act - facade.DiscoverInjectables(); - - // Assert - ServiceProvider services = _services.BuildServiceProvider(); - - var resourceDefinition = services.GetRequiredService>(); - resourceDefinition.Should().BeOfType(); - } + // Arrange + var facade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, LoggerFactory); + facade.AddAssembly(typeof(Person).Assembly); + + // Act + facade.DiscoverResources(); + + // Assert + IResourceGraph resourceGraph = _resourceGraphBuilder.Build(); + + ResourceType? personType = resourceGraph.FindResourceType(typeof(Person)); + personType.ShouldNotBeNull(); + + ResourceType? todoItemType = resourceGraph.FindResourceType(typeof(TodoItem)); + todoItemType.ShouldNotBeNull(); + } + + [Fact] + public void Can_add_resource_from_current_assembly_to_graph() + { + // Arrange + var facade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, LoggerFactory); + facade.AddCurrentAssembly(); + + // Act + facade.DiscoverResources(); + + // Assert + IResourceGraph resourceGraph = _resourceGraphBuilder.Build(); + + ResourceType? testResourceType = resourceGraph.FindResourceType(typeof(PrivateResource)); + testResourceType.ShouldNotBeNull(); + } + + [Fact] + public void Can_add_resource_service_from_current_assembly_to_container() + { + // Arrange + var facade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, LoggerFactory); + facade.AddCurrentAssembly(); + + // Act + facade.DiscoverInjectables(); + + // Assert + ServiceProvider services = _services.BuildServiceProvider(); + + var resourceService = services.GetRequiredService>(); + resourceService.Should().BeOfType(); + } + + [Fact] + public void Can_add_resource_repository_from_current_assembly_to_container() + { + // Arrange + var facade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, LoggerFactory); + facade.AddCurrentAssembly(); + + // Act + facade.DiscoverInjectables(); + + // Assert + ServiceProvider services = _services.BuildServiceProvider(); + + var resourceRepository = services.GetRequiredService>(); + resourceRepository.Should().BeOfType(); + } + + [Fact] + public void Can_add_resource_definition_from_current_assembly_to_container() + { + // Arrange + var facade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, LoggerFactory); + facade.AddCurrentAssembly(); + + // Act + facade.DiscoverInjectables(); + + // Assert + ServiceProvider services = _services.BuildServiceProvider(); + + var resourceDefinition = services.GetRequiredService>(); + resourceDefinition.Should().BeOfType(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/ArchiveTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/ArchiveTests.cs index 039cd0840e..5053d17e09 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/ArchiveTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/ArchiveTests.cs @@ -1,681 +1,663 @@ -using System; -using System.Collections.Generic; -using System.Linq; using System.Net; -using System.Net.Http; -using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.Archiving +namespace JsonApiDotNetCoreTests.IntegrationTests.Archiving; + +public sealed class ArchiveTests : IClassFixture, TelevisionDbContext>> { - public sealed class ArchiveTests : IClassFixture, TelevisionDbContext>> + private readonly IntegrationTestContext, TelevisionDbContext> _testContext; + private readonly TelevisionFakers _fakers = new(); + + public ArchiveTests(IntegrationTestContext, TelevisionDbContext> testContext) { - private readonly IntegrationTestContext, TelevisionDbContext> _testContext; - private readonly TelevisionFakers _fakers = new(); + _testContext = testContext; - public ArchiveTests(IntegrationTestContext, TelevisionDbContext> testContext) - { - _testContext = testContext; + testContext.UseController(); + testContext.UseController(); + testContext.UseController(); + testContext.UseController(); - testContext.UseController(); - testContext.UseController(); - testContext.UseController(); - testContext.UseController(); + testContext.ConfigureServicesAfterStartup(services => + { + services.AddResourceDefinition(); + }); + } - testContext.ConfigureServicesAfterStartup(services => - { - services.AddResourceDefinition(); - }); - } + [Fact] + public async Task Can_get_archived_resource_by_ID() + { + // Arrange + TelevisionBroadcast broadcast = _fakers.TelevisionBroadcast.Generate(); - [Fact] - public async Task Can_get_archived_resource_by_ID() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - TelevisionBroadcast broadcast = _fakers.TelevisionBroadcast.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Broadcasts.Add(broadcast); - await dbContext.SaveChangesAsync(); - }); + dbContext.Broadcasts.Add(broadcast); + await dbContext.SaveChangesAsync(); + }); - string route = $"/televisionBroadcasts/{broadcast.StringId}"; + string route = $"/televisionBroadcasts/{broadcast.StringId}"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Id.Should().Be(broadcast.StringId); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Id.Should().Be(broadcast.StringId); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("archivedAt").With(value => value.Should().Be(broadcast.ArchivedAt)); + } - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("archivedAt") - .With(value => value.As().Should().BeCloseTo(broadcast.ArchivedAt!.Value)); - } + [Fact] + public async Task Can_get_unarchived_resource_by_ID() + { + // Arrange + TelevisionBroadcast broadcast = _fakers.TelevisionBroadcast.Generate(); + broadcast.ArchivedAt = null; - [Fact] - public async Task Can_get_unarchived_resource_by_ID() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - TelevisionBroadcast broadcast = _fakers.TelevisionBroadcast.Generate(); - broadcast.ArchivedAt = null; + dbContext.Broadcasts.Add(broadcast); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Broadcasts.Add(broadcast); - await dbContext.SaveChangesAsync(); - }); + string route = $"/televisionBroadcasts/{broadcast.StringId}"; - string route = $"/televisionBroadcasts/{broadcast.StringId}"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Id.Should().Be(broadcast.StringId); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("archivedAt").With(value => value.Should().BeNull()); + } - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Id.Should().Be(broadcast.StringId); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("archivedAt").With(value => value.As().Should().BeNull()); - } + [Fact] + public async Task Get_primary_resources_excludes_archived() + { + // Arrange + List broadcasts = _fakers.TelevisionBroadcast.Generate(2); + broadcasts[1].ArchivedAt = null; - [Fact] - public async Task Get_primary_resources_excludes_archived() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - List broadcasts = _fakers.TelevisionBroadcast.Generate(2); - broadcasts[1].ArchivedAt = null; + await dbContext.ClearTableAsync(); + dbContext.Broadcasts.AddRange(broadcasts); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Broadcasts.AddRange(broadcasts); - await dbContext.SaveChangesAsync(); - }); + const string route = "/televisionBroadcasts"; - const string route = "/televisionBroadcasts"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be(broadcasts[1].StringId); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("archivedAt").With(value => value.Should().BeNull()); + } - responseDocument.Data.ManyValue.ShouldHaveCount(1); - responseDocument.Data.ManyValue[0].Id.Should().Be(broadcasts[1].StringId); - responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("archivedAt").With(value => value.As().Should().BeNull()); - } + [Fact] + public async Task Get_primary_resources_with_filter_includes_archived() + { + // Arrange + List broadcasts = _fakers.TelevisionBroadcast.Generate(2); + broadcasts[1].ArchivedAt = null; - [Fact] - public async Task Get_primary_resources_with_filter_includes_archived() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - List broadcasts = _fakers.TelevisionBroadcast.Generate(2); - broadcasts[1].ArchivedAt = null; + await dbContext.ClearTableAsync(); + dbContext.Broadcasts.AddRange(broadcasts); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Broadcasts.AddRange(broadcasts); - await dbContext.SaveChangesAsync(); - }); - - const string route = "/televisionBroadcasts?filter=or(equals(archivedAt,null),not(equals(archivedAt,null)))"; + const string route = "/televisionBroadcasts?filter=or(equals(archivedAt,null),not(equals(archivedAt,null)))"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(2); - responseDocument.Data.ManyValue[0].Id.Should().Be(broadcasts[0].StringId); + responseDocument.Data.ManyValue.ShouldHaveCount(2); + responseDocument.Data.ManyValue[0].Id.Should().Be(broadcasts[0].StringId); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("archivedAt").With(value => value.Should().Be(broadcasts[0].ArchivedAt)); - responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("archivedAt") - .With(value => value.As().Should().BeCloseTo(broadcasts[0].ArchivedAt!.Value)); + responseDocument.Data.ManyValue[1].Id.Should().Be(broadcasts[1].StringId); + responseDocument.Data.ManyValue[1].Attributes.ShouldContainKey("archivedAt").With(value => value.Should().BeNull()); + } - responseDocument.Data.ManyValue[1].Id.Should().Be(broadcasts[1].StringId); - responseDocument.Data.ManyValue[1].Attributes.ShouldContainKey("archivedAt").With(value => value.Should().BeNull()); - } + [Fact] + public async Task Get_primary_resource_by_ID_with_include_excludes_archived() + { + // Arrange + TelevisionStation station = _fakers.TelevisionStation.Generate(); + station.Broadcasts = _fakers.TelevisionBroadcast.Generate(2).ToHashSet(); + station.Broadcasts.ElementAt(1).ArchivedAt = null; - [Fact] - public async Task Get_primary_resource_by_ID_with_include_excludes_archived() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - TelevisionStation station = _fakers.TelevisionStation.Generate(); - station.Broadcasts = _fakers.TelevisionBroadcast.Generate(2).ToHashSet(); - station.Broadcasts.ElementAt(1).ArchivedAt = null; + dbContext.Stations.Add(station); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Stations.Add(station); - await dbContext.SaveChangesAsync(); - }); + string route = $"/televisionStations/{station.StringId}?include=broadcasts"; - string route = $"/televisionStations/{station.StringId}?include=broadcasts"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Id.Should().Be(station.StringId); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Id.Should().Be(station.StringId); + responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included[0].Id.Should().Be(station.Broadcasts.ElementAt(1).StringId); + responseDocument.Included[0].Attributes.ShouldContainKey("archivedAt").With(value => value.Should().BeNull()); + } - responseDocument.Included.ShouldHaveCount(1); - responseDocument.Included[0].Id.Should().Be(station.Broadcasts.ElementAt(1).StringId); - responseDocument.Included[0].Attributes.ShouldContainKey("archivedAt").With(value => value.Should().BeNull()); - } + [Fact] + public async Task Get_primary_resource_by_ID_with_include_and_filter_includes_archived() + { + // Arrange + TelevisionStation station = _fakers.TelevisionStation.Generate(); + station.Broadcasts = _fakers.TelevisionBroadcast.Generate(2).ToHashSet(); + station.Broadcasts.ElementAt(1).ArchivedAt = null; - [Fact] - public async Task Get_primary_resource_by_ID_with_include_and_filter_includes_archived() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - TelevisionStation station = _fakers.TelevisionStation.Generate(); - station.Broadcasts = _fakers.TelevisionBroadcast.Generate(2).ToHashSet(); - station.Broadcasts.ElementAt(1).ArchivedAt = null; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Stations.Add(station); - await dbContext.SaveChangesAsync(); - }); + dbContext.Stations.Add(station); + await dbContext.SaveChangesAsync(); + }); - string route = - $"/televisionStations/{station.StringId}?include=broadcasts&filter[broadcasts]=or(equals(archivedAt,null),not(equals(archivedAt,null)))"; + string route = $"/televisionStations/{station.StringId}?include=broadcasts&filter[broadcasts]=or(equals(archivedAt,null),not(equals(archivedAt,null)))"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - DateTimeOffset archivedAt0 = station.Broadcasts.ElementAt(0).ArchivedAt!.Value; + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Id.Should().Be(station.StringId); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Id.Should().Be(station.StringId); + responseDocument.Included.ShouldHaveCount(2); + responseDocument.Included[0].Id.Should().Be(station.Broadcasts.ElementAt(0).StringId); + responseDocument.Included[0].Attributes.ShouldContainKey("archivedAt").With(value => value.Should().Be(station.Broadcasts.ElementAt(0).ArchivedAt)); + responseDocument.Included[1].Id.Should().Be(station.Broadcasts.ElementAt(1).StringId); + responseDocument.Included[1].Attributes.ShouldContainKey("archivedAt").With(value => value.Should().BeNull()); + } - responseDocument.Included.ShouldHaveCount(2); - responseDocument.Included[0].Id.Should().Be(station.Broadcasts.ElementAt(0).StringId); - responseDocument.Included[0].Attributes.ShouldContainKey("archivedAt").With(value => value.As().Should().BeCloseTo(archivedAt0)); - responseDocument.Included[1].Id.Should().Be(station.Broadcasts.ElementAt(1).StringId); - responseDocument.Included[1].Attributes.ShouldContainKey("archivedAt").With(value => value.Should().BeNull()); - } + [Fact] + public async Task Get_secondary_resource_includes_archived() + { + // Arrange + BroadcastComment comment = _fakers.BroadcastComment.Generate(); + comment.AppliesTo = _fakers.TelevisionBroadcast.Generate(); - [Fact] - public async Task Get_secondary_resource_includes_archived() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - BroadcastComment comment = _fakers.BroadcastComment.Generate(); - comment.AppliesTo = _fakers.TelevisionBroadcast.Generate(); + dbContext.Comments.Add(comment); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Comments.Add(comment); - await dbContext.SaveChangesAsync(); - }); - - string route = $"/broadcastComments/{comment.StringId}/appliesTo"; + string route = $"/broadcastComments/{comment.StringId}/appliesTo"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Id.Should().Be(comment.AppliesTo.StringId); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Id.Should().Be(comment.AppliesTo.StringId); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("archivedAt").With(value => value.Should().Be(comment.AppliesTo.ArchivedAt)); + } - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("archivedAt") - .With(value => value.As().Should().BeCloseTo(comment.AppliesTo.ArchivedAt!.Value)); - } + [Fact] + public async Task Get_secondary_resources_excludes_archived() + { + // Arrange + TelevisionStation station = _fakers.TelevisionStation.Generate(); + station.Broadcasts = _fakers.TelevisionBroadcast.Generate(2).ToHashSet(); + station.Broadcasts.ElementAt(1).ArchivedAt = null; - [Fact] - public async Task Get_secondary_resources_excludes_archived() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - TelevisionStation station = _fakers.TelevisionStation.Generate(); - station.Broadcasts = _fakers.TelevisionBroadcast.Generate(2).ToHashSet(); - station.Broadcasts.ElementAt(1).ArchivedAt = null; + dbContext.Stations.Add(station); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Stations.Add(station); - await dbContext.SaveChangesAsync(); - }); + string route = $"/televisionStations/{station.StringId}/broadcasts"; - string route = $"/televisionStations/{station.StringId}/broadcasts"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be(station.Broadcasts.ElementAt(1).StringId); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("archivedAt").With(value => value.Should().BeNull()); + } - responseDocument.Data.ManyValue.ShouldHaveCount(1); - responseDocument.Data.ManyValue[0].Id.Should().Be(station.Broadcasts.ElementAt(1).StringId); - responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("archivedAt").With(value => value.Should().BeNull()); - } + [Fact] + public async Task Get_secondary_resources_with_filter_includes_archived() + { + // Arrange + TelevisionStation station = _fakers.TelevisionStation.Generate(); + station.Broadcasts = _fakers.TelevisionBroadcast.Generate(2).ToHashSet(); + station.Broadcasts.ElementAt(1).ArchivedAt = null; - [Fact] - public async Task Get_secondary_resources_with_filter_includes_archived() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - TelevisionStation station = _fakers.TelevisionStation.Generate(); - station.Broadcasts = _fakers.TelevisionBroadcast.Generate(2).ToHashSet(); - station.Broadcasts.ElementAt(1).ArchivedAt = null; + dbContext.Stations.Add(station); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Stations.Add(station); - await dbContext.SaveChangesAsync(); - }); + string route = $"/televisionStations/{station.StringId}/broadcasts?filter=or(equals(archivedAt,null),not(equals(archivedAt,null)))"; - string route = $"/televisionStations/{station.StringId}/broadcasts?filter=or(equals(archivedAt,null),not(equals(archivedAt,null)))"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + DateTimeOffset archivedAt0 = station.Broadcasts.ElementAt(0).ArchivedAt!.Value; - responseDocument.Data.ManyValue.ShouldHaveCount(2); - responseDocument.Data.ManyValue[0].Id.Should().Be(station.Broadcasts.ElementAt(0).StringId); + responseDocument.Data.ManyValue.ShouldHaveCount(2); + responseDocument.Data.ManyValue[0].Id.Should().Be(station.Broadcasts.ElementAt(0).StringId); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("archivedAt").With(value => value.Should().Be(archivedAt0)); - responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("archivedAt").With(value => - value.As().Should().BeCloseTo(station.Broadcasts.ElementAt(0).ArchivedAt!.Value)); + responseDocument.Data.ManyValue[1].Id.Should().Be(station.Broadcasts.ElementAt(1).StringId); + responseDocument.Data.ManyValue[1].Attributes.ShouldContainKey("archivedAt").With(value => value.Should().BeNull()); + } - responseDocument.Data.ManyValue[1].Id.Should().Be(station.Broadcasts.ElementAt(1).StringId); - responseDocument.Data.ManyValue[1].Attributes.ShouldContainKey("archivedAt").With(value => value.Should().BeNull()); - } + [Fact] + public async Task Get_secondary_resource_by_ID_with_include_excludes_archived() + { + // Arrange + TelevisionNetwork network = _fakers.TelevisionNetwork.Generate(); + network.Stations = _fakers.TelevisionStation.Generate(1).ToHashSet(); + network.Stations.ElementAt(0).Broadcasts = _fakers.TelevisionBroadcast.Generate(2).ToHashSet(); + network.Stations.ElementAt(0).Broadcasts.ElementAt(1).ArchivedAt = null; - [Fact] - public async Task Get_secondary_resource_by_ID_with_include_excludes_archived() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - TelevisionNetwork network = _fakers.TelevisionNetwork.Generate(); - network.Stations = _fakers.TelevisionStation.Generate(1).ToHashSet(); - network.Stations.ElementAt(0).Broadcasts = _fakers.TelevisionBroadcast.Generate(2).ToHashSet(); - network.Stations.ElementAt(0).Broadcasts.ElementAt(1).ArchivedAt = null; + dbContext.Networks.Add(network); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Networks.Add(network); - await dbContext.SaveChangesAsync(); - }); + string route = $"/televisionNetworks/{network.StringId}/stations?include=broadcasts"; - string route = $"/televisionNetworks/{network.StringId}/stations?include=broadcasts"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be(network.Stations.ElementAt(0).StringId); - responseDocument.Data.ManyValue.ShouldHaveCount(1); - responseDocument.Data.ManyValue[0].Id.Should().Be(network.Stations.ElementAt(0).StringId); + responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included[0].Id.Should().Be(network.Stations.ElementAt(0).Broadcasts.ElementAt(1).StringId); + responseDocument.Included[0].Attributes.ShouldContainKey("archivedAt").With(value => value.Should().BeNull()); + } - responseDocument.Included.ShouldHaveCount(1); - responseDocument.Included[0].Id.Should().Be(network.Stations.ElementAt(0).Broadcasts.ElementAt(1).StringId); - responseDocument.Included[0].Attributes.ShouldContainKey("archivedAt").With(value => value.Should().BeNull()); - } + [Fact] + public async Task Get_secondary_resource_by_ID_with_include_and_filter_includes_archived() + { + TelevisionNetwork network = _fakers.TelevisionNetwork.Generate(); + network.Stations = _fakers.TelevisionStation.Generate(1).ToHashSet(); + network.Stations.ElementAt(0).Broadcasts = _fakers.TelevisionBroadcast.Generate(2).ToHashSet(); + network.Stations.ElementAt(0).Broadcasts.ElementAt(1).ArchivedAt = null; - [Fact] - public async Task Get_secondary_resource_by_ID_with_include_and_filter_includes_archived() + await _testContext.RunOnDatabaseAsync(async dbContext => { - TelevisionNetwork network = _fakers.TelevisionNetwork.Generate(); - network.Stations = _fakers.TelevisionStation.Generate(1).ToHashSet(); - network.Stations.ElementAt(0).Broadcasts = _fakers.TelevisionBroadcast.Generate(2).ToHashSet(); - network.Stations.ElementAt(0).Broadcasts.ElementAt(1).ArchivedAt = null; + dbContext.Networks.Add(network); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Networks.Add(network); - await dbContext.SaveChangesAsync(); - }); + string route = + $"/televisionNetworks/{network.StringId}/stations?include=broadcasts&filter[broadcasts]=or(equals(archivedAt,null),not(equals(archivedAt,null)))"; - string route = - $"/televisionNetworks/{network.StringId}/stations?include=broadcasts&filter[broadcasts]=or(equals(archivedAt,null),not(equals(archivedAt,null)))"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + DateTimeOffset archivedAt0 = network.Stations.ElementAt(0).Broadcasts.ElementAt(0).ArchivedAt!.Value; - DateTimeOffset archivedAt0 = network.Stations.ElementAt(0).Broadcasts.ElementAt(0).ArchivedAt!.Value; + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be(network.Stations.ElementAt(0).StringId); - responseDocument.Data.ManyValue.ShouldHaveCount(1); - responseDocument.Data.ManyValue[0].Id.Should().Be(network.Stations.ElementAt(0).StringId); + responseDocument.Included.ShouldHaveCount(2); + responseDocument.Included[0].Id.Should().Be(network.Stations.ElementAt(0).Broadcasts.ElementAt(0).StringId); + responseDocument.Included[0].Attributes.ShouldContainKey("archivedAt").With(value => value.Should().Be(archivedAt0)); + responseDocument.Included[1].Id.Should().Be(network.Stations.ElementAt(0).Broadcasts.ElementAt(1).StringId); + responseDocument.Included[1].Attributes.ShouldContainKey("archivedAt").With(value => value.Should().BeNull()); + } - responseDocument.Included.ShouldHaveCount(2); - responseDocument.Included[0].Id.Should().Be(network.Stations.ElementAt(0).Broadcasts.ElementAt(0).StringId); - responseDocument.Included[0].Attributes.ShouldContainKey("archivedAt").With(value => value.As().Should().BeCloseTo(archivedAt0)); - responseDocument.Included[1].Id.Should().Be(network.Stations.ElementAt(0).Broadcasts.ElementAt(1).StringId); - responseDocument.Included[1].Attributes.ShouldContainKey("archivedAt").With(value => value.Should().BeNull()); - } + [Fact] + public async Task Get_ToMany_relationship_excludes_archived() + { + // Arrange + TelevisionStation station = _fakers.TelevisionStation.Generate(); + station.Broadcasts = _fakers.TelevisionBroadcast.Generate(2).ToHashSet(); + station.Broadcasts.ElementAt(1).ArchivedAt = null; - [Fact] - public async Task Get_ToMany_relationship_excludes_archived() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - TelevisionStation station = _fakers.TelevisionStation.Generate(); - station.Broadcasts = _fakers.TelevisionBroadcast.Generate(2).ToHashSet(); - station.Broadcasts.ElementAt(1).ArchivedAt = null; + dbContext.Stations.Add(station); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Stations.Add(station); - await dbContext.SaveChangesAsync(); - }); + string route = $"/televisionStations/{station.StringId}/relationships/broadcasts"; - string route = $"/televisionStations/{station.StringId}/relationships/broadcasts"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be(station.Broadcasts.ElementAt(1).StringId); + } - responseDocument.Data.ManyValue.ShouldHaveCount(1); - responseDocument.Data.ManyValue[0].Id.Should().Be(station.Broadcasts.ElementAt(1).StringId); - } + [Fact] + public async Task Get_ToMany_relationship_with_filter_includes_archived() + { + // Arrange + TelevisionStation station = _fakers.TelevisionStation.Generate(); + station.Broadcasts = _fakers.TelevisionBroadcast.Generate(2).ToHashSet(); + station.Broadcasts.ElementAt(1).ArchivedAt = null; - [Fact] - public async Task Get_ToMany_relationship_with_filter_includes_archived() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - TelevisionStation station = _fakers.TelevisionStation.Generate(); - station.Broadcasts = _fakers.TelevisionBroadcast.Generate(2).ToHashSet(); - station.Broadcasts.ElementAt(1).ArchivedAt = null; + dbContext.Stations.Add(station); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Stations.Add(station); - await dbContext.SaveChangesAsync(); - }); + string route = $"/televisionStations/{station.StringId}/relationships/broadcasts?filter=or(equals(archivedAt,null),not(equals(archivedAt,null)))"; - string route = $"/televisionStations/{station.StringId}/relationships/broadcasts?filter=or(equals(archivedAt,null),not(equals(archivedAt,null)))"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.ManyValue.ShouldHaveCount(2); + responseDocument.Data.ManyValue[0].Id.Should().Be(station.Broadcasts.ElementAt(0).StringId); + responseDocument.Data.ManyValue[1].Id.Should().Be(station.Broadcasts.ElementAt(1).StringId); + } - responseDocument.Data.ManyValue.ShouldHaveCount(2); - responseDocument.Data.ManyValue[0].Id.Should().Be(station.Broadcasts.ElementAt(0).StringId); - responseDocument.Data.ManyValue[1].Id.Should().Be(station.Broadcasts.ElementAt(1).StringId); - } + [Fact] + public async Task Can_create_unarchived_resource() + { + // Arrange + TelevisionBroadcast newBroadcast = _fakers.TelevisionBroadcast.Generate(); - [Fact] - public async Task Can_create_unarchived_resource() + var requestBody = new { - // Arrange - TelevisionBroadcast newBroadcast = _fakers.TelevisionBroadcast.Generate(); - - var requestBody = new + data = new { - data = new + type = "televisionBroadcasts", + attributes = new { - type = "televisionBroadcasts", - attributes = new - { - title = newBroadcast.Title, - airedAt = newBroadcast.AiredAt - } + title = newBroadcast.Title, + airedAt = newBroadcast.AiredAt } - }; - - const string route = "/televisionBroadcasts"; + } + }; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + const string route = "/televisionBroadcasts"; - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newBroadcast.Title)); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("airedAt") - .With(value => value.As().Should().BeCloseTo(newBroadcast.AiredAt)); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newBroadcast.Title)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("airedAt").With(value => value.Should().Be(newBroadcast.AiredAt)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("archivedAt").With(value => value.Should().BeNull()); + } - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("archivedAt").With(value => value.Should().BeNull()); - } + [Fact] + public async Task Cannot_create_archived_resource() + { + // Arrange + TelevisionBroadcast newBroadcast = _fakers.TelevisionBroadcast.Generate(); - [Fact] - public async Task Cannot_create_archived_resource() + var requestBody = new { - // Arrange - TelevisionBroadcast newBroadcast = _fakers.TelevisionBroadcast.Generate(); - - var requestBody = new + data = new { - data = new + type = "televisionBroadcasts", + attributes = new { - type = "televisionBroadcasts", - attributes = new - { - title = newBroadcast.Title, - airedAt = newBroadcast.AiredAt, - archivedAt = newBroadcast.ArchivedAt - } + title = newBroadcast.Title, + airedAt = newBroadcast.AiredAt, + archivedAt = newBroadcast.ArchivedAt } - }; + } + }; - const string route = "/televisionBroadcasts"; + const string route = "/televisionBroadcasts"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.Forbidden); - error.Title.Should().Be("Television broadcasts cannot be created in archived state."); - error.Detail.Should().BeNull(); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Forbidden); + error.Title.Should().Be("Television broadcasts cannot be created in archived state."); + error.Detail.Should().BeNull(); + } - [Fact] - public async Task Can_archive_resource() - { - // Arrange - TelevisionBroadcast existingBroadcast = _fakers.TelevisionBroadcast.Generate(); - existingBroadcast.ArchivedAt = null; + [Fact] + public async Task Can_archive_resource() + { + // Arrange + TelevisionBroadcast existingBroadcast = _fakers.TelevisionBroadcast.Generate(); + existingBroadcast.ArchivedAt = null; - DateTimeOffset newArchivedAt = _fakers.TelevisionBroadcast.Generate().ArchivedAt!.Value; + DateTimeOffset newArchivedAt = _fakers.TelevisionBroadcast.Generate().ArchivedAt!.Value; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Broadcasts.Add(existingBroadcast); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Broadcasts.Add(existingBroadcast); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new { - data = new + type = "televisionBroadcasts", + id = existingBroadcast.StringId, + attributes = new { - type = "televisionBroadcasts", - id = existingBroadcast.StringId, - attributes = new - { - archivedAt = newArchivedAt - } + archivedAt = newArchivedAt } - }; + } + }; - string route = $"/televisionBroadcasts/{existingBroadcast.StringId}"; + string route = $"/televisionBroadcasts/{existingBroadcast.StringId}"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - TelevisionBroadcast broadcastInDatabase = await dbContext.Broadcasts.FirstWithIdAsync(existingBroadcast.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + TelevisionBroadcast broadcastInDatabase = await dbContext.Broadcasts.FirstWithIdAsync(existingBroadcast.Id); - broadcastInDatabase.ArchivedAt.Should().BeCloseTo(newArchivedAt); - }); - } + broadcastInDatabase.ArchivedAt.Should().Be(newArchivedAt); + }); + } - [Fact] - public async Task Can_unarchive_resource() - { - // Arrange - TelevisionBroadcast broadcast = _fakers.TelevisionBroadcast.Generate(); + [Fact] + public async Task Can_unarchive_resource() + { + // Arrange + TelevisionBroadcast broadcast = _fakers.TelevisionBroadcast.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Broadcasts.Add(broadcast); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Broadcasts.Add(broadcast); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new { - data = new + type = "televisionBroadcasts", + id = broadcast.StringId, + attributes = new { - type = "televisionBroadcasts", - id = broadcast.StringId, - attributes = new - { - archivedAt = (DateTimeOffset?)null - } + archivedAt = (DateTimeOffset?)null } - }; + } + }; - string route = $"/televisionBroadcasts/{broadcast.StringId}"; + string route = $"/televisionBroadcasts/{broadcast.StringId}"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - TelevisionBroadcast broadcastInDatabase = await dbContext.Broadcasts.FirstWithIdAsync(broadcast.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + TelevisionBroadcast broadcastInDatabase = await dbContext.Broadcasts.FirstWithIdAsync(broadcast.Id); - broadcastInDatabase.ArchivedAt.Should().BeNull(); - }); - } + broadcastInDatabase.ArchivedAt.Should().BeNull(); + }); + } - [Fact] - public async Task Cannot_shift_archive_date() - { - // Arrange - TelevisionBroadcast broadcast = _fakers.TelevisionBroadcast.Generate(); + [Fact] + public async Task Cannot_shift_archive_date() + { + // Arrange + TelevisionBroadcast broadcast = _fakers.TelevisionBroadcast.Generate(); - DateTimeOffset? newArchivedAt = _fakers.TelevisionBroadcast.Generate().ArchivedAt; + DateTimeOffset? newArchivedAt = _fakers.TelevisionBroadcast.Generate().ArchivedAt; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Broadcasts.Add(broadcast); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Broadcasts.Add(broadcast); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new { - data = new + type = "televisionBroadcasts", + id = broadcast.StringId, + attributes = new { - type = "televisionBroadcasts", - id = broadcast.StringId, - attributes = new - { - archivedAt = newArchivedAt - } + archivedAt = newArchivedAt } - }; + } + }; - string route = $"/televisionBroadcasts/{broadcast.StringId}"; + string route = $"/televisionBroadcasts/{broadcast.StringId}"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.Forbidden); - error.Title.Should().Be("Archive date of television broadcasts cannot be shifted. Unarchive it first."); - error.Detail.Should().BeNull(); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Forbidden); + error.Title.Should().Be("Archive date of television broadcasts cannot be shifted. Unarchive it first."); + error.Detail.Should().BeNull(); + } + + [Fact] + public async Task Can_delete_archived_resource() + { + // Arrange + TelevisionBroadcast broadcast = _fakers.TelevisionBroadcast.Generate(); - [Fact] - public async Task Can_delete_archived_resource() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - TelevisionBroadcast broadcast = _fakers.TelevisionBroadcast.Generate(); + dbContext.Broadcasts.Add(broadcast); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Broadcasts.Add(broadcast); - await dbContext.SaveChangesAsync(); - }); + string route = $"/televisionBroadcasts/{broadcast.StringId}"; - string route = $"/televisionBroadcasts/{broadcast.StringId}"; + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + responseDocument.Should().BeEmpty(); - responseDocument.Should().BeEmpty(); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + TelevisionBroadcast? broadcastInDatabase = await dbContext.Broadcasts.FirstWithIdOrDefaultAsync(broadcast.Id); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - TelevisionBroadcast? broadcastInDatabase = await dbContext.Broadcasts.FirstWithIdOrDefaultAsync(broadcast.Id); + broadcastInDatabase.Should().BeNull(); + }); + } - broadcastInDatabase.Should().BeNull(); - }); - } + [Fact] + public async Task Cannot_delete_unarchived_resource() + { + // Arrange + TelevisionBroadcast broadcast = _fakers.TelevisionBroadcast.Generate(); + broadcast.ArchivedAt = null; - [Fact] - public async Task Cannot_delete_unarchived_resource() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - TelevisionBroadcast broadcast = _fakers.TelevisionBroadcast.Generate(); - broadcast.ArchivedAt = null; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Broadcasts.Add(broadcast); - await dbContext.SaveChangesAsync(); - }); + dbContext.Broadcasts.Add(broadcast); + await dbContext.SaveChangesAsync(); + }); - string route = $"/televisionBroadcasts/{broadcast.StringId}"; + string route = $"/televisionBroadcasts/{broadcast.StringId}"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteDeleteAsync(route); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteDeleteAsync(route); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.Forbidden); - error.Title.Should().Be("Television broadcasts must first be archived before they can be deleted."); - error.Detail.Should().BeNull(); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Forbidden); + error.Title.Should().Be("Television broadcasts must first be archived before they can be deleted."); + error.Detail.Should().BeNull(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/BroadcastComment.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/BroadcastComment.cs index 7c101a8ddd..b7ed37341f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/BroadcastComment.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/BroadcastComment.cs @@ -1,21 +1,19 @@ -using System; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.Archiving +namespace JsonApiDotNetCoreTests.IntegrationTests.Archiving; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Archiving")] +public sealed class BroadcastComment : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - [Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Archiving")] - public sealed class BroadcastComment : Identifiable - { - [Attr] - public string Text { get; set; } = null!; + [Attr] + public string Text { get; set; } = null!; - [Attr] - public DateTimeOffset CreatedAt { get; set; } + [Attr] + public DateTimeOffset CreatedAt { get; set; } - [HasOne] - public TelevisionBroadcast AppliesTo { get; set; } = null!; - } + [HasOne] + public TelevisionBroadcast AppliesTo { get; set; } = null!; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionBroadcast.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionBroadcast.cs index a57efcfd03..c6c6dc2a44 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionBroadcast.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionBroadcast.cs @@ -1,28 +1,25 @@ -using System; -using System.Collections.Generic; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.Archiving +namespace JsonApiDotNetCoreTests.IntegrationTests.Archiving; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Archiving")] +public sealed class TelevisionBroadcast : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - [Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Archiving")] - public sealed class TelevisionBroadcast : Identifiable - { - [Attr] - public string Title { get; set; } = null!; + [Attr] + public string Title { get; set; } = null!; - [Attr] - public DateTimeOffset AiredAt { get; set; } + [Attr] + public DateTimeOffset AiredAt { get; set; } - [Attr] - public DateTimeOffset? ArchivedAt { get; set; } + [Attr] + public DateTimeOffset? ArchivedAt { get; set; } - [HasOne] - public TelevisionStation? AiredOn { get; set; } + [HasOne] + public TelevisionStation? AiredOn { get; set; } - [HasMany] - public ISet Comments { get; set; } = new HashSet(); - } + [HasMany] + public ISet Comments { get; set; } = new HashSet(); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionBroadcastDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionBroadcastDefinition.cs index 35df2904e3..4037f59b62 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionBroadcastDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionBroadcastDefinition.cs @@ -1,9 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; using System.Net; -using System.Threading; -using System.Threading.Tasks; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Errors; @@ -15,185 +10,184 @@ using JsonApiDotNetCore.Serialization.Objects; using TestBuildingBlocks; -namespace JsonApiDotNetCoreTests.IntegrationTests.Archiving +namespace JsonApiDotNetCoreTests.IntegrationTests.Archiving; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class TelevisionBroadcastDefinition : JsonApiResourceDefinition { - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class TelevisionBroadcastDefinition : JsonApiResourceDefinition - { - private readonly TelevisionDbContext _dbContext; - private readonly IJsonApiRequest _request; - private readonly IEnumerable _constraintProviders; + private readonly TelevisionDbContext _dbContext; + private readonly IJsonApiRequest _request; + private readonly IEnumerable _constraintProviders; - private DateTimeOffset? _storedArchivedAt; + private DateTimeOffset? _storedArchivedAt; - public TelevisionBroadcastDefinition(IResourceGraph resourceGraph, TelevisionDbContext dbContext, IJsonApiRequest request, - IEnumerable constraintProviders) - : base(resourceGraph) - { - _dbContext = dbContext; - _request = request; - _constraintProviders = constraintProviders; - } + public TelevisionBroadcastDefinition(IResourceGraph resourceGraph, TelevisionDbContext dbContext, IJsonApiRequest request, + IEnumerable constraintProviders) + : base(resourceGraph) + { + _dbContext = dbContext; + _request = request; + _constraintProviders = constraintProviders; + } - public override FilterExpression? OnApplyFilter(FilterExpression? existingFilter) + public override FilterExpression? OnApplyFilter(FilterExpression? existingFilter) + { + if (_request.IsReadOnly) { - if (_request.IsReadOnly) - { - // Rule: hide archived broadcasts in collections, unless a filter is specified. + // Rule: hide archived broadcasts in collections, unless a filter is specified. - if (IsReturningCollectionOfTelevisionBroadcasts() && !HasFilterOnArchivedAt(existingFilter)) - { - AttrAttribute archivedAtAttribute = ResourceType.GetAttributeByPropertyName(nameof(TelevisionBroadcast.ArchivedAt)); - var archivedAtChain = new ResourceFieldChainExpression(archivedAtAttribute); + if (IsReturningCollectionOfTelevisionBroadcasts() && !HasFilterOnArchivedAt(existingFilter)) + { + AttrAttribute archivedAtAttribute = ResourceType.GetAttributeByPropertyName(nameof(TelevisionBroadcast.ArchivedAt)); + var archivedAtChain = new ResourceFieldChainExpression(archivedAtAttribute); - FilterExpression isUnarchived = new ComparisonExpression(ComparisonOperator.Equals, archivedAtChain, NullConstantExpression.Instance); + FilterExpression isUnarchived = new ComparisonExpression(ComparisonOperator.Equals, archivedAtChain, NullConstantExpression.Instance); - return LogicalExpression.Compose(LogicalOperator.And, existingFilter, isUnarchived); - } + return LogicalExpression.Compose(LogicalOperator.And, existingFilter, isUnarchived); } - - return existingFilter; } - private bool IsReturningCollectionOfTelevisionBroadcasts() - { - return IsRequestingCollectionOfTelevisionBroadcasts() || IsIncludingCollectionOfTelevisionBroadcasts(); - } + return existingFilter; + } - private bool IsRequestingCollectionOfTelevisionBroadcasts() + private bool IsReturningCollectionOfTelevisionBroadcasts() + { + return IsRequestingCollectionOfTelevisionBroadcasts() || IsIncludingCollectionOfTelevisionBroadcasts(); + } + + private bool IsRequestingCollectionOfTelevisionBroadcasts() + { + if (_request.IsCollection) { - if (_request.IsCollection) + if (ResourceType.Equals(_request.PrimaryResourceType) || ResourceType.Equals(_request.SecondaryResourceType)) { - if (ResourceType.Equals(_request.PrimaryResourceType) || ResourceType.Equals(_request.SecondaryResourceType)) - { - return true; - } + return true; } - - return false; } - private bool IsIncludingCollectionOfTelevisionBroadcasts() - { - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true + return false; + } - IncludeElementExpression[] includeElements = _constraintProviders - .SelectMany(provider => provider.GetConstraints()) - .Select(expressionInScope => expressionInScope.Expression) - .OfType() - .SelectMany(include => include.Elements) - .ToArray(); + private bool IsIncludingCollectionOfTelevisionBroadcasts() + { + // @formatter:wrap_chained_method_calls chop_always + // @formatter:keep_existing_linebreaks true - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore + IncludeElementExpression[] includeElements = _constraintProviders + .SelectMany(provider => provider.GetConstraints()) + .Select(expressionInScope => expressionInScope.Expression) + .OfType() + .SelectMany(include => include.Elements) + .ToArray(); - foreach (IncludeElementExpression includeElement in includeElements) + // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_chained_method_calls restore + + foreach (IncludeElementExpression includeElement in includeElements) + { + if (includeElement.Relationship is HasManyAttribute && includeElement.Relationship.RightType.Equals(ResourceType)) { - if (includeElement.Relationship is HasManyAttribute && includeElement.Relationship.RightType.Equals(ResourceType)) - { - return true; - } + return true; } + } + return false; + } + + private bool HasFilterOnArchivedAt(FilterExpression? existingFilter) + { + if (existingFilter == null) + { return false; } - private bool HasFilterOnArchivedAt(FilterExpression? existingFilter) - { - if (existingFilter == null) - { - return false; - } + var walker = new FilterWalker(); + walker.Visit(existingFilter, null); - var walker = new FilterWalker(); - walker.Visit(existingFilter, null); + return walker.HasFilterOnArchivedAt; + } - return walker.HasFilterOnArchivedAt; + public override Task OnPrepareWriteAsync(TelevisionBroadcast broadcast, WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + if (writeOperation == WriteOperationKind.UpdateResource) + { + _storedArchivedAt = broadcast.ArchivedAt; } - public override Task OnPrepareWriteAsync(TelevisionBroadcast broadcast, WriteOperationKind writeOperation, CancellationToken cancellationToken) - { - if (writeOperation == WriteOperationKind.UpdateResource) - { - _storedArchivedAt = broadcast.ArchivedAt; - } + return Task.CompletedTask; + } - return Task.CompletedTask; + public override async Task OnWritingAsync(TelevisionBroadcast broadcast, WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + if (writeOperation == WriteOperationKind.CreateResource) + { + AssertIsNotArchived(broadcast); } - - public override async Task OnWritingAsync(TelevisionBroadcast broadcast, WriteOperationKind writeOperation, CancellationToken cancellationToken) + else if (writeOperation == WriteOperationKind.UpdateResource) { - if (writeOperation == WriteOperationKind.CreateResource) - { - AssertIsNotArchived(broadcast); - } - else if (writeOperation == WriteOperationKind.UpdateResource) - { - AssertIsNotShiftingArchiveDate(broadcast); - } - else if (writeOperation == WriteOperationKind.DeleteResource) - { - TelevisionBroadcast? broadcastToDelete = await _dbContext.Broadcasts.FirstWithIdAsync(broadcast.Id, cancellationToken); + AssertIsNotShiftingArchiveDate(broadcast); + } + else if (writeOperation == WriteOperationKind.DeleteResource) + { + TelevisionBroadcast? broadcastToDelete = await _dbContext.Broadcasts.FirstWithIdAsync(broadcast.Id, cancellationToken); - if (broadcastToDelete != null) - { - AssertIsArchived(broadcastToDelete); - } + if (broadcastToDelete != null) + { + AssertIsArchived(broadcastToDelete); } - - await base.OnWritingAsync(broadcast, writeOperation, cancellationToken); } - [AssertionMethod] - private static void AssertIsNotArchived(TelevisionBroadcast broadcast) + await base.OnWritingAsync(broadcast, writeOperation, cancellationToken); + } + + [AssertionMethod] + private static void AssertIsNotArchived(TelevisionBroadcast broadcast) + { + if (broadcast.ArchivedAt != null) { - if (broadcast.ArchivedAt != null) + throw new JsonApiException(new ErrorObject(HttpStatusCode.Forbidden) { - throw new JsonApiException(new ErrorObject(HttpStatusCode.Forbidden) - { - Title = "Television broadcasts cannot be created in archived state." - }); - } + Title = "Television broadcasts cannot be created in archived state." + }); } + } - [AssertionMethod] - private void AssertIsNotShiftingArchiveDate(TelevisionBroadcast broadcast) + [AssertionMethod] + private void AssertIsNotShiftingArchiveDate(TelevisionBroadcast broadcast) + { + if (_storedArchivedAt != null && broadcast.ArchivedAt != null && _storedArchivedAt != broadcast.ArchivedAt) { - if (_storedArchivedAt != null && broadcast.ArchivedAt != null && _storedArchivedAt != broadcast.ArchivedAt) + throw new JsonApiException(new ErrorObject(HttpStatusCode.Forbidden) { - throw new JsonApiException(new ErrorObject(HttpStatusCode.Forbidden) - { - Title = "Archive date of television broadcasts cannot be shifted. Unarchive it first." - }); - } + Title = "Archive date of television broadcasts cannot be shifted. Unarchive it first." + }); } + } - [AssertionMethod] - private static void AssertIsArchived(TelevisionBroadcast broadcast) + [AssertionMethod] + private static void AssertIsArchived(TelevisionBroadcast broadcast) + { + if (broadcast.ArchivedAt == null) { - if (broadcast.ArchivedAt == null) + throw new JsonApiException(new ErrorObject(HttpStatusCode.Forbidden) { - throw new JsonApiException(new ErrorObject(HttpStatusCode.Forbidden) - { - Title = "Television broadcasts must first be archived before they can be deleted." - }); - } + Title = "Television broadcasts must first be archived before they can be deleted." + }); } + } - private sealed class FilterWalker : QueryExpressionRewriter - { - public bool HasFilterOnArchivedAt { get; private set; } + private sealed class FilterWalker : QueryExpressionRewriter + { + public bool HasFilterOnArchivedAt { get; private set; } - public override QueryExpression? VisitResourceFieldChain(ResourceFieldChainExpression expression, object? argument) + public override QueryExpression? VisitResourceFieldChain(ResourceFieldChainExpression expression, object? argument) + { + if (expression.Fields[0].Property.Name == nameof(TelevisionBroadcast.ArchivedAt)) { - if (expression.Fields[0].Property.Name == nameof(TelevisionBroadcast.ArchivedAt)) - { - HasFilterOnArchivedAt = true; - } - - return base.VisitResourceFieldChain(expression, argument); + HasFilterOnArchivedAt = true; } + + return base.VisitResourceFieldChain(expression, argument); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionDbContext.cs index 5ff8a1406c..471f471028 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionDbContext.cs @@ -1,19 +1,18 @@ using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; -namespace JsonApiDotNetCoreTests.IntegrationTests.Archiving +namespace JsonApiDotNetCoreTests.IntegrationTests.Archiving; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class TelevisionDbContext : DbContext { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class TelevisionDbContext : DbContext - { - public DbSet Networks => Set(); - public DbSet Stations => Set(); - public DbSet Broadcasts => Set(); - public DbSet Comments => Set(); + public DbSet Networks => Set(); + public DbSet Stations => Set(); + public DbSet Broadcasts => Set(); + public DbSet Comments => Set(); - public TelevisionDbContext(DbContextOptions options) - : base(options) - { - } + public TelevisionDbContext(DbContextOptions options) + : base(options) + { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionFakers.cs index 84f7be5288..6c3116d789 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionFakers.cs @@ -1,43 +1,41 @@ -using System; using Bogus; using TestBuildingBlocks; // @formatter:wrap_chained_method_calls chop_always // @formatter:keep_existing_linebreaks true -namespace JsonApiDotNetCoreTests.IntegrationTests.Archiving +namespace JsonApiDotNetCoreTests.IntegrationTests.Archiving; + +internal sealed class TelevisionFakers : FakerContainer { - internal sealed class TelevisionFakers : FakerContainer - { - private readonly Lazy> _lazyTelevisionNetworkFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(network => network.Name, faker => faker.Company.CompanyName())); + private readonly Lazy> _lazyTelevisionNetworkFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(network => network.Name, faker => faker.Company.CompanyName())); - private readonly Lazy> _lazyTelevisionStationFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(station => station.Name, faker => faker.Company.CompanyName())); + private readonly Lazy> _lazyTelevisionStationFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(station => station.Name, faker => faker.Company.CompanyName())); - private readonly Lazy> _lazyTelevisionBroadcastFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(broadcast => broadcast.Title, faker => faker.Lorem.Sentence()) - .RuleFor(broadcast => broadcast.AiredAt, faker => faker.Date.PastOffset() - .TruncateToWholeMilliseconds()) - .RuleFor(broadcast => broadcast.ArchivedAt, faker => faker.Date.RecentOffset() - .TruncateToWholeMilliseconds())); + private readonly Lazy> _lazyTelevisionBroadcastFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(broadcast => broadcast.Title, faker => faker.Lorem.Sentence()) + .RuleFor(broadcast => broadcast.AiredAt, faker => faker.Date.PastOffset() + .TruncateToWholeMilliseconds()) + .RuleFor(broadcast => broadcast.ArchivedAt, faker => faker.Date.RecentOffset() + .TruncateToWholeMilliseconds())); - private readonly Lazy> _lazyBroadcastCommentFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(comment => comment.Text, faker => faker.Lorem.Paragraph()) - .RuleFor(comment => comment.CreatedAt, faker => faker.Date.PastOffset() - .TruncateToWholeMilliseconds())); + private readonly Lazy> _lazyBroadcastCommentFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(comment => comment.Text, faker => faker.Lorem.Paragraph()) + .RuleFor(comment => comment.CreatedAt, faker => faker.Date.PastOffset() + .TruncateToWholeMilliseconds())); - public Faker TelevisionNetwork => _lazyTelevisionNetworkFaker.Value; - public Faker TelevisionStation => _lazyTelevisionStationFaker.Value; - public Faker TelevisionBroadcast => _lazyTelevisionBroadcastFaker.Value; - public Faker BroadcastComment => _lazyBroadcastCommentFaker.Value; - } + public Faker TelevisionNetwork => _lazyTelevisionNetworkFaker.Value; + public Faker TelevisionStation => _lazyTelevisionStationFaker.Value; + public Faker TelevisionBroadcast => _lazyTelevisionBroadcastFaker.Value; + public Faker BroadcastComment => _lazyBroadcastCommentFaker.Value; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionNetwork.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionNetwork.cs index 70c12b6bac..cf6404f039 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionNetwork.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionNetwork.cs @@ -1,18 +1,16 @@ -using System.Collections.Generic; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.Archiving +namespace JsonApiDotNetCoreTests.IntegrationTests.Archiving; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Archiving")] +public sealed class TelevisionNetwork : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - [Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Archiving")] - public sealed class TelevisionNetwork : Identifiable - { - [Attr] - public string Name { get; set; } = null!; + [Attr] + public string Name { get; set; } = null!; - [HasMany] - public ISet Stations { get; set; } = new HashSet(); - } + [HasMany] + public ISet Stations { get; set; } = new HashSet(); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionStation.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionStation.cs index d929fd384e..47000c5733 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionStation.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionStation.cs @@ -1,18 +1,16 @@ -using System.Collections.Generic; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.Archiving +namespace JsonApiDotNetCoreTests.IntegrationTests.Archiving; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Archiving")] +public sealed class TelevisionStation : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - [Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Archiving")] - public sealed class TelevisionStation : Identifiable - { - [Attr] - public string Name { get; set; } = null!; + [Attr] + public string Name { get; set; } = null!; - [HasMany] - public ISet Broadcasts { get; set; } = new HashSet(); - } + [HasMany] + public ISet Broadcasts { get; set; } = new HashSet(); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/AtomicConstrainedOperationsControllerTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/AtomicConstrainedOperationsControllerTests.cs index 022ae35c3c..6908a2b296 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/AtomicConstrainedOperationsControllerTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/AtomicConstrainedOperationsControllerTests.cs @@ -1,218 +1,215 @@ using System.Net; -using System.Net.Http; -using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Controllers +namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Controllers; + +public sealed class AtomicConstrainedOperationsControllerTests + : IClassFixture, OperationsDbContext>> { - public sealed class AtomicConstrainedOperationsControllerTests - : IClassFixture, OperationsDbContext>> + private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new(); + + public AtomicConstrainedOperationsControllerTests(IntegrationTestContext, OperationsDbContext> testContext) { - private readonly IntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new(); + _testContext = testContext; - public AtomicConstrainedOperationsControllerTests(IntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; + testContext.UseController(); + } - testContext.UseController(); - } + [Fact] + public async Task Can_create_resources_for_matching_resource_type() + { + // Arrange + string newTitle1 = _fakers.MusicTrack.Generate().Title; + string newTitle2 = _fakers.MusicTrack.Generate().Title; - [Fact] - public async Task Can_create_resources_for_matching_resource_type() + var requestBody = new { - // Arrange - string newTitle1 = _fakers.MusicTrack.Generate().Title; - string newTitle2 = _fakers.MusicTrack.Generate().Title; - - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "musicTracks", + attributes = new { - type = "musicTracks", - attributes = new - { - title = newTitle1 - } + title = newTitle1 } - }, - new + } + }, + new + { + op = "add", + data = new { - op = "add", - data = new + type = "musicTracks", + attributes = new { - type = "musicTracks", - attributes = new - { - title = newTitle2 - } + title = newTitle2 } } } - }; + } + }; - const string route = "/operations/musicTracks/create"; + const string route = "/operations/musicTracks/create"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(2); - } + responseDocument.Results.ShouldHaveCount(2); + } - [Fact] - public async Task Cannot_create_resource_for_mismatching_resource_type() + [Fact] + public async Task Cannot_create_resource_for_mismatching_resource_type() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "performers", + attributes = new { - type = "performers", - attributes = new - { - } } } } - }; + } + }; - const string route = "/operations/musicTracks/create"; + const string route = "/operations/musicTracks/create"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Unsupported combination of operation code and resource type at this endpoint."); - error.Detail.Should().Be("This endpoint can only be used to create resources of type 'musicTracks'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Unsupported combination of operation code and resource type at this endpoint."); + error.Detail.Should().Be("This endpoint can only be used to create resources of type 'musicTracks'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]"); + } - [Fact] - public async Task Cannot_update_resources_for_matching_resource_type() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + [Fact] + public async Task Cannot_update_resources_for_matching_resource_type() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = "musicTracks", + id = existingTrack.StringId, + attributes = new { - type = "musicTracks", - id = existingTrack.StringId, - attributes = new - { - } } } } - }; + } + }; - const string route = "/operations/musicTracks/create"; + const string route = "/operations/musicTracks/create"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Unsupported combination of operation code and resource type at this endpoint."); - error.Detail.Should().Be("This endpoint can only be used to create resources of type 'musicTracks'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Unsupported combination of operation code and resource type at this endpoint."); + error.Detail.Should().Be("This endpoint can only be used to create resources of type 'musicTracks'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]"); + } - [Fact] - public async Task Cannot_add_to_ToMany_relationship_for_matching_resource_type() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - Performer existingPerformer = _fakers.Performer.Generate(); + [Fact] + public async Task Cannot_add_to_ToMany_relationship_for_matching_resource_type() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + Performer existingPerformer = _fakers.Performer.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddInRange(existingTrack, existingPerformer); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingTrack, existingPerformer); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + @ref = new { - op = "add", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" - }, - data = new[] + type = "musicTracks", + id = existingTrack.StringId, + relationship = "performers" + }, + data = new[] + { + new { - new - { - type = "performers", - id = existingPerformer.StringId - } + type = "performers", + id = existingPerformer.StringId } } } - }; + } + }; - const string route = "/operations/musicTracks/create"; + const string route = "/operations/musicTracks/create"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Unsupported combination of operation code and resource type at this endpoint."); - error.Detail.Should().Be("This endpoint can only be used to create resources of type 'musicTracks'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Unsupported combination of operation code and resource type at this endpoint."); + error.Detail.Should().Be("This endpoint can only be used to create resources of type 'musicTracks'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]"); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/CreateMusicTrackOperationsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/CreateMusicTrackOperationsController.cs index 257920b48f..517cf4c792 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/CreateMusicTrackOperationsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/CreateMusicTrackOperationsController.cs @@ -1,7 +1,4 @@ -using System.Collections.Generic; using System.Net; -using System.Threading; -using System.Threading.Tasks; using JsonApiDotNetCore.AtomicOperations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; @@ -13,46 +10,45 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Controllers +namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Controllers; + +[DisableRoutingConvention] +[Route("/operations/musicTracks/create")] +public sealed class CreateMusicTrackOperationsController : JsonApiOperationsController { - [DisableRoutingConvention] - [Route("/operations/musicTracks/create")] - public sealed class CreateMusicTrackOperationsController : JsonApiOperationsController + public CreateMusicTrackOperationsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IOperationsProcessor processor, IJsonApiRequest request, ITargetedFields targetedFields) + : base(options, resourceGraph, loggerFactory, processor, request, targetedFields) { - public CreateMusicTrackOperationsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, - IOperationsProcessor processor, IJsonApiRequest request, ITargetedFields targetedFields) - : base(options, resourceGraph, loggerFactory, processor, request, targetedFields) - { - } + } - public override async Task PostOperationsAsync(IList operations, CancellationToken cancellationToken) - { - AssertOnlyCreatingMusicTracks(operations); + public override async Task PostOperationsAsync(IList operations, CancellationToken cancellationToken) + { + AssertOnlyCreatingMusicTracks(operations); - return await base.PostOperationsAsync(operations, cancellationToken); - } + return await base.PostOperationsAsync(operations, cancellationToken); + } - private static void AssertOnlyCreatingMusicTracks(IEnumerable operations) - { - int index = 0; + private static void AssertOnlyCreatingMusicTracks(IEnumerable operations) + { + int index = 0; - foreach (OperationContainer operation in operations) + foreach (OperationContainer operation in operations) + { + if (operation.Request.WriteOperation != WriteOperationKind.CreateResource || operation.Resource.GetType() != typeof(MusicTrack)) { - if (operation.Request.WriteOperation != WriteOperationKind.CreateResource || operation.Resource.GetType() != typeof(MusicTrack)) + throw new JsonApiException(new ErrorObject(HttpStatusCode.UnprocessableEntity) { - throw new JsonApiException(new ErrorObject(HttpStatusCode.UnprocessableEntity) + Title = "Unsupported combination of operation code and resource type at this endpoint.", + Detail = "This endpoint can only be used to create resources of type 'musicTracks'.", + Source = new ErrorSource { - Title = "Unsupported combination of operation code and resource type at this endpoint.", - Detail = "This endpoint can only be used to create resources of type 'musicTracks'.", - Source = new ErrorSource - { - Pointer = $"/atomic:operations[{index}]" - } - }); - } - - index++; + Pointer = $"/atomic:operations[{index}]" + } + }); } + + index++; } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs index fc963b2fae..f37d53b275 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs @@ -1,9 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; using System.Net; -using System.Net.Http; -using System.Threading.Tasks; using FluentAssertions; using FluentAssertions.Extensions; using JsonApiDotNetCore.Configuration; @@ -13,989 +8,986 @@ using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Creating +namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Creating; + +public sealed class AtomicCreateResourceTests : IClassFixture, OperationsDbContext>> { - public sealed class AtomicCreateResourceTests : IClassFixture, OperationsDbContext>> + private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new(); + + public AtomicCreateResourceTests(IntegrationTestContext, OperationsDbContext> testContext) { - private readonly IntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new(); + _testContext = testContext; - public AtomicCreateResourceTests(IntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; + testContext.UseController(); - testContext.UseController(); + // These routes need to be registered in ASP.NET for rendering links to resource/relationship endpoints. + testContext.UseController(); + testContext.UseController(); + testContext.UseController(); - // These routes need to be registered in ASP.NET for rendering links to resource/relationship endpoints. - testContext.UseController(); - testContext.UseController(); - testContext.UseController(); + var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); + options.AllowUnknownFieldsInRequestBody = false; + } - var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); - options.AllowUnknownFieldsInRequestBody = false; - } + [Fact] + public async Task Can_create_resource() + { + // Arrange + string newArtistName = _fakers.Performer.Generate().ArtistName!; + DateTimeOffset newBornAt = _fakers.Performer.Generate().BornAt; - [Fact] - public async Task Can_create_resource() + var requestBody = new { - // Arrange - string newArtistName = _fakers.Performer.Generate().ArtistName!; - DateTimeOffset newBornAt = _fakers.Performer.Generate().BornAt; - - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "performers", + attributes = new { - type = "performers", - attributes = new - { - artistName = newArtistName, - bornAt = newBornAt - } + artistName = newArtistName, + bornAt = newBornAt } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(1); + responseDocument.Results.ShouldHaveCount(1); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("performers"); - resource.Attributes.ShouldContainKey("artistName").With(value => value.Should().Be(newArtistName)); - resource.Attributes.ShouldContainKey("bornAt").With(value => value.As().Should().BeCloseTo(newBornAt)); - resource.Relationships.Should().BeNull(); - }); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("performers"); + resource.Attributes.ShouldContainKey("artistName").With(value => value.Should().Be(newArtistName)); + resource.Attributes.ShouldContainKey("bornAt").With(value => value.Should().Be(newBornAt)); + resource.Relationships.Should().BeNull(); + }); - int newPerformerId = int.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + int newPerformerId = int.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Performer performerInDatabase = await dbContext.Performers.FirstWithIdAsync(newPerformerId); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Performer performerInDatabase = await dbContext.Performers.FirstWithIdAsync(newPerformerId); - performerInDatabase.ArtistName.Should().Be(newArtistName); - performerInDatabase.BornAt.Should().BeCloseTo(newBornAt); - }); - } + performerInDatabase.ArtistName.Should().Be(newArtistName); + performerInDatabase.BornAt.Should().Be(newBornAt); + }); + } - [Fact] - public async Task Can_create_resources() - { - // Arrange - const int elementCount = 5; + [Fact] + public async Task Can_create_resources() + { + // Arrange + const int elementCount = 5; - List newTracks = _fakers.MusicTrack.Generate(elementCount); + List newTracks = _fakers.MusicTrack.Generate(elementCount); - var operationElements = new List(elementCount); + var operationElements = new List(elementCount); - for (int index = 0; index < elementCount; index++) + for (int index = 0; index < elementCount; index++) + { + operationElements.Add(new { - operationElements.Add(new + op = "add", + data = new { - op = "add", - data = new + type = "musicTracks", + attributes = new { - type = "musicTracks", - attributes = new - { - title = newTracks[index].Title, - lengthInSeconds = newTracks[index].LengthInSeconds, - genre = newTracks[index].Genre, - releasedAt = newTracks[index].ReleasedAt - } + title = newTracks[index].Title, + lengthInSeconds = newTracks[index].LengthInSeconds, + genre = newTracks[index].Genre, + releasedAt = newTracks[index].ReleasedAt } - }); - } + } + }); + } - var requestBody = new - { - atomic__operations = operationElements - }; + var requestBody = new + { + atomic__operations = operationElements + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(elementCount); + responseDocument.Results.ShouldHaveCount(elementCount); - for (int index = 0; index < elementCount; index++) + for (int index = 0; index < elementCount; index++) + { + responseDocument.Results[index].Data.SingleValue.ShouldNotBeNull().With(resource => { - responseDocument.Results[index].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.ShouldNotBeNull(); - resource.Type.Should().Be("musicTracks"); - resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTracks[index].Title)); - - resource.Attributes.ShouldContainKey("lengthInSeconds") - .With(value => value.As().Should().BeApproximately(newTracks[index].LengthInSeconds)); + resource.ShouldNotBeNull(); + resource.Type.Should().Be("musicTracks"); + resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTracks[index].Title)); - resource.Attributes.ShouldContainKey("genre").With(value => value.Should().Be(newTracks[index].Genre)); + resource.Attributes.ShouldContainKey("lengthInSeconds") + .With(value => value.As().Should().BeApproximately(newTracks[index].LengthInSeconds)); - resource.Attributes.ShouldContainKey("releasedAt") - .With(value => value.As().Should().BeCloseTo(newTracks[index].ReleasedAt)); + resource.Attributes.ShouldContainKey("genre").With(value => value.Should().Be(newTracks[index].Genre)); + resource.Attributes.ShouldContainKey("releasedAt").With(value => value.Should().Be(newTracks[index].ReleasedAt)); - resource.Relationships.ShouldNotBeEmpty(); - }); - } + resource.Relationships.ShouldNotBeEmpty(); + }); + } - Guid[] newTrackIds = responseDocument.Results.Select(result => Guid.Parse(result.Data.SingleValue!.Id.ShouldNotBeNull())).ToArray(); + Guid[] newTrackIds = responseDocument.Results.Select(result => Guid.Parse(result.Data.SingleValue!.Id.ShouldNotBeNull())).ToArray(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List tracksInDatabase = await dbContext.MusicTracks.Where(musicTrack => newTrackIds.Contains(musicTrack.Id)).ToListAsync(); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List tracksInDatabase = await dbContext.MusicTracks.Where(musicTrack => newTrackIds.Contains(musicTrack.Id)).ToListAsync(); - tracksInDatabase.ShouldHaveCount(elementCount); + tracksInDatabase.ShouldHaveCount(elementCount); - for (int index = 0; index < elementCount; index++) - { - MusicTrack trackInDatabase = tracksInDatabase.Single(musicTrack => musicTrack.Id == newTrackIds[index]); + for (int index = 0; index < elementCount; index++) + { + MusicTrack trackInDatabase = tracksInDatabase.Single(musicTrack => musicTrack.Id == newTrackIds[index]); - trackInDatabase.Title.Should().Be(newTracks[index].Title); - trackInDatabase.LengthInSeconds.Should().BeApproximately(newTracks[index].LengthInSeconds); - trackInDatabase.Genre.Should().Be(newTracks[index].Genre); - trackInDatabase.ReleasedAt.Should().BeCloseTo(newTracks[index].ReleasedAt); - } - }); - } + trackInDatabase.Title.Should().Be(newTracks[index].Title); + trackInDatabase.LengthInSeconds.Should().BeApproximately(newTracks[index].LengthInSeconds); + trackInDatabase.Genre.Should().Be(newTracks[index].Genre); + trackInDatabase.ReleasedAt.Should().Be(newTracks[index].ReleasedAt); + } + }); + } - [Fact] - public async Task Can_create_resource_without_attributes_or_relationships() + [Fact] + public async Task Can_create_resource_without_attributes_or_relationships() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "performers", + attributes = new + { + }, + relationship = new { - type = "performers", - attributes = new - { - }, - relationship = new - { - } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(1); + responseDocument.Results.ShouldHaveCount(1); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("performers"); - resource.Attributes.ShouldContainKey("artistName").With(value => value.Should().BeNull()); - resource.Attributes.ShouldContainKey("bornAt").With(value => value.As().Should().BeCloseTo(default)); - resource.Relationships.Should().BeNull(); - }); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("performers"); + resource.Attributes.ShouldContainKey("artistName").With(value => value.Should().BeNull()); + resource.Attributes.ShouldContainKey("bornAt").With(value => value.Should().Be(default(DateTimeOffset))); + resource.Relationships.Should().BeNull(); + }); - int newPerformerId = int.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + int newPerformerId = int.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Performer performerInDatabase = await dbContext.Performers.FirstWithIdAsync(newPerformerId); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Performer performerInDatabase = await dbContext.Performers.FirstWithIdAsync(newPerformerId); - performerInDatabase.ArtistName.Should().BeNull(); - performerInDatabase.BornAt.Should().Be(default); - }); - } + performerInDatabase.ArtistName.Should().BeNull(); + performerInDatabase.BornAt.Should().Be(default); + }); + } - [Fact] - public async Task Cannot_create_resource_with_unknown_attribute() - { - // Arrange - string newName = _fakers.Playlist.Generate().Name; + [Fact] + public async Task Cannot_create_resource_with_unknown_attribute() + { + // Arrange + string newName = _fakers.Playlist.Generate().Name; - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "playlists", + attributes = new { - type = "playlists", - attributes = new - { - doesNotExist = "ignored", - name = newName - } + doesNotExist = "ignored", + name = newName } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Unknown attribute found."); - error.Detail.Should().Be("Attribute 'doesNotExist' does not exist on resource type 'playlists'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/doesNotExist"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Unknown attribute found."); + error.Detail.Should().Be("Attribute 'doesNotExist' does not exist on resource type 'playlists'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/doesNotExist"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Can_create_resource_with_unknown_attribute() - { - // Arrange - var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); - options.AllowUnknownFieldsInRequestBody = true; + [Fact] + public async Task Can_create_resource_with_unknown_attribute() + { + // Arrange + var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); + options.AllowUnknownFieldsInRequestBody = true; - string newName = _fakers.Playlist.Generate().Name; + string newName = _fakers.Playlist.Generate().Name; - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "playlists", + attributes = new { - type = "playlists", - attributes = new - { - doesNotExist = "ignored", - name = newName - } + doesNotExist = "ignored", + name = newName } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(1); + responseDocument.Results.ShouldHaveCount(1); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("playlists"); - resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newName)); - resource.Relationships.ShouldNotBeEmpty(); - }); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("playlists"); + resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newName)); + resource.Relationships.ShouldNotBeEmpty(); + }); - long newPlaylistId = long.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + long newPlaylistId = long.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Playlist performerInDatabase = await dbContext.Playlists.FirstWithIdAsync(newPlaylistId); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Playlist performerInDatabase = await dbContext.Playlists.FirstWithIdAsync(newPlaylistId); - performerInDatabase.Name.Should().Be(newName); - }); - } + performerInDatabase.Name.Should().Be(newName); + }); + } - [Fact] - public async Task Cannot_create_resource_with_unknown_relationship() + [Fact] + public async Task Cannot_create_resource_with_unknown_relationship() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "lyrics", + relationships = new { - type = "lyrics", - relationships = new + doesNotExist = new { - doesNotExist = new + data = new { - data = new - { - type = Unknown.ResourceType, - id = Unknown.StringId.Int32 - } + type = Unknown.ResourceType, + id = Unknown.StringId.Int32 } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Unknown relationship found."); - error.Detail.Should().Be("Relationship 'doesNotExist' does not exist on resource type 'lyrics'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/doesNotExist"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Unknown relationship found."); + error.Detail.Should().Be("Relationship 'doesNotExist' does not exist on resource type 'lyrics'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/doesNotExist"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Can_create_resource_with_unknown_relationship() - { - // Arrange - var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); - options.AllowUnknownFieldsInRequestBody = true; + [Fact] + public async Task Can_create_resource_with_unknown_relationship() + { + // Arrange + var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); + options.AllowUnknownFieldsInRequestBody = true; - string newLyricText = _fakers.Lyric.Generate().Text; + string newLyricText = _fakers.Lyric.Generate().Text; - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "lyrics", + attributes = new { - type = "lyrics", - attributes = new - { - text = newLyricText - }, - relationships = new + text = newLyricText + }, + relationships = new + { + doesNotExist = new { - doesNotExist = new + data = new { - data = new - { - type = Unknown.ResourceType, - id = Unknown.StringId.Int32 - } + type = Unknown.ResourceType, + id = Unknown.StringId.Int32 } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(1); + responseDocument.Results.ShouldHaveCount(1); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("lyrics"); - resource.Attributes.ShouldNotBeEmpty(); - resource.Relationships.ShouldNotBeEmpty(); - }); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("lyrics"); + resource.Attributes.ShouldNotBeEmpty(); + resource.Relationships.ShouldNotBeEmpty(); + }); - long newLyricId = long.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + long newLyricId = long.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Lyric lyricInDatabase = await dbContext.Lyrics.FirstWithIdAsync(newLyricId); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Lyric lyricInDatabase = await dbContext.Lyrics.FirstWithIdAsync(newLyricId); - lyricInDatabase.ShouldNotBeNull(); - }); - } + lyricInDatabase.ShouldNotBeNull(); + }); + } - [Fact] - public async Task Cannot_create_resource_with_client_generated_ID() - { - // Arrange - MusicTrack newTrack = _fakers.MusicTrack.Generate(); - newTrack.Id = Guid.NewGuid(); + [Fact] + public async Task Cannot_create_resource_with_client_generated_ID() + { + // Arrange + MusicTrack newTrack = _fakers.MusicTrack.Generate(); + newTrack.Id = Guid.NewGuid(); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "musicTracks", + id = newTrack.StringId, + attributes = new { - type = "musicTracks", - id = newTrack.StringId, - attributes = new - { - title = newTrack.Title - } + title = newTrack.Title } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.Forbidden); - error.Title.Should().Be("Failed to deserialize request body: The use of client-generated IDs is disabled."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/id"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Forbidden); + error.Title.Should().Be("Failed to deserialize request body: The use of client-generated IDs is disabled."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/id"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_create_resource_for_href_element() + [Fact] + public async Task Cannot_create_resource_for_href_element() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new - { - op = "add", - href = "/api/v1/musicTracks" - } + op = "add", + href = "/api/v1/musicTracks" } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'href' element is not supported."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/href"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'href' element is not supported."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/href"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_create_resource_for_ref_element() + [Fact] + public async Task Cannot_create_resource_for_ref_element() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + @ref = new { - op = "add", - @ref = new - { - type = "musicTracks" - } + type = "musicTracks" } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'relationship' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'relationship' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_create_resource_for_missing_data() + [Fact] + public async Task Cannot_create_resource_for_missing_data() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new object[] { - atomic__operations = new object[] + new { - new - { - op = "add" - } + op = "add" } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'data' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_create_resource_for_null_data() + [Fact] + public async Task Cannot_create_resource_for_null_data() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new object[] { - atomic__operations = new object[] + new { - new - { - op = "add", - data = (object?)null - } + op = "add", + data = (object?)null } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected an object, instead of 'null'."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Expected an object, instead of 'null'."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_create_resource_for_array_data() - { - // Arrange - string newArtistName = _fakers.Performer.Generate().ArtistName!; + [Fact] + public async Task Cannot_create_resource_for_array_data() + { + // Arrange + string newArtistName = _fakers.Performer.Generate().ArtistName!; - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + data = new[] { - op = "add", - data = new[] + new { - new + type = "performers", + attributes = new { - type = "performers", - attributes = new - { - artistName = newArtistName - } + artistName = newArtistName } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected an object, instead of an array."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Expected an object, instead of an array."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_create_resource_for_missing_type() + [Fact] + public async Task Cannot_create_resource_for_missing_type() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + data = new { - op = "add", - data = new + attributes = new { - attributes = new - { - } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_create_resource_for_unknown_type() + [Fact] + public async Task Cannot_create_resource_for_unknown_type() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + data = new { - op = "add", - data = new - { - type = Unknown.ResourceType - } + type = Unknown.ResourceType } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); - error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); + error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_create_resource_attribute_with_blocked_capability() + [Fact] + public async Task Cannot_create_resource_attribute_with_blocked_capability() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "lyrics", + attributes = new { - type = "lyrics", - attributes = new - { - createdAt = 12.July(1980) - } + createdAt = 12.July(1980) } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Attribute value cannot be assigned when creating resource."); - error.Detail.Should().Be("The attribute 'createdAt' on resource type 'lyrics' cannot be assigned to."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/createdAt"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Attribute value cannot be assigned when creating resource."); + error.Detail.Should().Be("The attribute 'createdAt' on resource type 'lyrics' cannot be assigned to."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/createdAt"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_create_resource_with_readonly_attribute() - { - // Arrange - string newPlaylistName = _fakers.Playlist.Generate().Name; + [Fact] + public async Task Cannot_create_resource_with_readonly_attribute() + { + // Arrange + string newPlaylistName = _fakers.Playlist.Generate().Name; - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "playlists", + attributes = new { - type = "playlists", - attributes = new - { - name = newPlaylistName, - isArchived = true - } + name = newPlaylistName, + isArchived = true } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Attribute is read-only."); - error.Detail.Should().Be("Attribute 'isArchived' on resource type 'playlists' is read-only."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/isArchived"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Attribute is read-only."); + error.Detail.Should().Be("Attribute 'isArchived' on resource type 'playlists' is read-only."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/isArchived"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_create_resource_with_incompatible_attribute_value() + [Fact] + public async Task Cannot_create_resource_with_incompatible_attribute_value() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "performers", + attributes = new { - type = "performers", - attributes = new - { - bornAt = 12345 - } + bornAt = 12345 } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Incompatible attribute value found."); - error.Detail.Should().Be("Failed to convert attribute 'bornAt' with value '12345' of type 'Number' to type 'DateTimeOffset'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/bornAt"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Incompatible attribute value found."); + error.Detail.Should().Be("Failed to convert attribute 'bornAt' with value '12345' of type 'Number' to type 'DateTimeOffset'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/bornAt"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Can_create_resource_with_attributes_and_multiple_relationship_types() - { - // Arrange - Lyric existingLyric = _fakers.Lyric.Generate(); - RecordCompany existingCompany = _fakers.RecordCompany.Generate(); - Performer existingPerformer = _fakers.Performer.Generate(); + [Fact] + public async Task Can_create_resource_with_attributes_and_multiple_relationship_types() + { + // Arrange + Lyric existingLyric = _fakers.Lyric.Generate(); + RecordCompany existingCompany = _fakers.RecordCompany.Generate(); + Performer existingPerformer = _fakers.Performer.Generate(); - string newTitle = _fakers.MusicTrack.Generate().Title; + string newTitle = _fakers.MusicTrack.Generate().Title; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddInRange(existingLyric, existingCompany, existingPerformer); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingLyric, existingCompany, existingPerformer); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "musicTracks", + attributes = new { - type = "musicTracks", - attributes = new + title = newTitle + }, + relationships = new + { + lyric = new { - title = newTitle + data = new + { + type = "lyrics", + id = existingLyric.StringId + } }, - relationships = new + ownedBy = new { - lyric = new - { - data = new - { - type = "lyrics", - id = existingLyric.StringId - } - }, - ownedBy = new + data = new { - data = new - { - type = "recordCompanies", - id = existingCompany.StringId - } - }, - performers = new + type = "recordCompanies", + id = existingCompany.StringId + } + }, + performers = new + { + data = new[] { - data = new[] + new { - new - { - type = "performers", - id = existingPerformer.StringId - } + type = "performers", + id = existingPerformer.StringId } } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(1); + responseDocument.Results.ShouldHaveCount(1); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("musicTracks"); - resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTitle)); - resource.Relationships.ShouldNotBeEmpty(); - }); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("musicTracks"); + resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTitle)); + resource.Relationships.ShouldNotBeEmpty(); + }); - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true + await _testContext.RunOnDatabaseAsync(async dbContext => + { + // @formatter:wrap_chained_method_calls chop_always + // @formatter:keep_existing_linebreaks true - MusicTrack trackInDatabase = await dbContext.MusicTracks - .Include(musicTrack => musicTrack.Lyric) - .Include(musicTrack => musicTrack.OwnedBy) - .Include(musicTrack => musicTrack.Performers) - .FirstWithIdAsync(newTrackId); + MusicTrack trackInDatabase = await dbContext.MusicTracks + .Include(musicTrack => musicTrack.Lyric) + .Include(musicTrack => musicTrack.OwnedBy) + .Include(musicTrack => musicTrack.Performers) + .FirstWithIdAsync(newTrackId); - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore + // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_chained_method_calls restore - trackInDatabase.Title.Should().Be(newTitle); + trackInDatabase.Title.Should().Be(newTitle); - trackInDatabase.Lyric.ShouldNotBeNull(); - trackInDatabase.Lyric.Id.Should().Be(existingLyric.Id); + trackInDatabase.Lyric.ShouldNotBeNull(); + trackInDatabase.Lyric.Id.Should().Be(existingLyric.Id); - trackInDatabase.OwnedBy.ShouldNotBeNull(); - trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); + trackInDatabase.OwnedBy.ShouldNotBeNull(); + trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); - trackInDatabase.Performers.ShouldHaveCount(1); - trackInDatabase.Performers[0].Id.Should().Be(existingPerformer.Id); - }); - } + trackInDatabase.Performers.ShouldHaveCount(1); + trackInDatabase.Performers[0].Id.Should().Be(existingPerformer.Id); + }); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs index cc663f92f8..ef64ebc64a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs @@ -1,7 +1,4 @@ -using System; using System.Net; -using System.Net.Http; -using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; @@ -9,274 +6,273 @@ using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Creating +namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Creating; + +public sealed class AtomicCreateResourceWithClientGeneratedIdTests + : IClassFixture, OperationsDbContext>> { - public sealed class AtomicCreateResourceWithClientGeneratedIdTests - : IClassFixture, OperationsDbContext>> + private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new(); + + public AtomicCreateResourceWithClientGeneratedIdTests(IntegrationTestContext, OperationsDbContext> testContext) { - private readonly IntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new(); + _testContext = testContext; - public AtomicCreateResourceWithClientGeneratedIdTests(IntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; + testContext.UseController(); - testContext.UseController(); + // These routes need to be registered in ASP.NET for rendering links to resource/relationship endpoints. + testContext.UseController(); - // These routes need to be registered in ASP.NET for rendering links to resource/relationship endpoints. - testContext.UseController(); + testContext.ConfigureServicesAfterStartup(services => + { + services.AddResourceDefinition(); - testContext.ConfigureServicesAfterStartup(services => - { - services.AddResourceDefinition(); + services.AddSingleton(); + }); - services.AddSingleton(); - }); + var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); + options.AllowClientGeneratedIds = true; + } - var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); - options.AllowClientGeneratedIds = true; - } + [Fact] + public async Task Can_create_resource_with_client_generated_guid_ID_having_side_effects() + { + // Arrange + TextLanguage newLanguage = _fakers.TextLanguage.Generate(); + newLanguage.Id = Guid.NewGuid(); - [Fact] - public async Task Can_create_resource_with_client_generated_guid_ID_having_side_effects() + var requestBody = new { - // Arrange - TextLanguage newLanguage = _fakers.TextLanguage.Generate(); - newLanguage.Id = Guid.NewGuid(); - - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "textLanguages", + id = newLanguage.StringId, + attributes = new { - type = "textLanguages", - id = newLanguage.StringId, - attributes = new - { - isoCode = newLanguage.IsoCode - } + isoCode = newLanguage.IsoCode } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - string isoCode = $"{newLanguage.IsoCode}{ImplicitlyChangingTextLanguageDefinition.Suffix}"; + string isoCode = $"{newLanguage.IsoCode}{ImplicitlyChangingTextLanguageDefinition.Suffix}"; - responseDocument.Results.ShouldHaveCount(1); + responseDocument.Results.ShouldHaveCount(1); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("textLanguages"); - resource.Attributes.ShouldContainKey("isoCode").With(value => value.Should().Be(isoCode)); - resource.Attributes.Should().NotContainKey("isRightToLeft"); - resource.Relationships.ShouldNotBeEmpty(); - }); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("textLanguages"); + resource.Attributes.ShouldContainKey("isoCode").With(value => value.Should().Be(isoCode)); + resource.Attributes.Should().NotContainKey("isRightToLeft"); + resource.Relationships.ShouldNotBeEmpty(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - TextLanguage languageInDatabase = await dbContext.TextLanguages.FirstWithIdAsync(newLanguage.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + TextLanguage languageInDatabase = await dbContext.TextLanguages.FirstWithIdAsync(newLanguage.Id); - languageInDatabase.IsoCode.Should().Be(isoCode); - }); - } + languageInDatabase.IsoCode.Should().Be(isoCode); + }); + } - [Fact] - public async Task Can_create_resource_with_client_generated_string_ID_having_no_side_effects() - { - // Arrange - MusicTrack newTrack = _fakers.MusicTrack.Generate(); - newTrack.Id = Guid.NewGuid(); + [Fact] + public async Task Can_create_resource_with_client_generated_string_ID_having_no_side_effects() + { + // Arrange + MusicTrack newTrack = _fakers.MusicTrack.Generate(); + newTrack.Id = Guid.NewGuid(); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "musicTracks", + id = newTrack.StringId, + attributes = new { - type = "musicTracks", - id = newTrack.StringId, - attributes = new - { - title = newTrack.Title, - lengthInSeconds = newTrack.LengthInSeconds, - releasedAt = newTrack.ReleasedAt - } + title = newTrack.Title, + lengthInSeconds = newTrack.LengthInSeconds, + releasedAt = newTrack.ReleasedAt } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.FirstWithIdAsync(newTrack.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + MusicTrack trackInDatabase = await dbContext.MusicTracks.FirstWithIdAsync(newTrack.Id); - trackInDatabase.Title.Should().Be(newTrack.Title); - trackInDatabase.LengthInSeconds.Should().BeApproximately(newTrack.LengthInSeconds); - }); - } + trackInDatabase.Title.Should().Be(newTrack.Title); + trackInDatabase.LengthInSeconds.Should().BeApproximately(newTrack.LengthInSeconds); + }); + } - [Fact] - public async Task Cannot_create_resource_for_existing_client_generated_ID() - { - // Arrange - TextLanguage existingLanguage = _fakers.TextLanguage.Generate(); - existingLanguage.Id = Guid.NewGuid(); + [Fact] + public async Task Cannot_create_resource_for_existing_client_generated_ID() + { + // Arrange + TextLanguage existingLanguage = _fakers.TextLanguage.Generate(); + existingLanguage.Id = Guid.NewGuid(); - TextLanguage languageToCreate = _fakers.TextLanguage.Generate(); - languageToCreate.Id = existingLanguage.Id; + TextLanguage languageToCreate = _fakers.TextLanguage.Generate(); + languageToCreate.Id = existingLanguage.Id; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.TextLanguages.Add(languageToCreate); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TextLanguages.Add(languageToCreate); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "textLanguages", + id = languageToCreate.StringId, + attributes = new { - type = "textLanguages", - id = languageToCreate.StringId, - attributes = new - { - isoCode = languageToCreate.IsoCode - } + isoCode = languageToCreate.IsoCode } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.Conflict); - error.Title.Should().Be("Another resource with the specified ID already exists."); - error.Detail.Should().Be($"Another resource of type 'textLanguages' with ID '{languageToCreate.StringId}' already exists."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - error.Meta.Should().NotContainKey("requestBody"); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Conflict); + error.Title.Should().Be("Another resource with the specified ID already exists."); + error.Detail.Should().Be($"Another resource of type 'textLanguages' with ID '{languageToCreate.StringId}' already exists."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Meta.Should().NotContainKey("requestBody"); + } - [Fact] - public async Task Cannot_create_resource_for_incompatible_ID() - { - // Arrange - string guid = Unknown.StringId.Guid; + [Fact] + public async Task Cannot_create_resource_for_incompatible_ID() + { + // Arrange + string guid = Unknown.StringId.Guid; - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "performers", + id = guid, + attributes = new { - type = "performers", - id = guid, - attributes = new - { - } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Incompatible 'id' value found."); - error.Detail.Should().Be($"Failed to convert '{guid}' of type 'String' to type 'Int32'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/id"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Incompatible 'id' value found."); + error.Detail.Should().Be($"Failed to convert '{guid}' of type 'String' to type 'Int32'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/id"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_create_resource_for_ID_and_local_ID() + [Fact] + public async Task Cannot_create_resource_for_ID_and_local_ID() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + data = new { - op = "add", - data = new - { - type = "lyrics", - id = Unknown.StringId.For(), - lid = "local-1" - } + type = "lyrics", + id = Unknown.StringId.For(), + lid = "local-1" } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs index ebb7dbc272..465f39cee1 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs @@ -1,692 +1,687 @@ -using System; -using System.Collections.Generic; using System.Net; -using System.Net.Http; -using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.EntityFrameworkCore; using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Creating +namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Creating; + +public sealed class AtomicCreateResourceWithToManyRelationshipTests + : IClassFixture, OperationsDbContext>> { - public sealed class AtomicCreateResourceWithToManyRelationshipTests - : IClassFixture, OperationsDbContext>> + private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new(); + + public AtomicCreateResourceWithToManyRelationshipTests(IntegrationTestContext, OperationsDbContext> testContext) { - private readonly IntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new(); + _testContext = testContext; - public AtomicCreateResourceWithToManyRelationshipTests(IntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; + testContext.UseController(); - testContext.UseController(); + // These routes need to be registered in ASP.NET for rendering links to resource/relationship endpoints. + testContext.UseController(); + testContext.UseController(); + } - // These routes need to be registered in ASP.NET for rendering links to resource/relationship endpoints. - testContext.UseController(); - testContext.UseController(); - } + [Fact] + public async Task Can_create_OneToMany_relationship() + { + // Arrange + List existingPerformers = _fakers.Performer.Generate(2); + string newTitle = _fakers.MusicTrack.Generate().Title; - [Fact] - public async Task Can_create_OneToMany_relationship() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - List existingPerformers = _fakers.Performer.Generate(2); - string newTitle = _fakers.MusicTrack.Generate().Title; + dbContext.Performers.AddRange(existingPerformers); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Performers.AddRange(existingPerformers); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "musicTracks", + attributes = new { - type = "musicTracks", - attributes = new - { - title = newTitle - }, - relationships = new + title = newTitle + }, + relationships = new + { + performers = new { - performers = new + data = new[] { - data = new[] + new { - new - { - type = "performers", - id = existingPerformers[0].StringId - }, - new - { - type = "performers", - id = existingPerformers[1].StringId - } + type = "performers", + id = existingPerformers[0].StringId + }, + new + { + type = "performers", + id = existingPerformers[1].StringId } } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(1); + responseDocument.Results.ShouldHaveCount(1); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("musicTracks"); - resource.Attributes.ShouldNotBeEmpty(); - resource.Relationships.ShouldNotBeEmpty(); - }); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("musicTracks"); + resource.Attributes.ShouldNotBeEmpty(); + resource.Relationships.ShouldNotBeEmpty(); + }); - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(newTrackId); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(newTrackId); - trackInDatabase.Performers.ShouldHaveCount(2); - trackInDatabase.Performers.Should().ContainSingle(performer => performer.Id == existingPerformers[0].Id); - trackInDatabase.Performers.Should().ContainSingle(performer => performer.Id == existingPerformers[1].Id); - }); - } + trackInDatabase.Performers.ShouldHaveCount(2); + trackInDatabase.Performers.Should().ContainSingle(performer => performer.Id == existingPerformers[0].Id); + trackInDatabase.Performers.Should().ContainSingle(performer => performer.Id == existingPerformers[1].Id); + }); + } - [Fact] - public async Task Can_create_ManyToMany_relationship() - { - // Arrange - List existingTracks = _fakers.MusicTrack.Generate(3); - string newName = _fakers.Playlist.Generate().Name; + [Fact] + public async Task Can_create_ManyToMany_relationship() + { + // Arrange + List existingTracks = _fakers.MusicTrack.Generate(3); + string newName = _fakers.Playlist.Generate().Name; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.AddRange(existingTracks); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.AddRange(existingTracks); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "playlists", + attributes = new { - type = "playlists", - attributes = new - { - name = newName - }, - relationships = new + name = newName + }, + relationships = new + { + tracks = new { - tracks = new + data = new[] { - data = new[] + new + { + type = "musicTracks", + id = existingTracks[0].StringId + }, + new { - new - { - type = "musicTracks", - id = existingTracks[0].StringId - }, - new - { - type = "musicTracks", - id = existingTracks[1].StringId - }, - new - { - type = "musicTracks", - id = existingTracks[2].StringId - } + type = "musicTracks", + id = existingTracks[1].StringId + }, + new + { + type = "musicTracks", + id = existingTracks[2].StringId } } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(1); + responseDocument.Results.ShouldHaveCount(1); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("playlists"); - resource.Attributes.ShouldNotBeEmpty(); - resource.Relationships.ShouldNotBeEmpty(); - }); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("playlists"); + resource.Attributes.ShouldNotBeEmpty(); + resource.Relationships.ShouldNotBeEmpty(); + }); - long newPlaylistId = long.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + long newPlaylistId = long.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(newPlaylistId); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(newPlaylistId); - playlistInDatabase.Tracks.ShouldHaveCount(3); - playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[0].Id); - playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[1].Id); - playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[2].Id); - }); - } + playlistInDatabase.Tracks.ShouldHaveCount(3); + playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[0].Id); + playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[1].Id); + playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[2].Id); + }); + } - [Fact] - public async Task Cannot_create_for_missing_relationship_type() + [Fact] + public async Task Cannot_create_for_missing_relationship_type() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "musicTracks", + relationships = new { - type = "musicTracks", - relationships = new + performers = new { - performers = new + data = new[] { - data = new[] + new { - new - { - id = Unknown.StringId.For() - } + id = Unknown.StringId.For() } } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data[0]"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_create_for_unknown_relationship_type() + [Fact] + public async Task Cannot_create_for_unknown_relationship_type() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "musicTracks", + relationships = new { - type = "musicTracks", - relationships = new + performers = new { - performers = new + data = new[] { - data = new[] + new { - new - { - type = Unknown.ResourceType, - id = Unknown.StringId.For() - } + type = Unknown.ResourceType, + id = Unknown.StringId.For() } } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); - error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data[0]/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); + error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data[0]/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_create_for_missing_relationship_ID() + [Fact] + public async Task Cannot_create_for_missing_relationship_ID() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "musicTracks", + relationships = new { - type = "musicTracks", - relationships = new + performers = new { - performers = new + data = new[] { - data = new[] + new { - new - { - type = "performers" - } + type = "performers" } } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data[0]"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_create_for_unknown_relationship_IDs() - { - // Arrange - string newTitle = _fakers.MusicTrack.Generate().Title; + [Fact] + public async Task Cannot_create_for_unknown_relationship_IDs() + { + // Arrange + string newTitle = _fakers.MusicTrack.Generate().Title; - string performerId1 = Unknown.StringId.For(); - string performerId2 = Unknown.StringId.AltFor(); + string performerId1 = Unknown.StringId.For(); + string performerId2 = Unknown.StringId.AltFor(); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "musicTracks", + attributes = new { - type = "musicTracks", - attributes = new - { - title = newTitle - }, - relationships = new + title = newTitle + }, + relationships = new + { + performers = new { - performers = new + data = new[] { - data = new[] + new { - new - { - type = "performers", - id = performerId1 - }, - new - { - type = "performers", - id = performerId2 - } + type = "performers", + id = performerId1 + }, + new + { + type = "performers", + id = performerId2 } } } } } } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.ShouldHaveCount(2); - - ErrorObject error1 = responseDocument.Errors[0]; - error1.StatusCode.Should().Be(HttpStatusCode.NotFound); - error1.Title.Should().Be("A related resource does not exist."); - error1.Detail.Should().Be($"Related resource of type 'performers' with ID '{performerId1}' in relationship 'performers' does not exist."); - error1.Source.ShouldNotBeNull(); - error1.Source.Pointer.Should().Be("/atomic:operations[0]"); - error1.Meta.Should().NotContainKey("requestBody"); - - ErrorObject error2 = responseDocument.Errors[1]; - error2.StatusCode.Should().Be(HttpStatusCode.NotFound); - error2.Title.Should().Be("A related resource does not exist."); - error2.Detail.Should().Be($"Related resource of type 'performers' with ID '{performerId2}' in relationship 'performers' does not exist."); - error2.Source.ShouldNotBeNull(); - error2.Source.Pointer.Should().Be("/atomic:operations[0]"); - error2.Meta.Should().NotContainKey("requestBody"); - } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.ShouldHaveCount(2); + + ErrorObject error1 = responseDocument.Errors[0]; + error1.StatusCode.Should().Be(HttpStatusCode.NotFound); + error1.Title.Should().Be("A related resource does not exist."); + error1.Detail.Should().Be($"Related resource of type 'performers' with ID '{performerId1}' in relationship 'performers' does not exist."); + error1.Source.ShouldNotBeNull(); + error1.Source.Pointer.Should().Be("/atomic:operations[0]"); + error1.Meta.Should().NotContainKey("requestBody"); + + ErrorObject error2 = responseDocument.Errors[1]; + error2.StatusCode.Should().Be(HttpStatusCode.NotFound); + error2.Title.Should().Be("A related resource does not exist."); + error2.Detail.Should().Be($"Related resource of type 'performers' with ID '{performerId2}' in relationship 'performers' does not exist."); + error2.Source.ShouldNotBeNull(); + error2.Source.Pointer.Should().Be("/atomic:operations[0]"); + error2.Meta.Should().NotContainKey("requestBody"); + } - [Fact] - public async Task Cannot_create_on_relationship_type_mismatch() + [Fact] + public async Task Cannot_create_on_relationship_type_mismatch() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "musicTracks", + relationships = new { - type = "musicTracks", - relationships = new + performers = new { - performers = new + data = new[] { - data = new[] + new { - new - { - type = "playlists", - id = Unknown.StringId.For() - } + type = "playlists", + id = Unknown.StringId.For() } } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.Conflict); - error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found."); - error.Detail.Should().Be("Type 'playlists' is incompatible with type 'performers' of relationship 'performers'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data[0]/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Conflict); + error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found."); + error.Detail.Should().Be("Type 'playlists' is incompatible with type 'performers' of relationship 'performers'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data[0]/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Can_create_with_duplicates() - { - // Arrange - Performer existingPerformer = _fakers.Performer.Generate(); - string newTitle = _fakers.MusicTrack.Generate().Title; + [Fact] + public async Task Can_create_with_duplicates() + { + // Arrange + Performer existingPerformer = _fakers.Performer.Generate(); + string newTitle = _fakers.MusicTrack.Generate().Title; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Performers.Add(existingPerformer); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Performers.Add(existingPerformer); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "musicTracks", + attributes = new { - type = "musicTracks", - attributes = new - { - title = newTitle - }, - relationships = new + title = newTitle + }, + relationships = new + { + performers = new { - performers = new + data = new[] { - data = new[] + new + { + type = "performers", + id = existingPerformer.StringId + }, + new { - new - { - type = "performers", - id = existingPerformer.StringId - }, - new - { - type = "performers", - id = existingPerformer.StringId - } + type = "performers", + id = existingPerformer.StringId } } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(1); + responseDocument.Results.ShouldHaveCount(1); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("musicTracks"); - resource.Attributes.ShouldNotBeEmpty(); - resource.Relationships.ShouldNotBeEmpty(); - }); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("musicTracks"); + resource.Attributes.ShouldNotBeEmpty(); + resource.Relationships.ShouldNotBeEmpty(); + }); - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(newTrackId); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(newTrackId); - trackInDatabase.Performers.ShouldHaveCount(1); - trackInDatabase.Performers[0].Id.Should().Be(existingPerformer.Id); - }); - } + trackInDatabase.Performers.ShouldHaveCount(1); + trackInDatabase.Performers[0].Id.Should().Be(existingPerformer.Id); + }); + } - [Fact] - public async Task Cannot_create_with_missing_data_in_OneToMany_relationship() + [Fact] + public async Task Cannot_create_with_missing_data_in_OneToMany_relationship() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "musicTracks", + relationships = new { - type = "musicTracks", - relationships = new + performers = new { - performers = new - { - } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'data' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_create_with_null_data_in_ManyToMany_relationship() + [Fact] + public async Task Cannot_create_with_null_data_in_ManyToMany_relationship() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "playlists", + relationships = new { - type = "playlists", - relationships = new + tracks = new { - tracks = new - { - data = (object?)null - } + data = (object?)null } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected an array, instead of 'null'."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/tracks/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Expected an array, instead of 'null'."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/tracks/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_create_with_object_data_in_ManyToMany_relationship() + [Fact] + public async Task Cannot_create_with_object_data_in_ManyToMany_relationship() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "playlists", + relationships = new { - type = "playlists", - relationships = new + tracks = new { - tracks = new + data = new { - data = new - { - } } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected an array, instead of an object."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/tracks/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Expected an array, instead of an object."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/tracks/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs index 776947b303..64fc6e66d3 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs @@ -1,743 +1,737 @@ -using System; -using System.Collections.Generic; -using System.Linq; using System.Net; -using System.Net.Http; using System.Text.Json; -using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.EntityFrameworkCore; using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Creating +namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Creating; + +public sealed class AtomicCreateResourceWithToOneRelationshipTests + : IClassFixture, OperationsDbContext>> { - public sealed class AtomicCreateResourceWithToOneRelationshipTests - : IClassFixture, OperationsDbContext>> - { - private readonly IntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new(); + private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new(); - public AtomicCreateResourceWithToOneRelationshipTests(IntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; + public AtomicCreateResourceWithToOneRelationshipTests(IntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; - testContext.UseController(); + testContext.UseController(); - // These routes need to be registered in ASP.NET for rendering links to resource/relationship endpoints. - testContext.UseController(); - testContext.UseController(); - } + // These routes need to be registered in ASP.NET for rendering links to resource/relationship endpoints. + testContext.UseController(); + testContext.UseController(); + } - [Fact] - public async Task Can_create_OneToOne_relationship_from_principal_side() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + [Fact] + public async Task Can_create_OneToOne_relationship_from_principal_side() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - string newLyricText = _fakers.Lyric.Generate().Text; + string newLyricText = _fakers.Lyric.Generate().Text; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "lyrics", + attributes = new { - type = "lyrics", - attributes = new - { - text = newLyricText - }, - relationships = new + text = newLyricText + }, + relationships = new + { + track = new { - track = new + data = new { - data = new - { - type = "musicTracks", - id = existingTrack.StringId - } + type = "musicTracks", + id = existingTrack.StringId } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(1); + responseDocument.Results.ShouldHaveCount(1); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("lyrics"); - resource.Attributes.ShouldNotBeEmpty(); - resource.Relationships.ShouldNotBeEmpty(); - }); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("lyrics"); + resource.Attributes.ShouldNotBeEmpty(); + resource.Relationships.ShouldNotBeEmpty(); + }); - long newLyricId = long.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + long newLyricId = long.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Lyric lyricInDatabase = await dbContext.Lyrics.Include(lyric => lyric.Track).FirstWithIdAsync(newLyricId); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Lyric lyricInDatabase = await dbContext.Lyrics.Include(lyric => lyric.Track).FirstWithIdAsync(newLyricId); - lyricInDatabase.Track.ShouldNotBeNull(); - lyricInDatabase.Track.Id.Should().Be(existingTrack.Id); - }); - } + lyricInDatabase.Track.ShouldNotBeNull(); + lyricInDatabase.Track.Id.Should().Be(existingTrack.Id); + }); + } - [Fact] - public async Task Can_create_OneToOne_relationship_from_dependent_side() - { - // Arrange - Lyric existingLyric = _fakers.Lyric.Generate(); - string newTrackTitle = _fakers.MusicTrack.Generate().Title; + [Fact] + public async Task Can_create_OneToOne_relationship_from_dependent_side() + { + // Arrange + Lyric existingLyric = _fakers.Lyric.Generate(); + string newTrackTitle = _fakers.MusicTrack.Generate().Title; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Lyrics.Add(existingLyric); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Lyrics.Add(existingLyric); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "musicTracks", + attributes = new { - type = "musicTracks", - attributes = new - { - title = newTrackTitle - }, - relationships = new + title = newTrackTitle + }, + relationships = new + { + lyric = new { - lyric = new + data = new { - data = new - { - type = "lyrics", - id = existingLyric.StringId - } + type = "lyrics", + id = existingLyric.StringId } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(1); + responseDocument.Results.ShouldHaveCount(1); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("musicTracks"); - resource.Attributes.ShouldNotBeEmpty(); - resource.Relationships.ShouldNotBeEmpty(); - }); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("musicTracks"); + resource.Attributes.ShouldNotBeEmpty(); + resource.Relationships.ShouldNotBeEmpty(); + }); - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Lyric).FirstWithIdAsync(newTrackId); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Lyric).FirstWithIdAsync(newTrackId); - trackInDatabase.Lyric.ShouldNotBeNull(); - trackInDatabase.Lyric.Id.Should().Be(existingLyric.Id); - }); - } + trackInDatabase.Lyric.ShouldNotBeNull(); + trackInDatabase.Lyric.Id.Should().Be(existingLyric.Id); + }); + } - [Fact] - public async Task Can_create_resources_with_ToOne_relationship() - { - // Arrange - const int elementCount = 5; + [Fact] + public async Task Can_create_resources_with_ToOne_relationship() + { + // Arrange + const int elementCount = 5; - RecordCompany existingCompany = _fakers.RecordCompany.Generate(); - string[] newTrackTitles = _fakers.MusicTrack.Generate(elementCount).Select(musicTrack => musicTrack.Title).ToArray(); + RecordCompany existingCompany = _fakers.RecordCompany.Generate(); + string[] newTrackTitles = _fakers.MusicTrack.Generate(elementCount).Select(musicTrack => musicTrack.Title).ToArray(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.RecordCompanies.Add(existingCompany); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.RecordCompanies.Add(existingCompany); + await dbContext.SaveChangesAsync(); + }); - var operationElements = new List(elementCount); + var operationElements = new List(elementCount); - for (int index = 0; index < elementCount; index++) + for (int index = 0; index < elementCount; index++) + { + operationElements.Add(new { - operationElements.Add(new + op = "add", + data = new { - op = "add", - data = new + type = "musicTracks", + attributes = new { - type = "musicTracks", - attributes = new - { - title = newTrackTitles[index] - }, - relationships = new + title = newTrackTitles[index] + }, + relationships = new + { + ownedBy = new { - ownedBy = new + data = new { - data = new - { - type = "recordCompanies", - id = existingCompany.StringId - } + type = "recordCompanies", + id = existingCompany.StringId } } } - }); - } + } + }); + } - var requestBody = new - { - atomic__operations = operationElements - }; + var requestBody = new + { + atomic__operations = operationElements + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(elementCount); + responseDocument.Results.ShouldHaveCount(elementCount); - for (int index = 0; index < elementCount; index++) + for (int index = 0; index < elementCount; index++) + { + responseDocument.Results[index].Data.SingleValue.ShouldNotBeNull().With(resource => { - responseDocument.Results[index].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("musicTracks"); - resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitles[index])); - }); - } + resource.Type.Should().Be("musicTracks"); + resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitles[index])); + }); + } - Guid[] newTrackIds = responseDocument.Results.Select(result => Guid.Parse(result.Data.SingleValue!.Id.ShouldNotBeNull())).ToArray(); + Guid[] newTrackIds = responseDocument.Results.Select(result => Guid.Parse(result.Data.SingleValue!.Id.ShouldNotBeNull())).ToArray(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true + await _testContext.RunOnDatabaseAsync(async dbContext => + { + // @formatter:wrap_chained_method_calls chop_always + // @formatter:keep_existing_linebreaks true - List tracksInDatabase = await dbContext.MusicTracks - .Include(musicTrack => musicTrack.OwnedBy) - .Where(musicTrack => newTrackIds.Contains(musicTrack.Id)) - .ToListAsync(); + List tracksInDatabase = await dbContext.MusicTracks + .Include(musicTrack => musicTrack.OwnedBy) + .Where(musicTrack => newTrackIds.Contains(musicTrack.Id)) + .ToListAsync(); - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore + // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_chained_method_calls restore - tracksInDatabase.ShouldHaveCount(elementCount); + tracksInDatabase.ShouldHaveCount(elementCount); - for (int index = 0; index < elementCount; index++) - { - MusicTrack trackInDatabase = tracksInDatabase.Single(musicTrack => musicTrack.Id == newTrackIds[index]); + for (int index = 0; index < elementCount; index++) + { + MusicTrack trackInDatabase = tracksInDatabase.Single(musicTrack => musicTrack.Id == newTrackIds[index]); - trackInDatabase.Title.Should().Be(newTrackTitles[index]); + trackInDatabase.Title.Should().Be(newTrackTitles[index]); - trackInDatabase.OwnedBy.ShouldNotBeNull(); - trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); - } - }); - } + trackInDatabase.OwnedBy.ShouldNotBeNull(); + trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); + } + }); + } - [Fact] - public async Task Cannot_create_for_null_relationship() + [Fact] + public async Task Cannot_create_for_null_relationship() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "musicTracks", + relationships = new { - type = "musicTracks", - relationships = new - { - lyric = (object?)null - } + lyric = (object?)null } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected an object, instead of 'null'."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Expected an object, instead of 'null'."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_create_for_missing_data_in_relationship() + [Fact] + public async Task Cannot_create_for_missing_data_in_relationship() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "musicTracks", + relationships = new { - type = "musicTracks", - relationships = new + lyric = new { - lyric = new - { - } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'data' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_create_for_array_data_in_relationship() + [Fact] + public async Task Cannot_create_for_array_data_in_relationship() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "musicTracks", + relationships = new { - type = "musicTracks", - relationships = new + lyric = new { - lyric = new + data = new[] { - data = new[] + new { - new - { - type = "lyrics", - id = Unknown.StringId.For() - } + type = "lyrics", + id = Unknown.StringId.For() } } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected an object or 'null', instead of an array."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Expected an object or 'null', instead of an array."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_create_for_missing_relationship_type() + [Fact] + public async Task Cannot_create_for_missing_relationship_type() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "musicTracks", + relationships = new { - type = "musicTracks", - relationships = new + lyric = new { - lyric = new + data = new { - data = new - { - id = Unknown.StringId.For() - } + id = Unknown.StringId.For() } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_create_for_unknown_relationship_type() + [Fact] + public async Task Cannot_create_for_unknown_relationship_type() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "musicTracks", + relationships = new { - type = "musicTracks", - relationships = new + lyric = new { - lyric = new + data = new { - data = new - { - type = Unknown.ResourceType, - id = Unknown.StringId.For() - } + type = Unknown.ResourceType, + id = Unknown.StringId.For() } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); - error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); + error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_create_for_missing_relationship_ID() + [Fact] + public async Task Cannot_create_for_missing_relationship_ID() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "musicTracks", + relationships = new { - type = "musicTracks", - relationships = new + lyric = new { - lyric = new + data = new { - data = new - { - type = "lyrics" - } + type = "lyrics" } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_create_with_unknown_relationship_ID() - { - // Arrange - string newTrackTitle = _fakers.MusicTrack.Generate().Title; + [Fact] + public async Task Cannot_create_with_unknown_relationship_ID() + { + // Arrange + string newTrackTitle = _fakers.MusicTrack.Generate().Title; - string lyricId = Unknown.StringId.For(); + string lyricId = Unknown.StringId.For(); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "musicTracks", + attributes = new { - type = "musicTracks", - attributes = new - { - title = newTrackTitle - }, - relationships = new + title = newTrackTitle + }, + relationships = new + { + lyric = new { - lyric = new + data = new { - data = new - { - type = "lyrics", - id = lyricId - } + type = "lyrics", + id = lyricId } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.NotFound); - error.Title.Should().Be("A related resource does not exist."); - error.Detail.Should().Be($"Related resource of type 'lyrics' with ID '{lyricId}' in relationship 'lyric' does not exist."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - error.Meta.Should().NotContainKey("requestBody"); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("A related resource does not exist."); + error.Detail.Should().Be($"Related resource of type 'lyrics' with ID '{lyricId}' in relationship 'lyric' does not exist."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Meta.Should().NotContainKey("requestBody"); + } - [Fact] - public async Task Cannot_create_on_relationship_type_mismatch() + [Fact] + public async Task Cannot_create_on_relationship_type_mismatch() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "musicTracks", + relationships = new { - type = "musicTracks", - relationships = new + lyric = new { - lyric = new + data = new { - data = new - { - type = "playlists", - id = Unknown.StringId.For() - } + type = "playlists", + id = Unknown.StringId.For() } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.Conflict); - error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found."); - error.Detail.Should().Be("Type 'playlists' is incompatible with type 'lyrics' of relationship 'lyric'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Conflict); + error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found."); + error.Detail.Should().Be("Type 'playlists' is incompatible with type 'lyrics' of relationship 'lyric'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Can_create_resource_with_duplicate_relationship() - { - // Arrange - RecordCompany existingCompany = _fakers.RecordCompany.Generate(); - string newTrackTitle = _fakers.MusicTrack.Generate().Title; + [Fact] + public async Task Can_create_resource_with_duplicate_relationship() + { + // Arrange + RecordCompany existingCompany = _fakers.RecordCompany.Generate(); + string newTrackTitle = _fakers.MusicTrack.Generate().Title; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.RecordCompanies.Add(existingCompany); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.RecordCompanies.Add(existingCompany); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "musicTracks", + attributes = new + { + title = newTrackTitle + }, + relationships = new { - type = "musicTracks", - attributes = new + ownedBy = new { - title = newTrackTitle + data = new + { + type = "recordCompanies", + id = existingCompany.StringId + } }, - relationships = new + ownedBy_duplicate = new { - ownedBy = new - { - data = new - { - type = "recordCompanies", - id = existingCompany.StringId - } - }, - ownedBy_duplicate = new + data = new { - data = new - { - type = "recordCompanies", - id = existingCompany.StringId - } + type = "recordCompanies", + id = existingCompany.StringId } } } } } - }; + } + }; - string requestBodyText = JsonSerializer.Serialize(requestBody).Replace("ownedBy_duplicate", "ownedBy"); + string requestBodyText = JsonSerializer.Serialize(requestBody).Replace("ownedBy_duplicate", "ownedBy"); - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBodyText); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBodyText); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(1); + responseDocument.Results.ShouldHaveCount(1); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("musicTracks"); - resource.Attributes.ShouldNotBeEmpty(); - resource.Relationships.ShouldNotBeEmpty(); - }); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("musicTracks"); + resource.Attributes.ShouldNotBeEmpty(); + resource.Relationships.ShouldNotBeEmpty(); + }); - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(newTrackId); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(newTrackId); - trackInDatabase.OwnedBy.ShouldNotBeNull(); - trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); - }); - } + trackInDatabase.OwnedBy.ShouldNotBeNull(); + trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); + }); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs index e17e0966bc..45dbb11f5e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs @@ -1,637 +1,631 @@ -using System; -using System.Collections.Generic; -using System.Linq; using System.Net; -using System.Net.Http; -using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.EntityFrameworkCore; using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Deleting +namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Deleting; + +public sealed class AtomicDeleteResourceTests : IClassFixture, OperationsDbContext>> { - public sealed class AtomicDeleteResourceTests : IClassFixture, OperationsDbContext>> + private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new(); + + public AtomicDeleteResourceTests(IntegrationTestContext, OperationsDbContext> testContext) { - private readonly IntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new(); + _testContext = testContext; - public AtomicDeleteResourceTests(IntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; + testContext.UseController(); + } - testContext.UseController(); - } + [Fact] + public async Task Can_delete_existing_resource() + { + // Arrange + Performer existingPerformer = _fakers.Performer.Generate(); - [Fact] - public async Task Can_delete_existing_resource() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - Performer existingPerformer = _fakers.Performer.Generate(); + dbContext.Performers.Add(existingPerformer); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Performers.Add(existingPerformer); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "remove", + @ref = new { - op = "remove", - @ref = new - { - type = "performers", - id = existingPerformer.StringId - } + type = "performers", + id = existingPerformer.StringId } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Performer? performerInDatabase = await dbContext.Performers.FirstWithIdOrDefaultAsync(existingPerformer.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Performer? performerInDatabase = await dbContext.Performers.FirstWithIdOrDefaultAsync(existingPerformer.Id); - performerInDatabase.Should().BeNull(); - }); - } + performerInDatabase.Should().BeNull(); + }); + } - [Fact] - public async Task Can_delete_existing_resources() - { - // Arrange - const int elementCount = 5; + [Fact] + public async Task Can_delete_existing_resources() + { + // Arrange + const int elementCount = 5; - List existingTracks = _fakers.MusicTrack.Generate(elementCount); + List existingTracks = _fakers.MusicTrack.Generate(elementCount); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.MusicTracks.AddRange(existingTracks); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.MusicTracks.AddRange(existingTracks); + await dbContext.SaveChangesAsync(); + }); - var operationElements = new List(elementCount); + var operationElements = new List(elementCount); - for (int index = 0; index < elementCount; index++) + for (int index = 0; index < elementCount; index++) + { + operationElements.Add(new { - operationElements.Add(new + op = "remove", + @ref = new { - op = "remove", - @ref = new - { - type = "musicTracks", - id = existingTracks[index].StringId - } - }); - } + type = "musicTracks", + id = existingTracks[index].StringId + } + }); + } - var requestBody = new - { - atomic__operations = operationElements - }; + var requestBody = new + { + atomic__operations = operationElements + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.Should().BeEmpty(); - }); - } + tracksInDatabase.Should().BeEmpty(); + }); + } - [Fact] - public async Task Can_delete_resource_with_OneToOne_relationship_from_principal_side() - { - // Arrange - Lyric existingLyric = _fakers.Lyric.Generate(); - existingLyric.Track = _fakers.MusicTrack.Generate(); + [Fact] + public async Task Can_delete_resource_with_OneToOne_relationship_from_principal_side() + { + // Arrange + Lyric existingLyric = _fakers.Lyric.Generate(); + existingLyric.Track = _fakers.MusicTrack.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Lyrics.Add(existingLyric); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Lyrics.Add(existingLyric); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "remove", + @ref = new { - op = "remove", - @ref = new - { - type = "lyrics", - id = existingLyric.StringId - } + type = "lyrics", + id = existingLyric.StringId } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Lyric? lyricInDatabase = await dbContext.Lyrics.FirstWithIdOrDefaultAsync(existingLyric.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Lyric? lyricInDatabase = await dbContext.Lyrics.FirstWithIdOrDefaultAsync(existingLyric.Id); - lyricInDatabase.Should().BeNull(); + lyricInDatabase.Should().BeNull(); - MusicTrack trackInDatabase = await dbContext.MusicTracks.FirstWithIdAsync(existingLyric.Track.Id); + MusicTrack trackInDatabase = await dbContext.MusicTracks.FirstWithIdAsync(existingLyric.Track.Id); - trackInDatabase.Lyric.Should().BeNull(); - }); - } + trackInDatabase.Lyric.Should().BeNull(); + }); + } - [Fact] - public async Task Can_delete_resource_with_OneToOne_relationship_from_dependent_side() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.Lyric = _fakers.Lyric.Generate(); + [Fact] + public async Task Can_delete_resource_with_OneToOne_relationship_from_dependent_side() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + existingTrack.Lyric = _fakers.Lyric.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "remove", + @ref = new { - op = "remove", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId - } + type = "musicTracks", + id = existingTrack.StringId } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack? trackInDatabase = await dbContext.MusicTracks.FirstWithIdOrDefaultAsync(existingTrack.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + MusicTrack? trackInDatabase = await dbContext.MusicTracks.FirstWithIdOrDefaultAsync(existingTrack.Id); - trackInDatabase.Should().BeNull(); + trackInDatabase.Should().BeNull(); - Lyric lyricInDatabase = await dbContext.Lyrics.FirstWithIdAsync(existingTrack.Lyric.Id); + Lyric lyricInDatabase = await dbContext.Lyrics.FirstWithIdAsync(existingTrack.Lyric.Id); - lyricInDatabase.Track.Should().BeNull(); - }); - } + lyricInDatabase.Track.Should().BeNull(); + }); + } - [Fact] - public async Task Can_delete_existing_resource_with_OneToMany_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.Performers = _fakers.Performer.Generate(2); + [Fact] + public async Task Can_delete_existing_resource_with_OneToMany_relationship() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + existingTrack.Performers = _fakers.Performer.Generate(2); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "remove", + @ref = new { - op = "remove", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId - } + type = "musicTracks", + id = existingTrack.StringId } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack? trackInDatabase = await dbContext.MusicTracks.FirstWithIdOrDefaultAsync(existingTrack.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + MusicTrack? trackInDatabase = await dbContext.MusicTracks.FirstWithIdOrDefaultAsync(existingTrack.Id); - trackInDatabase.Should().BeNull(); + trackInDatabase.Should().BeNull(); - List performersInDatabase = await dbContext.Performers.ToListAsync(); + List performersInDatabase = await dbContext.Performers.ToListAsync(); - performersInDatabase.Should().ContainSingle(userAccount => userAccount.Id == existingTrack.Performers.ElementAt(0).Id); - performersInDatabase.Should().ContainSingle(userAccount => userAccount.Id == existingTrack.Performers.ElementAt(1).Id); - }); - } + performersInDatabase.Should().ContainSingle(userAccount => userAccount.Id == existingTrack.Performers.ElementAt(0).Id); + performersInDatabase.Should().ContainSingle(userAccount => userAccount.Id == existingTrack.Performers.ElementAt(1).Id); + }); + } - [Fact] - public async Task Can_delete_existing_resource_with_ManyToMany_relationship() - { - // Arrange - Playlist existingPlaylist = _fakers.Playlist.Generate(); - existingPlaylist.Tracks = _fakers.MusicTrack.Generate(1); + [Fact] + public async Task Can_delete_existing_resource_with_ManyToMany_relationship() + { + // Arrange + Playlist existingPlaylist = _fakers.Playlist.Generate(); + existingPlaylist.Tracks = _fakers.MusicTrack.Generate(1); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Playlists.Add(existingPlaylist); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Playlists.Add(existingPlaylist); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "remove", + @ref = new { - op = "remove", - @ref = new - { - type = "playlists", - id = existingPlaylist.StringId - } + type = "playlists", + id = existingPlaylist.StringId } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Playlist? playlistInDatabase = await dbContext.Playlists.FirstWithIdOrDefaultAsync(existingPlaylist.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Playlist? playlistInDatabase = await dbContext.Playlists.FirstWithIdOrDefaultAsync(existingPlaylist.Id); - playlistInDatabase.Should().BeNull(); + playlistInDatabase.Should().BeNull(); - MusicTrack? trackInDatabase = await dbContext.MusicTracks.FirstWithIdOrDefaultAsync(existingPlaylist.Tracks[0].Id); + MusicTrack? trackInDatabase = await dbContext.MusicTracks.FirstWithIdOrDefaultAsync(existingPlaylist.Tracks[0].Id); - trackInDatabase.ShouldNotBeNull(); - }); - } + trackInDatabase.ShouldNotBeNull(); + }); + } - [Fact] - public async Task Cannot_delete_resource_for_href_element() + [Fact] + public async Task Cannot_delete_resource_for_href_element() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new - { - op = "remove", - href = "/api/v1/musicTracks/1" - } + op = "remove", + href = "/api/v1/musicTracks/1" } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'href' element is not supported."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/href"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'href' element is not supported."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/href"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_delete_resource_for_missing_ref_element() + [Fact] + public async Task Cannot_delete_resource_for_missing_ref_element() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new - { - op = "remove" - } + op = "remove" } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'ref' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_delete_resource_for_missing_type() + [Fact] + public async Task Cannot_delete_resource_for_missing_type() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "remove", + @ref = new { - op = "remove", - @ref = new - { - id = Unknown.StringId.Int32 - } + id = Unknown.StringId.Int32 } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_delete_resource_for_unknown_type() + [Fact] + public async Task Cannot_delete_resource_for_unknown_type() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "remove", + @ref = new { - op = "remove", - @ref = new - { - type = Unknown.ResourceType, - id = Unknown.StringId.Int32 - } + type = Unknown.ResourceType, + id = Unknown.StringId.Int32 } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); - error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); + error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_delete_resource_for_missing_ID() + [Fact] + public async Task Cannot_delete_resource_for_missing_ID() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "remove", + @ref = new { - op = "remove", - @ref = new - { - type = "musicTracks" - } + type = "musicTracks" } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_delete_resource_for_unknown_ID() - { - // Arrange - string performerId = Unknown.StringId.For(); + [Fact] + public async Task Cannot_delete_resource_for_unknown_ID() + { + // Arrange + string performerId = Unknown.StringId.For(); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "remove", + @ref = new { - op = "remove", - @ref = new - { - type = "performers", - id = performerId - } + type = "performers", + id = performerId } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.NotFound); - error.Title.Should().Be("The requested resource does not exist."); - error.Detail.Should().Be($"Resource of type 'performers' with ID '{performerId}' does not exist."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - error.Meta.Should().NotContainKey("requestBody"); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("The requested resource does not exist."); + error.Detail.Should().Be($"Resource of type 'performers' with ID '{performerId}' does not exist."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Meta.Should().NotContainKey("requestBody"); + } - [Fact] - public async Task Cannot_delete_resource_for_incompatible_ID() - { - // Arrange - string guid = Unknown.StringId.Guid; + [Fact] + public async Task Cannot_delete_resource_for_incompatible_ID() + { + // Arrange + string guid = Unknown.StringId.Guid; - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "remove", + @ref = new { - op = "remove", - @ref = new - { - type = "playlists", - id = guid - } + type = "playlists", + id = guid } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Incompatible 'id' value found."); - error.Detail.Should().Be($"Failed to convert '{guid}' of type 'String' to type 'Int64'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/id"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Incompatible 'id' value found."); + error.Detail.Should().Be($"Failed to convert '{guid}' of type 'String' to type 'Int64'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/id"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_delete_resource_for_ID_and_local_ID() + [Fact] + public async Task Cannot_delete_resource_for_ID_and_local_ID() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "remove", + @ref = new { - op = "remove", - @ref = new - { - type = "musicTracks", - id = Unknown.StringId.For(), - lid = "local-1" - } + type = "musicTracks", + id = Unknown.StringId.For(), + lid = "local-1" } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ImplicitlyChangingTextLanguageDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ImplicitlyChangingTextLanguageDefinition.cs index ec80bc14b8..4548e31c16 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ImplicitlyChangingTextLanguageDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ImplicitlyChangingTextLanguageDefinition.cs @@ -1,38 +1,34 @@ -using System; -using System.Threading; -using System.Threading.Tasks; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using Microsoft.EntityFrameworkCore; -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations +namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations; + +/// +/// Used to simulate side effects that occur in the database while saving, typically caused by database triggers. +/// +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public class ImplicitlyChangingTextLanguageDefinition : HitCountingResourceDefinition { - /// - /// Used to simulate side effects that occur in the database while saving, typically caused by database triggers. - /// - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public class ImplicitlyChangingTextLanguageDefinition : HitCountingResourceDefinition - { - internal const string Suffix = " (changed)"; + internal const string Suffix = " (changed)"; - private readonly OperationsDbContext _dbContext; + private readonly OperationsDbContext _dbContext; - public ImplicitlyChangingTextLanguageDefinition(IResourceGraph resourceGraph, ResourceDefinitionHitCounter hitCounter, OperationsDbContext dbContext) - : base(resourceGraph, hitCounter) - { - _dbContext = dbContext; - } + public ImplicitlyChangingTextLanguageDefinition(IResourceGraph resourceGraph, ResourceDefinitionHitCounter hitCounter, OperationsDbContext dbContext) + : base(resourceGraph, hitCounter) + { + _dbContext = dbContext; + } - public override async Task OnWriteSucceededAsync(TextLanguage resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) - { - await base.OnWriteSucceededAsync(resource, writeOperation, cancellationToken); + public override async Task OnWriteSucceededAsync(TextLanguage resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + await base.OnWriteSucceededAsync(resource, writeOperation, cancellationToken); - if (writeOperation is not WriteOperationKind.DeleteResource) - { - string statement = $"Update \"TextLanguages\" SET \"IsoCode\" = '{resource.IsoCode}{Suffix}' WHERE \"Id\" = '{resource.StringId}'"; - await _dbContext.Database.ExecuteSqlRawAsync(statement, cancellationToken); - } + if (writeOperation is not WriteOperationKind.DeleteResource) + { + string statement = $"Update \"TextLanguages\" SET \"IsoCode\" = '{resource.IsoCode}{Suffix}' WHERE \"Id\" = '{resource.StringId}'"; + await _dbContext.Database.ExecuteSqlRawAsync(statement, cancellationToken); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Links/AtomicAbsoluteLinksTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Links/AtomicAbsoluteLinksTests.cs index 8331548d1f..792e565f3a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Links/AtomicAbsoluteLinksTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Links/AtomicAbsoluteLinksTests.cs @@ -1,6 +1,4 @@ using System.Net; -using System.Net.Http; -using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; @@ -8,167 +6,166 @@ using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Links +namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Links; + +public sealed class AtomicAbsoluteLinksTests : IClassFixture, OperationsDbContext>> { - public sealed class AtomicAbsoluteLinksTests : IClassFixture, OperationsDbContext>> - { - private const string HostPrefix = "http://localhost"; + private const string HostPrefix = "http://localhost"; - private readonly IntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new(); + private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new(); - public AtomicAbsoluteLinksTests(IntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; + public AtomicAbsoluteLinksTests(IntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; - testContext.UseController(); + testContext.UseController(); - // These routes need to be registered in ASP.NET for rendering links to resource/relationship endpoints. - testContext.UseController(); - testContext.UseController(); + // These routes need to be registered in ASP.NET for rendering links to resource/relationship endpoints. + testContext.UseController(); + testContext.UseController(); - testContext.ConfigureServicesAfterStartup(services => - { - services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); - }); - } - - [Fact] - public async Task Update_resource_with_side_effects_returns_absolute_links() + testContext.ConfigureServicesAfterStartup(services => { - // Arrange - TextLanguage existingLanguage = _fakers.TextLanguage.Generate(); - RecordCompany existingCompany = _fakers.RecordCompany.Generate(); + services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); + }); + } - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddInRange(existingLanguage, existingCompany); - await dbContext.SaveChangesAsync(); - }); + [Fact] + public async Task Update_resource_with_side_effects_returns_absolute_links() + { + // Arrange + TextLanguage existingLanguage = _fakers.TextLanguage.Generate(); + RecordCompany existingCompany = _fakers.RecordCompany.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingLanguage, existingCompany); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new object[] { - atomic__operations = new object[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = "textLanguages", + id = existingLanguage.StringId, + attributes = new { - type = "textLanguages", - id = existingLanguage.StringId, - attributes = new - { - } } - }, - new + } + }, + new + { + op = "update", + data = new { - op = "update", - data = new + type = "recordCompanies", + id = existingCompany.StringId, + attributes = new { - type = "recordCompanies", - id = existingCompany.StringId, - attributes = new - { - } } } } - }; - - const string route = "/operations"; + } + }; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + const string route = "/operations"; - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - responseDocument.Results.ShouldHaveCount(2); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - string languageLink = $"{HostPrefix}/textLanguages/{existingLanguage.StringId}"; + responseDocument.Results.ShouldHaveCount(2); - resource.ShouldNotBeNull(); - resource.Links.ShouldNotBeNull(); - resource.Links.Self.Should().Be(languageLink); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + string languageLink = $"{HostPrefix}/textLanguages/{existingLanguage.StringId}"; - resource.Relationships.ShouldContainKey("lyrics").With(value => - { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - value.Links.Self.Should().Be($"{languageLink}/relationships/lyrics"); - value.Links.Related.Should().Be($"{languageLink}/lyrics"); - }); - }); + resource.ShouldNotBeNull(); + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(languageLink); - responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + resource.Relationships.ShouldContainKey("lyrics").With(value => { - string companyLink = $"{HostPrefix}/recordCompanies/{existingCompany.StringId}"; - - resource.ShouldNotBeNull(); - resource.Links.ShouldNotBeNull(); - resource.Links.Self.Should().Be(companyLink); - - resource.Relationships.ShouldContainKey("tracks").With(value => - { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - value.Links.Self.Should().Be($"{companyLink}/relationships/tracks"); - value.Links.Related.Should().Be($"{companyLink}/tracks"); - }); + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{languageLink}/relationships/lyrics"); + value.Links.Related.Should().Be($"{languageLink}/lyrics"); }); - } + }); - [Fact] - public async Task Update_resource_with_side_effects_and_missing_resource_controller_hides_links() + responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => { - // Arrange - Playlist existingPlaylist = _fakers.Playlist.Generate(); + string companyLink = $"{HostPrefix}/recordCompanies/{existingCompany.StringId}"; - await _testContext.RunOnDatabaseAsync(async dbContext => + resource.ShouldNotBeNull(); + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(companyLink); + + resource.Relationships.ShouldContainKey("tracks").With(value => { - dbContext.Playlists.Add(existingPlaylist); - await dbContext.SaveChangesAsync(); + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{companyLink}/relationships/tracks"); + value.Links.Related.Should().Be($"{companyLink}/tracks"); }); + }); + } - var requestBody = new + [Fact] + public async Task Update_resource_with_side_effects_and_missing_resource_controller_hides_links() + { + // Arrange + Playlist existingPlaylist = _fakers.Playlist.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Playlists.Add(existingPlaylist); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new object[] { - atomic__operations = new object[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = "playlists", + id = existingPlaylist.StringId, + attributes = new { - type = "playlists", - id = existingPlaylist.StringId, - attributes = new - { - } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(1); + responseDocument.Results.ShouldHaveCount(1); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.ShouldNotBeNull(); - resource.Links.Should().BeNull(); - resource.Relationships.Should().BeNull(); - }); - } + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.ShouldNotBeNull(); + resource.Links.Should().BeNull(); + resource.Relationships.Should().BeNull(); + }); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Links/AtomicRelativeLinksWithNamespaceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Links/AtomicRelativeLinksWithNamespaceTests.cs index 06ebfbab3c..4fa5027418 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Links/AtomicRelativeLinksWithNamespaceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Links/AtomicRelativeLinksWithNamespaceTests.cs @@ -1,7 +1,4 @@ -using System; using System.Net; -using System.Net.Http; -using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; @@ -10,112 +7,111 @@ using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Links +namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Links; + +public sealed class AtomicRelativeLinksWithNamespaceTests + : IClassFixture, OperationsDbContext>> { - public sealed class AtomicRelativeLinksWithNamespaceTests - : IClassFixture, OperationsDbContext>> + private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new(); + + public AtomicRelativeLinksWithNamespaceTests( + IntegrationTestContext, OperationsDbContext> testContext) { - private readonly IntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new(); + _testContext = testContext; - public AtomicRelativeLinksWithNamespaceTests( - IntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; + testContext.UseController(); - testContext.UseController(); + // These routes need to be registered in ASP.NET for rendering links to resource/relationship endpoints. + testContext.UseController(); + testContext.UseController(); - // These routes need to be registered in ASP.NET for rendering links to resource/relationship endpoints. - testContext.UseController(); - testContext.UseController(); + testContext.ConfigureServicesAfterStartup(services => + { + services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); + }); + } - testContext.ConfigureServicesAfterStartup(services => - { - services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); - }); - } + [Fact] + public async Task Create_resource_with_side_effects_returns_relative_links() + { + // Arrange + string newCompanyName = _fakers.RecordCompany.Generate().Name; - [Fact] - public async Task Create_resource_with_side_effects_returns_relative_links() + var requestBody = new { - // Arrange - string newCompanyName = _fakers.RecordCompany.Generate().Name; - - var requestBody = new + atomic__operations = new object[] { - atomic__operations = new object[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "textLanguages", + attributes = new { - type = "textLanguages", - attributes = new - { - } } - }, - new + } + }, + new + { + op = "add", + data = new { - op = "add", - data = new + type = "recordCompanies", + attributes = new { - type = "recordCompanies", - attributes = new - { - name = newCompanyName - } + name = newCompanyName } } } - }; + } + }; - const string route = "/api/operations"; + const string route = "/api/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(2); + responseDocument.Results.ShouldHaveCount(2); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull(); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull(); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - string languageLink = $"/api/textLanguages/{Guid.Parse(resource.Id.ShouldNotBeNull())}"; + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + string languageLink = $"/api/textLanguages/{Guid.Parse(resource.Id.ShouldNotBeNull())}"; - resource.Links.ShouldNotBeNull(); - resource.Links.Self.Should().Be(languageLink); + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(languageLink); - resource.Relationships.ShouldContainKey("lyrics").With(value => - { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - value.Links.Self.Should().Be($"{languageLink}/relationships/lyrics"); - value.Links.Related.Should().Be($"{languageLink}/lyrics"); - }); + resource.Relationships.ShouldContainKey("lyrics").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{languageLink}/relationships/lyrics"); + value.Links.Related.Should().Be($"{languageLink}/lyrics"); }); + }); - responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull(); + responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull(); - responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => - { - string companyLink = $"/api/recordCompanies/{short.Parse(resource.Id.ShouldNotBeNull())}"; + responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + { + string companyLink = $"/api/recordCompanies/{short.Parse(resource.Id.ShouldNotBeNull())}"; - resource.Links.ShouldNotBeNull(); - resource.Links.Self.Should().Be(companyLink); + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(companyLink); - resource.Relationships.ShouldContainKey("tracks").With(value => - { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - value.Links.Self.Should().Be($"{companyLink}/relationships/tracks"); - value.Links.Related.Should().Be($"{companyLink}/tracks"); - }); + resource.Relationships.ShouldContainKey("tracks").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{companyLink}/relationships/tracks"); + value.Links.Related.Should().Be($"{companyLink}/tracks"); }); - } + }); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/LocalIds/AtomicLocalIdTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/LocalIds/AtomicLocalIdTests.cs index 11af30d546..05b7c22ae1 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/LocalIds/AtomicLocalIdTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/LocalIds/AtomicLocalIdTests.cs @@ -1,2556 +1,2551 @@ -using System; -using System.Collections.Generic; using System.Net; -using System.Net.Http; -using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.EntityFrameworkCore; using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.LocalIds +namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.LocalIds; + +public sealed class AtomicLocalIdTests : IClassFixture, OperationsDbContext>> { - public sealed class AtomicLocalIdTests : IClassFixture, OperationsDbContext>> - { - private readonly IntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new(); + private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new(); - public AtomicLocalIdTests(IntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; + public AtomicLocalIdTests(IntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; - testContext.UseController(); - } + testContext.UseController(); + } - [Fact] - public async Task Can_create_resource_with_ManyToOne_relationship_using_local_ID() - { - // Arrange - RecordCompany newCompany = _fakers.RecordCompany.Generate(); - string newTrackTitle = _fakers.MusicTrack.Generate().Title; + [Fact] + public async Task Can_create_resource_with_ManyToOne_relationship_using_local_ID() + { + // Arrange + RecordCompany newCompany = _fakers.RecordCompany.Generate(); + string newTrackTitle = _fakers.MusicTrack.Generate().Title; - const string companyLocalId = "company-1"; + const string companyLocalId = "company-1"; - var requestBody = new + var requestBody = new + { + atomic__operations = new object[] { - atomic__operations = new object[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "recordCompanies", + lid = companyLocalId, + attributes = new { - type = "recordCompanies", - lid = companyLocalId, - attributes = new - { - name = newCompany.Name, - countryOfResidence = newCompany.CountryOfResidence - } + name = newCompany.Name, + countryOfResidence = newCompany.CountryOfResidence } - }, - new + } + }, + new + { + op = "add", + data = new { - op = "add", - data = new + type = "musicTracks", + attributes = new { - type = "musicTracks", - attributes = new - { - title = newTrackTitle - }, - relationships = new + title = newTrackTitle + }, + relationships = new + { + ownedBy = new { - ownedBy = new + data = new { - data = new - { - type = "recordCompanies", - lid = companyLocalId - } + type = "recordCompanies", + lid = companyLocalId } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(2); + responseDocument.Results.ShouldHaveCount(2); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("recordCompanies"); - resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newCompany.Name)); - resource.Attributes.ShouldContainKey("countryOfResidence").With(value => value.Should().Be(newCompany.CountryOfResidence)); - }); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("recordCompanies"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newCompany.Name)); + resource.Attributes.ShouldContainKey("countryOfResidence").With(value => value.Should().Be(newCompany.CountryOfResidence)); + }); - responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("musicTracks"); - resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); - }); + responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("musicTracks"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); + }); - short newCompanyId = short.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - Guid newTrackId = Guid.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); + short newCompanyId = short.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + Guid newTrackId = Guid.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(newTrackId); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(newTrackId); - trackInDatabase.Title.Should().Be(newTrackTitle); + trackInDatabase.Title.Should().Be(newTrackTitle); - trackInDatabase.OwnedBy.ShouldNotBeNull(); - trackInDatabase.OwnedBy.Id.Should().Be(newCompanyId); - trackInDatabase.OwnedBy.Name.Should().Be(newCompany.Name); - trackInDatabase.OwnedBy.CountryOfResidence.Should().Be(newCompany.CountryOfResidence); - }); - } + trackInDatabase.OwnedBy.ShouldNotBeNull(); + trackInDatabase.OwnedBy.Id.Should().Be(newCompanyId); + trackInDatabase.OwnedBy.Name.Should().Be(newCompany.Name); + trackInDatabase.OwnedBy.CountryOfResidence.Should().Be(newCompany.CountryOfResidence); + }); + } - [Fact] - public async Task Can_create_resource_with_OneToMany_relationship_using_local_ID() - { - // Arrange - Performer newPerformer = _fakers.Performer.Generate(); - string newTrackTitle = _fakers.MusicTrack.Generate().Title; + [Fact] + public async Task Can_create_resource_with_OneToMany_relationship_using_local_ID() + { + // Arrange + Performer newPerformer = _fakers.Performer.Generate(); + string newTrackTitle = _fakers.MusicTrack.Generate().Title; - const string performerLocalId = "performer-1"; + const string performerLocalId = "performer-1"; - var requestBody = new + var requestBody = new + { + atomic__operations = new object[] { - atomic__operations = new object[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "performers", + lid = performerLocalId, + attributes = new { - type = "performers", - lid = performerLocalId, - attributes = new - { - artistName = newPerformer.ArtistName, - bornAt = newPerformer.BornAt - } + artistName = newPerformer.ArtistName, + bornAt = newPerformer.BornAt } - }, - new + } + }, + new + { + op = "add", + data = new { - op = "add", - data = new + type = "musicTracks", + attributes = new { - type = "musicTracks", - attributes = new - { - title = newTrackTitle - }, - relationships = new + title = newTrackTitle + }, + relationships = new + { + performers = new { - performers = new + data = new[] { - data = new[] + new { - new - { - type = "performers", - lid = performerLocalId - } + type = "performers", + lid = performerLocalId } } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(2); + responseDocument.Results.ShouldHaveCount(2); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("performers"); - resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("artistName").With(value => value.Should().Be(newPerformer.ArtistName)); - resource.Attributes.ShouldContainKey("bornAt").With(value => value.As().Should().BeCloseTo(newPerformer.BornAt)); - }); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("performers"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("artistName").With(value => value.Should().Be(newPerformer.ArtistName)); + resource.Attributes.ShouldContainKey("bornAt").With(value => value.Should().Be(newPerformer.BornAt)); + }); - responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("musicTracks"); - resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); - }); + responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("musicTracks"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); + }); - int newPerformerId = int.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - Guid newTrackId = Guid.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); + int newPerformerId = int.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + Guid newTrackId = Guid.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(newTrackId); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(newTrackId); - trackInDatabase.Title.Should().Be(newTrackTitle); + trackInDatabase.Title.Should().Be(newTrackTitle); - trackInDatabase.Performers.ShouldHaveCount(1); - trackInDatabase.Performers[0].Id.Should().Be(newPerformerId); - trackInDatabase.Performers[0].ArtistName.Should().Be(newPerformer.ArtistName); - trackInDatabase.Performers[0].BornAt.Should().BeCloseTo(newPerformer.BornAt); - }); - } + trackInDatabase.Performers.ShouldHaveCount(1); + trackInDatabase.Performers[0].Id.Should().Be(newPerformerId); + trackInDatabase.Performers[0].ArtistName.Should().Be(newPerformer.ArtistName); + trackInDatabase.Performers[0].BornAt.Should().Be(newPerformer.BornAt); + }); + } - [Fact] - public async Task Can_create_resource_with_ManyToMany_relationship_using_local_ID() - { - // Arrange - string newTrackTitle = _fakers.MusicTrack.Generate().Title; - string newPlaylistName = _fakers.Playlist.Generate().Name; + [Fact] + public async Task Can_create_resource_with_ManyToMany_relationship_using_local_ID() + { + // Arrange + string newTrackTitle = _fakers.MusicTrack.Generate().Title; + string newPlaylistName = _fakers.Playlist.Generate().Name; - const string trackLocalId = "track-1"; + const string trackLocalId = "track-1"; - var requestBody = new + var requestBody = new + { + atomic__operations = new object[] { - atomic__operations = new object[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "musicTracks", + lid = trackLocalId, + attributes = new { - type = "musicTracks", - lid = trackLocalId, - attributes = new - { - title = newTrackTitle - } + title = newTrackTitle } - }, - new + } + }, + new + { + op = "add", + data = new { - op = "add", - data = new + type = "playlists", + attributes = new { - type = "playlists", - attributes = new - { - name = newPlaylistName - }, - relationships = new + name = newPlaylistName + }, + relationships = new + { + tracks = new { - tracks = new + data = new[] { - data = new[] + new { - new - { - type = "musicTracks", - lid = trackLocalId - } + type = "musicTracks", + lid = trackLocalId } } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(2); + responseDocument.Results.ShouldHaveCount(2); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("musicTracks"); - resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); - }); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("musicTracks"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); + }); - responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("playlists"); - resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newPlaylistName)); - }); + responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("playlists"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newPlaylistName)); + }); - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - long newPlaylistId = long.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); + Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + long newPlaylistId = long.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(newPlaylistId); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(newPlaylistId); - playlistInDatabase.Name.Should().Be(newPlaylistName); + playlistInDatabase.Name.Should().Be(newPlaylistName); - playlistInDatabase.Tracks.ShouldHaveCount(1); - playlistInDatabase.Tracks[0].Id.Should().Be(newTrackId); - playlistInDatabase.Tracks[0].Title.Should().Be(newTrackTitle); - }); - } + playlistInDatabase.Tracks.ShouldHaveCount(1); + playlistInDatabase.Tracks[0].Id.Should().Be(newTrackId); + playlistInDatabase.Tracks[0].Title.Should().Be(newTrackTitle); + }); + } - [Fact] - public async Task Cannot_consume_local_ID_that_is_assigned_in_same_operation() - { - // Arrange - const string companyLocalId = "company-1"; + [Fact] + public async Task Cannot_consume_local_ID_that_is_assigned_in_same_operation() + { + // Arrange + const string companyLocalId = "company-1"; - string newCompanyName = _fakers.RecordCompany.Generate().Name; + string newCompanyName = _fakers.RecordCompany.Generate().Name; - var requestBody = new + var requestBody = new + { + atomic__operations = new object[] { - atomic__operations = new object[] + new { - new + op = "remove", + @ref = new { - op = "remove", - @ref = new - { - type = "lyrics", - id = Unknown.StringId.For() - } - }, - new + type = "lyrics", + id = Unknown.StringId.For() + } + }, + new + { + op = "add", + data = new { - op = "add", - data = new + type = "recordCompanies", + lid = companyLocalId, + attributes = new { - type = "recordCompanies", - lid = companyLocalId, - attributes = new - { - name = newCompanyName - }, - relationships = new + name = newCompanyName + }, + relationships = new + { + parent = new { - parent = new + data = new { - data = new - { - type = "recordCompanies", - lid = companyLocalId - } + type = "recordCompanies", + lid = companyLocalId } } } } } - }; + } + }; + + const string route = "/operations"; - const string route = "/operations"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + responseDocument.Errors.ShouldHaveCount(1); - responseDocument.Errors.ShouldHaveCount(1); + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("Local ID cannot be both defined and used within the same operation."); + error.Detail.Should().Be("Local ID 'company-1' cannot be both defined and used within the same operation."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[1]"); + } - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Local ID cannot be both defined and used within the same operation."); - error.Detail.Should().Be("Local ID 'company-1' cannot be both defined and used within the same operation."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[1]"); - } + [Fact] + public async Task Cannot_reassign_local_ID() + { + // Arrange + string newPlaylistName = _fakers.Playlist.Generate().Name; + const string playlistLocalId = "playlist-1"; - [Fact] - public async Task Cannot_reassign_local_ID() + var requestBody = new { - // Arrange - string newPlaylistName = _fakers.Playlist.Generate().Name; - const string playlistLocalId = "playlist-1"; - - var requestBody = new + atomic__operations = new object[] { - atomic__operations = new object[] + new { - new + op = "remove", + @ref = new { - op = "remove", - @ref = new - { - type = "lyrics", - id = Unknown.StringId.For() - } - }, - new + type = "lyrics", + id = Unknown.StringId.For() + } + }, + new + { + op = "add", + data = new { - op = "add", - data = new + type = "playlists", + lid = playlistLocalId, + attributes = new { - type = "playlists", - lid = playlistLocalId, - attributes = new - { - name = newPlaylistName - } + name = newPlaylistName } - }, - new + } + }, + new + { + op = "add", + data = new { - op = "add", - data = new + type = "playlists", + lid = playlistLocalId, + attributes = new { - type = "playlists", - lid = playlistLocalId, - attributes = new - { - name = newPlaylistName - } + name = newPlaylistName } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Another local ID with the same name is already defined at this point."); - error.Detail.Should().Be("Another local ID with name 'playlist-1' is already defined at this point."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[2]"); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("Another local ID with the same name is already defined at this point."); + error.Detail.Should().Be("Another local ID with name 'playlist-1' is already defined at this point."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[2]"); + } - [Fact] - public async Task Can_update_resource_using_local_ID() - { - // Arrange - string newTrackTitle = _fakers.MusicTrack.Generate().Title; - string newTrackGenre = _fakers.MusicTrack.Generate().Genre!; + [Fact] + public async Task Can_update_resource_using_local_ID() + { + // Arrange + string newTrackTitle = _fakers.MusicTrack.Generate().Title; + string newTrackGenre = _fakers.MusicTrack.Generate().Genre!; - const string trackLocalId = "track-1"; + const string trackLocalId = "track-1"; - var requestBody = new + var requestBody = new + { + atomic__operations = new object[] { - atomic__operations = new object[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "musicTracks", + lid = trackLocalId, + attributes = new { - type = "musicTracks", - lid = trackLocalId, - attributes = new - { - title = newTrackTitle - } + title = newTrackTitle } - }, - new + } + }, + new + { + op = "update", + data = new { - op = "update", - data = new + type = "musicTracks", + lid = trackLocalId, + attributes = new { - type = "musicTracks", - lid = trackLocalId, - attributes = new - { - genre = newTrackGenre - } + genre = newTrackGenre } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(2); + responseDocument.Results.ShouldHaveCount(2); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("musicTracks"); - resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); - resource.Attributes.ShouldContainKey("genre").With(value => value.Should().BeNull()); - }); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("musicTracks"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); + resource.Attributes.ShouldContainKey("genre").With(value => value.Should().BeNull()); + }); - responseDocument.Results[1].Data.Value.Should().BeNull(); + responseDocument.Results[1].Data.Value.Should().BeNull(); - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.FirstWithIdAsync(newTrackId); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + MusicTrack trackInDatabase = await dbContext.MusicTracks.FirstWithIdAsync(newTrackId); - trackInDatabase.Title.Should().Be(newTrackTitle); - trackInDatabase.Genre.Should().Be(newTrackGenre); - }); - } + trackInDatabase.Title.Should().Be(newTrackTitle); + trackInDatabase.Genre.Should().Be(newTrackGenre); + }); + } - [Fact] - public async Task Can_update_resource_with_relationships_using_local_ID() - { - // Arrange - string newTrackTitle = _fakers.MusicTrack.Generate().Title; - string newArtistName = _fakers.Performer.Generate().ArtistName!; - string newCompanyName = _fakers.RecordCompany.Generate().Name; + [Fact] + public async Task Can_update_resource_with_relationships_using_local_ID() + { + // Arrange + string newTrackTitle = _fakers.MusicTrack.Generate().Title; + string newArtistName = _fakers.Performer.Generate().ArtistName!; + string newCompanyName = _fakers.RecordCompany.Generate().Name; - const string trackLocalId = "track-1"; - const string performerLocalId = "performer-1"; - const string companyLocalId = "company-1"; + const string trackLocalId = "track-1"; + const string performerLocalId = "performer-1"; + const string companyLocalId = "company-1"; - var requestBody = new + var requestBody = new + { + atomic__operations = new object[] { - atomic__operations = new object[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "musicTracks", + lid = trackLocalId, + attributes = new { - type = "musicTracks", - lid = trackLocalId, - attributes = new - { - title = newTrackTitle - } + title = newTrackTitle } - }, - new + } + }, + new + { + op = "add", + data = new { - op = "add", - data = new + type = "performers", + lid = performerLocalId, + attributes = new { - type = "performers", - lid = performerLocalId, - attributes = new - { - artistName = newArtistName - } + artistName = newArtistName } - }, - new + } + }, + new + { + op = "add", + data = new { - op = "add", - data = new + type = "recordCompanies", + lid = companyLocalId, + attributes = new { - type = "recordCompanies", - lid = companyLocalId, - attributes = new - { - name = newCompanyName - } + name = newCompanyName } - }, - new + } + }, + new + { + op = "update", + data = new { - op = "update", - data = new + type = "musicTracks", + lid = trackLocalId, + relationships = new { - type = "musicTracks", - lid = trackLocalId, - relationships = new + ownedBy = new { - ownedBy = new + data = new { - data = new - { - type = "recordCompanies", - lid = companyLocalId - } - }, - performers = new + type = "recordCompanies", + lid = companyLocalId + } + }, + performers = new + { + data = new[] { - data = new[] + new { - new - { - type = "performers", - lid = performerLocalId - } + type = "performers", + lid = performerLocalId } } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(4); + responseDocument.Results.ShouldHaveCount(4); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("musicTracks"); - resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); - }); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("musicTracks"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); + }); - responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("performers"); - resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("artistName").With(value => value.Should().Be(newArtistName)); - }); + responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("performers"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("artistName").With(value => value.Should().Be(newArtistName)); + }); - responseDocument.Results[2].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("recordCompanies"); - resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newCompanyName)); - }); + responseDocument.Results[2].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("recordCompanies"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newCompanyName)); + }); - responseDocument.Results[3].Data.Value.Should().BeNull(); + responseDocument.Results[3].Data.Value.Should().BeNull(); - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - int newPerformerId = int.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); - short newCompanyId = short.Parse(responseDocument.Results[2].Data.SingleValue!.Id.ShouldNotBeNull()); + Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + int newPerformerId = int.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); + short newCompanyId = short.Parse(responseDocument.Results[2].Data.SingleValue!.Id.ShouldNotBeNull()); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true + await _testContext.RunOnDatabaseAsync(async dbContext => + { + // @formatter:wrap_chained_method_calls chop_always + // @formatter:keep_existing_linebreaks true - MusicTrack trackInDatabase = await dbContext.MusicTracks - .Include(musicTrack => musicTrack.OwnedBy) - .Include(musicTrack => musicTrack.Performers) - .FirstWithIdAsync(newTrackId); + MusicTrack trackInDatabase = await dbContext.MusicTracks + .Include(musicTrack => musicTrack.OwnedBy) + .Include(musicTrack => musicTrack.Performers) + .FirstWithIdAsync(newTrackId); - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore + // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_chained_method_calls restore - trackInDatabase.Title.Should().Be(newTrackTitle); + trackInDatabase.Title.Should().Be(newTrackTitle); - trackInDatabase.OwnedBy.ShouldNotBeNull(); - trackInDatabase.OwnedBy.Id.Should().Be(newCompanyId); + trackInDatabase.OwnedBy.ShouldNotBeNull(); + trackInDatabase.OwnedBy.Id.Should().Be(newCompanyId); - trackInDatabase.Performers.ShouldHaveCount(1); - trackInDatabase.Performers[0].Id.Should().Be(newPerformerId); - trackInDatabase.Performers[0].ArtistName.Should().Be(newArtistName); - }); - } + trackInDatabase.Performers.ShouldHaveCount(1); + trackInDatabase.Performers[0].Id.Should().Be(newPerformerId); + trackInDatabase.Performers[0].ArtistName.Should().Be(newArtistName); + }); + } - [Fact] - public async Task Can_create_ManyToOne_relationship_using_local_ID() - { - // Arrange - string newTrackTitle = _fakers.MusicTrack.Generate().Title; - string newCompanyName = _fakers.RecordCompany.Generate().Name; + [Fact] + public async Task Can_create_ManyToOne_relationship_using_local_ID() + { + // Arrange + string newTrackTitle = _fakers.MusicTrack.Generate().Title; + string newCompanyName = _fakers.RecordCompany.Generate().Name; - const string trackLocalId = "track-1"; - const string companyLocalId = "company-1"; + const string trackLocalId = "track-1"; + const string companyLocalId = "company-1"; - var requestBody = new + var requestBody = new + { + atomic__operations = new object[] { - atomic__operations = new object[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "musicTracks", + lid = trackLocalId, + attributes = new { - type = "musicTracks", - lid = trackLocalId, - attributes = new - { - title = newTrackTitle - } + title = newTrackTitle } - }, - new + } + }, + new + { + op = "add", + data = new { - op = "add", - data = new + type = "recordCompanies", + lid = companyLocalId, + attributes = new { - type = "recordCompanies", - lid = companyLocalId, - attributes = new - { - name = newCompanyName - } + name = newCompanyName } + } + }, + new + { + op = "update", + @ref = new + { + type = "musicTracks", + lid = trackLocalId, + relationship = "ownedBy" }, - new + data = new { - op = "update", - @ref = new - { - type = "musicTracks", - lid = trackLocalId, - relationship = "ownedBy" - }, - data = new - { - type = "recordCompanies", - lid = companyLocalId - } + type = "recordCompanies", + lid = companyLocalId } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(3); + responseDocument.Results.ShouldHaveCount(3); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("musicTracks"); - resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); - }); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("musicTracks"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); + }); - responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("recordCompanies"); - resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newCompanyName)); - }); + responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("recordCompanies"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newCompanyName)); + }); - responseDocument.Results[2].Data.Value.Should().BeNull(); + responseDocument.Results[2].Data.Value.Should().BeNull(); - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - short newCompanyId = short.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); + Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + short newCompanyId = short.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(newTrackId); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(newTrackId); - trackInDatabase.Title.Should().Be(newTrackTitle); + trackInDatabase.Title.Should().Be(newTrackTitle); - trackInDatabase.OwnedBy.ShouldNotBeNull(); - trackInDatabase.OwnedBy.Id.Should().Be(newCompanyId); - trackInDatabase.OwnedBy.Name.Should().Be(newCompanyName); - }); - } + trackInDatabase.OwnedBy.ShouldNotBeNull(); + trackInDatabase.OwnedBy.Id.Should().Be(newCompanyId); + trackInDatabase.OwnedBy.Name.Should().Be(newCompanyName); + }); + } - [Fact] - public async Task Can_create_OneToMany_relationship_using_local_ID() - { - // Arrange - string newTrackTitle = _fakers.MusicTrack.Generate().Title; - string newArtistName = _fakers.Performer.Generate().ArtistName!; + [Fact] + public async Task Can_create_OneToMany_relationship_using_local_ID() + { + // Arrange + string newTrackTitle = _fakers.MusicTrack.Generate().Title; + string newArtistName = _fakers.Performer.Generate().ArtistName!; - const string trackLocalId = "track-1"; - const string performerLocalId = "performer-1"; + const string trackLocalId = "track-1"; + const string performerLocalId = "performer-1"; - var requestBody = new + var requestBody = new + { + atomic__operations = new object[] { - atomic__operations = new object[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "musicTracks", + lid = trackLocalId, + attributes = new { - type = "musicTracks", - lid = trackLocalId, - attributes = new - { - title = newTrackTitle - } + title = newTrackTitle } - }, - new + } + }, + new + { + op = "add", + data = new { - op = "add", - data = new + type = "performers", + lid = performerLocalId, + attributes = new { - type = "performers", - lid = performerLocalId, - attributes = new - { - artistName = newArtistName - } + artistName = newArtistName } + } + }, + new + { + op = "update", + @ref = new + { + type = "musicTracks", + lid = trackLocalId, + relationship = "performers" }, - new + data = new[] { - op = "update", - @ref = new - { - type = "musicTracks", - lid = trackLocalId, - relationship = "performers" - }, - data = new[] + new { - new - { - type = "performers", - lid = performerLocalId - } + type = "performers", + lid = performerLocalId } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(3); + responseDocument.Results.ShouldHaveCount(3); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("musicTracks"); - resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); - }); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("musicTracks"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); + }); - responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("performers"); - resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("artistName").With(value => value.Should().Be(newArtistName)); - }); + responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("performers"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("artistName").With(value => value.Should().Be(newArtistName)); + }); - responseDocument.Results[2].Data.Value.Should().BeNull(); + responseDocument.Results[2].Data.Value.Should().BeNull(); - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - int newPerformerId = int.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); + Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + int newPerformerId = int.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(newTrackId); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(newTrackId); - trackInDatabase.Title.Should().Be(newTrackTitle); + trackInDatabase.Title.Should().Be(newTrackTitle); - trackInDatabase.Performers.ShouldHaveCount(1); - trackInDatabase.Performers[0].Id.Should().Be(newPerformerId); - trackInDatabase.Performers[0].ArtistName.Should().Be(newArtistName); - }); - } + trackInDatabase.Performers.ShouldHaveCount(1); + trackInDatabase.Performers[0].Id.Should().Be(newPerformerId); + trackInDatabase.Performers[0].ArtistName.Should().Be(newArtistName); + }); + } - [Fact] - public async Task Can_create_ManyToMany_relationship_using_local_ID() - { - // Arrange - string newPlaylistName = _fakers.Playlist.Generate().Name; - string newTrackTitle = _fakers.MusicTrack.Generate().Title; + [Fact] + public async Task Can_create_ManyToMany_relationship_using_local_ID() + { + // Arrange + string newPlaylistName = _fakers.Playlist.Generate().Name; + string newTrackTitle = _fakers.MusicTrack.Generate().Title; - const string playlistLocalId = "playlist-1"; - const string trackLocalId = "track-1"; + const string playlistLocalId = "playlist-1"; + const string trackLocalId = "track-1"; - var requestBody = new + var requestBody = new + { + atomic__operations = new object[] { - atomic__operations = new object[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "playlists", + lid = playlistLocalId, + attributes = new { - type = "playlists", - lid = playlistLocalId, - attributes = new - { - name = newPlaylistName - } + name = newPlaylistName } - }, - new + } + }, + new + { + op = "add", + data = new { - op = "add", - data = new + type = "musicTracks", + lid = trackLocalId, + attributes = new { - type = "musicTracks", - lid = trackLocalId, - attributes = new - { - title = newTrackTitle - } + title = newTrackTitle } + } + }, + new + { + op = "update", + @ref = new + { + type = "playlists", + lid = playlistLocalId, + relationship = "tracks" }, - new + data = new[] { - op = "update", - @ref = new - { - type = "playlists", - lid = playlistLocalId, - relationship = "tracks" - }, - data = new[] + new { - new - { - type = "musicTracks", - lid = trackLocalId - } + type = "musicTracks", + lid = trackLocalId } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(3); + responseDocument.Results.ShouldHaveCount(3); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("playlists"); - resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newPlaylistName)); - }); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("playlists"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newPlaylistName)); + }); - responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("musicTracks"); - resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); - }); + responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("musicTracks"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); + }); - responseDocument.Results[2].Data.Value.Should().BeNull(); + responseDocument.Results[2].Data.Value.Should().BeNull(); - long newPlaylistId = long.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - Guid newTrackId = Guid.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); + long newPlaylistId = long.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + Guid newTrackId = Guid.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(newPlaylistId); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(newPlaylistId); - playlistInDatabase.Name.Should().Be(newPlaylistName); + playlistInDatabase.Name.Should().Be(newPlaylistName); - playlistInDatabase.Tracks.ShouldHaveCount(1); - playlistInDatabase.Tracks[0].Id.Should().Be(newTrackId); - playlistInDatabase.Tracks[0].Title.Should().Be(newTrackTitle); - }); - } + playlistInDatabase.Tracks.ShouldHaveCount(1); + playlistInDatabase.Tracks[0].Id.Should().Be(newTrackId); + playlistInDatabase.Tracks[0].Title.Should().Be(newTrackTitle); + }); + } - [Fact] - public async Task Can_replace_OneToMany_relationship_using_local_ID() - { - // Arrange - Performer existingPerformer = _fakers.Performer.Generate(); + [Fact] + public async Task Can_replace_OneToMany_relationship_using_local_ID() + { + // Arrange + Performer existingPerformer = _fakers.Performer.Generate(); - string newTrackTitle = _fakers.MusicTrack.Generate().Title; - string newArtistName = _fakers.Performer.Generate().ArtistName!; + string newTrackTitle = _fakers.MusicTrack.Generate().Title; + string newArtistName = _fakers.Performer.Generate().ArtistName!; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Performers.Add(existingPerformer); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Performers.Add(existingPerformer); + await dbContext.SaveChangesAsync(); + }); - const string trackLocalId = "track-1"; - const string performerLocalId = "performer-1"; + const string trackLocalId = "track-1"; + const string performerLocalId = "performer-1"; - var requestBody = new + var requestBody = new + { + atomic__operations = new object[] { - atomic__operations = new object[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "musicTracks", + lid = trackLocalId, + attributes = new { - type = "musicTracks", - lid = trackLocalId, - attributes = new - { - title = newTrackTitle - }, - relationships = new + title = newTrackTitle + }, + relationships = new + { + performers = new { - performers = new + data = new[] { - data = new[] + new { - new - { - type = "performers", - id = existingPerformer.StringId - } + type = "performers", + id = existingPerformer.StringId } } } } - }, - new + } + }, + new + { + op = "add", + data = new { - op = "add", - data = new + type = "performers", + lid = performerLocalId, + attributes = new { - type = "performers", - lid = performerLocalId, - attributes = new - { - artistName = newArtistName - } + artistName = newArtistName } + } + }, + new + { + op = "update", + @ref = new + { + type = "musicTracks", + lid = trackLocalId, + relationship = "performers" }, - new + data = new[] { - op = "update", - @ref = new + new { - type = "musicTracks", - lid = trackLocalId, - relationship = "performers" - }, - data = new[] - { - new - { - type = "performers", - lid = performerLocalId - } + type = "performers", + lid = performerLocalId } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(3); + responseDocument.Results.ShouldHaveCount(3); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("musicTracks"); - resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); - }); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("musicTracks"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); + }); - responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("performers"); - resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("artistName").With(value => value.Should().Be(newArtistName)); - }); + responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("performers"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("artistName").With(value => value.Should().Be(newArtistName)); + }); - responseDocument.Results[2].Data.Value.Should().BeNull(); + responseDocument.Results[2].Data.Value.Should().BeNull(); - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - int newPerformerId = int.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); + Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + int newPerformerId = int.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(newTrackId); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(newTrackId); - trackInDatabase.Title.Should().Be(newTrackTitle); + trackInDatabase.Title.Should().Be(newTrackTitle); - trackInDatabase.Performers.ShouldHaveCount(1); - trackInDatabase.Performers[0].Id.Should().Be(newPerformerId); - trackInDatabase.Performers[0].ArtistName.Should().Be(newArtistName); - }); - } + trackInDatabase.Performers.ShouldHaveCount(1); + trackInDatabase.Performers[0].Id.Should().Be(newPerformerId); + trackInDatabase.Performers[0].ArtistName.Should().Be(newArtistName); + }); + } - [Fact] - public async Task Can_replace_ManyToMany_relationship_using_local_ID() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + [Fact] + public async Task Can_replace_ManyToMany_relationship_using_local_ID() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - string newPlaylistName = _fakers.Playlist.Generate().Name; - string newTrackTitle = _fakers.MusicTrack.Generate().Title; + string newPlaylistName = _fakers.Playlist.Generate().Name; + string newTrackTitle = _fakers.MusicTrack.Generate().Title; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); - const string playlistLocalId = "playlist-1"; - const string trackLocalId = "track-1"; + const string playlistLocalId = "playlist-1"; + const string trackLocalId = "track-1"; - var requestBody = new + var requestBody = new + { + atomic__operations = new object[] { - atomic__operations = new object[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "playlists", + lid = playlistLocalId, + attributes = new { - type = "playlists", - lid = playlistLocalId, - attributes = new - { - name = newPlaylistName - }, - relationships = new + name = newPlaylistName + }, + relationships = new + { + tracks = new { - tracks = new + data = new[] { - data = new[] + new { - new - { - type = "musicTracks", - id = existingTrack.StringId - } + type = "musicTracks", + id = existingTrack.StringId } } } } - }, - new + } + }, + new + { + op = "add", + data = new { - op = "add", - data = new + type = "musicTracks", + lid = trackLocalId, + attributes = new { - type = "musicTracks", - lid = trackLocalId, - attributes = new - { - title = newTrackTitle - } + title = newTrackTitle } + } + }, + new + { + op = "update", + @ref = new + { + type = "playlists", + lid = playlistLocalId, + relationship = "tracks" }, - new + data = new[] { - op = "update", - @ref = new + new { - type = "playlists", - lid = playlistLocalId, - relationship = "tracks" - }, - data = new[] - { - new - { - type = "musicTracks", - lid = trackLocalId - } + type = "musicTracks", + lid = trackLocalId } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(3); + responseDocument.Results.ShouldHaveCount(3); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("playlists"); - resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newPlaylistName)); - }); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("playlists"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newPlaylistName)); + }); - responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("musicTracks"); - resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); - }); + responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("musicTracks"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); + }); - responseDocument.Results[2].Data.Value.Should().BeNull(); + responseDocument.Results[2].Data.Value.Should().BeNull(); - long newPlaylistId = long.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - Guid newTrackId = Guid.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); + long newPlaylistId = long.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + Guid newTrackId = Guid.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(newPlaylistId); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(newPlaylistId); - playlistInDatabase.Name.Should().Be(newPlaylistName); + playlistInDatabase.Name.Should().Be(newPlaylistName); - playlistInDatabase.Tracks.ShouldHaveCount(1); - playlistInDatabase.Tracks[0].Id.Should().Be(newTrackId); - playlistInDatabase.Tracks[0].Title.Should().Be(newTrackTitle); - }); - } + playlistInDatabase.Tracks.ShouldHaveCount(1); + playlistInDatabase.Tracks[0].Id.Should().Be(newTrackId); + playlistInDatabase.Tracks[0].Title.Should().Be(newTrackTitle); + }); + } - [Fact] - public async Task Can_add_to_OneToMany_relationship_using_local_ID() - { - // Arrange - Performer existingPerformer = _fakers.Performer.Generate(); + [Fact] + public async Task Can_add_to_OneToMany_relationship_using_local_ID() + { + // Arrange + Performer existingPerformer = _fakers.Performer.Generate(); - string newTrackTitle = _fakers.MusicTrack.Generate().Title; - string newArtistName = _fakers.Performer.Generate().ArtistName!; + string newTrackTitle = _fakers.MusicTrack.Generate().Title; + string newArtistName = _fakers.Performer.Generate().ArtistName!; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Performers.Add(existingPerformer); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Performers.Add(existingPerformer); + await dbContext.SaveChangesAsync(); + }); - const string trackLocalId = "track-1"; - const string performerLocalId = "performer-1"; + const string trackLocalId = "track-1"; + const string performerLocalId = "performer-1"; - var requestBody = new + var requestBody = new + { + atomic__operations = new object[] { - atomic__operations = new object[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "musicTracks", + lid = trackLocalId, + attributes = new { - type = "musicTracks", - lid = trackLocalId, - attributes = new - { - title = newTrackTitle - }, - relationships = new + title = newTrackTitle + }, + relationships = new + { + performers = new { - performers = new + data = new[] { - data = new[] + new { - new - { - type = "performers", - id = existingPerformer.StringId - } + type = "performers", + id = existingPerformer.StringId } } } } - }, - new + } + }, + new + { + op = "add", + data = new { - op = "add", - data = new + type = "performers", + lid = performerLocalId, + attributes = new { - type = "performers", - lid = performerLocalId, - attributes = new - { - artistName = newArtistName - } + artistName = newArtistName } + } + }, + new + { + op = "add", + @ref = new + { + type = "musicTracks", + lid = trackLocalId, + relationship = "performers" }, - new + data = new[] { - op = "add", - @ref = new + new { - type = "musicTracks", - lid = trackLocalId, - relationship = "performers" - }, - data = new[] - { - new - { - type = "performers", - lid = performerLocalId - } + type = "performers", + lid = performerLocalId } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(3); + responseDocument.Results.ShouldHaveCount(3); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("musicTracks"); - resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); - }); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("musicTracks"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); + }); - responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("performers"); - resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("artistName").With(value => value.Should().Be(newArtistName)); - }); + responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("performers"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("artistName").With(value => value.Should().Be(newArtistName)); + }); - responseDocument.Results[2].Data.Value.Should().BeNull(); + responseDocument.Results[2].Data.Value.Should().BeNull(); - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - int newPerformerId = int.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); + Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + int newPerformerId = int.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(newTrackId); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(newTrackId); - trackInDatabase.Title.Should().Be(newTrackTitle); + trackInDatabase.Title.Should().Be(newTrackTitle); - trackInDatabase.Performers.ShouldHaveCount(2); + trackInDatabase.Performers.ShouldHaveCount(2); - trackInDatabase.Performers[0].Id.Should().Be(existingPerformer.Id); - trackInDatabase.Performers[0].ArtistName.Should().Be(existingPerformer.ArtistName); + trackInDatabase.Performers[0].Id.Should().Be(existingPerformer.Id); + trackInDatabase.Performers[0].ArtistName.Should().Be(existingPerformer.ArtistName); - trackInDatabase.Performers[1].Id.Should().Be(newPerformerId); - trackInDatabase.Performers[1].ArtistName.Should().Be(newArtistName); - }); - } + trackInDatabase.Performers[1].Id.Should().Be(newPerformerId); + trackInDatabase.Performers[1].ArtistName.Should().Be(newArtistName); + }); + } - [Fact] - public async Task Can_add_to_ManyToMany_relationship_using_local_ID() - { - // Arrange - List existingTracks = _fakers.MusicTrack.Generate(2); + [Fact] + public async Task Can_add_to_ManyToMany_relationship_using_local_ID() + { + // Arrange + List existingTracks = _fakers.MusicTrack.Generate(2); - string newPlaylistName = _fakers.Playlist.Generate().Name; - string newTrackTitle = _fakers.MusicTrack.Generate().Title; + string newPlaylistName = _fakers.Playlist.Generate().Name; + string newTrackTitle = _fakers.MusicTrack.Generate().Title; - const string playlistLocalId = "playlist-1"; - const string trackLocalId = "track-1"; + const string playlistLocalId = "playlist-1"; + const string trackLocalId = "track-1"; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.AddRange(existingTracks); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.AddRange(existingTracks); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new object[] { - atomic__operations = new object[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "playlists", + lid = playlistLocalId, + attributes = new { - type = "playlists", - lid = playlistLocalId, - attributes = new - { - name = newPlaylistName - }, - relationships = new + name = newPlaylistName + }, + relationships = new + { + tracks = new { - tracks = new + data = new[] { - data = new[] + new { - new - { - type = "musicTracks", - id = existingTracks[0].StringId - } + type = "musicTracks", + id = existingTracks[0].StringId } } } } - }, - new + } + }, + new + { + op = "add", + data = new { - op = "add", - data = new + type = "musicTracks", + lid = trackLocalId, + attributes = new { - type = "musicTracks", - lid = trackLocalId, - attributes = new - { - title = newTrackTitle - } + title = newTrackTitle } + } + }, + new + { + op = "add", + @ref = new + { + type = "playlists", + lid = playlistLocalId, + relationship = "tracks" }, - new + data = new[] { - op = "add", - @ref = new - { - type = "playlists", - lid = playlistLocalId, - relationship = "tracks" - }, - data = new[] + new { - new - { - type = "musicTracks", - lid = trackLocalId - } + type = "musicTracks", + lid = trackLocalId } + } + }, + new + { + op = "add", + @ref = new + { + type = "playlists", + lid = playlistLocalId, + relationship = "tracks" }, - new + data = new[] { - op = "add", - @ref = new - { - type = "playlists", - lid = playlistLocalId, - relationship = "tracks" - }, - data = new[] + new { - new - { - type = "musicTracks", - id = existingTracks[1].StringId - } + type = "musicTracks", + id = existingTracks[1].StringId } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(4); + responseDocument.Results.ShouldHaveCount(4); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("playlists"); - resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newPlaylistName)); - }); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("playlists"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newPlaylistName)); + }); - responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("musicTracks"); - resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); - }); + responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("musicTracks"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); + }); - responseDocument.Results[2].Data.Value.Should().BeNull(); + responseDocument.Results[2].Data.Value.Should().BeNull(); - responseDocument.Results[3].Data.Value.Should().BeNull(); + responseDocument.Results[3].Data.Value.Should().BeNull(); - long newPlaylistId = long.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - Guid newTrackId = Guid.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); + long newPlaylistId = long.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + Guid newTrackId = Guid.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(newPlaylistId); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(newPlaylistId); - playlistInDatabase.Name.Should().Be(newPlaylistName); + playlistInDatabase.Name.Should().Be(newPlaylistName); - playlistInDatabase.Tracks.ShouldHaveCount(3); - playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[0].Id); - playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[1].Id); - playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == newTrackId); - }); - } + playlistInDatabase.Tracks.ShouldHaveCount(3); + playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[0].Id); + playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[1].Id); + playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == newTrackId); + }); + } - [Fact] - public async Task Can_remove_from_OneToMany_relationship_using_local_ID() - { - // Arrange - Performer existingPerformer = _fakers.Performer.Generate(); + [Fact] + public async Task Can_remove_from_OneToMany_relationship_using_local_ID() + { + // Arrange + Performer existingPerformer = _fakers.Performer.Generate(); - string newTrackTitle = _fakers.MusicTrack.Generate().Title; - string newArtistName1 = _fakers.Performer.Generate().ArtistName!; - string newArtistName2 = _fakers.Performer.Generate().ArtistName!; + string newTrackTitle = _fakers.MusicTrack.Generate().Title; + string newArtistName1 = _fakers.Performer.Generate().ArtistName!; + string newArtistName2 = _fakers.Performer.Generate().ArtistName!; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Performers.Add(existingPerformer); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Performers.Add(existingPerformer); + await dbContext.SaveChangesAsync(); + }); - const string trackLocalId = "track-1"; - const string performerLocalId1 = "performer-1"; - const string performerLocalId2 = "performer-2"; + const string trackLocalId = "track-1"; + const string performerLocalId1 = "performer-1"; + const string performerLocalId2 = "performer-2"; - var requestBody = new + var requestBody = new + { + atomic__operations = new object[] { - atomic__operations = new object[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "performers", + lid = performerLocalId1, + attributes = new { - type = "performers", - lid = performerLocalId1, - attributes = new - { - artistName = newArtistName1 - } + artistName = newArtistName1 } - }, - new + } + }, + new + { + op = "add", + data = new { - op = "add", - data = new + type = "performers", + lid = performerLocalId2, + attributes = new { - type = "performers", - lid = performerLocalId2, - attributes = new - { - artistName = newArtistName2 - } + artistName = newArtistName2 } - }, - new + } + }, + new + { + op = "add", + data = new { - op = "add", - data = new + type = "musicTracks", + lid = trackLocalId, + attributes = new { - type = "musicTracks", - lid = trackLocalId, - attributes = new - { - title = newTrackTitle - }, - relationships = new + title = newTrackTitle + }, + relationships = new + { + performers = new { - performers = new + data = new object[] { - data = new object[] + new + { + type = "performers", + id = existingPerformer.StringId + }, + new + { + type = "performers", + lid = performerLocalId1 + }, + new { - new - { - type = "performers", - id = existingPerformer.StringId - }, - new - { - type = "performers", - lid = performerLocalId1 - }, - new - { - type = "performers", - lid = performerLocalId2 - } + type = "performers", + lid = performerLocalId2 } } } } + } + }, + new + { + op = "remove", + @ref = new + { + type = "musicTracks", + lid = trackLocalId, + relationship = "performers" }, - new + data = new[] { - op = "remove", - @ref = new + new { - type = "musicTracks", - lid = trackLocalId, - relationship = "performers" + type = "performers", + lid = performerLocalId1 }, - data = new[] + new { - new - { - type = "performers", - lid = performerLocalId1 - }, - new - { - type = "performers", - lid = performerLocalId2 - } + type = "performers", + lid = performerLocalId2 } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(4); + responseDocument.Results.ShouldHaveCount(4); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("performers"); - resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("artistName").With(value => value.Should().Be(newArtistName1)); - }); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("performers"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("artistName").With(value => value.Should().Be(newArtistName1)); + }); - responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("performers"); - resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("artistName").With(value => value.Should().Be(newArtistName2)); - }); + responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("performers"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("artistName").With(value => value.Should().Be(newArtistName2)); + }); - responseDocument.Results[2].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("musicTracks"); - resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); - }); + responseDocument.Results[2].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("musicTracks"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); + }); - responseDocument.Results[3].Data.Value.Should().BeNull(); + responseDocument.Results[3].Data.Value.Should().BeNull(); - Guid newTrackId = Guid.Parse(responseDocument.Results[2].Data.SingleValue!.Id.ShouldNotBeNull()); + Guid newTrackId = Guid.Parse(responseDocument.Results[2].Data.SingleValue!.Id.ShouldNotBeNull()); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(newTrackId); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(newTrackId); - trackInDatabase.Title.Should().Be(newTrackTitle); + trackInDatabase.Title.Should().Be(newTrackTitle); - trackInDatabase.Performers.ShouldHaveCount(1); - trackInDatabase.Performers[0].Id.Should().Be(existingPerformer.Id); - trackInDatabase.Performers[0].ArtistName.Should().Be(existingPerformer.ArtistName); - }); - } + trackInDatabase.Performers.ShouldHaveCount(1); + trackInDatabase.Performers[0].Id.Should().Be(existingPerformer.Id); + trackInDatabase.Performers[0].ArtistName.Should().Be(existingPerformer.ArtistName); + }); + } - [Fact] - public async Task Can_remove_from_ManyToMany_relationship_using_local_ID() - { - // Arrange - Playlist existingPlaylist = _fakers.Playlist.Generate(); - existingPlaylist.Tracks = _fakers.MusicTrack.Generate(2); + [Fact] + public async Task Can_remove_from_ManyToMany_relationship_using_local_ID() + { + // Arrange + Playlist existingPlaylist = _fakers.Playlist.Generate(); + existingPlaylist.Tracks = _fakers.MusicTrack.Generate(2); - string newTrackTitle = _fakers.MusicTrack.Generate().Title; + string newTrackTitle = _fakers.MusicTrack.Generate().Title; - const string trackLocalId = "track-1"; + const string trackLocalId = "track-1"; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Playlists.Add(existingPlaylist); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Playlists.Add(existingPlaylist); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new object[] { - atomic__operations = new object[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "musicTracks", + lid = trackLocalId, + attributes = new { - type = "musicTracks", - lid = trackLocalId, - attributes = new - { - title = newTrackTitle - } + title = newTrackTitle } + } + }, + new + { + op = "add", + @ref = new + { + type = "playlists", + id = existingPlaylist.StringId, + relationship = "tracks" }, - new + data = new[] { - op = "add", - @ref = new - { - type = "playlists", - id = existingPlaylist.StringId, - relationship = "tracks" - }, - data = new[] + new { - new - { - type = "musicTracks", - lid = trackLocalId - } + type = "musicTracks", + lid = trackLocalId } + } + }, + new + { + op = "remove", + @ref = new + { + type = "playlists", + id = existingPlaylist.StringId, + relationship = "tracks" }, - new + data = new[] { - op = "remove", - @ref = new - { - type = "playlists", - id = existingPlaylist.StringId, - relationship = "tracks" - }, - data = new[] + new { - new - { - type = "musicTracks", - id = existingPlaylist.Tracks[1].StringId - } + type = "musicTracks", + id = existingPlaylist.Tracks[1].StringId } + } + }, + new + { + op = "remove", + @ref = new + { + type = "playlists", + id = existingPlaylist.StringId, + relationship = "tracks" }, - new + data = new[] { - op = "remove", - @ref = new + new { - type = "playlists", - id = existingPlaylist.StringId, - relationship = "tracks" - }, - data = new[] - { - new - { - type = "musicTracks", - lid = trackLocalId - } + type = "musicTracks", + lid = trackLocalId } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(4); + responseDocument.Results.ShouldHaveCount(4); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("musicTracks"); - resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); - }); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("musicTracks"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); + }); - responseDocument.Results[1].Data.Value.Should().BeNull(); + responseDocument.Results[1].Data.Value.Should().BeNull(); - responseDocument.Results[2].Data.Value.Should().BeNull(); + responseDocument.Results[2].Data.Value.Should().BeNull(); - responseDocument.Results[3].Data.Value.Should().BeNull(); + responseDocument.Results[3].Data.Value.Should().BeNull(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(existingPlaylist.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(existingPlaylist.Id); - playlistInDatabase.Tracks.ShouldHaveCount(1); - playlistInDatabase.Tracks[0].Id.Should().Be(existingPlaylist.Tracks[0].Id); - }); - } + playlistInDatabase.Tracks.ShouldHaveCount(1); + playlistInDatabase.Tracks[0].Id.Should().Be(existingPlaylist.Tracks[0].Id); + }); + } - [Fact] - public async Task Can_delete_resource_using_local_ID() - { - // Arrange - string newTrackTitle = _fakers.MusicTrack.Generate().Title; + [Fact] + public async Task Can_delete_resource_using_local_ID() + { + // Arrange + string newTrackTitle = _fakers.MusicTrack.Generate().Title; - const string trackLocalId = "track-1"; + const string trackLocalId = "track-1"; - var requestBody = new + var requestBody = new + { + atomic__operations = new object[] { - atomic__operations = new object[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "musicTracks", + lid = trackLocalId, + attributes = new { - type = "musicTracks", - lid = trackLocalId, - attributes = new - { - title = newTrackTitle - } + title = newTrackTitle } - }, - new + } + }, + new + { + op = "remove", + @ref = new { - op = "remove", - @ref = new - { - type = "musicTracks", - lid = trackLocalId - } + type = "musicTracks", + lid = trackLocalId } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(2); + responseDocument.Results.ShouldHaveCount(2); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("musicTracks"); - resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); - }); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("musicTracks"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); + }); - responseDocument.Results[1].Data.Value.Should().BeNull(); + responseDocument.Results[1].Data.Value.Should().BeNull(); - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack? trackInDatabase = await dbContext.MusicTracks.FirstWithIdOrDefaultAsync(newTrackId); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + MusicTrack? trackInDatabase = await dbContext.MusicTracks.FirstWithIdOrDefaultAsync(newTrackId); - trackInDatabase.Should().BeNull(); - }); - } + trackInDatabase.Should().BeNull(); + }); + } - [Fact] - public async Task Cannot_consume_unassigned_local_ID_in_ref() + [Fact] + public async Task Cannot_consume_unassigned_local_ID_in_ref() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new object[] { - atomic__operations = new object[] + new { - new + op = "remove", + @ref = new { - op = "remove", - @ref = new - { - type = "lyrics", - id = Unknown.StringId.For() - } - }, - new + type = "lyrics", + id = Unknown.StringId.For() + } + }, + new + { + op = "remove", + @ref = new { - op = "remove", - @ref = new - { - type = "musicTracks", - lid = Unknown.LocalId - } + type = "musicTracks", + lid = Unknown.LocalId } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Server-generated value for local ID is not available at this point."); - error.Detail.Should().Be($"Server-generated value for local ID '{Unknown.LocalId}' is not available at this point."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[1]"); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("Server-generated value for local ID is not available at this point."); + error.Detail.Should().Be($"Server-generated value for local ID '{Unknown.LocalId}' is not available at this point."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[1]"); + } - [Fact] - public async Task Cannot_consume_unassigned_local_ID_in_data_element() + [Fact] + public async Task Cannot_consume_unassigned_local_ID_in_data_element() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new object[] { - atomic__operations = new object[] + new { - new + op = "remove", + @ref = new { - op = "remove", - @ref = new - { - type = "lyrics", - id = Unknown.StringId.For() - } - }, - new + type = "lyrics", + id = Unknown.StringId.For() + } + }, + new + { + op = "update", + data = new { - op = "update", - data = new + type = "musicTracks", + lid = Unknown.LocalId, + attributes = new { - type = "musicTracks", - lid = Unknown.LocalId, - attributes = new - { - } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Server-generated value for local ID is not available at this point."); - error.Detail.Should().Be($"Server-generated value for local ID '{Unknown.LocalId}' is not available at this point."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[1]"); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("Server-generated value for local ID is not available at this point."); + error.Detail.Should().Be($"Server-generated value for local ID '{Unknown.LocalId}' is not available at this point."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[1]"); + } - [Fact] - public async Task Cannot_consume_unassigned_local_ID_in_data_array() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + [Fact] + public async Task Cannot_consume_unassigned_local_ID_in_data_array() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new object[] { - atomic__operations = new object[] + new { - new + op = "remove", + @ref = new { - op = "remove", - @ref = new - { - type = "lyrics", - id = Unknown.StringId.For() - } + type = "lyrics", + id = Unknown.StringId.For() + } + }, + new + { + op = "add", + @ref = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationship = "performers" }, - new + data = new[] { - op = "add", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" - }, - data = new[] + new { - new - { - type = "performers", - lid = Unknown.LocalId - } + type = "performers", + lid = Unknown.LocalId } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Server-generated value for local ID is not available at this point."); - error.Detail.Should().Be($"Server-generated value for local ID '{Unknown.LocalId}' is not available at this point."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[1]"); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("Server-generated value for local ID is not available at this point."); + error.Detail.Should().Be($"Server-generated value for local ID '{Unknown.LocalId}' is not available at this point."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[1]"); + } - [Fact] - public async Task Cannot_consume_unassigned_local_ID_in_relationship_data_element() - { - // Arrange - string newTrackTitle = _fakers.MusicTrack.Generate().Title; + [Fact] + public async Task Cannot_consume_unassigned_local_ID_in_relationship_data_element() + { + // Arrange + string newTrackTitle = _fakers.MusicTrack.Generate().Title; - var requestBody = new + var requestBody = new + { + atomic__operations = new object[] { - atomic__operations = new object[] + new { - new + op = "remove", + @ref = new { - op = "remove", - @ref = new - { - type = "lyrics", - id = Unknown.StringId.For() - } - }, - new + type = "lyrics", + id = Unknown.StringId.For() + } + }, + new + { + op = "add", + data = new { - op = "add", - data = new + type = "musicTracks", + attributes = new { - type = "musicTracks", - attributes = new - { - title = newTrackTitle - }, - relationships = new + title = newTrackTitle + }, + relationships = new + { + ownedBy = new { - ownedBy = new + data = new { - data = new - { - type = "recordCompanies", - lid = Unknown.LocalId - } + type = "recordCompanies", + lid = Unknown.LocalId } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Server-generated value for local ID is not available at this point."); - error.Detail.Should().Be($"Server-generated value for local ID '{Unknown.LocalId}' is not available at this point."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[1]"); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("Server-generated value for local ID is not available at this point."); + error.Detail.Should().Be($"Server-generated value for local ID '{Unknown.LocalId}' is not available at this point."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[1]"); + } - [Fact] - public async Task Cannot_consume_unassigned_local_ID_in_relationship_data_array() - { - // Arrange - string newPlaylistName = _fakers.Playlist.Generate().Name; + [Fact] + public async Task Cannot_consume_unassigned_local_ID_in_relationship_data_array() + { + // Arrange + string newPlaylistName = _fakers.Playlist.Generate().Name; - var requestBody = new + var requestBody = new + { + atomic__operations = new object[] { - atomic__operations = new object[] + new { - new + op = "remove", + @ref = new { - op = "remove", - @ref = new - { - type = "lyrics", - id = Unknown.StringId.For() - } - }, - new + type = "lyrics", + id = Unknown.StringId.For() + } + }, + new + { + op = "add", + data = new { - op = "add", - data = new + type = "playlists", + attributes = new { - type = "playlists", - attributes = new - { - name = newPlaylistName - }, - relationships = new + name = newPlaylistName + }, + relationships = new + { + tracks = new { - tracks = new + data = new[] { - data = new[] + new { - new - { - type = "musicTracks", - lid = Unknown.LocalId - } + type = "musicTracks", + lid = Unknown.LocalId } } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Server-generated value for local ID is not available at this point."); - error.Detail.Should().Be($"Server-generated value for local ID '{Unknown.LocalId}' is not available at this point."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[1]"); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("Server-generated value for local ID is not available at this point."); + error.Detail.Should().Be($"Server-generated value for local ID '{Unknown.LocalId}' is not available at this point."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[1]"); + } - [Fact] - public async Task Cannot_consume_local_ID_of_different_type_in_same_operation() - { - // Arrange - const string trackLocalId = "track-1"; - string newTrackTitle = _fakers.MusicTrack.Generate().Title; + [Fact] + public async Task Cannot_consume_local_ID_of_different_type_in_same_operation() + { + // Arrange + const string trackLocalId = "track-1"; + string newTrackTitle = _fakers.MusicTrack.Generate().Title; - var requestBody = new + var requestBody = new + { + atomic__operations = new object[] { - atomic__operations = new object[] + new { - new + op = "remove", + @ref = new { - op = "remove", - @ref = new - { - type = "lyrics", - id = Unknown.StringId.For() - } - }, - new + type = "lyrics", + id = Unknown.StringId.For() + } + }, + new + { + op = "add", + data = new { - op = "add", - data = new + type = "musicTracks", + lid = trackLocalId, + attributes = new { - type = "musicTracks", - lid = trackLocalId, - attributes = new - { - title = newTrackTitle - }, - relationships = new + title = newTrackTitle + }, + relationships = new + { + ownedBy = new { - ownedBy = new + data = new { - data = new - { - type = "recordCompanies", - lid = trackLocalId - } + type = "recordCompanies", + lid = trackLocalId } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Incompatible type in Local ID usage."); - error.Detail.Should().Be("Local ID 'track-1' belongs to resource type 'musicTracks' instead of 'recordCompanies'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[1]"); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("Incompatible type in Local ID usage."); + error.Detail.Should().Be("Local ID 'track-1' belongs to resource type 'musicTracks' instead of 'recordCompanies'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[1]"); + } - [Fact] - public async Task Cannot_consume_local_ID_of_different_type_in_ref() - { - // Arrange - const string companyLocalId = "company-1"; + [Fact] + public async Task Cannot_consume_local_ID_of_different_type_in_ref() + { + // Arrange + const string companyLocalId = "company-1"; - string newCompanyName = _fakers.RecordCompany.Generate().Name; + string newCompanyName = _fakers.RecordCompany.Generate().Name; - var requestBody = new + var requestBody = new + { + atomic__operations = new object[] { - atomic__operations = new object[] + new { - new + op = "remove", + @ref = new { - op = "remove", - @ref = new - { - type = "lyrics", - id = Unknown.StringId.For() - } - }, - new + type = "lyrics", + id = Unknown.StringId.For() + } + }, + new + { + op = "add", + data = new { - op = "add", - data = new + type = "recordCompanies", + lid = companyLocalId, + attributes = new { - type = "recordCompanies", - lid = companyLocalId, - attributes = new - { - name = newCompanyName - } + name = newCompanyName } - }, - new + } + }, + new + { + op = "remove", + @ref = new { - op = "remove", - @ref = new - { - type = "musicTracks", - lid = companyLocalId - } + type = "musicTracks", + lid = companyLocalId } } - }; + } + }; + + const string route = "/operations"; - const string route = "/operations"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + responseDocument.Errors.ShouldHaveCount(1); - responseDocument.Errors.ShouldHaveCount(1); + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("Incompatible type in Local ID usage."); + error.Detail.Should().Be("Local ID 'company-1' belongs to resource type 'recordCompanies' instead of 'musicTracks'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[2]"); + } - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Incompatible type in Local ID usage."); - error.Detail.Should().Be("Local ID 'company-1' belongs to resource type 'recordCompanies' instead of 'musicTracks'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[2]"); - } + [Fact] + public async Task Cannot_consume_local_ID_of_different_type_in_data_element() + { + // Arrange + const string performerLocalId = "performer-1"; - [Fact] - public async Task Cannot_consume_local_ID_of_different_type_in_data_element() + var requestBody = new { - // Arrange - const string performerLocalId = "performer-1"; - - var requestBody = new + atomic__operations = new object[] { - atomic__operations = new object[] + new { - new + op = "remove", + @ref = new { - op = "remove", - @ref = new - { - type = "lyrics", - id = Unknown.StringId.For() - } - }, - new + type = "lyrics", + id = Unknown.StringId.For() + } + }, + new + { + op = "add", + data = new { - op = "add", - data = new - { - type = "performers", - lid = performerLocalId - } - }, - new + type = "performers", + lid = performerLocalId + } + }, + new + { + op = "update", + data = new { - op = "update", - data = new + type = "playlists", + lid = performerLocalId, + attributes = new { - type = "playlists", - lid = performerLocalId, - attributes = new - { - } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Incompatible type in Local ID usage."); - error.Detail.Should().Be("Local ID 'performer-1' belongs to resource type 'performers' instead of 'playlists'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[2]"); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("Incompatible type in Local ID usage."); + error.Detail.Should().Be("Local ID 'performer-1' belongs to resource type 'performers' instead of 'playlists'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[2]"); + } - [Fact] - public async Task Cannot_consume_local_ID_of_different_type_in_data_array() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + [Fact] + public async Task Cannot_consume_local_ID_of_different_type_in_data_array() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - const string companyLocalId = "company-1"; + const string companyLocalId = "company-1"; - string newCompanyName = _fakers.RecordCompany.Generate().Name; + string newCompanyName = _fakers.RecordCompany.Generate().Name; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new object[] { - atomic__operations = new object[] + new { - new + op = "remove", + @ref = new { - op = "remove", - @ref = new - { - type = "lyrics", - id = Unknown.StringId.For() - } - }, - new + type = "lyrics", + id = Unknown.StringId.For() + } + }, + new + { + op = "add", + data = new { - op = "add", - data = new + type = "recordCompanies", + lid = companyLocalId, + attributes = new { - type = "recordCompanies", - lid = companyLocalId, - attributes = new - { - name = newCompanyName - } + name = newCompanyName } + } + }, + new + { + op = "add", + @ref = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationship = "performers" }, - new + data = new[] { - op = "add", - @ref = new + new { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" - }, - data = new[] - { - new - { - type = "performers", - lid = companyLocalId - } + type = "performers", + lid = companyLocalId } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Incompatible type in Local ID usage."); - error.Detail.Should().Be("Local ID 'company-1' belongs to resource type 'recordCompanies' instead of 'performers'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[2]"); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("Incompatible type in Local ID usage."); + error.Detail.Should().Be("Local ID 'company-1' belongs to resource type 'recordCompanies' instead of 'performers'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[2]"); + } - [Fact] - public async Task Cannot_consume_local_ID_of_different_type_in_relationship_data_element() - { - // Arrange - string newPlaylistName = _fakers.Playlist.Generate().Name; - string newTrackTitle = _fakers.MusicTrack.Generate().Title; + [Fact] + public async Task Cannot_consume_local_ID_of_different_type_in_relationship_data_element() + { + // Arrange + string newPlaylistName = _fakers.Playlist.Generate().Name; + string newTrackTitle = _fakers.MusicTrack.Generate().Title; - const string playlistLocalId = "playlist-1"; + const string playlistLocalId = "playlist-1"; - var requestBody = new + var requestBody = new + { + atomic__operations = new object[] { - atomic__operations = new object[] + new { - new + op = "remove", + @ref = new { - op = "remove", - @ref = new - { - type = "lyrics", - id = Unknown.StringId.For() - } - }, - new + type = "lyrics", + id = Unknown.StringId.For() + } + }, + new + { + op = "add", + data = new { - op = "add", - data = new + type = "playlists", + lid = playlistLocalId, + attributes = new { - type = "playlists", - lid = playlistLocalId, - attributes = new - { - name = newPlaylistName - } + name = newPlaylistName } - }, - new + } + }, + new + { + op = "add", + data = new { - op = "add", - data = new + type = "musicTracks", + attributes = new { - type = "musicTracks", - attributes = new - { - title = newTrackTitle - }, - relationships = new + title = newTrackTitle + }, + relationships = new + { + ownedBy = new { - ownedBy = new + data = new { - data = new - { - type = "recordCompanies", - lid = playlistLocalId - } + type = "recordCompanies", + lid = playlistLocalId } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Incompatible type in Local ID usage."); - error.Detail.Should().Be("Local ID 'playlist-1' belongs to resource type 'playlists' instead of 'recordCompanies'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[2]"); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("Incompatible type in Local ID usage."); + error.Detail.Should().Be("Local ID 'playlist-1' belongs to resource type 'playlists' instead of 'recordCompanies'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[2]"); + } - [Fact] - public async Task Cannot_consume_local_ID_of_different_type_in_relationship_data_array() - { - // Arrange - const string performerLocalId = "performer-1"; - string newPlaylistName = _fakers.Playlist.Generate().Name; + [Fact] + public async Task Cannot_consume_local_ID_of_different_type_in_relationship_data_array() + { + // Arrange + const string performerLocalId = "performer-1"; + string newPlaylistName = _fakers.Playlist.Generate().Name; - var requestBody = new + var requestBody = new + { + atomic__operations = new object[] { - atomic__operations = new object[] + new { - new + op = "remove", + @ref = new { - op = "remove", - @ref = new - { - type = "lyrics", - id = Unknown.StringId.For() - } - }, - new + type = "lyrics", + id = Unknown.StringId.For() + } + }, + new + { + op = "add", + data = new { - op = "add", - data = new - { - type = "performers", - lid = performerLocalId - } - }, - new + type = "performers", + lid = performerLocalId + } + }, + new + { + op = "add", + data = new { - op = "add", - data = new + type = "playlists", + attributes = new { - type = "playlists", - attributes = new - { - name = newPlaylistName - }, - relationships = new + name = newPlaylistName + }, + relationships = new + { + tracks = new { - tracks = new + data = new[] { - data = new[] + new { - new - { - type = "musicTracks", - lid = performerLocalId - } + type = "musicTracks", + lid = performerLocalId } } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Incompatible type in Local ID usage."); - error.Detail.Should().Be("Local ID 'performer-1' belongs to resource type 'performers' instead of 'musicTracks'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[2]"); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("Incompatible type in Local ID usage."); + error.Detail.Should().Be("Local ID 'performer-1' belongs to resource type 'performers' instead of 'musicTracks'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[2]"); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Lyric.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Lyric.cs index ba3ced81b5..2baa9ac431 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Lyric.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Lyric.cs @@ -1,27 +1,25 @@ -using System; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations +namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations")] +public sealed class Lyric : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - [Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations")] - public sealed class Lyric : Identifiable - { - [Attr] - public string? Format { get; set; } + [Attr] + public string? Format { get; set; } - [Attr] - public string Text { get; set; } = null!; + [Attr] + public string Text { get; set; } = null!; - [Attr(Capabilities = AttrCapabilities.None)] - public DateTimeOffset CreatedAt { get; set; } + [Attr(Capabilities = AttrCapabilities.None)] + public DateTimeOffset CreatedAt { get; set; } - [HasOne] - public TextLanguage? Language { get; set; } + [HasOne] + public TextLanguage? Language { get; set; } - [HasOne] - public MusicTrack? Track { get; set; } - } + [HasOne] + public MusicTrack? Track { get; set; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs index 6f26bd8b9b..8fcda54494 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs @@ -1,7 +1,5 @@ using System.Net; -using System.Net.Http; using System.Text.Json; -using System.Threading.Tasks; using FluentAssertions; using FluentAssertions.Extensions; using JsonApiDotNetCore.Configuration; @@ -10,170 +8,169 @@ using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Meta +namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Meta; + +public sealed class AtomicResourceMetaTests : IClassFixture, OperationsDbContext>> { - public sealed class AtomicResourceMetaTests : IClassFixture, OperationsDbContext>> + private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new(); + + public AtomicResourceMetaTests(IntegrationTestContext, OperationsDbContext> testContext) { - private readonly IntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new(); + _testContext = testContext; + + testContext.UseController(); - public AtomicResourceMetaTests(IntegrationTestContext, OperationsDbContext> testContext) + testContext.ConfigureServicesAfterStartup(services => { - _testContext = testContext; + services.AddResourceDefinition(); + services.AddResourceDefinition(); - testContext.UseController(); + services.AddSingleton(); + }); - testContext.ConfigureServicesAfterStartup(services => - { - services.AddResourceDefinition(); - services.AddResourceDefinition(); + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + hitCounter.Reset(); + } - services.AddSingleton(); - }); + [Fact] + public async Task Returns_resource_meta_in_create_resource_with_side_effects() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - hitCounter.Reset(); - } + string newTitle1 = _fakers.MusicTrack.Generate().Title; + string newTitle2 = _fakers.MusicTrack.Generate().Title; - [Fact] - public async Task Returns_resource_meta_in_create_resource_with_side_effects() + var requestBody = new { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - - string newTitle1 = _fakers.MusicTrack.Generate().Title; - string newTitle2 = _fakers.MusicTrack.Generate().Title; - - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "musicTracks", + attributes = new { - type = "musicTracks", - attributes = new - { - title = newTitle1, - releasedAt = 1.January(2018) - } + title = newTitle1, + releasedAt = 1.January(2018).AsUtc() } - }, - new + } + }, + new + { + op = "add", + data = new { - op = "add", - data = new + type = "musicTracks", + attributes = new { - type = "musicTracks", - attributes = new - { - title = newTitle2, - releasedAt = 23.August(1994) - } + title = newTitle2, + releasedAt = 23.August(1994).AsUtc() } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(2); - - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Meta.ShouldHaveCount(1); + responseDocument.Results.ShouldHaveCount(2); - resource.Meta.ShouldContainKey("copyright").With(value => - { - JsonElement element = value.Should().BeOfType().Subject; - element.GetString().Should().Be("(C) 2018. All rights reserved."); - }); - }); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Meta.ShouldHaveCount(1); - responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + resource.Meta.ShouldContainKey("copyright").With(value => { - resource.Meta.ShouldHaveCount(1); - - resource.Meta.ShouldContainKey("copyright").With(value => - { - JsonElement element = value.Should().BeOfType().Subject; - element.GetString().Should().Be("(C) 1994. All rights reserved."); - }); + JsonElement element = value.Should().BeOfType().Subject; + element.GetString().Should().Be("(C) 2018. All rights reserved."); }); + }); + + responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Meta.ShouldHaveCount(1); - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] + resource.Meta.ShouldContainKey("copyright").With(value => { - (typeof(MusicTrack), ResourceDefinitionExtensibilityPoints.GetMeta), - (typeof(MusicTrack), ResourceDefinitionExtensibilityPoints.GetMeta) - }, options => options.WithStrictOrdering()); - } + JsonElement element = value.Should().BeOfType().Subject; + element.GetString().Should().Be("(C) 1994. All rights reserved."); + }); + }); - [Fact] - public async Task Returns_resource_meta_in_update_resource_with_side_effects() + hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); + (typeof(MusicTrack), ResourceDefinitionExtensibilityPoints.GetMeta), + (typeof(MusicTrack), ResourceDefinitionExtensibilityPoints.GetMeta) + }, options => options.WithStrictOrdering()); + } - TextLanguage existingLanguage = _fakers.TextLanguage.Generate(); + [Fact] + public async Task Returns_resource_meta_in_update_resource_with_side_effects() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.TextLanguages.Add(existingLanguage); - await dbContext.SaveChangesAsync(); - }); + TextLanguage existingLanguage = _fakers.TextLanguage.Generate(); - var requestBody = new + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TextLanguages.Add(existingLanguage); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = "textLanguages", + id = existingLanguage.StringId, + attributes = new { - type = "textLanguages", - id = existingLanguage.StringId, - attributes = new - { - } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(1); + responseDocument.Results.ShouldHaveCount(1); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Meta.ShouldHaveCount(1); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Meta.ShouldHaveCount(1); - resource.Meta.ShouldContainKey("notice").With(value => - { - JsonElement element = value.Should().BeOfType().Subject; - element.GetString().Should().Be(TextLanguageMetaDefinition.NoticeText); - }); + resource.Meta.ShouldContainKey("notice").With(value => + { + JsonElement element = value.Should().BeOfType().Subject; + element.GetString().Should().Be(TextLanguageMetaDefinition.NoticeText); }); + }); - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(TextLanguage), ResourceDefinitionExtensibilityPoints.GetMeta) - }, options => options.WithStrictOrdering()); - } + hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] + { + (typeof(TextLanguage), ResourceDefinitionExtensibilityPoints.GetMeta) + }, options => options.WithStrictOrdering()); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMeta.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMeta.cs index f1b4d771b1..a41a2bb22c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMeta.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMeta.cs @@ -1,24 +1,22 @@ -using System.Collections.Generic; using JsonApiDotNetCore.Serialization.Response; -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Meta +namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Meta; + +public sealed class AtomicResponseMeta : IResponseMeta { - public sealed class AtomicResponseMeta : IResponseMeta + public IReadOnlyDictionary GetMeta() { - public IReadOnlyDictionary GetMeta() + return new Dictionary { - return new Dictionary + ["license"] = "MIT", + ["projectUrl"] = "https://github.com/json-api-dotnet/JsonApiDotNetCore/", + ["versions"] = new[] { - ["license"] = "MIT", - ["projectUrl"] = "https://github.com/json-api-dotnet/JsonApiDotNetCore/", - ["versions"] = new[] - { - "v4.0.0", - "v3.1.0", - "v2.5.2", - "v1.3.1" - } - }; - } + "v4.0.0", + "v3.1.0", + "v2.5.2", + "v1.3.1" + } + }; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMetaTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMetaTests.cs index 5f74d971fa..327ff8e18c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMetaTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMetaTests.cs @@ -1,8 +1,5 @@ -using System.Linq; using System.Net; -using System.Net.Http; using System.Text.Json; -using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; @@ -11,149 +8,148 @@ using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Meta +namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Meta; + +public sealed class AtomicResponseMetaTests : IClassFixture, OperationsDbContext>> { - public sealed class AtomicResponseMetaTests : IClassFixture, OperationsDbContext>> - { - private readonly IntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new(); + private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new(); - public AtomicResponseMetaTests(IntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; + public AtomicResponseMetaTests(IntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; - testContext.UseController(); + testContext.UseController(); - testContext.ConfigureServicesAfterStartup(services => - { - services.AddResourceDefinition(); + testContext.ConfigureServicesAfterStartup(services => + { + services.AddResourceDefinition(); - services.AddSingleton(); - services.AddSingleton(); - }); - } + services.AddSingleton(); + services.AddSingleton(); + }); + } - [Fact] - public async Task Returns_top_level_meta_in_create_resource_with_side_effects() + [Fact] + public async Task Returns_top_level_meta_in_create_resource_with_side_effects() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "performers", + attributes = new { - type = "performers", - attributes = new - { - } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Meta.ShouldHaveCount(3); + responseDocument.Meta.ShouldHaveCount(3); - responseDocument.Meta.ShouldContainKey("license").With(value => - { - JsonElement element = value.Should().BeOfType().Subject; - element.GetString().Should().Be("MIT"); - }); + responseDocument.Meta.ShouldContainKey("license").With(value => + { + JsonElement element = value.Should().BeOfType().Subject; + element.GetString().Should().Be("MIT"); + }); - responseDocument.Meta.ShouldContainKey("projectUrl").With(value => - { - JsonElement element = value.Should().BeOfType().Subject; - element.GetString().Should().Be("https://github.com/json-api-dotnet/JsonApiDotNetCore/"); - }); + responseDocument.Meta.ShouldContainKey("projectUrl").With(value => + { + JsonElement element = value.Should().BeOfType().Subject; + element.GetString().Should().Be("https://github.com/json-api-dotnet/JsonApiDotNetCore/"); + }); - responseDocument.Meta.ShouldContainKey("versions").With(value => - { - JsonElement element = value.Should().BeOfType().Subject; - string?[] versionArray = element.EnumerateArray().Select(arrayItem => arrayItem.GetString()).ToArray(); - - versionArray.ShouldHaveCount(4); - versionArray.Should().Contain("v4.0.0"); - versionArray.Should().Contain("v3.1.0"); - versionArray.Should().Contain("v2.5.2"); - versionArray.Should().Contain("v1.3.1"); - }); - } - - [Fact] - public async Task Returns_top_level_meta_in_update_resource_with_side_effects() + responseDocument.Meta.ShouldContainKey("versions").With(value => { - // Arrange - TextLanguage existingLanguage = _fakers.TextLanguage.Generate(); + JsonElement element = value.Should().BeOfType().Subject; + string?[] versionArray = element.EnumerateArray().Select(arrayItem => arrayItem.GetString()).ToArray(); + + versionArray.ShouldHaveCount(4); + versionArray.Should().Contain("v4.0.0"); + versionArray.Should().Contain("v3.1.0"); + versionArray.Should().Contain("v2.5.2"); + versionArray.Should().Contain("v1.3.1"); + }); + } - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.TextLanguages.Add(existingLanguage); - await dbContext.SaveChangesAsync(); - }); + [Fact] + public async Task Returns_top_level_meta_in_update_resource_with_side_effects() + { + // Arrange + TextLanguage existingLanguage = _fakers.TextLanguage.Generate(); - var requestBody = new + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TextLanguages.Add(existingLanguage); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = "textLanguages", + id = existingLanguage.StringId, + attributes = new { - type = "textLanguages", - id = existingLanguage.StringId, - attributes = new - { - } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Meta.ShouldHaveCount(3); + responseDocument.Meta.ShouldHaveCount(3); - responseDocument.Meta.ShouldContainKey("license").With(value => - { - JsonElement element = value.Should().BeOfType().Subject; - element.GetString().Should().Be("MIT"); - }); + responseDocument.Meta.ShouldContainKey("license").With(value => + { + JsonElement element = value.Should().BeOfType().Subject; + element.GetString().Should().Be("MIT"); + }); - responseDocument.Meta.ShouldContainKey("projectUrl").With(value => - { - JsonElement element = value.Should().BeOfType().Subject; - element.GetString().Should().Be("https://github.com/json-api-dotnet/JsonApiDotNetCore/"); - }); + responseDocument.Meta.ShouldContainKey("projectUrl").With(value => + { + JsonElement element = value.Should().BeOfType().Subject; + element.GetString().Should().Be("https://github.com/json-api-dotnet/JsonApiDotNetCore/"); + }); - responseDocument.Meta.ShouldContainKey("versions").With(value => - { - JsonElement element = value.Should().BeOfType().Subject; - string?[] versionArray = element.EnumerateArray().Select(arrayItem => arrayItem.GetString()).ToArray(); - - versionArray.ShouldHaveCount(4); - versionArray.Should().Contain("v4.0.0"); - versionArray.Should().Contain("v3.1.0"); - versionArray.Should().Contain("v2.5.2"); - versionArray.Should().Contain("v1.3.1"); - }); - } + responseDocument.Meta.ShouldContainKey("versions").With(value => + { + JsonElement element = value.Should().BeOfType().Subject; + string?[] versionArray = element.EnumerateArray().Select(arrayItem => arrayItem.GetString()).ToArray(); + + versionArray.ShouldHaveCount(4); + versionArray.Should().Contain("v4.0.0"); + versionArray.Should().Contain("v3.1.0"); + versionArray.Should().Contain("v2.5.2"); + versionArray.Should().Contain("v1.3.1"); + }); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/MusicTrackMetaDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/MusicTrackMetaDefinition.cs index 69557713bd..be59668c1c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/MusicTrackMetaDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/MusicTrackMetaDefinition.cs @@ -1,28 +1,25 @@ -using System; -using System.Collections.Generic; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Meta +namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Meta; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class MusicTrackMetaDefinition : HitCountingResourceDefinition { - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class MusicTrackMetaDefinition : HitCountingResourceDefinition + protected override ResourceDefinitionExtensibilityPoints ExtensibilityPointsToTrack => ResourceDefinitionExtensibilityPoints.GetMeta; + + public MusicTrackMetaDefinition(IResourceGraph resourceGraph, ResourceDefinitionHitCounter hitCounter) + : base(resourceGraph, hitCounter) { - protected override ResourceDefinitionExtensibilityPoints ExtensibilityPointsToTrack => ResourceDefinitionExtensibilityPoints.GetMeta; + } - public MusicTrackMetaDefinition(IResourceGraph resourceGraph, ResourceDefinitionHitCounter hitCounter) - : base(resourceGraph, hitCounter) - { - } + public override IDictionary GetMeta(MusicTrack resource) + { + base.GetMeta(resource); - public override IDictionary GetMeta(MusicTrack resource) + return new Dictionary { - base.GetMeta(resource); - - return new Dictionary - { - ["Copyright"] = $"(C) {resource.ReleasedAt.Year}. All rights reserved." - }; - } + ["Copyright"] = $"(C) {resource.ReleasedAt.Year}. All rights reserved." + }; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/TextLanguageMetaDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/TextLanguageMetaDefinition.cs index 2badcd6f88..f46b2940a6 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/TextLanguageMetaDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/TextLanguageMetaDefinition.cs @@ -1,29 +1,27 @@ -using System.Collections.Generic; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Meta +namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Meta; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class TextLanguageMetaDefinition : ImplicitlyChangingTextLanguageDefinition { - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class TextLanguageMetaDefinition : ImplicitlyChangingTextLanguageDefinition - { - internal const string NoticeText = "See https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes for ISO 639-1 language codes."; + internal const string NoticeText = "See https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes for ISO 639-1 language codes."; - protected override ResourceDefinitionExtensibilityPoints ExtensibilityPointsToTrack => ResourceDefinitionExtensibilityPoints.GetMeta; + protected override ResourceDefinitionExtensibilityPoints ExtensibilityPointsToTrack => ResourceDefinitionExtensibilityPoints.GetMeta; - public TextLanguageMetaDefinition(IResourceGraph resourceGraph, ResourceDefinitionHitCounter hitCounter, OperationsDbContext dbContext) - : base(resourceGraph, hitCounter, dbContext) - { - } + public TextLanguageMetaDefinition(IResourceGraph resourceGraph, ResourceDefinitionHitCounter hitCounter, OperationsDbContext dbContext) + : base(resourceGraph, hitCounter, dbContext) + { + } - public override IDictionary GetMeta(TextLanguage resource) - { - base.GetMeta(resource); + public override IDictionary GetMeta(TextLanguage resource) + { + base.GetMeta(resource); - return new Dictionary - { - ["Notice"] = NoticeText - }; - } + return new Dictionary + { + ["Notice"] = NoticeText + }; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicLoggingTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicLoggingTests.cs index f37cbd170f..19c9b2d872 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicLoggingTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicLoggingTests.cs @@ -1,8 +1,4 @@ -using System; using System.Net; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.AtomicOperations; using JsonApiDotNetCore.Serialization.Objects; @@ -11,176 +7,175 @@ using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Mixed -{ - public sealed class AtomicLoggingTests : IClassFixture, OperationsDbContext>> - { - private readonly IntegrationTestContext, OperationsDbContext> _testContext; +namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Mixed; - public AtomicLoggingTests(IntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; +public sealed class AtomicLoggingTests : IClassFixture, OperationsDbContext>> +{ + private readonly IntegrationTestContext, OperationsDbContext> _testContext; - testContext.UseController(); + public AtomicLoggingTests(IntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; - var loggerFactory = new FakeLoggerFactory(LogLevel.Information); + testContext.UseController(); - testContext.ConfigureLogging(options => - { - options.ClearProviders(); - options.AddProvider(loggerFactory); - options.SetMinimumLevel(LogLevel.Information); - }); + var loggerFactory = new FakeLoggerFactory(LogLevel.Information); - testContext.ConfigureServicesBeforeStartup(services => - { - services.AddSingleton(loggerFactory); - }); + testContext.ConfigureLogging(options => + { + options.ClearProviders(); + options.AddProvider(loggerFactory); + options.SetMinimumLevel(LogLevel.Information); + }); - testContext.ConfigureServicesAfterStartup(services => - { - services.AddSingleton(); - }); - } + testContext.ConfigureServicesBeforeStartup(services => + { + services.AddSingleton(loggerFactory); + }); - [Fact] - public async Task Logs_at_error_level_on_unhandled_exception() + testContext.ConfigureServicesAfterStartup(services => { - // Arrange - var loggerFactory = _testContext.Factory.Services.GetRequiredService(); - loggerFactory.Logger.Clear(); + services.AddSingleton(); + }); + } + + [Fact] + public async Task Logs_at_error_level_on_unhandled_exception() + { + // Arrange + var loggerFactory = _testContext.Factory.Services.GetRequiredService(); + loggerFactory.Logger.Clear(); - var transactionFactory = (ThrowingOperationsTransactionFactory)_testContext.Factory.Services.GetRequiredService(); - transactionFactory.ThrowOnOperationStart = true; + var transactionFactory = (ThrowingOperationsTransactionFactory)_testContext.Factory.Services.GetRequiredService(); + transactionFactory.ThrowOnOperationStart = true; - var requestBody = new + var requestBody = new + { + atomic__operations = new object[] { - atomic__operations = new object[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "performers", + attributes = new { - type = "performers", - attributes = new - { - } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.InternalServerError); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.InternalServerError); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.InternalServerError); - error.Title.Should().Be("An unhandled error occurred while processing an operation in this request."); - error.Detail.Should().Be("Simulated failure."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.InternalServerError); + error.Title.Should().Be("An unhandled error occurred while processing an operation in this request."); + error.Detail.Should().Be("Simulated failure."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]"); - loggerFactory.Logger.Messages.ShouldNotBeEmpty(); + loggerFactory.Logger.Messages.ShouldNotBeEmpty(); - loggerFactory.Logger.Messages.Should().ContainSingle(message => message.LogLevel == LogLevel.Error && - message.Text.Contains("Simulated failure.", StringComparison.Ordinal)); - } + loggerFactory.Logger.Messages.Should().ContainSingle(message => message.LogLevel == LogLevel.Error && + message.Text.Contains("Simulated failure.", StringComparison.Ordinal)); + } - [Fact] - public async Task Logs_at_info_level_on_invalid_request_body() - { - // Arrange - var loggerFactory = _testContext.Factory.Services.GetRequiredService(); - loggerFactory.Logger.Clear(); + [Fact] + public async Task Logs_at_info_level_on_invalid_request_body() + { + // Arrange + var loggerFactory = _testContext.Factory.Services.GetRequiredService(); + loggerFactory.Logger.Clear(); - var transactionFactory = (ThrowingOperationsTransactionFactory)_testContext.Factory.Services.GetRequiredService(); - transactionFactory.ThrowOnOperationStart = false; + var transactionFactory = (ThrowingOperationsTransactionFactory)_testContext.Factory.Services.GetRequiredService(); + transactionFactory.ThrowOnOperationStart = false; - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new - { - op = "update" - } + op = "update" } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - loggerFactory.Logger.Messages.ShouldNotBeEmpty(); + loggerFactory.Logger.Messages.ShouldNotBeEmpty(); - loggerFactory.Logger.Messages.Should().ContainSingle(message => message.LogLevel == LogLevel.Information && - message.Text.Contains("Failed to deserialize request body", StringComparison.Ordinal)); + loggerFactory.Logger.Messages.Should().ContainSingle(message => message.LogLevel == LogLevel.Information && + message.Text.Contains("Failed to deserialize request body", StringComparison.Ordinal)); + } + + private sealed class ThrowingOperationsTransactionFactory : IOperationsTransactionFactory + { + public bool ThrowOnOperationStart { get; set; } + + public Task BeginTransactionAsync(CancellationToken cancellationToken) + { + IOperationsTransaction transaction = new ThrowingOperationsTransaction(this); + return Task.FromResult(transaction); } - private sealed class ThrowingOperationsTransactionFactory : IOperationsTransactionFactory + private sealed class ThrowingOperationsTransaction : IOperationsTransaction { - public bool ThrowOnOperationStart { get; set; } + private readonly ThrowingOperationsTransactionFactory _owner; - public Task BeginTransactionAsync(CancellationToken cancellationToken) + public string TransactionId => "some"; + + public ThrowingOperationsTransaction(ThrowingOperationsTransactionFactory owner) { - IOperationsTransaction transaction = new ThrowingOperationsTransaction(this); - return Task.FromResult(transaction); + _owner = owner; } - private sealed class ThrowingOperationsTransaction : IOperationsTransaction + public ValueTask DisposeAsync() { - private readonly ThrowingOperationsTransactionFactory _owner; - - public string TransactionId => "some"; - - public ThrowingOperationsTransaction(ThrowingOperationsTransactionFactory owner) - { - _owner = owner; - } + return ValueTask.CompletedTask; + } - public ValueTask DisposeAsync() - { - return ValueTask.CompletedTask; - } + public Task BeforeProcessOperationAsync(CancellationToken cancellationToken) + { + return ThrowIfEnabled(); + } - public Task BeforeProcessOperationAsync(CancellationToken cancellationToken) - { - return ThrowIfEnabled(); - } + public Task AfterProcessOperationAsync(CancellationToken cancellationToken) + { + return ThrowIfEnabled(); + } - public Task AfterProcessOperationAsync(CancellationToken cancellationToken) - { - return ThrowIfEnabled(); - } + public Task CommitAsync(CancellationToken cancellationToken) + { + return ThrowIfEnabled(); + } - public Task CommitAsync(CancellationToken cancellationToken) + private Task ThrowIfEnabled() + { + if (_owner.ThrowOnOperationStart) { - return ThrowIfEnabled(); + throw new Exception("Simulated failure."); } - private Task ThrowIfEnabled() - { - if (_owner.ThrowOnOperationStart) - { - throw new Exception("Simulated failure."); - } - - return Task.CompletedTask; - } + return Task.CompletedTask; } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicRequestBodyTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicRequestBodyTests.cs index 8bc07968e8..aaf3722093 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicRequestBodyTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicRequestBodyTests.cs @@ -1,219 +1,215 @@ -using System; using System.Net; -using System.Net.Http; -using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Mixed +namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Mixed; + +public sealed class AtomicRequestBodyTests : IClassFixture, OperationsDbContext>> { - public sealed class AtomicRequestBodyTests : IClassFixture, OperationsDbContext>> + private readonly IntegrationTestContext, OperationsDbContext> _testContext; + + public AtomicRequestBodyTests(IntegrationTestContext, OperationsDbContext> testContext) { - private readonly IntegrationTestContext, OperationsDbContext> _testContext; + _testContext = testContext; - public AtomicRequestBodyTests(IntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; + testContext.UseController(); + } - testContext.UseController(); - } + [Fact] + public async Task Cannot_process_for_missing_request_body() + { + // Arrange + const string route = "/operations"; - [Fact] - public async Task Cannot_process_for_missing_request_body() - { - // Arrange - const string route = "/operations"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, null!); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, null!); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + responseDocument.Errors.ShouldHaveCount(1); - responseDocument.Errors.ShouldHaveCount(1); + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("Failed to deserialize request body: Missing request body."); + error.Detail.Should().BeNull(); + error.Source.Should().BeNull(); + error.Meta.Should().NotContainKey("requestBody"); + } - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Failed to deserialize request body: Missing request body."); - error.Detail.Should().BeNull(); - error.Source.Should().BeNull(); - error.Meta.Should().NotContainKey("requestBody"); - } + [Fact] + public async Task Cannot_process_for_null_request_body() + { + // Arrange + const string requestBody = "null"; - [Fact] - public async Task Cannot_process_for_null_request_body() - { - // Arrange - const string requestBody = "null"; + const string route = "/operations"; - const string route = "/operations"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors.ShouldHaveCount(1); - responseDocument.Errors.ShouldHaveCount(1); + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Expected an object, instead of 'null'."); + error.Detail.Should().BeNull(); + error.Source.Should().BeNull(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected an object, instead of 'null'."); - error.Detail.Should().BeNull(); - error.Source.Should().BeNull(); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + [Fact] + public async Task Cannot_process_for_broken_JSON_request_body() + { + // Arrange + const string requestBody = "{\"atomic__operations\":[{\"op\":"; - [Fact] - public async Task Cannot_process_for_broken_JSON_request_body() - { - // Arrange - const string requestBody = "{\"atomic__operations\":[{\"op\":"; + const string route = "/operations"; - const string route = "/operations"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors.ShouldHaveCount(1); - responseDocument.Errors.ShouldHaveCount(1); + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body."); + error.Detail.Should().Match("* There is an open JSON object or array that should be closed. *"); + error.Source.Should().BeNull(); + } - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body."); - error.Detail.Should().Match("* There is an open JSON object or array that should be closed. *"); - error.Source.Should().BeNull(); - } + [Fact] + public async Task Cannot_process_for_missing_operations_array() + { + // Arrange + const string route = "/operations"; - [Fact] - public async Task Cannot_process_for_missing_operations_array() + var requestBody = new { - // Arrange - const string route = "/operations"; - - var requestBody = new + meta = new { - meta = new - { - key = "value" - } - }; + key = "value" + } + }; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: No operations found."); - error.Detail.Should().BeNull(); - error.Source.Should().BeNull(); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: No operations found."); + error.Detail.Should().BeNull(); + error.Source.Should().BeNull(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_process_empty_operations_array() + [Fact] + public async Task Cannot_process_empty_operations_array() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new - { - atomic__operations = Array.Empty() - }; + atomic__operations = Array.Empty() + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: No operations found."); - error.Detail.Should().BeNull(); - error.Source.Should().BeNull(); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: No operations found."); + error.Detail.Should().BeNull(); + error.Source.Should().BeNull(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_process_null_operation() + [Fact] + public async Task Cannot_process_null_operation() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] - { - (object?)null - } - }; + (object?)null + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected an object, instead of 'null'."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Expected an object, instead of 'null'."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_process_for_unknown_operation_code() + [Fact] + public async Task Cannot_process_for_unknown_operation_code() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new object[] { - atomic__operations = new object[] + new { - new + op = "merge", + data = new { - op = "merge", - data = new + type = "performers", + attributes = new { - type = "performers", - attributes = new - { - } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body."); - error.Detail.Should().StartWith("The JSON value could not be converted to "); - error.Source.Should().BeNull(); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body."); + error.Detail.Should().StartWith("The JSON value could not be converted to "); + error.Source.Should().BeNull(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicSerializationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicSerializationTests.cs index 358bb9935d..314e14746e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicSerializationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicSerializationTests.cs @@ -1,98 +1,95 @@ -using System; using System.Net; -using System.Net.Http; -using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Configuration; using Microsoft.Extensions.DependencyInjection; using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Mixed +namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Mixed; + +public sealed class AtomicSerializationTests : IClassFixture, OperationsDbContext>> { - public sealed class AtomicSerializationTests : IClassFixture, OperationsDbContext>> - { - private readonly IntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new(); + private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new(); - public AtomicSerializationTests(IntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; + public AtomicSerializationTests(IntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; - testContext.UseController(); + testContext.UseController(); - // These routes need to be registered in ASP.NET for rendering links to resource/relationship endpoints. - testContext.UseController(); + // These routes need to be registered in ASP.NET for rendering links to resource/relationship endpoints. + testContext.UseController(); - testContext.ConfigureServicesAfterStartup(services => - { - services.AddResourceDefinition(); + testContext.ConfigureServicesAfterStartup(services => + { + services.AddResourceDefinition(); - services.AddSingleton(); - }); + services.AddSingleton(); + }); - var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); - options.IncludeExceptionStackTraceInErrors = false; - options.IncludeJsonApiVersion = true; - options.AllowClientGeneratedIds = true; - } + var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); + options.IncludeExceptionStackTraceInErrors = false; + options.IncludeJsonApiVersion = true; + options.AllowClientGeneratedIds = true; + } - [Fact] - public async Task Hides_data_for_void_operation() - { - // Arrange - Performer existingPerformer = _fakers.Performer.Generate(); + [Fact] + public async Task Hides_data_for_void_operation() + { + // Arrange + Performer existingPerformer = _fakers.Performer.Generate(); - TextLanguage newLanguage = _fakers.TextLanguage.Generate(); - newLanguage.Id = Guid.NewGuid(); + TextLanguage newLanguage = _fakers.TextLanguage.Generate(); + newLanguage.Id = Guid.NewGuid(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Performers.Add(existingPerformer); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Performers.Add(existingPerformer); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new object[] { - atomic__operations = new object[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = "performers", + id = existingPerformer.StringId, + attributes = new { - type = "performers", - id = existingPerformer.StringId, - attributes = new - { - } } - }, - new + } + }, + new + { + op = "add", + data = new { - op = "add", - data = new + type = "textLanguages", + id = newLanguage.StringId, + attributes = new { - type = "textLanguages", - id = newLanguage.StringId, - attributes = new - { - isoCode = newLanguage.IsoCode - } + isoCode = newLanguage.IsoCode } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Should().BeJson(@"{ + responseDocument.Should().BeJson(@"{ ""jsonapi"": { ""version"": ""1.1"", ""ext"": [ @@ -128,41 +125,41 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } ] }"); - } + } - [Fact] - public async Task Includes_version_with_ext_on_error_in_operations_endpoint() - { - // Arrange - string musicTrackId = Unknown.StringId.For(); + [Fact] + public async Task Includes_version_with_ext_on_error_in_operations_endpoint() + { + // Arrange + string musicTrackId = Unknown.StringId.For(); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "remove", + @ref = new { - op = "remove", - @ref = new - { - type = "musicTracks", - id = musicTrackId - } + type = "musicTracks", + id = musicTrackId } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - string errorId = JsonApiStringConverter.ExtractErrorId(responseDocument); + string errorId = JsonApiStringConverter.ExtractErrorId(responseDocument); - responseDocument.Should().BeJson(@"{ + responseDocument.Should().BeJson(@"{ ""jsonapi"": { ""version"": ""1.1"", ""ext"": [ @@ -184,6 +181,5 @@ public async Task Includes_version_with_ext_on_error_in_operations_endpoint() } ] }"); - } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/MaximumOperationsPerRequestTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/MaximumOperationsPerRequestTests.cs index b15a8215e2..818fda8a70 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/MaximumOperationsPerRequestTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/MaximumOperationsPerRequestTests.cs @@ -1,7 +1,4 @@ -using System.Collections.Generic; using System.Net; -using System.Net.Http; -using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; @@ -9,159 +6,158 @@ using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Mixed +namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Mixed; + +public sealed class MaximumOperationsPerRequestTests : IClassFixture, OperationsDbContext>> { - public sealed class MaximumOperationsPerRequestTests : IClassFixture, OperationsDbContext>> + private readonly IntegrationTestContext, OperationsDbContext> _testContext; + + public MaximumOperationsPerRequestTests(IntegrationTestContext, OperationsDbContext> testContext) { - private readonly IntegrationTestContext, OperationsDbContext> _testContext; + _testContext = testContext; - public MaximumOperationsPerRequestTests(IntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; + testContext.UseController(); + } - testContext.UseController(); - } + [Fact] + public async Task Cannot_process_more_operations_than_maximum() + { + // Arrange + var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); + options.MaximumOperationsPerRequest = 2; - [Fact] - public async Task Cannot_process_more_operations_than_maximum() + var requestBody = new { - // Arrange - var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); - options.MaximumOperationsPerRequest = 2; - - var requestBody = new + atomic__operations = new object[] { - atomic__operations = new object[] + new { - new + op = "add", + data = new { - op = "add", - data = new - { - type = "performers" - } - }, - new + type = "performers" + } + }, + new + { + op = "remove", + data = new { - op = "remove", - data = new - { - type = "performers" - } - }, - new + type = "performers" + } + }, + new + { + op = "update", + data = new { - op = "update", - data = new - { - type = "performers" - } + type = "performers" } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Too many operations in request."); - error.Detail.Should().Be("The number of operations in this request (3) is higher than the maximum of 2."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Too many operations in request."); + error.Detail.Should().Be("The number of operations in this request (3) is higher than the maximum of 2."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Can_process_operations_same_as_maximum() - { - // Arrange - var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); - options.MaximumOperationsPerRequest = 2; + [Fact] + public async Task Can_process_operations_same_as_maximum() + { + // Arrange + var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); + options.MaximumOperationsPerRequest = 2; - var requestBody = new + var requestBody = new + { + atomic__operations = new object[] { - atomic__operations = new object[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "performers", + attributes = new { - type = "performers", - attributes = new - { - } } - }, - new + } + }, + new + { + op = "add", + data = new { - op = "add", - data = new + type = "performers", + attributes = new { - type = "performers", - attributes = new - { - } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - } + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + } - [Fact] - public async Task Can_process_high_number_of_operations_when_unconstrained() - { - // Arrange - var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); - options.MaximumOperationsPerRequest = null; + [Fact] + public async Task Can_process_high_number_of_operations_when_unconstrained() + { + // Arrange + var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); + options.MaximumOperationsPerRequest = null; - const int elementCount = 100; + const int elementCount = 100; - var operationElements = new List(elementCount); + var operationElements = new List(elementCount); - for (int index = 0; index < elementCount; index++) + for (int index = 0; index < elementCount; index++) + { + operationElements.Add(new { - operationElements.Add(new + op = "add", + data = new { - op = "add", - data = new + type = "performers", + attributes = new { - type = "performers", - attributes = new - { - } } - }); - } + } + }); + } - var requestBody = new - { - atomic__operations = operationElements - }; + var requestBody = new + { + atomic__operations = operationElements + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - } + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs index 9fad376626..9c69af2f71 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs @@ -1,574 +1,571 @@ using System.Net; -using System.Net.Http; -using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.EntityFrameworkCore; using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.ModelStateValidation +namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.ModelStateValidation; + +public sealed class AtomicModelStateValidationTests : IClassFixture, OperationsDbContext>> { - public sealed class AtomicModelStateValidationTests : IClassFixture, OperationsDbContext>> - { - private readonly IntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new(); + private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new(); - public AtomicModelStateValidationTests(IntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; + public AtomicModelStateValidationTests(IntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; - testContext.UseController(); - } + testContext.UseController(); + } - [Fact] - public async Task Cannot_create_resource_with_multiple_violations() + [Fact] + public async Task Cannot_create_resource_with_multiple_violations() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "musicTracks", + attributes = new { - type = "musicTracks", - attributes = new - { - lengthInSeconds = -1 - } + lengthInSeconds = -1 } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(2); + responseDocument.Errors.ShouldHaveCount(2); - ErrorObject error1 = responseDocument.Errors[0]; - error1.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error1.Title.Should().Be("Input validation failed."); - error1.Detail.Should().Be("The Title field is required."); - error1.Source.ShouldNotBeNull(); - error1.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/title"); + ErrorObject error1 = responseDocument.Errors[0]; + error1.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error1.Title.Should().Be("Input validation failed."); + error1.Detail.Should().Be("The Title field is required."); + error1.Source.ShouldNotBeNull(); + error1.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/title"); - ErrorObject error2 = responseDocument.Errors[1]; - error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error2.Title.Should().Be("Input validation failed."); - error2.Detail.Should().Be("The field LengthInSeconds must be between 1 and 1440."); - error2.Source.ShouldNotBeNull(); - error2.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/lengthInSeconds"); - } + ErrorObject error2 = responseDocument.Errors[1]; + error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error2.Title.Should().Be("Input validation failed."); + error2.Detail.Should().Be("The field LengthInSeconds must be between 1 and 1440."); + error2.Source.ShouldNotBeNull(); + error2.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/lengthInSeconds"); + } - [Fact] - public async Task Can_create_resource_with_annotated_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - string newPlaylistName = _fakers.Playlist.Generate().Name; + [Fact] + public async Task Can_create_resource_with_annotated_relationship() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + string newPlaylistName = _fakers.Playlist.Generate().Name; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "playlists", + attributes = new { - type = "playlists", - attributes = new - { - name = newPlaylistName - }, - relationships = new + name = newPlaylistName + }, + relationships = new + { + tracks = new { - tracks = new + data = new[] { - data = new[] + new { - new - { - type = "musicTracks", - id = existingTrack.StringId - } + type = "musicTracks", + id = existingTrack.StringId } } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(1); + responseDocument.Results.ShouldHaveCount(1); - long newPlaylistId = long.Parse(responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().Id.ShouldNotBeNull()); + long newPlaylistId = long.Parse(responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().Id.ShouldNotBeNull()); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(newPlaylistId); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(newPlaylistId); - playlistInDatabase.Tracks.ShouldHaveCount(1); - playlistInDatabase.Tracks[0].Id.Should().Be(existingTrack.Id); - }); - } + playlistInDatabase.Tracks.ShouldHaveCount(1); + playlistInDatabase.Tracks[0].Id.Should().Be(existingTrack.Id); + }); + } - [Fact] - public async Task Cannot_update_resource_with_multiple_violations() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + [Fact] + public async Task Cannot_update_resource_with_multiple_violations() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = "musicTracks", + id = existingTrack.StringId, + attributes = new { - type = "musicTracks", - id = existingTrack.StringId, - attributes = new - { - title = (string?)null, - lengthInSeconds = -1 - } + title = (string?)null, + lengthInSeconds = -1 } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(2); + responseDocument.Errors.ShouldHaveCount(2); - ErrorObject error1 = responseDocument.Errors[0]; - error1.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error1.Title.Should().Be("Input validation failed."); - error1.Detail.Should().Be("The Title field is required."); - error1.Source.ShouldNotBeNull(); - error1.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/title"); + ErrorObject error1 = responseDocument.Errors[0]; + error1.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error1.Title.Should().Be("Input validation failed."); + error1.Detail.Should().Be("The Title field is required."); + error1.Source.ShouldNotBeNull(); + error1.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/title"); - ErrorObject error2 = responseDocument.Errors[1]; - error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error2.Title.Should().Be("Input validation failed."); - error2.Detail.Should().Be("The field LengthInSeconds must be between 1 and 1440."); - error2.Source.ShouldNotBeNull(); - error2.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/lengthInSeconds"); - } + ErrorObject error2 = responseDocument.Errors[1]; + error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error2.Title.Should().Be("Input validation failed."); + error2.Detail.Should().Be("The field LengthInSeconds must be between 1 and 1440."); + error2.Source.ShouldNotBeNull(); + error2.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/lengthInSeconds"); + } - [Fact] - public async Task Can_update_resource_with_omitted_required_attribute() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - string newTrackGenre = _fakers.MusicTrack.Generate().Genre!; + [Fact] + public async Task Can_update_resource_with_omitted_required_attribute() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + string newTrackGenre = _fakers.MusicTrack.Generate().Genre!; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = "musicTracks", + id = existingTrack.StringId, + attributes = new { - type = "musicTracks", - id = existingTrack.StringId, - attributes = new - { - genre = newTrackGenre - } + genre = newTrackGenre } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.FirstWithIdAsync(existingTrack.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + MusicTrack trackInDatabase = await dbContext.MusicTracks.FirstWithIdAsync(existingTrack.Id); - trackInDatabase.Title.Should().Be(existingTrack.Title); - trackInDatabase.Genre.Should().Be(newTrackGenre); - }); - } + trackInDatabase.Title.Should().Be(existingTrack.Title); + trackInDatabase.Genre.Should().Be(newTrackGenre); + }); + } - [Fact] - public async Task Can_update_resource_with_annotated_relationship() - { - // Arrange - Playlist existingPlaylist = _fakers.Playlist.Generate(); - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + [Fact] + public async Task Can_update_resource_with_annotated_relationship() + { + // Arrange + Playlist existingPlaylist = _fakers.Playlist.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddInRange(existingPlaylist, existingTrack); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingPlaylist, existingTrack); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = "playlists", + id = existingPlaylist.StringId, + relationships = new { - type = "playlists", - id = existingPlaylist.StringId, - relationships = new + tracks = new { - tracks = new + data = new[] { - data = new[] + new { - new - { - type = "musicTracks", - id = existingTrack.StringId - } + type = "musicTracks", + id = existingTrack.StringId } } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(existingPlaylist.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(existingPlaylist.Id); - playlistInDatabase.Tracks.ShouldHaveCount(1); - playlistInDatabase.Tracks[0].Id.Should().Be(existingTrack.Id); - }); - } + playlistInDatabase.Tracks.ShouldHaveCount(1); + playlistInDatabase.Tracks[0].Id.Should().Be(existingTrack.Id); + }); + } - [Fact] - public async Task Can_update_ManyToOne_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - RecordCompany existingCompany = _fakers.RecordCompany.Generate(); + [Fact] + public async Task Can_update_ManyToOne_relationship() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + RecordCompany existingCompany = _fakers.RecordCompany.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddInRange(existingTrack, existingCompany); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingTrack, existingCompany); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + @ref = new { - op = "update", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "ownedBy" - }, - data = new - { - type = "recordCompanies", - id = existingCompany.StringId - } + type = "musicTracks", + id = existingTrack.StringId, + relationship = "ownedBy" + }, + data = new + { + type = "recordCompanies", + id = existingCompany.StringId } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(existingTrack.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(existingTrack.Id); - trackInDatabase.OwnedBy.ShouldNotBeNull(); - trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); - }); - } + trackInDatabase.OwnedBy.ShouldNotBeNull(); + trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); + }); + } - [Fact] - public async Task Can_update_ManyToMany_relationship() - { - // Arrange - Playlist existingPlaylist = _fakers.Playlist.Generate(); - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + [Fact] + public async Task Can_update_ManyToMany_relationship() + { + // Arrange + Playlist existingPlaylist = _fakers.Playlist.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddInRange(existingPlaylist, existingTrack); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingPlaylist, existingTrack); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + @ref = new { - op = "update", - @ref = new - { - type = "playlists", - id = existingPlaylist.StringId, - relationship = "tracks" - }, - data = new[] + type = "playlists", + id = existingPlaylist.StringId, + relationship = "tracks" + }, + data = new[] + { + new { - new - { - type = "musicTracks", - id = existingTrack.StringId - } + type = "musicTracks", + id = existingTrack.StringId } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(existingPlaylist.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(existingPlaylist.Id); - playlistInDatabase.Tracks.ShouldHaveCount(1); - playlistInDatabase.Tracks[0].Id.Should().Be(existingTrack.Id); - }); - } + playlistInDatabase.Tracks.ShouldHaveCount(1); + playlistInDatabase.Tracks[0].Id.Should().Be(existingTrack.Id); + }); + } - [Fact] - public async Task Validates_all_operations_before_execution_starts() + [Fact] + public async Task Validates_all_operations_before_execution_starts() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new object[] { - atomic__operations = new object[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = "playlists", + id = Unknown.StringId.For(), + attributes = new { - type = "playlists", - id = Unknown.StringId.For(), - attributes = new - { - name = (string?)null - } + name = (string?)null } - }, - new + } + }, + new + { + op = "add", + data = new { - op = "add", - data = new + type = "musicTracks", + attributes = new { - type = "musicTracks", - attributes = new - { - title = "some", - lengthInSeconds = -1 - } + title = "some", + lengthInSeconds = -1 } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(2); + responseDocument.Errors.ShouldHaveCount(2); - ErrorObject error1 = responseDocument.Errors[0]; - error1.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error1.Title.Should().Be("Input validation failed."); - error1.Detail.Should().Be("The Name field is required."); - error1.Source.ShouldNotBeNull(); - error1.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/name"); + ErrorObject error1 = responseDocument.Errors[0]; + error1.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error1.Title.Should().Be("Input validation failed."); + error1.Detail.Should().Be("The Name field is required."); + error1.Source.ShouldNotBeNull(); + error1.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/name"); - ErrorObject error2 = responseDocument.Errors[1]; - error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error2.Title.Should().Be("Input validation failed."); - error2.Detail.Should().Be("The field LengthInSeconds must be between 1 and 1440."); - error2.Source.ShouldNotBeNull(); - error2.Source.Pointer.Should().Be("/atomic:operations[1]/data/attributes/lengthInSeconds"); - } + ErrorObject error2 = responseDocument.Errors[1]; + error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error2.Title.Should().Be("Input validation failed."); + error2.Detail.Should().Be("The field LengthInSeconds must be between 1 and 1440."); + error2.Source.ShouldNotBeNull(); + error2.Source.Pointer.Should().Be("/atomic:operations[1]/data/attributes/lengthInSeconds"); + } - [Fact] - public async Task Does_not_exceed_MaxModelValidationErrors() + [Fact] + public async Task Does_not_exceed_MaxModelValidationErrors() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new object[] { - atomic__operations = new object[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "playlists", + attributes = new { - type = "playlists", - attributes = new - { - name = (string?)null - } + name = (string?)null } - }, - new + } + }, + new + { + op = "add", + data = new { - op = "add", - data = new + type = "playlists", + attributes = new { - type = "playlists", - attributes = new - { - name = (string?)null - } + name = (string?)null } - }, - new + } + }, + new + { + op = "add", + data = new { - op = "add", - data = new + type = "musicTracks", + attributes = new { - type = "musicTracks", - attributes = new - { - lengthInSeconds = -1 - } + lengthInSeconds = -1 } - }, - new + } + }, + new + { + op = "add", + data = new { - op = "add", - data = new + type = "playlists", + attributes = new { - type = "playlists", - attributes = new - { - name = (string?)null - } + name = (string?)null } } } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(3); - - ErrorObject error1 = responseDocument.Errors[0]; - error1.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error1.Title.Should().Be("Input validation failed."); - error1.Detail.Should().Be("The maximum number of allowed model errors has been reached."); - error1.Source.Should().BeNull(); - - ErrorObject error2 = responseDocument.Errors[1]; - error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error2.Title.Should().Be("Input validation failed."); - error2.Detail.Should().Be("The Name field is required."); - error2.Source.ShouldNotBeNull(); - error2.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/name"); - - ErrorObject error3 = responseDocument.Errors[2]; - error3.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error3.Title.Should().Be("Input validation failed."); - error3.Detail.Should().Be("The Name field is required."); - error3.Source.ShouldNotBeNull(); - error3.Source.Pointer.Should().Be("/atomic:operations[1]/data/attributes/name"); - } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.ShouldHaveCount(3); + + ErrorObject error1 = responseDocument.Errors[0]; + error1.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error1.Title.Should().Be("Input validation failed."); + error1.Detail.Should().Be("The maximum number of allowed model errors has been reached."); + error1.Source.Should().BeNull(); + + ErrorObject error2 = responseDocument.Errors[1]; + error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error2.Title.Should().Be("Input validation failed."); + error2.Detail.Should().Be("The Name field is required."); + error2.Source.ShouldNotBeNull(); + error2.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/name"); + + ErrorObject error3 = responseDocument.Errors[2]; + error3.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error3.Title.Should().Be("Input validation failed."); + error3.Detail.Should().Be("The Name field is required."); + error3.Source.ShouldNotBeNull(); + error3.Source.Pointer.Should().Be("/atomic:operations[1]/data/attributes/name"); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/MusicTrack.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/MusicTrack.cs index e2e7710e76..42bbbdfd3b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/MusicTrack.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/MusicTrack.cs @@ -1,42 +1,39 @@ -using System; -using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations +namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations")] +public sealed class MusicTrack : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - [Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations")] - public sealed class MusicTrack : Identifiable - { - [RegularExpression(@"(?im)^[{(]?[0-9A-F]{8}[-]?(?:[0-9A-F]{4}[-]?){3}[0-9A-F]{12}[)}]?$")] - public override Guid Id { get; set; } + [RegularExpression(@"(?im)^[{(]?[0-9A-F]{8}[-]?(?:[0-9A-F]{4}[-]?){3}[0-9A-F]{12}[)}]?$")] + public override Guid Id { get; set; } - [Attr] - public string Title { get; set; } = null!; + [Attr] + public string Title { get; set; } = null!; - [Attr] - [Range(1, 24 * 60)] - public decimal? LengthInSeconds { get; set; } + [Attr] + [Range(1, 24 * 60)] + public decimal? LengthInSeconds { get; set; } - [Attr] - public string? Genre { get; set; } + [Attr] + public string? Genre { get; set; } - [Attr] - public DateTimeOffset ReleasedAt { get; set; } + [Attr] + public DateTimeOffset ReleasedAt { get; set; } - [HasOne] - public Lyric? Lyric { get; set; } + [HasOne] + public Lyric? Lyric { get; set; } - [HasOne] - public RecordCompany? OwnedBy { get; set; } + [HasOne] + public RecordCompany? OwnedBy { get; set; } - [HasMany] - public IList Performers { get; set; } = new List(); + [HasMany] + public IList Performers { get; set; } = new List(); - [HasMany] - public IList OccursIn { get; set; } = new List(); - } + [HasMany] + public IList OccursIn { get; set; } = new List(); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/OperationsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/OperationsController.cs index eb1aa68911..08ef7b169b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/OperationsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/OperationsController.cs @@ -5,14 +5,13 @@ using JsonApiDotNetCore.Resources; using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations +namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations; + +public sealed class OperationsController : JsonApiOperationsController { - public sealed class OperationsController : JsonApiOperationsController + public OperationsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IOperationsProcessor processor, + IJsonApiRequest request, ITargetedFields targetedFields) + : base(options, resourceGraph, loggerFactory, processor, request, targetedFields) { - public OperationsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IOperationsProcessor processor, - IJsonApiRequest request, ITargetedFields targetedFields) - : base(options, resourceGraph, loggerFactory, processor, request, targetedFields) - { - } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/OperationsDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/OperationsDbContext.cs index dc46d4e672..45be92ce8b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/OperationsDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/OperationsDbContext.cs @@ -3,33 +3,32 @@ // @formatter:wrap_chained_method_calls chop_always -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations +namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class OperationsDbContext : DbContext { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class OperationsDbContext : DbContext - { - public DbSet Playlists => Set(); - public DbSet MusicTracks => Set(); - public DbSet Lyrics => Set(); - public DbSet TextLanguages => Set(); - public DbSet Performers => Set(); - public DbSet RecordCompanies => Set(); + public DbSet Playlists => Set(); + public DbSet MusicTracks => Set(); + public DbSet Lyrics => Set(); + public DbSet TextLanguages => Set(); + public DbSet Performers => Set(); + public DbSet RecordCompanies => Set(); - public OperationsDbContext(DbContextOptions options) - : base(options) - { - } + public OperationsDbContext(DbContextOptions options) + : base(options) + { + } - protected override void OnModelCreating(ModelBuilder builder) - { - builder.Entity() - .HasOne(musicTrack => musicTrack.Lyric) - .WithOne(lyric => lyric!.Track!) - .HasForeignKey("LyricId"); + protected override void OnModelCreating(ModelBuilder builder) + { + builder.Entity() + .HasOne(musicTrack => musicTrack.Lyric) + .WithOne(lyric => lyric.Track!) + .HasForeignKey("LyricId"); - builder.Entity() - .HasMany(musicTrack => musicTrack.OccursIn) - .WithMany(playlist => playlist.Tracks); - } + builder.Entity() + .HasMany(musicTrack => musicTrack.OccursIn) + .WithMany(playlist => playlist.Tracks); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/OperationsFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/OperationsFakers.cs index 1632eb6af8..8cf3c07e41 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/OperationsFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/OperationsFakers.cs @@ -1,67 +1,63 @@ -using System; -using System.Collections.Generic; using System.Globalization; -using System.Linq; using Bogus; using TestBuildingBlocks; // @formatter:wrap_chained_method_calls chop_always // @formatter:keep_existing_linebreaks true -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations -{ - internal sealed class OperationsFakers : FakerContainer - { - private static readonly Lazy> LazyLanguageIsoCodes = - new(() => CultureInfo - .GetCultures(CultureTypes.NeutralCultures) - .Where(culture => !string.IsNullOrEmpty(culture.Name)) - .Select(culture => culture.Name) - .ToArray()); - - private readonly Lazy> _lazyPlaylistFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(playlist => playlist.Name, faker => faker.Lorem.Sentence())); - - private readonly Lazy> _lazyMusicTrackFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(musicTrack => musicTrack.Title, faker => faker.Lorem.Word()) - .RuleFor(musicTrack => musicTrack.LengthInSeconds, faker => faker.Random.Decimal(3 * 60, 5 * 60)) - .RuleFor(musicTrack => musicTrack.Genre, faker => faker.Lorem.Word()) - .RuleFor(musicTrack => musicTrack.ReleasedAt, faker => faker.Date.PastOffset() - .TruncateToWholeMilliseconds())); - - private readonly Lazy> _lazyLyricFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(lyric => lyric.Text, faker => faker.Lorem.Text()) - .RuleFor(lyric => lyric.Format, "LRC")); +namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations; - private readonly Lazy> _lazyTextLanguageFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(textLanguage => textLanguage.IsoCode, faker => faker.PickRandom(LazyLanguageIsoCodes.Value))); - - private readonly Lazy> _lazyPerformerFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(performer => performer.ArtistName, faker => faker.Name.FullName()) - .RuleFor(performer => performer.BornAt, faker => faker.Date.PastOffset() - .TruncateToWholeMilliseconds())); - - private readonly Lazy> _lazyRecordCompanyFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(recordCompany => recordCompany.Name, faker => faker.Company.CompanyName()) - .RuleFor(recordCompany => recordCompany.CountryOfResidence, faker => faker.Address.Country())); - - public Faker Playlist => _lazyPlaylistFaker.Value; - public Faker MusicTrack => _lazyMusicTrackFaker.Value; - public Faker Lyric => _lazyLyricFaker.Value; - public Faker TextLanguage => _lazyTextLanguageFaker.Value; - public Faker Performer => _lazyPerformerFaker.Value; - public Faker RecordCompany => _lazyRecordCompanyFaker.Value; - } +internal sealed class OperationsFakers : FakerContainer +{ + private static readonly Lazy> LazyLanguageIsoCodes = + new(() => CultureInfo + .GetCultures(CultureTypes.NeutralCultures) + .Where(culture => !string.IsNullOrEmpty(culture.Name)) + .Select(culture => culture.Name) + .ToArray()); + + private readonly Lazy> _lazyPlaylistFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(playlist => playlist.Name, faker => faker.Lorem.Sentence())); + + private readonly Lazy> _lazyMusicTrackFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(musicTrack => musicTrack.Title, faker => faker.Lorem.Word()) + .RuleFor(musicTrack => musicTrack.LengthInSeconds, faker => faker.Random.Decimal(3 * 60, 5 * 60)) + .RuleFor(musicTrack => musicTrack.Genre, faker => faker.Lorem.Word()) + .RuleFor(musicTrack => musicTrack.ReleasedAt, faker => faker.Date.PastOffset() + .TruncateToWholeMilliseconds())); + + private readonly Lazy> _lazyLyricFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(lyric => lyric.Text, faker => faker.Lorem.Text()) + .RuleFor(lyric => lyric.Format, "LRC")); + + private readonly Lazy> _lazyTextLanguageFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(textLanguage => textLanguage.IsoCode, faker => faker.PickRandom(LazyLanguageIsoCodes.Value))); + + private readonly Lazy> _lazyPerformerFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(performer => performer.ArtistName, faker => faker.Name.FullName()) + .RuleFor(performer => performer.BornAt, faker => faker.Date.PastOffset() + .TruncateToWholeMilliseconds())); + + private readonly Lazy> _lazyRecordCompanyFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(recordCompany => recordCompany.Name, faker => faker.Company.CompanyName()) + .RuleFor(recordCompany => recordCompany.CountryOfResidence, faker => faker.Address.Country())); + + public Faker Playlist => _lazyPlaylistFaker.Value; + public Faker MusicTrack => _lazyMusicTrackFaker.Value; + public Faker Lyric => _lazyLyricFaker.Value; + public Faker TextLanguage => _lazyTextLanguageFaker.Value; + public Faker Performer => _lazyPerformerFaker.Value; + public Faker RecordCompany => _lazyRecordCompanyFaker.Value; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Performer.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Performer.cs index 31f9d2f807..97e5a8d1d3 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Performer.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Performer.cs @@ -1,18 +1,16 @@ -using System; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations +namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations")] +public sealed class Performer : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - [Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations")] - public sealed class Performer : Identifiable - { - [Attr] - public string? ArtistName { get; set; } + [Attr] + public string? ArtistName { get; set; } - [Attr] - public DateTimeOffset BornAt { get; set; } - } + [Attr] + public DateTimeOffset BornAt { get; set; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Playlist.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Playlist.cs index cc4d9cb458..e8baf731d0 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Playlist.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Playlist.cs @@ -1,23 +1,21 @@ -using System.Collections.Generic; using System.ComponentModel.DataAnnotations.Schema; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations +namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations")] +public sealed class Playlist : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - [Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations")] - public sealed class Playlist : Identifiable - { - [Attr] - public string Name { get; set; } = null!; + [Attr] + public string Name { get; set; } = null!; - [NotMapped] - [Attr] - public bool IsArchived => false; + [NotMapped] + [Attr] + public bool IsArchived => false; - [HasMany] - public IList Tracks { get; set; } = new List(); - } + [HasMany] + public IList Tracks { get; set; } = new List(); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs index a9d0f8a1c7..cf0db00e3e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Generic; using System.Net; -using System.Net.Http; -using System.Threading.Tasks; using FluentAssertions; using FluentAssertions.Extensions; using JsonApiDotNetCore.Configuration; @@ -12,345 +8,344 @@ using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.QueryStrings +namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.QueryStrings; + +public sealed class AtomicQueryStringTests : IClassFixture, OperationsDbContext>> { - public sealed class AtomicQueryStringTests : IClassFixture, OperationsDbContext>> - { - private static readonly DateTime FrozenTime = 30.July(2018).At(13, 46, 12); + private static readonly DateTime FrozenTime = 30.July(2018).At(13, 46, 12).AsUtc(); - private readonly IntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new(); + private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new(); - public AtomicQueryStringTests(IntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; + public AtomicQueryStringTests(IntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; - testContext.UseController(); - testContext.UseController(); + testContext.UseController(); + testContext.UseController(); - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServicesAfterStartup(services => + { + services.AddSingleton(new FrozenSystemClock { - services.AddSingleton(new FrozenSystemClock - { - UtcNow = FrozenTime - }); - - services.AddResourceDefinition(); + UtcNow = FrozenTime }); - } - [Fact] - public async Task Cannot_include_on_operations_endpoint() + services.AddResourceDefinition(); + }); + } + + [Fact] + public async Task Cannot_include_on_operations_endpoint() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new object[] { - atomic__operations = new object[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "recordCompanies", + attributes = new { - type = "recordCompanies", - attributes = new - { - } } } } - }; + } + }; - const string route = "/operations?include=recordCompanies"; + const string route = "/operations?include=recordCompanies"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Usage of one or more query string parameters is not allowed at the requested endpoint."); - error.Detail.Should().Be("The parameter 'include' cannot be used at this endpoint."); - error.Source.ShouldNotBeNull(); - error.Source.Parameter.Should().Be("include"); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("Usage of one or more query string parameters is not allowed at the requested endpoint."); + error.Detail.Should().Be("The parameter 'include' cannot be used at this endpoint."); + error.Source.ShouldNotBeNull(); + error.Source.Parameter.Should().Be("include"); + } - [Fact] - public async Task Cannot_filter_on_operations_endpoint() + [Fact] + public async Task Cannot_filter_on_operations_endpoint() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new object[] { - atomic__operations = new object[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "recordCompanies", + attributes = new { - type = "recordCompanies", - attributes = new - { - } } } } - }; + } + }; - const string route = "/operations?filter=equals(id,'1')"; + const string route = "/operations?filter=equals(id,'1')"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Usage of one or more query string parameters is not allowed at the requested endpoint."); - error.Detail.Should().Be("The parameter 'filter' cannot be used at this endpoint."); - error.Source.ShouldNotBeNull(); - error.Source.Parameter.Should().Be("filter"); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("Usage of one or more query string parameters is not allowed at the requested endpoint."); + error.Detail.Should().Be("The parameter 'filter' cannot be used at this endpoint."); + error.Source.ShouldNotBeNull(); + error.Source.Parameter.Should().Be("filter"); + } - [Fact] - public async Task Cannot_sort_on_operations_endpoint() + [Fact] + public async Task Cannot_sort_on_operations_endpoint() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new object[] { - atomic__operations = new object[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "recordCompanies", + attributes = new { - type = "recordCompanies", - attributes = new - { - } } } } - }; + } + }; - const string route = "/operations?sort=-id"; + const string route = "/operations?sort=-id"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Usage of one or more query string parameters is not allowed at the requested endpoint."); - error.Detail.Should().Be("The parameter 'sort' cannot be used at this endpoint."); - error.Source.ShouldNotBeNull(); - error.Source.Parameter.Should().Be("sort"); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("Usage of one or more query string parameters is not allowed at the requested endpoint."); + error.Detail.Should().Be("The parameter 'sort' cannot be used at this endpoint."); + error.Source.ShouldNotBeNull(); + error.Source.Parameter.Should().Be("sort"); + } - [Fact] - public async Task Cannot_use_pagination_number_on_operations_endpoint() + [Fact] + public async Task Cannot_use_pagination_number_on_operations_endpoint() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new object[] { - atomic__operations = new object[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "recordCompanies", + attributes = new { - type = "recordCompanies", - attributes = new - { - } } } } - }; + } + }; - const string route = "/operations?page[number]=1"; + const string route = "/operations?page[number]=1"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Usage of one or more query string parameters is not allowed at the requested endpoint."); - error.Detail.Should().Be("The parameter 'page[number]' cannot be used at this endpoint."); - error.Source.ShouldNotBeNull(); - error.Source.Parameter.Should().Be("page[number]"); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("Usage of one or more query string parameters is not allowed at the requested endpoint."); + error.Detail.Should().Be("The parameter 'page[number]' cannot be used at this endpoint."); + error.Source.ShouldNotBeNull(); + error.Source.Parameter.Should().Be("page[number]"); + } - [Fact] - public async Task Cannot_use_pagination_size_on_operations_endpoint() + [Fact] + public async Task Cannot_use_pagination_size_on_operations_endpoint() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new object[] { - atomic__operations = new object[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "recordCompanies", + attributes = new { - type = "recordCompanies", - attributes = new - { - } } } } - }; + } + }; - const string route = "/operations?page[size]=1"; + const string route = "/operations?page[size]=1"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Usage of one or more query string parameters is not allowed at the requested endpoint."); - error.Detail.Should().Be("The parameter 'page[size]' cannot be used at this endpoint."); - error.Source.ShouldNotBeNull(); - error.Source.Parameter.Should().Be("page[size]"); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("Usage of one or more query string parameters is not allowed at the requested endpoint."); + error.Detail.Should().Be("The parameter 'page[size]' cannot be used at this endpoint."); + error.Source.ShouldNotBeNull(); + error.Source.Parameter.Should().Be("page[size]"); + } - [Fact] - public async Task Cannot_use_sparse_fieldset_on_operations_endpoint() + [Fact] + public async Task Cannot_use_sparse_fieldset_on_operations_endpoint() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new object[] { - atomic__operations = new object[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "recordCompanies", + attributes = new { - type = "recordCompanies", - attributes = new - { - } } } } - }; + } + }; + + const string route = "/operations?fields[recordCompanies]=id"; - const string route = "/operations?fields[recordCompanies]=id"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + responseDocument.Errors.ShouldHaveCount(1); - responseDocument.Errors.ShouldHaveCount(1); + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("Usage of one or more query string parameters is not allowed at the requested endpoint."); + error.Detail.Should().Be("The parameter 'fields[recordCompanies]' cannot be used at this endpoint."); + error.Source.ShouldNotBeNull(); + error.Source.Parameter.Should().Be("fields[recordCompanies]"); + } - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Usage of one or more query string parameters is not allowed at the requested endpoint."); - error.Detail.Should().Be("The parameter 'fields[recordCompanies]' cannot be used at this endpoint."); - error.Source.ShouldNotBeNull(); - error.Source.Parameter.Should().Be("fields[recordCompanies]"); - } + [Fact] + public async Task Can_use_Queryable_handler_on_resource_endpoint() + { + // Arrange + List musicTracks = _fakers.MusicTrack.Generate(3); + musicTracks[0].ReleasedAt = FrozenTime.AddMonths(5); + musicTracks[1].ReleasedAt = FrozenTime.AddMonths(-5); + musicTracks[2].ReleasedAt = FrozenTime.AddMonths(-1); - [Fact] - public async Task Can_use_Queryable_handler_on_resource_endpoint() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - List musicTracks = _fakers.MusicTrack.Generate(3); - musicTracks[0].ReleasedAt = FrozenTime.AddMonths(5); - musicTracks[1].ReleasedAt = FrozenTime.AddMonths(-5); - musicTracks[2].ReleasedAt = FrozenTime.AddMonths(-1); + await dbContext.ClearTableAsync(); + dbContext.MusicTracks.AddRange(musicTracks); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.MusicTracks.AddRange(musicTracks); - await dbContext.SaveChangesAsync(); - }); + const string route = "/musicTracks?isRecentlyReleased=true"; - const string route = "/musicTracks?isRecentlyReleased=true"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be(musicTracks[2].StringId); + } - responseDocument.Data.ManyValue.ShouldHaveCount(1); - responseDocument.Data.ManyValue[0].Id.Should().Be(musicTracks[2].StringId); - } + [Fact] + public async Task Cannot_use_Queryable_handler_on_operations_endpoint() + { + // Arrange + string newTrackTitle = _fakers.MusicTrack.Generate().Title; - [Fact] - public async Task Cannot_use_Queryable_handler_on_operations_endpoint() + var requestBody = new { - // Arrange - string newTrackTitle = _fakers.MusicTrack.Generate().Title; - - var requestBody = new + atomic__operations = new object[] { - atomic__operations = new object[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "musicTracks", + attributes = new { - type = "musicTracks", - attributes = new - { - title = newTrackTitle - } + title = newTrackTitle } } } - }; + } + }; - const string route = "/operations?isRecentlyReleased=true"; + const string route = "/operations?isRecentlyReleased=true"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Unknown query string parameter."); + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("Unknown query string parameter."); - error.Detail.Should().Be("Query string parameter 'isRecentlyReleased' is unknown. " + - "Set 'AllowUnknownQueryStringParameters' to 'true' in options to ignore unknown parameters."); + error.Detail.Should().Be("Query string parameter 'isRecentlyReleased' is unknown. " + + "Set 'AllowUnknownQueryStringParameters' to 'true' in options to ignore unknown parameters."); - error.Source.ShouldNotBeNull(); - error.Source.Parameter.Should().Be("isRecentlyReleased"); - } + error.Source.ShouldNotBeNull(); + error.Source.Parameter.Should().Be("isRecentlyReleased"); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/MusicTrackReleaseDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/MusicTrackReleaseDefinition.cs index 1d3264bda7..fdf91e9744 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/MusicTrackReleaseDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/MusicTrackReleaseDefinition.cs @@ -1,5 +1,3 @@ -using System; -using System.Linq; using JetBrains.Annotations; using JsonApiDotNetCore; using JsonApiDotNetCore.Configuration; @@ -7,39 +5,38 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Primitives; -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.QueryStrings +namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.QueryStrings; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class MusicTrackReleaseDefinition : JsonApiResourceDefinition { - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class MusicTrackReleaseDefinition : JsonApiResourceDefinition + private readonly ISystemClock _systemClock; + + public MusicTrackReleaseDefinition(IResourceGraph resourceGraph, ISystemClock systemClock) + : base(resourceGraph) { - private readonly ISystemClock _systemClock; + ArgumentGuard.NotNull(systemClock, nameof(systemClock)); + + _systemClock = systemClock; + } - public MusicTrackReleaseDefinition(IResourceGraph resourceGraph, ISystemClock systemClock) - : base(resourceGraph) + public override QueryStringParameterHandlers OnRegisterQueryableHandlersForQueryStringParameters() + { + return new QueryStringParameterHandlers { - ArgumentGuard.NotNull(systemClock, nameof(systemClock)); + ["isRecentlyReleased"] = FilterOnRecentlyReleased + }; + } - _systemClock = systemClock; - } + private IQueryable FilterOnRecentlyReleased(IQueryable source, StringValues parameterValue) + { + IQueryable tracks = source; - public override QueryStringParameterHandlers OnRegisterQueryableHandlersForQueryStringParameters() + if (bool.Parse(parameterValue)) { - return new QueryStringParameterHandlers - { - ["isRecentlyReleased"] = FilterOnRecentlyReleased - }; + tracks = tracks.Where(musicTrack => musicTrack.ReleasedAt < _systemClock.UtcNow && musicTrack.ReleasedAt > _systemClock.UtcNow.AddMonths(-3)); } - private IQueryable FilterOnRecentlyReleased(IQueryable source, StringValues parameterValue) - { - IQueryable tracks = source; - - if (bool.Parse(parameterValue)) - { - tracks = tracks.Where(musicTrack => musicTrack.ReleasedAt < _systemClock.UtcNow && musicTrack.ReleasedAt > _systemClock.UtcNow.AddMonths(-3)); - } - - return tracks; - } + return tracks; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/RecordCompany.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/RecordCompany.cs index bf1abab6ab..5e0509a57f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/RecordCompany.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/RecordCompany.cs @@ -1,24 +1,22 @@ -using System.Collections.Generic; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations +namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations")] +public sealed class RecordCompany : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - [Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations")] - public sealed class RecordCompany : Identifiable - { - [Attr] - public string Name { get; set; } = null!; + [Attr] + public string Name { get; set; } = null!; - [Attr] - public string? CountryOfResidence { get; set; } + [Attr] + public string? CountryOfResidence { get; set; } - [HasMany] - public IList Tracks { get; set; } = new List(); + [HasMany] + public IList Tracks { get; set; } = new List(); - [HasOne] - public RecordCompany? Parent { get; set; } - } + [HasOne] + public RecordCompany? Parent { get; set; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/AtomicSerializationResourceDefinitionTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/AtomicSerializationResourceDefinitionTests.cs index 277b3f6122..acbd3e0895 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/AtomicSerializationResourceDefinitionTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/AtomicSerializationResourceDefinitionTests.cs @@ -1,7 +1,4 @@ -using System.Collections.Generic; using System.Net; -using System.Net.Http; -using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; @@ -11,383 +8,382 @@ using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.ResourceDefinitions.Serialization +namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.ResourceDefinitions.Serialization; + +public sealed class AtomicSerializationResourceDefinitionTests + : IClassFixture, OperationsDbContext>> { - public sealed class AtomicSerializationResourceDefinitionTests - : IClassFixture, OperationsDbContext>> + private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new(); + + public AtomicSerializationResourceDefinitionTests(IntegrationTestContext, OperationsDbContext> testContext) { - private readonly IntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new(); + _testContext = testContext; + + testContext.UseController(); - public AtomicSerializationResourceDefinitionTests(IntegrationTestContext, OperationsDbContext> testContext) + testContext.ConfigureServicesAfterStartup(services => { - _testContext = testContext; + services.AddResourceDefinition(); - testContext.UseController(); + services.AddSingleton(); + services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); + }); - testContext.ConfigureServicesAfterStartup(services => - { - services.AddResourceDefinition(); + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + hitCounter.Reset(); + } - services.AddSingleton(); - services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); - }); + [Fact] + public async Task Transforms_on_create_resource_with_side_effects() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - hitCounter.Reset(); - } + List newCompanies = _fakers.RecordCompany.Generate(2); - [Fact] - public async Task Transforms_on_create_resource_with_side_effects() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - - List newCompanies = _fakers.RecordCompany.Generate(2); + await dbContext.ClearTableAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - }); - - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "recordCompanies", + attributes = new { - type = "recordCompanies", - attributes = new - { - name = newCompanies[0].Name, - countryOfResidence = newCompanies[0].CountryOfResidence - } + name = newCompanies[0].Name, + countryOfResidence = newCompanies[0].CountryOfResidence } - }, - new + } + }, + new + { + op = "add", + data = new { - op = "add", - data = new + type = "recordCompanies", + attributes = new { - type = "recordCompanies", - attributes = new - { - name = newCompanies[1].Name, - countryOfResidence = newCompanies[1].CountryOfResidence - } + name = newCompanies[1].Name, + countryOfResidence = newCompanies[1].CountryOfResidence } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(2); + responseDocument.Results.ShouldHaveCount(2); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newCompanies[0].Name.ToUpperInvariant())); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newCompanies[0].Name.ToUpperInvariant())); - string countryOfResidence = newCompanies[0].CountryOfResidence!.ToUpperInvariant(); - resource.Attributes.ShouldContainKey("countryOfResidence").With(value => value.Should().Be(countryOfResidence)); - }); + string countryOfResidence = newCompanies[0].CountryOfResidence!.ToUpperInvariant(); + resource.Attributes.ShouldContainKey("countryOfResidence").With(value => value.Should().Be(countryOfResidence)); + }); - responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newCompanies[1].Name.ToUpperInvariant())); + responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newCompanies[1].Name.ToUpperInvariant())); - string countryOfResidence = newCompanies[1].CountryOfResidence!.ToUpperInvariant(); - resource.Attributes.ShouldContainKey("countryOfResidence").With(value => value.Should().Be(countryOfResidence)); - }); + string countryOfResidence = newCompanies[1].CountryOfResidence!.ToUpperInvariant(); + resource.Attributes.ShouldContainKey("countryOfResidence").With(value => value.Should().Be(countryOfResidence)); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List companiesInDatabase = await dbContext.RecordCompanies.ToListAsync(); - companiesInDatabase.ShouldHaveCount(2); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List companiesInDatabase = await dbContext.RecordCompanies.ToListAsync(); + companiesInDatabase.ShouldHaveCount(2); - companiesInDatabase[0].Name.Should().Be(newCompanies[0].Name.ToUpperInvariant()); - companiesInDatabase[0].CountryOfResidence.Should().Be(newCompanies[0].CountryOfResidence); + companiesInDatabase[0].Name.Should().Be(newCompanies[0].Name.ToUpperInvariant()); + companiesInDatabase[0].CountryOfResidence.Should().Be(newCompanies[0].CountryOfResidence); - companiesInDatabase[1].Name.Should().Be(newCompanies[1].Name.ToUpperInvariant()); - companiesInDatabase[1].CountryOfResidence.Should().Be(newCompanies[1].CountryOfResidence); - }); + companiesInDatabase[1].Name.Should().Be(newCompanies[1].Name.ToUpperInvariant()); + companiesInDatabase[1].CountryOfResidence.Should().Be(newCompanies[1].CountryOfResidence); + }); - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(RecordCompany), ResourceDefinitionExtensibilityPoints.OnDeserialize), - (typeof(RecordCompany), ResourceDefinitionExtensibilityPoints.OnDeserialize), - (typeof(RecordCompany), ResourceDefinitionExtensibilityPoints.OnSerialize), - (typeof(RecordCompany), ResourceDefinitionExtensibilityPoints.OnSerialize) - }, options => options.WithStrictOrdering()); - } - - [Fact] - public async Task Skips_on_create_resource_with_ToOne_relationship() + hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); + (typeof(RecordCompany), ResourceDefinitionExtensibilityPoints.OnDeserialize), + (typeof(RecordCompany), ResourceDefinitionExtensibilityPoints.OnDeserialize), + (typeof(RecordCompany), ResourceDefinitionExtensibilityPoints.OnSerialize), + (typeof(RecordCompany), ResourceDefinitionExtensibilityPoints.OnSerialize) + }, options => options.WithStrictOrdering()); + } + + [Fact] + public async Task Skips_on_create_resource_with_ToOne_relationship() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); - RecordCompany existingCompany = _fakers.RecordCompany.Generate(); + RecordCompany existingCompany = _fakers.RecordCompany.Generate(); - string newTrackTitle = _fakers.MusicTrack.Generate().Title; + string newTrackTitle = _fakers.MusicTrack.Generate().Title; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.RecordCompanies.Add(existingCompany); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.RecordCompanies.Add(existingCompany); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "musicTracks", + attributes = new { - type = "musicTracks", - attributes = new - { - title = newTrackTitle - }, - relationships = new + title = newTrackTitle + }, + relationships = new + { + ownedBy = new { - ownedBy = new + data = new { - data = new - { - type = "recordCompanies", - id = existingCompany.StringId - } + type = "recordCompanies", + id = existingCompany.StringId } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(1); + responseDocument.Results.ShouldHaveCount(1); - hitCounter.HitExtensibilityPoints.Should().BeEmpty(); - } + hitCounter.HitExtensibilityPoints.Should().BeEmpty(); + } - [Fact] - public async Task Transforms_on_update_resource_with_side_effects() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); + [Fact] + public async Task Transforms_on_update_resource_with_side_effects() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); - List existingCompanies = _fakers.RecordCompany.Generate(2); + List existingCompanies = _fakers.RecordCompany.Generate(2); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.RecordCompanies.AddRange(existingCompanies); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.RecordCompanies.AddRange(existingCompanies); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = "recordCompanies", + id = existingCompanies[0].StringId, + attributes = new { - type = "recordCompanies", - id = existingCompanies[0].StringId, - attributes = new - { - } } - }, - new + } + }, + new + { + op = "update", + data = new { - op = "update", - data = new + type = "recordCompanies", + id = existingCompanies[1].StringId, + attributes = new { - type = "recordCompanies", - id = existingCompanies[1].StringId, - attributes = new - { - } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(2); + responseDocument.Results.ShouldHaveCount(2); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(existingCompanies[0].Name)); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(existingCompanies[0].Name)); - string countryOfResidence = existingCompanies[0].CountryOfResidence!.ToUpperInvariant(); - resource.Attributes.ShouldContainKey("countryOfResidence").With(value => value.Should().Be(countryOfResidence)); - }); + string countryOfResidence = existingCompanies[0].CountryOfResidence!.ToUpperInvariant(); + resource.Attributes.ShouldContainKey("countryOfResidence").With(value => value.Should().Be(countryOfResidence)); + }); - responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(existingCompanies[1].Name)); + responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(existingCompanies[1].Name)); - string countryOfResidence = existingCompanies[1].CountryOfResidence!.ToUpperInvariant(); - resource.Attributes.ShouldContainKey("countryOfResidence").With(value => value.Should().Be(countryOfResidence)); - }); + string countryOfResidence = existingCompanies[1].CountryOfResidence!.ToUpperInvariant(); + resource.Attributes.ShouldContainKey("countryOfResidence").With(value => value.Should().Be(countryOfResidence)); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List companiesInDatabase = await dbContext.RecordCompanies.ToListAsync(); - companiesInDatabase.ShouldHaveCount(2); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List companiesInDatabase = await dbContext.RecordCompanies.ToListAsync(); + companiesInDatabase.ShouldHaveCount(2); - companiesInDatabase[0].Name.Should().Be(existingCompanies[0].Name); - companiesInDatabase[0].CountryOfResidence.Should().Be(existingCompanies[0].CountryOfResidence); + companiesInDatabase[0].Name.Should().Be(existingCompanies[0].Name); + companiesInDatabase[0].CountryOfResidence.Should().Be(existingCompanies[0].CountryOfResidence); - companiesInDatabase[1].Name.Should().Be(existingCompanies[1].Name); - companiesInDatabase[1].CountryOfResidence.Should().Be(existingCompanies[1].CountryOfResidence); - }); + companiesInDatabase[1].Name.Should().Be(existingCompanies[1].Name); + companiesInDatabase[1].CountryOfResidence.Should().Be(existingCompanies[1].CountryOfResidence); + }); - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(RecordCompany), ResourceDefinitionExtensibilityPoints.OnDeserialize), - (typeof(RecordCompany), ResourceDefinitionExtensibilityPoints.OnDeserialize), - (typeof(RecordCompany), ResourceDefinitionExtensibilityPoints.OnSerialize), - (typeof(RecordCompany), ResourceDefinitionExtensibilityPoints.OnSerialize) - }, options => options.WithStrictOrdering()); - } - - [Fact] - public async Task Skips_on_update_resource_with_ToOne_relationship() + hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); + (typeof(RecordCompany), ResourceDefinitionExtensibilityPoints.OnDeserialize), + (typeof(RecordCompany), ResourceDefinitionExtensibilityPoints.OnDeserialize), + (typeof(RecordCompany), ResourceDefinitionExtensibilityPoints.OnSerialize), + (typeof(RecordCompany), ResourceDefinitionExtensibilityPoints.OnSerialize) + }, options => options.WithStrictOrdering()); + } - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - RecordCompany existingCompany = _fakers.RecordCompany.Generate(); + [Fact] + public async Task Skips_on_update_resource_with_ToOne_relationship() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddInRange(existingTrack, existingCompany); - await dbContext.SaveChangesAsync(); - }); + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + RecordCompany existingCompany = _fakers.RecordCompany.Generate(); - var requestBody = new + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingTrack, existingCompany); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = "musicTracks", + id = existingTrack.StringId, + attributes = new { - type = "musicTracks", - id = existingTrack.StringId, - attributes = new - { - }, - relationships = new + }, + relationships = new + { + ownedBy = new { - ownedBy = new + data = new { - data = new - { - type = "recordCompanies", - id = existingCompany.StringId - } + type = "recordCompanies", + id = existingCompany.StringId } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(1); + responseDocument.Results.ShouldHaveCount(1); - hitCounter.HitExtensibilityPoints.Should().BeEmpty(); - } + hitCounter.HitExtensibilityPoints.Should().BeEmpty(); + } - [Fact] - public async Task Skips_on_update_ToOne_relationship() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); + [Fact] + public async Task Skips_on_update_ToOne_relationship() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - RecordCompany existingCompany = _fakers.RecordCompany.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + RecordCompany existingCompany = _fakers.RecordCompany.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddInRange(existingTrack, existingCompany); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingTrack, existingCompany); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + @ref = new { - op = "update", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "ownedBy" - }, - data = new - { - type = "recordCompanies", - id = existingCompany.StringId - } + type = "musicTracks", + id = existingTrack.StringId, + relationship = "ownedBy" + }, + data = new + { + type = "recordCompanies", + id = existingCompany.StringId } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - hitCounter.HitExtensibilityPoints.Should().BeEmpty(); - } + hitCounter.HitExtensibilityPoints.Should().BeEmpty(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/RecordCompanyDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/RecordCompanyDefinition.cs index 8f46b69336..4db0e6fe3c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/RecordCompanyDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/RecordCompanyDefinition.cs @@ -1,36 +1,35 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.ResourceDefinitions.Serialization +namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.ResourceDefinitions.Serialization; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class RecordCompanyDefinition : HitCountingResourceDefinition { - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class RecordCompanyDefinition : HitCountingResourceDefinition + protected override ResourceDefinitionExtensibilityPoints ExtensibilityPointsToTrack => ResourceDefinitionExtensibilityPoints.Serialization; + + public RecordCompanyDefinition(IResourceGraph resourceGraph, ResourceDefinitionHitCounter hitCounter) + : base(resourceGraph, hitCounter) { - protected override ResourceDefinitionExtensibilityPoints ExtensibilityPointsToTrack => ResourceDefinitionExtensibilityPoints.Serialization; + } - public RecordCompanyDefinition(IResourceGraph resourceGraph, ResourceDefinitionHitCounter hitCounter) - : base(resourceGraph, hitCounter) - { - } + public override void OnDeserialize(RecordCompany resource) + { + base.OnDeserialize(resource); - public override void OnDeserialize(RecordCompany resource) + if (!string.IsNullOrEmpty(resource.Name)) { - base.OnDeserialize(resource); - - if (!string.IsNullOrEmpty(resource.Name)) - { - resource.Name = resource.Name.ToUpperInvariant(); - } + resource.Name = resource.Name.ToUpperInvariant(); } + } - public override void OnSerialize(RecordCompany resource) - { - base.OnSerialize(resource); + public override void OnSerialize(RecordCompany resource) + { + base.OnSerialize(resource); - if (!string.IsNullOrEmpty(resource.CountryOfResidence)) - { - resource.CountryOfResidence = resource.CountryOfResidence.ToUpperInvariant(); - } + if (!string.IsNullOrEmpty(resource.CountryOfResidence)) + { + resource.CountryOfResidence = resource.CountryOfResidence.ToUpperInvariant(); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/AtomicSparseFieldSetResourceDefinitionTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/AtomicSparseFieldSetResourceDefinitionTests.cs index 1d6f4127ce..a25100b414 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/AtomicSparseFieldSetResourceDefinitionTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/AtomicSparseFieldSetResourceDefinitionTests.cs @@ -1,7 +1,4 @@ -using System.Collections.Generic; using System.Net; -using System.Net.Http; -using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; @@ -10,185 +7,184 @@ using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.ResourceDefinitions.SparseFieldSets +namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.ResourceDefinitions.SparseFieldSets; + +public sealed class AtomicSparseFieldSetResourceDefinitionTests + : IClassFixture, OperationsDbContext>> { - public sealed class AtomicSparseFieldSetResourceDefinitionTests - : IClassFixture, OperationsDbContext>> - { - private readonly IntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new(); + private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new(); - public AtomicSparseFieldSetResourceDefinitionTests(IntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; + public AtomicSparseFieldSetResourceDefinitionTests(IntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; - testContext.UseController(); + testContext.UseController(); - testContext.ConfigureServicesAfterStartup(services => - { - services.AddResourceDefinition(); + testContext.ConfigureServicesAfterStartup(services => + { + services.AddResourceDefinition(); - services.AddSingleton(); - services.AddSingleton(); - services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); - }); + services.AddSingleton(); + services.AddSingleton(); + services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); + }); - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - hitCounter.Reset(); - } + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + hitCounter.Reset(); + } - [Fact] - public async Task Hides_text_in_create_resource_with_side_effects() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); + [Fact] + public async Task Hides_text_in_create_resource_with_side_effects() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); - var provider = _testContext.Factory.Services.GetRequiredService(); - provider.CanViewText = false; + var provider = _testContext.Factory.Services.GetRequiredService(); + provider.CanViewText = false; - List newLyrics = _fakers.Lyric.Generate(2); + List newLyrics = _fakers.Lyric.Generate(2); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "lyrics", + attributes = new { - type = "lyrics", - attributes = new - { - format = newLyrics[0].Format, - text = newLyrics[0].Text - } + format = newLyrics[0].Format, + text = newLyrics[0].Text } - }, - new + } + }, + new + { + op = "add", + data = new { - op = "add", - data = new + type = "lyrics", + attributes = new { - type = "lyrics", - attributes = new - { - format = newLyrics[1].Format, - text = newLyrics[1].Text - } + format = newLyrics[1].Format, + text = newLyrics[1].Text } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(2); + responseDocument.Results.ShouldHaveCount(2); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Attributes.ShouldContainKey("format").With(value => value.Should().Be(newLyrics[0].Format)); - resource.Attributes.Should().NotContainKey("text"); - }); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Attributes.ShouldContainKey("format").With(value => value.Should().Be(newLyrics[0].Format)); + resource.Attributes.Should().NotContainKey("text"); + }); - responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Attributes.ShouldContainKey("format").With(value => value.Should().Be(newLyrics[1].Format)); - resource.Attributes.Should().NotContainKey("text"); - }); + responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Attributes.ShouldContainKey("format").With(value => value.Should().Be(newLyrics[1].Format)); + resource.Attributes.Should().NotContainKey("text"); + }); - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(Lyric), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), - (typeof(Lyric), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), - (typeof(Lyric), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), - (typeof(Lyric), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet) - }, options => options.WithStrictOrdering()); - } - - [Fact] - public async Task Hides_text_in_update_resource_with_side_effects() + hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); + (typeof(Lyric), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Lyric), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Lyric), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Lyric), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet) + }, options => options.WithStrictOrdering()); + } - var provider = _testContext.Factory.Services.GetRequiredService(); - provider.CanViewText = false; + [Fact] + public async Task Hides_text_in_update_resource_with_side_effects() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); - List existingLyrics = _fakers.Lyric.Generate(2); + var provider = _testContext.Factory.Services.GetRequiredService(); + provider.CanViewText = false; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Lyrics.AddRange(existingLyrics); - await dbContext.SaveChangesAsync(); - }); + List existingLyrics = _fakers.Lyric.Generate(2); - var requestBody = new + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Lyrics.AddRange(existingLyrics); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = "lyrics", + id = existingLyrics[0].StringId, + attributes = new { - type = "lyrics", - id = existingLyrics[0].StringId, - attributes = new - { - } } - }, - new + } + }, + new + { + op = "update", + data = new { - op = "update", - data = new + type = "lyrics", + id = existingLyrics[1].StringId, + attributes = new { - type = "lyrics", - id = existingLyrics[1].StringId, - attributes = new - { - } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(2); + responseDocument.Results.ShouldHaveCount(2); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Attributes.ShouldContainKey("format").With(value => value.Should().Be(existingLyrics[0].Format)); - resource.Attributes.Should().NotContainKey("text"); - }); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Attributes.ShouldContainKey("format").With(value => value.Should().Be(existingLyrics[0].Format)); + resource.Attributes.Should().NotContainKey("text"); + }); - responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Attributes.ShouldContainKey("format").With(value => value.Should().Be(existingLyrics[1].Format)); - resource.Attributes.Should().NotContainKey("text"); - }); + responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Attributes.ShouldContainKey("format").With(value => value.Should().Be(existingLyrics[1].Format)); + resource.Attributes.Should().NotContainKey("text"); + }); - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(Lyric), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), - (typeof(Lyric), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), - (typeof(Lyric), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), - (typeof(Lyric), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet) - }, options => options.WithStrictOrdering()); - } + hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] + { + (typeof(Lyric), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Lyric), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Lyric), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Lyric), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet) + }, options => options.WithStrictOrdering()); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/LyricPermissionProvider.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/LyricPermissionProvider.cs index 070ced4646..beea40bb3d 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/LyricPermissionProvider.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/LyricPermissionProvider.cs @@ -1,7 +1,6 @@ -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.ResourceDefinitions.SparseFieldSets +namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.ResourceDefinitions.SparseFieldSets; + +public sealed class LyricPermissionProvider { - public sealed class LyricPermissionProvider - { - internal bool CanViewText { get; set; } - } + internal bool CanViewText { get; set; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/LyricTextDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/LyricTextDefinition.cs index 8bab070619..3abc4a134c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/LyricTextDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/LyricTextDefinition.cs @@ -2,26 +2,25 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries.Expressions; -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.ResourceDefinitions.SparseFieldSets +namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.ResourceDefinitions.SparseFieldSets; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class LyricTextDefinition : HitCountingResourceDefinition { - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class LyricTextDefinition : HitCountingResourceDefinition - { - private readonly LyricPermissionProvider _lyricPermissionProvider; + private readonly LyricPermissionProvider _lyricPermissionProvider; - protected override ResourceDefinitionExtensibilityPoints ExtensibilityPointsToTrack => ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet; + protected override ResourceDefinitionExtensibilityPoints ExtensibilityPointsToTrack => ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet; - public LyricTextDefinition(IResourceGraph resourceGraph, LyricPermissionProvider lyricPermissionProvider, ResourceDefinitionHitCounter hitCounter) - : base(resourceGraph, hitCounter) - { - _lyricPermissionProvider = lyricPermissionProvider; - } + public LyricTextDefinition(IResourceGraph resourceGraph, LyricPermissionProvider lyricPermissionProvider, ResourceDefinitionHitCounter hitCounter) + : base(resourceGraph, hitCounter) + { + _lyricPermissionProvider = lyricPermissionProvider; + } - public override SparseFieldSetExpression? OnApplySparseFieldSet(SparseFieldSetExpression? existingSparseFieldSet) - { - base.OnApplySparseFieldSet(existingSparseFieldSet); + public override SparseFieldSetExpression? OnApplySparseFieldSet(SparseFieldSetExpression? existingSparseFieldSet) + { + base.OnApplySparseFieldSet(existingSparseFieldSet); - return _lyricPermissionProvider.CanViewText ? existingSparseFieldSet : existingSparseFieldSet.Excluding(lyric => lyric.Text, ResourceGraph); - } + return _lyricPermissionProvider.CanViewText ? existingSparseFieldSet : existingSparseFieldSet.Excluding(lyric => lyric.Text, ResourceGraph); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/TextLanguage.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/TextLanguage.cs index 10733b4bf6..02e8bf6278 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/TextLanguage.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/TextLanguage.cs @@ -1,22 +1,19 @@ -using System; -using System.Collections.Generic; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations +namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations")] +public sealed class TextLanguage : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - [Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations")] - public sealed class TextLanguage : Identifiable - { - [Attr] - public string? IsoCode { get; set; } + [Attr] + public string? IsoCode { get; set; } - [Attr(Capabilities = AttrCapabilities.None)] - public bool IsRightToLeft { get; set; } + [Attr(Capabilities = AttrCapabilities.None)] + public bool IsRightToLeft { get; set; } - [HasMany] - public ICollection Lyrics { get; set; } = new List(); - } + [HasMany] + public ICollection Lyrics { get; set; } = new List(); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicRollbackTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicRollbackTests.cs index 8b4c1d49f1..8e4baf0cef 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicRollbackTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicRollbackTests.cs @@ -1,194 +1,189 @@ -using System; -using System.Collections.Generic; using System.Net; -using System.Net.Http; -using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.EntityFrameworkCore; using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Transactions +namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Transactions; + +public sealed class AtomicRollbackTests : IClassFixture, OperationsDbContext>> { - public sealed class AtomicRollbackTests : IClassFixture, OperationsDbContext>> + private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new(); + + public AtomicRollbackTests(IntegrationTestContext, OperationsDbContext> testContext) { - private readonly IntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new(); + _testContext = testContext; - public AtomicRollbackTests(IntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; + testContext.UseController(); + } - testContext.UseController(); - } + [Fact] + public async Task Can_rollback_on_error() + { + // Arrange + string newArtistName = _fakers.Performer.Generate().ArtistName!; + DateTimeOffset newBornAt = _fakers.Performer.Generate().BornAt; + string newTitle = _fakers.MusicTrack.Generate().Title; - [Fact] - public async Task Can_rollback_on_error() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - string newArtistName = _fakers.Performer.Generate().ArtistName!; - DateTimeOffset newBornAt = _fakers.Performer.Generate().BornAt; - string newTitle = _fakers.MusicTrack.Generate().Title; + await dbContext.ClearTablesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTablesAsync(); - }); - - string unknownPerformerId = Unknown.StringId.For(); + string unknownPerformerId = Unknown.StringId.For(); - var requestBody = new + var requestBody = new + { + atomic__operations = new object[] { - atomic__operations = new object[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "performers", + attributes = new { - type = "performers", - attributes = new - { - artistName = newArtistName, - bornAt = newBornAt - } + artistName = newArtistName, + bornAt = newBornAt } - }, - new + } + }, + new + { + op = "add", + data = new { - op = "add", - data = new + type = "musicTracks", + attributes = new { - type = "musicTracks", - attributes = new - { - title = newTitle - }, - relationships = new + title = newTitle + }, + relationships = new + { + performers = new { - performers = new + data = new[] { - data = new[] + new { - new - { - type = "performers", - id = unknownPerformerId - } + type = "performers", + id = unknownPerformerId } } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.NotFound); - error.Title.Should().Be("A related resource does not exist."); - error.Detail.Should().Be($"Related resource of type 'performers' with ID '{unknownPerformerId}' in relationship 'performers' does not exist."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[1]"); + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("A related resource does not exist."); + error.Detail.Should().Be($"Related resource of type 'performers' with ID '{unknownPerformerId}' in relationship 'performers' does not exist."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[1]"); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List performersInDatabase = await dbContext.Performers.ToListAsync(); - performersInDatabase.Should().BeEmpty(); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List performersInDatabase = await dbContext.Performers.ToListAsync(); + performersInDatabase.Should().BeEmpty(); - List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.Should().BeEmpty(); - }); - } + List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); + tracksInDatabase.Should().BeEmpty(); + }); + } - [Fact] - public async Task Can_restore_to_previous_savepoint_on_error() - { - // Arrange - string newTrackTitle = _fakers.MusicTrack.Generate().Title; + [Fact] + public async Task Can_restore_to_previous_savepoint_on_error() + { + // Arrange + string newTrackTitle = _fakers.MusicTrack.Generate().Title; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTablesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTablesAsync(); + }); - const string trackLid = "track-1"; + const string trackLid = "track-1"; - string unknownPerformerId = Unknown.StringId.For(); + string unknownPerformerId = Unknown.StringId.For(); - var requestBody = new + var requestBody = new + { + atomic__operations = new object[] { - atomic__operations = new object[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "musicTracks", + lid = trackLid, + attributes = new { - type = "musicTracks", - lid = trackLid, - attributes = new - { - title = newTrackTitle - } + title = newTrackTitle } + } + }, + new + { + op = "add", + @ref = new + { + type = "musicTracks", + lid = trackLid, + relationship = "performers" }, - new + data = new[] { - op = "add", - @ref = new + new { - type = "musicTracks", - lid = trackLid, - relationship = "performers" - }, - data = new[] - { - new - { - type = "performers", - id = unknownPerformerId - } + type = "performers", + id = unknownPerformerId } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.NotFound); - error.Title.Should().Be("A related resource does not exist."); - error.Detail.Should().Be($"Related resource of type 'performers' with ID '{unknownPerformerId}' in relationship 'performers' does not exist."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[1]"); + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("A related resource does not exist."); + error.Detail.Should().Be($"Related resource of type 'performers' with ID '{unknownPerformerId}' in relationship 'performers' does not exist."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[1]"); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List performersInDatabase = await dbContext.Performers.ToListAsync(); - performersInDatabase.Should().BeEmpty(); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List performersInDatabase = await dbContext.Performers.ToListAsync(); + performersInDatabase.Should().BeEmpty(); - List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.Should().BeEmpty(); - }); - } + List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); + tracksInDatabase.Should().BeEmpty(); + }); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs index 1f7cc37cef..711c45de00 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs @@ -1,7 +1,4 @@ -using System; using System.Net; -using System.Net.Http; -using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; @@ -10,156 +7,155 @@ using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Transactions +namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Transactions; + +public sealed class AtomicTransactionConsistencyTests : IClassFixture, OperationsDbContext>> { - public sealed class AtomicTransactionConsistencyTests : IClassFixture, OperationsDbContext>> - { - private readonly IntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new(); + private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new(); - public AtomicTransactionConsistencyTests(IntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; + public AtomicTransactionConsistencyTests(IntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; - testContext.UseController(); + testContext.UseController(); - testContext.ConfigureServicesAfterStartup(services => - { - services.AddResourceRepository(); - services.AddResourceRepository(); - services.AddResourceRepository(); + testContext.ConfigureServicesAfterStartup(services => + { + services.AddResourceRepository(); + services.AddResourceRepository(); + services.AddResourceRepository(); - string postgresPassword = Environment.GetEnvironmentVariable("PGPASSWORD") ?? "postgres"; - string dbConnectionString = $"Host=localhost;Port=5432;Database=JsonApiTest-{Guid.NewGuid():N};User ID=postgres;Password={postgresPassword}"; + string postgresPassword = Environment.GetEnvironmentVariable("PGPASSWORD") ?? "postgres"; + string dbConnectionString = $"Host=localhost;Port=5432;Database=JsonApiTest-{Guid.NewGuid():N};User ID=postgres;Password={postgresPassword}"; - services.AddDbContext(options => options.UseNpgsql(dbConnectionString)); - }); - } + services.AddDbContext(options => options.UseNpgsql(dbConnectionString)); + }); + } - [Fact] - public async Task Cannot_use_non_transactional_repository() + [Fact] + public async Task Cannot_use_non_transactional_repository() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new object[] { - atomic__operations = new object[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "performers", + attributes = new { - type = "performers", - attributes = new - { - } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Unsupported resource type in atomic:operations request."); - error.Detail.Should().Be("Operations on resources of type 'performers' cannot be used because transaction support is unavailable."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Unsupported resource type in atomic:operations request."); + error.Detail.Should().Be("Operations on resources of type 'performers' cannot be used because transaction support is unavailable."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]"); + } - [Fact] - public async Task Cannot_use_transactional_repository_without_active_transaction() - { - // Arrange - string newTrackTitle = _fakers.MusicTrack.Generate().Title; + [Fact] + public async Task Cannot_use_transactional_repository_without_active_transaction() + { + // Arrange + string newTrackTitle = _fakers.MusicTrack.Generate().Title; - var requestBody = new + var requestBody = new + { + atomic__operations = new object[] { - atomic__operations = new object[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "musicTracks", + attributes = new { - type = "musicTracks", - attributes = new - { - title = newTrackTitle - } + title = newTrackTitle } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Unsupported combination of resource types in atomic:operations request."); - error.Detail.Should().Be("All operations need to participate in a single shared transaction, which is not the case for this request."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Unsupported combination of resource types in atomic:operations request."); + error.Detail.Should().Be("All operations need to participate in a single shared transaction, which is not the case for this request."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]"); + } - [Fact] - public async Task Cannot_use_distributed_transaction() - { - // Arrange - string newLyricText = _fakers.Lyric.Generate().Text; + [Fact] + public async Task Cannot_use_distributed_transaction() + { + // Arrange + string newLyricText = _fakers.Lyric.Generate().Text; - var requestBody = new + var requestBody = new + { + atomic__operations = new object[] { - atomic__operations = new object[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "lyrics", + attributes = new { - type = "lyrics", - attributes = new - { - text = newLyricText - } + text = newLyricText } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Unsupported combination of resource types in atomic:operations request."); - error.Detail.Should().Be("All operations need to participate in a single shared transaction, which is not the case for this request."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Unsupported combination of resource types in atomic:operations request."); + error.Detail.Should().Be("All operations need to participate in a single shared transaction, which is not the case for this request."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]"); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/ExtraDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/ExtraDbContext.cs index 2808f95c28..85fe191e86 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/ExtraDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/ExtraDbContext.cs @@ -1,14 +1,13 @@ using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Transactions +namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Transactions; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class ExtraDbContext : DbContext { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class ExtraDbContext : DbContext + public ExtraDbContext(DbContextOptions options) + : base(options) { - public ExtraDbContext(DbContextOptions options) - : base(options) - { - } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/LyricRepository.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/LyricRepository.cs index d6e25823bf..98be0da662 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/LyricRepository.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/LyricRepository.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries; @@ -6,24 +5,23 @@ using JsonApiDotNetCore.Resources; using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Transactions +namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Transactions; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class LyricRepository : EntityFrameworkCoreRepository { - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class LyricRepository : EntityFrameworkCoreRepository - { - private readonly ExtraDbContext _extraDbContext; + private readonly ExtraDbContext _extraDbContext; - public override string? TransactionId => _extraDbContext.Database.CurrentTransaction?.TransactionId.ToString(); + public override string? TransactionId => _extraDbContext.Database.CurrentTransaction?.TransactionId.ToString(); - public LyricRepository(ExtraDbContext extraDbContext, ITargetedFields targetedFields, IDbContextResolver dbContextResolver, - IResourceGraph resourceGraph, IResourceFactory resourceFactory, IEnumerable constraintProviders, - ILoggerFactory loggerFactory, IResourceDefinitionAccessor resourceDefinitionAccessor) - : base(targetedFields, dbContextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor) - { - _extraDbContext = extraDbContext; + public LyricRepository(ExtraDbContext extraDbContext, ITargetedFields targetedFields, IDbContextResolver dbContextResolver, IResourceGraph resourceGraph, + IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory, + IResourceDefinitionAccessor resourceDefinitionAccessor) + : base(targetedFields, dbContextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor) + { + _extraDbContext = extraDbContext; - extraDbContext.Database.EnsureCreated(); - extraDbContext.Database.BeginTransaction(); - } + extraDbContext.Database.EnsureCreated(); + extraDbContext.Database.BeginTransaction(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/MusicTrackRepository.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/MusicTrackRepository.cs index 524439bc18..6512b3fb27 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/MusicTrackRepository.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/MusicTrackRepository.cs @@ -1,5 +1,3 @@ -using System; -using System.Collections.Generic; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries; @@ -7,18 +5,17 @@ using JsonApiDotNetCore.Resources; using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Transactions +namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Transactions; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class MusicTrackRepository : EntityFrameworkCoreRepository { - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class MusicTrackRepository : EntityFrameworkCoreRepository - { - public override string? TransactionId => null; + public override string? TransactionId => null; - public MusicTrackRepository(ITargetedFields targetedFields, IDbContextResolver dbContextResolver, IResourceGraph resourceGraph, - IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory, - IResourceDefinitionAccessor resourceDefinitionAccessor) - : base(targetedFields, dbContextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor) - { - } + public MusicTrackRepository(ITargetedFields targetedFields, IDbContextResolver dbContextResolver, IResourceGraph resourceGraph, + IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory, + IResourceDefinitionAccessor resourceDefinitionAccessor) + : base(targetedFields, dbContextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor) + { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/PerformerRepository.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/PerformerRepository.cs index ead5d9234a..c50c52e08e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/PerformerRepository.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/PerformerRepository.cs @@ -1,66 +1,61 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using JetBrains.Annotations; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Resources; -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Transactions +namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Transactions; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class PerformerRepository : IResourceRepository { - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class PerformerRepository : IResourceRepository + public Task> GetAsync(QueryLayer queryLayer, CancellationToken cancellationToken) { - public Task> GetAsync(QueryLayer queryLayer, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } + throw new NotImplementedException(); + } - public Task CountAsync(FilterExpression? filter, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } + public Task CountAsync(FilterExpression? filter, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } - public Task GetForCreateAsync(int id, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } + public Task GetForCreateAsync(int id, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } - public Task CreateAsync(Performer resourceFromRequest, Performer resourceForDatabase, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } + public Task CreateAsync(Performer resourceFromRequest, Performer resourceForDatabase, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } - public Task GetForUpdateAsync(QueryLayer queryLayer, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } + public Task GetForUpdateAsync(QueryLayer queryLayer, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } - public Task UpdateAsync(Performer resourceFromRequest, Performer resourceFromDatabase, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } + public Task UpdateAsync(Performer resourceFromRequest, Performer resourceFromDatabase, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } - public Task DeleteAsync(int id, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } + public Task DeleteAsync(int id, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } - public Task SetRelationshipAsync(Performer leftResource, object? rightValue, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } + public Task SetRelationshipAsync(Performer leftResource, object? rightValue, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } - public Task AddToToManyRelationshipAsync(int leftId, ISet rightResourceIds, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } + public Task AddToToManyRelationshipAsync(int leftId, ISet rightResourceIds, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } - public Task RemoveFromToManyRelationshipAsync(Performer leftResource, ISet rightResourceIds, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } + public Task RemoveFromToManyRelationshipAsync(Performer leftResource, ISet rightResourceIds, CancellationToken cancellationToken) + { + throw new NotImplementedException(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs index 1dac3f571d..bd97c23411 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs @@ -1,1089 +1,1084 @@ -using System; -using System.Collections.Generic; using System.Net; -using System.Net.Http; -using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.EntityFrameworkCore; using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Updating.Relationships +namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Updating.Relationships; + +public sealed class AtomicAddToToManyRelationshipTests : IClassFixture, OperationsDbContext>> { - public sealed class AtomicAddToToManyRelationshipTests : IClassFixture, OperationsDbContext>> + private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new(); + + public AtomicAddToToManyRelationshipTests(IntegrationTestContext, OperationsDbContext> testContext) { - private readonly IntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new(); + _testContext = testContext; - public AtomicAddToToManyRelationshipTests(IntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; + testContext.UseController(); + } - testContext.UseController(); - } + [Fact] + public async Task Cannot_add_to_ManyToOne_relationship() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + RecordCompany existingCompany = _fakers.RecordCompany.Generate(); - [Fact] - public async Task Cannot_add_to_ManyToOne_relationship() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - RecordCompany existingCompany = _fakers.RecordCompany.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddInRange(existingTrack, existingCompany); - await dbContext.SaveChangesAsync(); - }); + dbContext.AddInRange(existingTrack, existingCompany); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + @ref = new { - op = "add", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "ownedBy" - }, - data = new - { - type = "recordCompanies", - id = existingCompany.StringId - } + type = "musicTracks", + id = existingTrack.StringId, + relationship = "ownedBy" + }, + data = new + { + type = "recordCompanies", + id = existingCompany.StringId } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.Forbidden); - error.Title.Should().Be("Failed to deserialize request body: Only to-many relationships can be targeted through this operation."); - error.Detail.Should().Be("Relationship 'ownedBy' is not a to-many relationship."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/relationship"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Forbidden); + error.Title.Should().Be("Failed to deserialize request body: Only to-many relationships can be targeted through this operation."); + error.Detail.Should().Be("Relationship 'ownedBy' is not a to-many relationship."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/relationship"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Can_add_to_OneToMany_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.Performers = _fakers.Performer.Generate(1); + [Fact] + public async Task Can_add_to_OneToMany_relationship() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + existingTrack.Performers = _fakers.Performer.Generate(1); - List existingPerformers = _fakers.Performer.Generate(2); + List existingPerformers = _fakers.Performer.Generate(2); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - dbContext.Performers.AddRange(existingPerformers); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + dbContext.Performers.AddRange(existingPerformers); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + @ref = new { - op = "add", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" - }, - data = new[] + type = "musicTracks", + id = existingTrack.StringId, + relationship = "performers" + }, + data = new[] + { + new { - new - { - type = "performers", - id = existingPerformers[0].StringId - } + type = "performers", + id = existingPerformers[0].StringId } + } + }, + new + { + op = "add", + @ref = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationship = "performers" }, - new + data = new[] { - op = "add", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" - }, - data = new[] + new { - new - { - type = "performers", - id = existingPerformers[1].StringId - } + type = "performers", + id = existingPerformers[1].StringId } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(existingTrack.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(existingTrack.Id); - trackInDatabase.Performers.ShouldHaveCount(3); - trackInDatabase.Performers.Should().ContainSingle(performer => performer.Id == existingTrack.Performers[0].Id); - trackInDatabase.Performers.Should().ContainSingle(performer => performer.Id == existingPerformers[0].Id); - trackInDatabase.Performers.Should().ContainSingle(performer => performer.Id == existingPerformers[1].Id); - }); - } + trackInDatabase.Performers.ShouldHaveCount(3); + trackInDatabase.Performers.Should().ContainSingle(performer => performer.Id == existingTrack.Performers[0].Id); + trackInDatabase.Performers.Should().ContainSingle(performer => performer.Id == existingPerformers[0].Id); + trackInDatabase.Performers.Should().ContainSingle(performer => performer.Id == existingPerformers[1].Id); + }); + } - [Fact] - public async Task Can_add_to_ManyToMany_relationship() - { - // Arrange - Playlist existingPlaylist = _fakers.Playlist.Generate(); - existingPlaylist.Tracks = _fakers.MusicTrack.Generate(1); + [Fact] + public async Task Can_add_to_ManyToMany_relationship() + { + // Arrange + Playlist existingPlaylist = _fakers.Playlist.Generate(); + existingPlaylist.Tracks = _fakers.MusicTrack.Generate(1); - List existingTracks = _fakers.MusicTrack.Generate(2); + List existingTracks = _fakers.MusicTrack.Generate(2); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Playlists.Add(existingPlaylist); - dbContext.MusicTracks.AddRange(existingTracks); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Playlists.Add(existingPlaylist); + dbContext.MusicTracks.AddRange(existingTracks); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + @ref = new { - op = "add", - @ref = new - { - type = "playlists", - id = existingPlaylist.StringId, - relationship = "tracks" - }, - data = new[] + type = "playlists", + id = existingPlaylist.StringId, + relationship = "tracks" + }, + data = new[] + { + new { - new - { - type = "musicTracks", - id = existingTracks[0].StringId - } + type = "musicTracks", + id = existingTracks[0].StringId } + } + }, + new + { + op = "add", + @ref = new + { + type = "playlists", + id = existingPlaylist.StringId, + relationship = "tracks" }, - new + data = new[] { - op = "add", - @ref = new + new { - type = "playlists", - id = existingPlaylist.StringId, - relationship = "tracks" - }, - data = new[] - { - new - { - type = "musicTracks", - id = existingTracks[1].StringId - } + type = "musicTracks", + id = existingTracks[1].StringId } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(existingPlaylist.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(existingPlaylist.Id); - playlistInDatabase.Tracks.ShouldHaveCount(3); - playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingPlaylist.Tracks[0].Id); - playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[0].Id); - playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[1].Id); - }); - } + playlistInDatabase.Tracks.ShouldHaveCount(3); + playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingPlaylist.Tracks[0].Id); + playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[0].Id); + playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[1].Id); + }); + } - [Fact] - public async Task Cannot_add_for_href_element() + [Fact] + public async Task Cannot_add_for_href_element() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new - { - op = "add", - href = "/api/v1/musicTracks/1/relationships/performers" - } + op = "add", + href = "/api/v1/musicTracks/1/relationships/performers" } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'href' element is not supported."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/href"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'href' element is not supported."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/href"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_add_for_missing_type_in_ref() + [Fact] + public async Task Cannot_add_for_missing_type_in_ref() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + @ref = new { - op = "add", - @ref = new - { - id = Unknown.StringId.For(), - relationship = "tracks" - } + id = Unknown.StringId.For(), + relationship = "tracks" } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_add_for_unknown_type_in_ref() + [Fact] + public async Task Cannot_add_for_unknown_type_in_ref() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + @ref = new { - op = "add", - @ref = new - { - type = Unknown.ResourceType, - id = Unknown.StringId.For(), - relationship = "tracks" - } + type = Unknown.ResourceType, + id = Unknown.StringId.For(), + relationship = "tracks" } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); - error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); + error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_add_for_missing_ID_in_ref() + [Fact] + public async Task Cannot_add_for_missing_ID_in_ref() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + @ref = new { - op = "add", - @ref = new - { - type = "musicTracks", - relationship = "performers" - } + type = "musicTracks", + relationship = "performers" } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_add_for_unknown_ID_in_ref() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + [Fact] + public async Task Cannot_add_for_unknown_ID_in_ref() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); - string companyId = Unknown.StringId.For(); + string companyId = Unknown.StringId.For(); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + @ref = new { - op = "add", - @ref = new - { - type = "recordCompanies", - id = companyId, - relationship = "tracks" - }, - data = new[] + type = "recordCompanies", + id = companyId, + relationship = "tracks" + }, + data = new[] + { + new { - new - { - type = "musicTracks", - id = existingTrack.StringId - } + type = "musicTracks", + id = existingTrack.StringId } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.NotFound); - error.Title.Should().Be("The requested resource does not exist."); - error.Detail.Should().Be($"Resource of type 'recordCompanies' with ID '{companyId}' does not exist."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - error.Meta.Should().NotContainKey("requestBody"); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("The requested resource does not exist."); + error.Detail.Should().Be($"Resource of type 'recordCompanies' with ID '{companyId}' does not exist."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Meta.Should().NotContainKey("requestBody"); + } - [Fact] - public async Task Cannot_add_for_ID_and_local_ID_in_ref() + [Fact] + public async Task Cannot_add_for_ID_and_local_ID_in_ref() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + @ref = new { - op = "add", - @ref = new - { - type = "musicTracks", - id = Unknown.StringId.For(), - lid = "local-1", - relationship = "performers" - } + type = "musicTracks", + id = Unknown.StringId.For(), + lid = "local-1", + relationship = "performers" } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_add_for_missing_relationship_in_ref() + [Fact] + public async Task Cannot_add_for_missing_relationship_in_ref() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + @ref = new { - op = "add", - @ref = new - { - type = "musicTracks", - id = Unknown.StringId.For() - } + type = "musicTracks", + id = Unknown.StringId.For() } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'relationship' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'relationship' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_add_for_unknown_relationship_in_ref() + [Fact] + public async Task Cannot_add_for_unknown_relationship_in_ref() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + @ref = new { - op = "add", - @ref = new - { - type = "performers", - id = Unknown.StringId.For(), - relationship = Unknown.Relationship - } + type = "performers", + id = Unknown.StringId.For(), + relationship = Unknown.Relationship } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Unknown relationship found."); - error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' does not exist on resource type 'performers'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/relationship"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Unknown relationship found."); + error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' does not exist on resource type 'performers'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/relationship"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_add_for_missing_data() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + [Fact] + public async Task Cannot_add_for_missing_data() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + @ref = new { - op = "add", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" - } + type = "musicTracks", + id = existingTrack.StringId, + relationship = "performers" } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'data' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_add_for_null_data() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + [Fact] + public async Task Cannot_add_for_null_data() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + @ref = new { - op = "add", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" - }, - data = (object?)null - } + type = "musicTracks", + id = existingTrack.StringId, + relationship = "performers" + }, + data = (object?)null } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected an array, instead of 'null'."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Expected an array, instead of 'null'."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_add_for_object_data() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + [Fact] + public async Task Cannot_add_for_object_data() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + @ref = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationship = "performers" + }, + data = new { - op = "add", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" - }, - data = new - { - } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected an array, instead of an object."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Expected an array, instead of an object."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_add_for_missing_type_in_data() + [Fact] + public async Task Cannot_add_for_missing_type_in_data() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + @ref = new { - op = "add", - @ref = new - { - type = "playlists", - id = Unknown.StringId.For(), - relationship = "tracks" - }, - data = new[] + type = "playlists", + id = Unknown.StringId.For(), + relationship = "tracks" + }, + data = new[] + { + new { - new - { - id = Unknown.StringId.For() - } + id = Unknown.StringId.For() } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_add_for_unknown_type_in_data() + [Fact] + public async Task Cannot_add_for_unknown_type_in_data() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + @ref = new { - op = "add", - @ref = new - { - type = "musicTracks", - id = Unknown.StringId.For(), - relationship = "performers" - }, - data = new[] + type = "musicTracks", + id = Unknown.StringId.For(), + relationship = "performers" + }, + data = new[] + { + new { - new - { - type = Unknown.ResourceType, - id = Unknown.StringId.For() - } + type = Unknown.ResourceType, + id = Unknown.StringId.For() } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); - error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); + error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_add_for_missing_ID_in_data() + [Fact] + public async Task Cannot_add_for_missing_ID_in_data() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + @ref = new { - op = "add", - @ref = new - { - type = "musicTracks", - id = Unknown.StringId.For(), - relationship = "performers" - }, - data = new[] + type = "musicTracks", + id = Unknown.StringId.For(), + relationship = "performers" + }, + data = new[] + { + new { - new - { - type = "performers" - } + type = "performers" } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_add_for_ID_and_local_ID_in_data() + [Fact] + public async Task Cannot_add_for_ID_and_local_ID_in_data() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + @ref = new { - op = "add", - @ref = new - { - type = "musicTracks", - id = Unknown.StringId.For(), - relationship = "performers" - }, - data = new[] + type = "musicTracks", + id = Unknown.StringId.For(), + relationship = "performers" + }, + data = new[] + { + new { - new - { - type = "performers", - id = Unknown.StringId.For(), - lid = "local-1" - } + type = "performers", + id = Unknown.StringId.For(), + lid = "local-1" } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_add_for_unknown_IDs_in_data() - { - // Arrange - RecordCompany existingCompany = _fakers.RecordCompany.Generate(); + [Fact] + public async Task Cannot_add_for_unknown_IDs_in_data() + { + // Arrange + RecordCompany existingCompany = _fakers.RecordCompany.Generate(); - string[] trackIds = - { - Unknown.StringId.For(), - Unknown.StringId.AltFor() - }; + string[] trackIds = + { + Unknown.StringId.For(), + Unknown.StringId.AltFor() + }; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.RecordCompanies.Add(existingCompany); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.RecordCompanies.Add(existingCompany); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + @ref = new + { + type = "recordCompanies", + id = existingCompany.StringId, + relationship = "tracks" + }, + data = new[] { - op = "add", - @ref = new + new { - type = "recordCompanies", - id = existingCompany.StringId, - relationship = "tracks" + type = "musicTracks", + id = trackIds[0] }, - data = new[] + new { - new - { - type = "musicTracks", - id = trackIds[0] - }, - new - { - type = "musicTracks", - id = trackIds[1] - } + type = "musicTracks", + id = trackIds[1] } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(2); + responseDocument.Errors.ShouldHaveCount(2); - ErrorObject error1 = responseDocument.Errors[0]; - error1.StatusCode.Should().Be(HttpStatusCode.NotFound); - error1.Title.Should().Be("A related resource does not exist."); - error1.Detail.Should().Be($"Related resource of type 'musicTracks' with ID '{trackIds[0]}' in relationship 'tracks' does not exist."); - error1.Source.ShouldNotBeNull(); - error1.Source.Pointer.Should().Be("/atomic:operations[0]"); + ErrorObject error1 = responseDocument.Errors[0]; + error1.StatusCode.Should().Be(HttpStatusCode.NotFound); + error1.Title.Should().Be("A related resource does not exist."); + error1.Detail.Should().Be($"Related resource of type 'musicTracks' with ID '{trackIds[0]}' in relationship 'tracks' does not exist."); + error1.Source.ShouldNotBeNull(); + error1.Source.Pointer.Should().Be("/atomic:operations[0]"); - ErrorObject error2 = responseDocument.Errors[1]; - error2.StatusCode.Should().Be(HttpStatusCode.NotFound); - error2.Title.Should().Be("A related resource does not exist."); - error2.Detail.Should().Be($"Related resource of type 'musicTracks' with ID '{trackIds[1]}' in relationship 'tracks' does not exist."); - error2.Source.ShouldNotBeNull(); - error2.Source.Pointer.Should().Be("/atomic:operations[0]"); - } + ErrorObject error2 = responseDocument.Errors[1]; + error2.StatusCode.Should().Be(HttpStatusCode.NotFound); + error2.Title.Should().Be("A related resource does not exist."); + error2.Detail.Should().Be($"Related resource of type 'musicTracks' with ID '{trackIds[1]}' in relationship 'tracks' does not exist."); + error2.Source.ShouldNotBeNull(); + error2.Source.Pointer.Should().Be("/atomic:operations[0]"); + } - [Fact] - public async Task Cannot_add_for_relationship_mismatch_between_ref_and_data() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + [Fact] + public async Task Cannot_add_for_relationship_mismatch_between_ref_and_data() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + @ref = new { - op = "add", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" - }, - data = new[] + type = "musicTracks", + id = existingTrack.StringId, + relationship = "performers" + }, + data = new[] + { + new { - new - { - type = "playlists", - id = Unknown.StringId.For() - } + type = "playlists", + id = Unknown.StringId.For() } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.Conflict); - error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found."); - error.Detail.Should().Be("Type 'playlists' is incompatible with type 'performers' of relationship 'performers'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Conflict); + error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found."); + error.Detail.Should().Be("Type 'playlists' is incompatible with type 'performers' of relationship 'performers'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Can_add_with_empty_data_array() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.Performers = _fakers.Performer.Generate(1); + [Fact] + public async Task Can_add_with_empty_data_array() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + existingTrack.Performers = _fakers.Performer.Generate(1); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + @ref = new { - op = "add", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" - }, - data = Array.Empty() - } + type = "musicTracks", + id = existingTrack.StringId, + relationship = "performers" + }, + data = Array.Empty() } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(existingTrack.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(existingTrack.Id); - trackInDatabase.Performers.ShouldHaveCount(1); - trackInDatabase.Performers[0].Id.Should().Be(existingTrack.Performers[0].Id); - }); - } + trackInDatabase.Performers.ShouldHaveCount(1); + trackInDatabase.Performers[0].Id.Should().Be(existingTrack.Performers[0].Id); + }); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs index d0673adab0..1e6787a0d7 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs @@ -1,1051 +1,1045 @@ -using System; -using System.Collections.Generic; using System.Net; -using System.Net.Http; -using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.EntityFrameworkCore; using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Updating.Relationships +namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Updating.Relationships; + +public sealed class AtomicRemoveFromToManyRelationshipTests : IClassFixture, OperationsDbContext>> { - public sealed class AtomicRemoveFromToManyRelationshipTests - : IClassFixture, OperationsDbContext>> + private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new(); + + public AtomicRemoveFromToManyRelationshipTests(IntegrationTestContext, OperationsDbContext> testContext) { - private readonly IntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new(); + _testContext = testContext; - public AtomicRemoveFromToManyRelationshipTests(IntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; + testContext.UseController(); + } - testContext.UseController(); - } + [Fact] + public async Task Cannot_remove_from_ManyToOne_relationship() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + existingTrack.OwnedBy = _fakers.RecordCompany.Generate(); - [Fact] - public async Task Cannot_remove_from_ManyToOne_relationship() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.OwnedBy = _fakers.RecordCompany.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "remove", + @ref = new { - op = "remove", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "ownedBy" - }, - data = new - { - type = "recordCompanies", - id = existingTrack.OwnedBy.StringId - } + type = "musicTracks", + id = existingTrack.StringId, + relationship = "ownedBy" + }, + data = new + { + type = "recordCompanies", + id = existingTrack.OwnedBy.StringId } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.Forbidden); - error.Title.Should().Be("Failed to deserialize request body: Only to-many relationships can be targeted through this operation."); - error.Detail.Should().Be("Relationship 'ownedBy' is not a to-many relationship."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/relationship"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Forbidden); + error.Title.Should().Be("Failed to deserialize request body: Only to-many relationships can be targeted through this operation."); + error.Detail.Should().Be("Relationship 'ownedBy' is not a to-many relationship."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/relationship"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Can_remove_from_OneToMany_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.Performers = _fakers.Performer.Generate(3); + [Fact] + public async Task Can_remove_from_OneToMany_relationship() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + existingTrack.Performers = _fakers.Performer.Generate(3); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "remove", + @ref = new { - op = "remove", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" - }, - data = new[] + type = "musicTracks", + id = existingTrack.StringId, + relationship = "performers" + }, + data = new[] + { + new { - new - { - type = "performers", - id = existingTrack.Performers[0].StringId - } + type = "performers", + id = existingTrack.Performers[0].StringId } + } + }, + new + { + op = "remove", + @ref = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationship = "performers" }, - new + data = new[] { - op = "remove", - @ref = new + new { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" - }, - data = new[] - { - new - { - type = "performers", - id = existingTrack.Performers[2].StringId - } + type = "performers", + id = existingTrack.Performers[2].StringId } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(existingTrack.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(existingTrack.Id); - trackInDatabase.Performers.ShouldHaveCount(1); - trackInDatabase.Performers[0].Id.Should().Be(existingTrack.Performers[1].Id); + trackInDatabase.Performers.ShouldHaveCount(1); + trackInDatabase.Performers[0].Id.Should().Be(existingTrack.Performers[1].Id); - List performersInDatabase = await dbContext.Performers.ToListAsync(); - performersInDatabase.ShouldHaveCount(3); - }); - } + List performersInDatabase = await dbContext.Performers.ToListAsync(); + performersInDatabase.ShouldHaveCount(3); + }); + } - [Fact] - public async Task Can_remove_from_ManyToMany_relationship() - { - // Arrange - Playlist existingPlaylist = _fakers.Playlist.Generate(); - existingPlaylist.Tracks = _fakers.MusicTrack.Generate(3); + [Fact] + public async Task Can_remove_from_ManyToMany_relationship() + { + // Arrange + Playlist existingPlaylist = _fakers.Playlist.Generate(); + existingPlaylist.Tracks = _fakers.MusicTrack.Generate(3); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Playlists.Add(existingPlaylist); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Playlists.Add(existingPlaylist); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "remove", + @ref = new { - op = "remove", - @ref = new - { - type = "playlists", - id = existingPlaylist.StringId, - relationship = "tracks" - }, - data = new[] + type = "playlists", + id = existingPlaylist.StringId, + relationship = "tracks" + }, + data = new[] + { + new { - new - { - type = "musicTracks", - id = existingPlaylist.Tracks[0].StringId - } + type = "musicTracks", + id = existingPlaylist.Tracks[0].StringId } + } + }, + new + { + op = "remove", + @ref = new + { + type = "playlists", + id = existingPlaylist.StringId, + relationship = "tracks" }, - new + data = new[] { - op = "remove", - @ref = new - { - type = "playlists", - id = existingPlaylist.StringId, - relationship = "tracks" - }, - data = new[] + new { - new - { - type = "musicTracks", - id = existingPlaylist.Tracks[2].StringId - } + type = "musicTracks", + id = existingPlaylist.Tracks[2].StringId } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(existingPlaylist.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(existingPlaylist.Id); - playlistInDatabase.Tracks.ShouldHaveCount(1); - playlistInDatabase.Tracks[0].Id.Should().Be(existingPlaylist.Tracks[1].Id); + playlistInDatabase.Tracks.ShouldHaveCount(1); + playlistInDatabase.Tracks[0].Id.Should().Be(existingPlaylist.Tracks[1].Id); - List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); + List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.ShouldHaveCount(3); - }); - } + tracksInDatabase.ShouldHaveCount(3); + }); + } - [Fact] - public async Task Cannot_remove_for_href_element() + [Fact] + public async Task Cannot_remove_for_href_element() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new - { - op = "remove", - href = "/api/v1/musicTracks/1/relationships/performers" - } + op = "remove", + href = "/api/v1/musicTracks/1/relationships/performers" } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'href' element is not supported."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/href"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'href' element is not supported."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/href"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_remove_for_missing_type_in_ref() + [Fact] + public async Task Cannot_remove_for_missing_type_in_ref() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "remove", + @ref = new { - op = "remove", - @ref = new - { - id = Unknown.StringId.For(), - relationship = "tracks" - } + id = Unknown.StringId.For(), + relationship = "tracks" } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_remove_for_unknown_type_in_ref() + [Fact] + public async Task Cannot_remove_for_unknown_type_in_ref() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "remove", + @ref = new { - op = "remove", - @ref = new - { - type = Unknown.ResourceType, - id = Unknown.StringId.For(), - relationship = "tracks" - } + type = Unknown.ResourceType, + id = Unknown.StringId.For(), + relationship = "tracks" } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); - error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); + error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_remove_for_missing_ID_in_ref() + [Fact] + public async Task Cannot_remove_for_missing_ID_in_ref() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "remove", + @ref = new { - op = "remove", - @ref = new - { - type = "musicTracks", - relationship = "performers" - } + type = "musicTracks", + relationship = "performers" } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_remove_for_unknown_ID_in_ref() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + [Fact] + public async Task Cannot_remove_for_unknown_ID_in_ref() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); - string companyId = Unknown.StringId.For(); + string companyId = Unknown.StringId.For(); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "remove", + @ref = new { - op = "remove", - @ref = new - { - type = "recordCompanies", - id = companyId, - relationship = "tracks" - }, - data = new[] + type = "recordCompanies", + id = companyId, + relationship = "tracks" + }, + data = new[] + { + new { - new - { - type = "musicTracks", - id = existingTrack.StringId - } + type = "musicTracks", + id = existingTrack.StringId } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.NotFound); - error.Title.Should().Be("The requested resource does not exist."); - error.Detail.Should().Be($"Resource of type 'recordCompanies' with ID '{companyId}' does not exist."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - error.Meta.Should().NotContainKey("requestBody"); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("The requested resource does not exist."); + error.Detail.Should().Be($"Resource of type 'recordCompanies' with ID '{companyId}' does not exist."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Meta.Should().NotContainKey("requestBody"); + } - [Fact] - public async Task Cannot_remove_for_ID_and_local_ID_in_ref() + [Fact] + public async Task Cannot_remove_for_ID_and_local_ID_in_ref() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "remove", + @ref = new { - op = "remove", - @ref = new - { - type = "musicTracks", - id = Unknown.StringId.For(), - lid = "local-1", - relationship = "performers" - } + type = "musicTracks", + id = Unknown.StringId.For(), + lid = "local-1", + relationship = "performers" } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_remove_for_unknown_relationship_in_ref() + [Fact] + public async Task Cannot_remove_for_unknown_relationship_in_ref() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "remove", + @ref = new { - op = "remove", - @ref = new - { - type = "performers", - id = Unknown.StringId.For(), - relationship = Unknown.Relationship - } + type = "performers", + id = Unknown.StringId.For(), + relationship = Unknown.Relationship } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Unknown relationship found."); - error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' does not exist on resource type 'performers'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/relationship"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Unknown relationship found."); + error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' does not exist on resource type 'performers'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/relationship"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_remove_for_missing_data() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + [Fact] + public async Task Cannot_remove_for_missing_data() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "remove", + @ref = new { - op = "remove", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" - } + type = "musicTracks", + id = existingTrack.StringId, + relationship = "performers" } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'data' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_remove_for_null_data() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + [Fact] + public async Task Cannot_remove_for_null_data() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "remove", + @ref = new { - op = "remove", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" - }, - data = (object?)null - } + type = "musicTracks", + id = existingTrack.StringId, + relationship = "performers" + }, + data = (object?)null } - }; + } + }; + + const string route = "/operations"; - const string route = "/operations"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors.ShouldHaveCount(1); - responseDocument.Errors.ShouldHaveCount(1); + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Expected an array, instead of 'null'."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected an array, instead of 'null'."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + [Fact] + public async Task Cannot_remove_for_object_data() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - [Fact] - public async Task Cannot_remove_for_object_data() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "remove", + @ref = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationship = "performers" + }, + data = new { - op = "remove", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" - }, - data = new - { - } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected an array, instead of an object."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Expected an array, instead of an object."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_remove_for_missing_type_in_data() + [Fact] + public async Task Cannot_remove_for_missing_type_in_data() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "remove", + @ref = new { - op = "remove", - @ref = new - { - type = "playlists", - id = Unknown.StringId.For(), - relationship = "tracks" - }, - data = new[] + type = "playlists", + id = Unknown.StringId.For(), + relationship = "tracks" + }, + data = new[] + { + new { - new - { - id = Unknown.StringId.For() - } + id = Unknown.StringId.For() } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_remove_for_unknown_type_in_data() + [Fact] + public async Task Cannot_remove_for_unknown_type_in_data() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "remove", + @ref = new { - op = "remove", - @ref = new - { - type = "musicTracks", - id = Unknown.StringId.For(), - relationship = "performers" - }, - data = new[] + type = "musicTracks", + id = Unknown.StringId.For(), + relationship = "performers" + }, + data = new[] + { + new { - new - { - type = Unknown.ResourceType, - id = Unknown.StringId.For() - } + type = Unknown.ResourceType, + id = Unknown.StringId.For() } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); - error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); + error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_remove_for_missing_ID_in_data() + [Fact] + public async Task Cannot_remove_for_missing_ID_in_data() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "remove", + @ref = new { - op = "remove", - @ref = new - { - type = "musicTracks", - id = Unknown.StringId.For(), - relationship = "performers" - }, - data = new[] + type = "musicTracks", + id = Unknown.StringId.For(), + relationship = "performers" + }, + data = new[] + { + new { - new - { - type = "performers" - } + type = "performers" } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_remove_for_ID_and_local_ID_in_data() + [Fact] + public async Task Cannot_remove_for_ID_and_local_ID_in_data() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "remove", + @ref = new { - op = "remove", - @ref = new - { - type = "musicTracks", - id = Unknown.StringId.For(), - relationship = "performers" - }, - data = new[] + type = "musicTracks", + id = Unknown.StringId.For(), + relationship = "performers" + }, + data = new[] + { + new { - new - { - type = "performers", - id = Unknown.StringId.For(), - lid = "local-1" - } + type = "performers", + id = Unknown.StringId.For(), + lid = "local-1" } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_remove_for_unknown_IDs_in_data() - { - // Arrange - RecordCompany existingCompany = _fakers.RecordCompany.Generate(); + [Fact] + public async Task Cannot_remove_for_unknown_IDs_in_data() + { + // Arrange + RecordCompany existingCompany = _fakers.RecordCompany.Generate(); - string[] trackIds = - { - Unknown.StringId.For(), - Unknown.StringId.AltFor() - }; + string[] trackIds = + { + Unknown.StringId.For(), + Unknown.StringId.AltFor() + }; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.RecordCompanies.Add(existingCompany); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.RecordCompanies.Add(existingCompany); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "remove", + @ref = new + { + type = "recordCompanies", + id = existingCompany.StringId, + relationship = "tracks" + }, + data = new[] { - op = "remove", - @ref = new + new { - type = "recordCompanies", - id = existingCompany.StringId, - relationship = "tracks" + type = "musicTracks", + id = trackIds[0] }, - data = new[] + new { - new - { - type = "musicTracks", - id = trackIds[0] - }, - new - { - type = "musicTracks", - id = trackIds[1] - } + type = "musicTracks", + id = trackIds[1] } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(2); + responseDocument.Errors.ShouldHaveCount(2); - ErrorObject error1 = responseDocument.Errors[0]; - error1.StatusCode.Should().Be(HttpStatusCode.NotFound); - error1.Title.Should().Be("A related resource does not exist."); - error1.Detail.Should().Be($"Related resource of type 'musicTracks' with ID '{trackIds[0]}' in relationship 'tracks' does not exist."); - error1.Source.ShouldNotBeNull(); - error1.Source.Pointer.Should().Be("/atomic:operations[0]"); + ErrorObject error1 = responseDocument.Errors[0]; + error1.StatusCode.Should().Be(HttpStatusCode.NotFound); + error1.Title.Should().Be("A related resource does not exist."); + error1.Detail.Should().Be($"Related resource of type 'musicTracks' with ID '{trackIds[0]}' in relationship 'tracks' does not exist."); + error1.Source.ShouldNotBeNull(); + error1.Source.Pointer.Should().Be("/atomic:operations[0]"); - ErrorObject error2 = responseDocument.Errors[1]; - error2.StatusCode.Should().Be(HttpStatusCode.NotFound); - error2.Title.Should().Be("A related resource does not exist."); - error2.Detail.Should().Be($"Related resource of type 'musicTracks' with ID '{trackIds[1]}' in relationship 'tracks' does not exist."); - error2.Source.ShouldNotBeNull(); - error2.Source.Pointer.Should().Be("/atomic:operations[0]"); - } + ErrorObject error2 = responseDocument.Errors[1]; + error2.StatusCode.Should().Be(HttpStatusCode.NotFound); + error2.Title.Should().Be("A related resource does not exist."); + error2.Detail.Should().Be($"Related resource of type 'musicTracks' with ID '{trackIds[1]}' in relationship 'tracks' does not exist."); + error2.Source.ShouldNotBeNull(); + error2.Source.Pointer.Should().Be("/atomic:operations[0]"); + } - [Fact] - public async Task Cannot_remove_for_relationship_mismatch_between_ref_and_data() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + [Fact] + public async Task Cannot_remove_for_relationship_mismatch_between_ref_and_data() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "remove", + @ref = new { - op = "remove", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" - }, - data = new[] + type = "musicTracks", + id = existingTrack.StringId, + relationship = "performers" + }, + data = new[] + { + new { - new - { - type = "playlists", - id = Unknown.StringId.For() - } + type = "playlists", + id = Unknown.StringId.For() } } } - }; + } + }; + + const string route = "/operations"; - const string route = "/operations"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); + responseDocument.Errors.ShouldHaveCount(1); - responseDocument.Errors.ShouldHaveCount(1); + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Conflict); + error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found."); + error.Detail.Should().Be("Type 'playlists' is incompatible with type 'performers' of relationship 'performers'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.Conflict); - error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found."); - error.Detail.Should().Be("Type 'playlists' is incompatible with type 'performers' of relationship 'performers'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + [Fact] + public async Task Can_remove_with_empty_data_array() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + existingTrack.Performers = _fakers.Performer.Generate(1); - [Fact] - public async Task Can_remove_with_empty_data_array() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.Performers = _fakers.Performer.Generate(1); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); + await dbContext.ClearTableAsync(); + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "remove", + @ref = new { - op = "remove", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" - }, - data = Array.Empty() - } + type = "musicTracks", + id = existingTrack.StringId, + relationship = "performers" + }, + data = Array.Empty() } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(existingTrack.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(existingTrack.Id); - trackInDatabase.Performers.ShouldHaveCount(1); - trackInDatabase.Performers[0].Id.Should().Be(existingTrack.Performers[0].Id); - }); - } + trackInDatabase.Performers.ShouldHaveCount(1); + trackInDatabase.Performers[0].Id.Should().Be(existingTrack.Performers[0].Id); + }); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs index a77441709a..ee3ea791a5 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs @@ -1,1148 +1,1143 @@ -using System; -using System.Collections.Generic; using System.Net; -using System.Net.Http; -using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.EntityFrameworkCore; using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Updating.Relationships +namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Updating.Relationships; + +public sealed class AtomicReplaceToManyRelationshipTests : IClassFixture, OperationsDbContext>> { - public sealed class AtomicReplaceToManyRelationshipTests : IClassFixture, OperationsDbContext>> + private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new(); + + public AtomicReplaceToManyRelationshipTests(IntegrationTestContext, OperationsDbContext> testContext) { - private readonly IntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new(); + _testContext = testContext; - public AtomicReplaceToManyRelationshipTests(IntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; + testContext.UseController(); + } - testContext.UseController(); - } + [Fact] + public async Task Can_clear_OneToMany_relationship() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + existingTrack.Performers = _fakers.Performer.Generate(2); - [Fact] - public async Task Can_clear_OneToMany_relationship() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.Performers = _fakers.Performer.Generate(2); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); + await dbContext.ClearTableAsync(); + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + @ref = new { - op = "update", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" - }, - data = Array.Empty() - } + type = "musicTracks", + id = existingTrack.StringId, + relationship = "performers" + }, + data = Array.Empty() } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(existingTrack.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(existingTrack.Id); - trackInDatabase.Performers.Should().BeEmpty(); + trackInDatabase.Performers.Should().BeEmpty(); - List performersInDatabase = await dbContext.Performers.ToListAsync(); - performersInDatabase.ShouldHaveCount(2); - }); - } + List performersInDatabase = await dbContext.Performers.ToListAsync(); + performersInDatabase.ShouldHaveCount(2); + }); + } - [Fact] - public async Task Can_clear_ManyToMany_relationship() - { - // Arrange - Playlist existingPlaylist = _fakers.Playlist.Generate(); - existingPlaylist.Tracks = _fakers.MusicTrack.Generate(2); + [Fact] + public async Task Can_clear_ManyToMany_relationship() + { + // Arrange + Playlist existingPlaylist = _fakers.Playlist.Generate(); + existingPlaylist.Tracks = _fakers.MusicTrack.Generate(2); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Playlists.Add(existingPlaylist); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Playlists.Add(existingPlaylist); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + @ref = new { - op = "update", - @ref = new - { - type = "playlists", - id = existingPlaylist.StringId, - relationship = "tracks" - }, - data = Array.Empty() - } + type = "playlists", + id = existingPlaylist.StringId, + relationship = "tracks" + }, + data = Array.Empty() } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(existingPlaylist.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(existingPlaylist.Id); - playlistInDatabase.Tracks.Should().BeEmpty(); + playlistInDatabase.Tracks.Should().BeEmpty(); - List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); + List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.ShouldHaveCount(2); - }); - } + tracksInDatabase.ShouldHaveCount(2); + }); + } - [Fact] - public async Task Can_replace_OneToMany_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.Performers = _fakers.Performer.Generate(1); + [Fact] + public async Task Can_replace_OneToMany_relationship() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + existingTrack.Performers = _fakers.Performer.Generate(1); - List existingPerformers = _fakers.Performer.Generate(2); + List existingPerformers = _fakers.Performer.Generate(2); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.MusicTracks.Add(existingTrack); - dbContext.Performers.AddRange(existingPerformers); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.MusicTracks.Add(existingTrack); + dbContext.Performers.AddRange(existingPerformers); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + @ref = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationship = "performers" + }, + data = new[] { - op = "update", - @ref = new + new { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" + type = "performers", + id = existingPerformers[0].StringId }, - data = new[] + new { - new - { - type = "performers", - id = existingPerformers[0].StringId - }, - new - { - type = "performers", - id = existingPerformers[1].StringId - } + type = "performers", + id = existingPerformers[1].StringId } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(existingTrack.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(existingTrack.Id); - trackInDatabase.Performers.ShouldHaveCount(2); - trackInDatabase.Performers.Should().ContainSingle(performer => performer.Id == existingPerformers[0].Id); - trackInDatabase.Performers.Should().ContainSingle(performer => performer.Id == existingPerformers[1].Id); + trackInDatabase.Performers.ShouldHaveCount(2); + trackInDatabase.Performers.Should().ContainSingle(performer => performer.Id == existingPerformers[0].Id); + trackInDatabase.Performers.Should().ContainSingle(performer => performer.Id == existingPerformers[1].Id); - List performersInDatabase = await dbContext.Performers.ToListAsync(); - performersInDatabase.ShouldHaveCount(3); - }); - } + List performersInDatabase = await dbContext.Performers.ToListAsync(); + performersInDatabase.ShouldHaveCount(3); + }); + } - [Fact] - public async Task Can_replace_ManyToMany_relationship() - { - // Arrange - Playlist existingPlaylist = _fakers.Playlist.Generate(); - existingPlaylist.Tracks = _fakers.MusicTrack.Generate(1); + [Fact] + public async Task Can_replace_ManyToMany_relationship() + { + // Arrange + Playlist existingPlaylist = _fakers.Playlist.Generate(); + existingPlaylist.Tracks = _fakers.MusicTrack.Generate(1); - List existingTracks = _fakers.MusicTrack.Generate(2); + List existingTracks = _fakers.MusicTrack.Generate(2); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Playlists.Add(existingPlaylist); - dbContext.MusicTracks.AddRange(existingTracks); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Playlists.Add(existingPlaylist); + dbContext.MusicTracks.AddRange(existingTracks); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + @ref = new + { + type = "playlists", + id = existingPlaylist.StringId, + relationship = "tracks" + }, + data = new[] { - op = "update", - @ref = new + new { - type = "playlists", - id = existingPlaylist.StringId, - relationship = "tracks" + type = "musicTracks", + id = existingTracks[0].StringId }, - data = new[] + new { - new - { - type = "musicTracks", - id = existingTracks[0].StringId - }, - new - { - type = "musicTracks", - id = existingTracks[1].StringId - } + type = "musicTracks", + id = existingTracks[1].StringId } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(existingPlaylist.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(existingPlaylist.Id); - playlistInDatabase.Tracks.ShouldHaveCount(2); - playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[0].Id); - playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[1].Id); + playlistInDatabase.Tracks.ShouldHaveCount(2); + playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[0].Id); + playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[1].Id); - List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); + List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.ShouldHaveCount(3); - }); - } + tracksInDatabase.ShouldHaveCount(3); + }); + } - [Fact] - public async Task Cannot_replace_for_href_element() + [Fact] + public async Task Cannot_replace_for_href_element() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new - { - op = "update", - href = "/api/v1/musicTracks/1/relationships/performers" - } + op = "update", + href = "/api/v1/musicTracks/1/relationships/performers" } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'href' element is not supported."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/href"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'href' element is not supported."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/href"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_replace_for_missing_type_in_ref() + [Fact] + public async Task Cannot_replace_for_missing_type_in_ref() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + @ref = new { - op = "update", - @ref = new - { - id = Unknown.StringId.For(), - relationship = "tracks" - } + id = Unknown.StringId.For(), + relationship = "tracks" } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_replace_for_unknown_type_in_ref() + [Fact] + public async Task Cannot_replace_for_unknown_type_in_ref() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + @ref = new { - op = "update", - @ref = new - { - type = Unknown.ResourceType, - id = Unknown.StringId.For(), - relationship = "tracks" - } + type = Unknown.ResourceType, + id = Unknown.StringId.For(), + relationship = "tracks" } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); - error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); + error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_replace_for_missing_ID_in_ref() + [Fact] + public async Task Cannot_replace_for_missing_ID_in_ref() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + @ref = new { - op = "update", - @ref = new - { - type = "musicTracks", - relationship = "performers" - } + type = "musicTracks", + relationship = "performers" } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_replace_for_unknown_ID_in_ref() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + [Fact] + public async Task Cannot_replace_for_unknown_ID_in_ref() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); - string companyId = Unknown.StringId.For(); + string companyId = Unknown.StringId.For(); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + @ref = new { - op = "update", - @ref = new - { - type = "recordCompanies", - id = companyId, - relationship = "tracks" - }, - data = new[] + type = "recordCompanies", + id = companyId, + relationship = "tracks" + }, + data = new[] + { + new { - new - { - type = "musicTracks", - id = existingTrack.StringId - } + type = "musicTracks", + id = existingTrack.StringId } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.NotFound); - error.Title.Should().Be("The requested resource does not exist."); - error.Detail.Should().Be($"Resource of type 'recordCompanies' with ID '{companyId}' does not exist."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - error.Meta.Should().NotContainKey("requestBody"); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("The requested resource does not exist."); + error.Detail.Should().Be($"Resource of type 'recordCompanies' with ID '{companyId}' does not exist."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Meta.Should().NotContainKey("requestBody"); + } - [Fact] - public async Task Cannot_replace_for_incompatible_ID_in_ref() - { - // Arrange - string guid = Unknown.StringId.Guid; + [Fact] + public async Task Cannot_replace_for_incompatible_ID_in_ref() + { + // Arrange + string guid = Unknown.StringId.Guid; - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + @ref = new { - op = "update", - @ref = new - { - type = "recordCompanies", - id = guid, - relationship = "tracks" - }, - data = new[] + type = "recordCompanies", + id = guid, + relationship = "tracks" + }, + data = new[] + { + new { - new - { - type = "musicTracks", - id = existingTrack.StringId - } + type = "musicTracks", + id = existingTrack.StringId } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Incompatible 'id' value found."); - error.Detail.Should().Be($"Failed to convert '{guid}' of type 'String' to type 'Int16'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/id"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Incompatible 'id' value found."); + error.Detail.Should().Be($"Failed to convert '{guid}' of type 'String' to type 'Int16'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/id"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_replace_for_ID_and_local_ID_in_ref() + [Fact] + public async Task Cannot_replace_for_ID_and_local_ID_in_ref() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + @ref = new { - op = "update", - @ref = new - { - type = "musicTracks", - id = Unknown.StringId.For(), - lid = "local-1", - relationship = "performers" - } + type = "musicTracks", + id = Unknown.StringId.For(), + lid = "local-1", + relationship = "performers" } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_replace_for_unknown_relationship_in_ref() + [Fact] + public async Task Cannot_replace_for_unknown_relationship_in_ref() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + @ref = new { - op = "update", - @ref = new - { - type = "performers", - id = Unknown.StringId.For(), - relationship = Unknown.Relationship - } + type = "performers", + id = Unknown.StringId.For(), + relationship = Unknown.Relationship } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Unknown relationship found."); - error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' does not exist on resource type 'performers'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/relationship"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Unknown relationship found."); + error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' does not exist on resource type 'performers'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/relationship"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_replace_for_missing_data() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + [Fact] + public async Task Cannot_replace_for_missing_data() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + @ref = new { - op = "update", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" - } + type = "musicTracks", + id = existingTrack.StringId, + relationship = "performers" } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'data' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_replace_for_null_data() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + [Fact] + public async Task Cannot_replace_for_null_data() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + @ref = new { - op = "update", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" - }, - data = (object?)null - } + type = "musicTracks", + id = existingTrack.StringId, + relationship = "performers" + }, + data = (object?)null } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected an array, instead of 'null'."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Expected an array, instead of 'null'."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_replace_for_object_data() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + [Fact] + public async Task Cannot_replace_for_object_data() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + @ref = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationship = "performers" + }, + data = new { - op = "update", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" - }, - data = new - { - } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected an array, instead of an object."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Expected an array, instead of an object."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_replace_for_missing_type_in_data() + [Fact] + public async Task Cannot_replace_for_missing_type_in_data() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + @ref = new { - op = "update", - @ref = new - { - type = "playlists", - id = Unknown.StringId.For(), - relationship = "tracks" - }, - data = new[] + type = "playlists", + id = Unknown.StringId.For(), + relationship = "tracks" + }, + data = new[] + { + new { - new - { - id = Unknown.StringId.For() - } + id = Unknown.StringId.For() } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_replace_for_unknown_type_in_data() + [Fact] + public async Task Cannot_replace_for_unknown_type_in_data() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + @ref = new { - op = "update", - @ref = new - { - type = "musicTracks", - id = Unknown.StringId.For(), - relationship = "performers" - }, - data = new[] + type = "musicTracks", + id = Unknown.StringId.For(), + relationship = "performers" + }, + data = new[] + { + new { - new - { - type = Unknown.ResourceType, - id = Unknown.StringId.For() - } + type = Unknown.ResourceType, + id = Unknown.StringId.For() } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); - error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); + error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_replace_for_missing_ID_in_data() + [Fact] + public async Task Cannot_replace_for_missing_ID_in_data() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + @ref = new { - op = "update", - @ref = new - { - type = "musicTracks", - id = Unknown.StringId.For(), - relationship = "performers" - }, - data = new[] + type = "musicTracks", + id = Unknown.StringId.For(), + relationship = "performers" + }, + data = new[] + { + new { - new - { - type = "performers" - } + type = "performers" } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_replace_for_ID_and_local_ID_in_data() + [Fact] + public async Task Cannot_replace_for_ID_and_local_ID_in_data() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + @ref = new { - op = "update", - @ref = new - { - type = "musicTracks", - id = Unknown.StringId.For(), - relationship = "performers" - }, - data = new[] + type = "musicTracks", + id = Unknown.StringId.For(), + relationship = "performers" + }, + data = new[] + { + new { - new - { - type = "performers", - id = Unknown.StringId.For(), - lid = "local-1" - } + type = "performers", + id = Unknown.StringId.For(), + lid = "local-1" } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_replace_for_unknown_IDs_in_data() - { - // Arrange - RecordCompany existingCompany = _fakers.RecordCompany.Generate(); + [Fact] + public async Task Cannot_replace_for_unknown_IDs_in_data() + { + // Arrange + RecordCompany existingCompany = _fakers.RecordCompany.Generate(); - string[] trackIds = - { - Unknown.StringId.For(), - Unknown.StringId.AltFor() - }; + string[] trackIds = + { + Unknown.StringId.For(), + Unknown.StringId.AltFor() + }; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.RecordCompanies.Add(existingCompany); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.RecordCompanies.Add(existingCompany); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + @ref = new + { + type = "recordCompanies", + id = existingCompany.StringId, + relationship = "tracks" + }, + data = new[] { - op = "update", - @ref = new + new { - type = "recordCompanies", - id = existingCompany.StringId, - relationship = "tracks" + type = "musicTracks", + id = trackIds[0] }, - data = new[] + new { - new - { - type = "musicTracks", - id = trackIds[0] - }, - new - { - type = "musicTracks", - id = trackIds[1] - } + type = "musicTracks", + id = trackIds[1] } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(2); + responseDocument.Errors.ShouldHaveCount(2); - ErrorObject error1 = responseDocument.Errors[0]; - error1.StatusCode.Should().Be(HttpStatusCode.NotFound); - error1.Title.Should().Be("A related resource does not exist."); - error1.Detail.Should().Be($"Related resource of type 'musicTracks' with ID '{trackIds[0]}' in relationship 'tracks' does not exist."); - error1.Source.ShouldNotBeNull(); - error1.Source.Pointer.Should().Be("/atomic:operations[0]"); + ErrorObject error1 = responseDocument.Errors[0]; + error1.StatusCode.Should().Be(HttpStatusCode.NotFound); + error1.Title.Should().Be("A related resource does not exist."); + error1.Detail.Should().Be($"Related resource of type 'musicTracks' with ID '{trackIds[0]}' in relationship 'tracks' does not exist."); + error1.Source.ShouldNotBeNull(); + error1.Source.Pointer.Should().Be("/atomic:operations[0]"); - ErrorObject error2 = responseDocument.Errors[1]; - error2.StatusCode.Should().Be(HttpStatusCode.NotFound); - error2.Title.Should().Be("A related resource does not exist."); - error2.Detail.Should().Be($"Related resource of type 'musicTracks' with ID '{trackIds[1]}' in relationship 'tracks' does not exist."); - error2.Source.ShouldNotBeNull(); - error2.Source.Pointer.Should().Be("/atomic:operations[0]"); - } + ErrorObject error2 = responseDocument.Errors[1]; + error2.StatusCode.Should().Be(HttpStatusCode.NotFound); + error2.Title.Should().Be("A related resource does not exist."); + error2.Detail.Should().Be($"Related resource of type 'musicTracks' with ID '{trackIds[1]}' in relationship 'tracks' does not exist."); + error2.Source.ShouldNotBeNull(); + error2.Source.Pointer.Should().Be("/atomic:operations[0]"); + } - [Fact] - public async Task Cannot_replace_for_incompatible_ID_in_data() - { - // Arrange - RecordCompany existingCompany = _fakers.RecordCompany.Generate(); + [Fact] + public async Task Cannot_replace_for_incompatible_ID_in_data() + { + // Arrange + RecordCompany existingCompany = _fakers.RecordCompany.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.RecordCompanies.Add(existingCompany); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.RecordCompanies.Add(existingCompany); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + @ref = new { - op = "update", - @ref = new - { - type = "recordCompanies", - id = existingCompany.StringId, - relationship = "tracks" - }, - data = new[] + type = "recordCompanies", + id = existingCompany.StringId, + relationship = "tracks" + }, + data = new[] + { + new { - new - { - type = "musicTracks", - id = "invalid-guid" - } + type = "musicTracks", + id = "invalid-guid" } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Incompatible 'id' value found."); - error.Detail.Should().Be("Failed to convert 'invalid-guid' of type 'String' to type 'Guid'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]/id"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Incompatible 'id' value found."); + error.Detail.Should().Be("Failed to convert 'invalid-guid' of type 'String' to type 'Guid'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]/id"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_replace_for_relationship_mismatch_between_ref_and_data() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + [Fact] + public async Task Cannot_replace_for_relationship_mismatch_between_ref_and_data() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + @ref = new { - op = "update", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" - }, - data = new[] + type = "musicTracks", + id = existingTrack.StringId, + relationship = "performers" + }, + data = new[] + { + new { - new - { - type = "playlists", - id = Unknown.StringId.For() - } + type = "playlists", + id = Unknown.StringId.For() } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.Conflict); - error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found."); - error.Detail.Should().Be("Type 'playlists' is incompatible with type 'performers' of relationship 'performers'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Conflict); + error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found."); + error.Detail.Should().Be("Type 'playlists' is incompatible with type 'performers' of relationship 'performers'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs index 6569ae5638..b66a5dfc26 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs @@ -1,1313 +1,1308 @@ -using System; -using System.Collections.Generic; using System.Net; -using System.Net.Http; -using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.EntityFrameworkCore; using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Updating.Relationships +namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Updating.Relationships; + +public sealed class AtomicUpdateToOneRelationshipTests : IClassFixture, OperationsDbContext>> { - public sealed class AtomicUpdateToOneRelationshipTests : IClassFixture, OperationsDbContext>> + private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new(); + + public AtomicUpdateToOneRelationshipTests(IntegrationTestContext, OperationsDbContext> testContext) { - private readonly IntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new(); + _testContext = testContext; - public AtomicUpdateToOneRelationshipTests(IntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; + testContext.UseController(); + } - testContext.UseController(); - } + [Fact] + public async Task Can_clear_OneToOne_relationship_from_principal_side() + { + // Arrange + Lyric existingLyric = _fakers.Lyric.Generate(); + existingLyric.Track = _fakers.MusicTrack.Generate(); - [Fact] - public async Task Can_clear_OneToOne_relationship_from_principal_side() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - Lyric existingLyric = _fakers.Lyric.Generate(); - existingLyric.Track = _fakers.MusicTrack.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Lyrics.Add(existingLyric); - await dbContext.SaveChangesAsync(); - }); + await dbContext.ClearTableAsync(); + dbContext.Lyrics.Add(existingLyric); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + @ref = new { - op = "update", - @ref = new - { - type = "lyrics", - id = existingLyric.StringId, - relationship = "track" - }, - data = (object?)null - } + type = "lyrics", + id = existingLyric.StringId, + relationship = "track" + }, + data = (object?)null } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Lyric lyricInDatabase = await dbContext.Lyrics.Include(lyric => lyric.Track).FirstWithIdAsync(existingLyric.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Lyric lyricInDatabase = await dbContext.Lyrics.Include(lyric => lyric.Track).FirstWithIdAsync(existingLyric.Id); - lyricInDatabase.Track.Should().BeNull(); + lyricInDatabase.Track.Should().BeNull(); - List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.ShouldHaveCount(1); - }); - } + List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); + tracksInDatabase.ShouldHaveCount(1); + }); + } - [Fact] - public async Task Can_clear_OneToOne_relationship_from_dependent_side() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.Lyric = _fakers.Lyric.Generate(); + [Fact] + public async Task Can_clear_OneToOne_relationship_from_dependent_side() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + existingTrack.Lyric = _fakers.Lyric.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + @ref = new { - op = "update", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "lyric" - }, - data = (object?)null - } + type = "musicTracks", + id = existingTrack.StringId, + relationship = "lyric" + }, + data = (object?)null } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Lyric).FirstWithIdAsync(existingTrack.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Lyric).FirstWithIdAsync(existingTrack.Id); - trackInDatabase.Lyric.Should().BeNull(); + trackInDatabase.Lyric.Should().BeNull(); - List lyricsInDatabase = await dbContext.Lyrics.ToListAsync(); - lyricsInDatabase.ShouldHaveCount(1); - }); - } + List lyricsInDatabase = await dbContext.Lyrics.ToListAsync(); + lyricsInDatabase.ShouldHaveCount(1); + }); + } - [Fact] - public async Task Can_clear_ManyToOne_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.OwnedBy = _fakers.RecordCompany.Generate(); + [Fact] + public async Task Can_clear_ManyToOne_relationship() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + existingTrack.OwnedBy = _fakers.RecordCompany.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + @ref = new { - op = "update", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "ownedBy" - }, - data = (object?)null - } + type = "musicTracks", + id = existingTrack.StringId, + relationship = "ownedBy" + }, + data = (object?)null } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(existingTrack.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(existingTrack.Id); - trackInDatabase.OwnedBy.Should().BeNull(); + trackInDatabase.OwnedBy.Should().BeNull(); - List companiesInDatabase = await dbContext.RecordCompanies.ToListAsync(); - companiesInDatabase.ShouldHaveCount(1); - }); - } + List companiesInDatabase = await dbContext.RecordCompanies.ToListAsync(); + companiesInDatabase.ShouldHaveCount(1); + }); + } - [Fact] - public async Task Can_create_OneToOne_relationship_from_principal_side() - { - // Arrange - Lyric existingLyric = _fakers.Lyric.Generate(); - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + [Fact] + public async Task Can_create_OneToOne_relationship_from_principal_side() + { + // Arrange + Lyric existingLyric = _fakers.Lyric.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddInRange(existingLyric, existingTrack); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingLyric, existingTrack); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + @ref = new { - op = "update", - @ref = new - { - type = "lyrics", - id = existingLyric.StringId, - relationship = "track" - }, - data = new - { - type = "musicTracks", - id = existingTrack.StringId - } + type = "lyrics", + id = existingLyric.StringId, + relationship = "track" + }, + data = new + { + type = "musicTracks", + id = existingTrack.StringId } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Lyric lyricInDatabase = await dbContext.Lyrics.Include(lyric => lyric.Track).FirstWithIdAsync(existingLyric.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Lyric lyricInDatabase = await dbContext.Lyrics.Include(lyric => lyric.Track).FirstWithIdAsync(existingLyric.Id); - lyricInDatabase.Track.ShouldNotBeNull(); - lyricInDatabase.Track.Id.Should().Be(existingTrack.Id); - }); - } + lyricInDatabase.Track.ShouldNotBeNull(); + lyricInDatabase.Track.Id.Should().Be(existingTrack.Id); + }); + } - [Fact] - public async Task Can_create_OneToOne_relationship_from_dependent_side() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - Lyric existingLyric = _fakers.Lyric.Generate(); + [Fact] + public async Task Can_create_OneToOne_relationship_from_dependent_side() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + Lyric existingLyric = _fakers.Lyric.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddInRange(existingTrack, existingLyric); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingTrack, existingLyric); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + @ref = new { - op = "update", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "lyric" - }, - data = new - { - type = "lyrics", - id = existingLyric.StringId - } + type = "musicTracks", + id = existingTrack.StringId, + relationship = "lyric" + }, + data = new + { + type = "lyrics", + id = existingLyric.StringId } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Lyric).FirstWithIdAsync(existingTrack.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Lyric).FirstWithIdAsync(existingTrack.Id); - trackInDatabase.Lyric.ShouldNotBeNull(); - trackInDatabase.Lyric.Id.Should().Be(existingLyric.Id); - }); - } + trackInDatabase.Lyric.ShouldNotBeNull(); + trackInDatabase.Lyric.Id.Should().Be(existingLyric.Id); + }); + } - [Fact] - public async Task Can_create_ManyToOne_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - RecordCompany existingCompany = _fakers.RecordCompany.Generate(); + [Fact] + public async Task Can_create_ManyToOne_relationship() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + RecordCompany existingCompany = _fakers.RecordCompany.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddInRange(existingTrack, existingCompany); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingTrack, existingCompany); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + @ref = new { - op = "update", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "ownedBy" - }, - data = new - { - type = "recordCompanies", - id = existingCompany.StringId - } + type = "musicTracks", + id = existingTrack.StringId, + relationship = "ownedBy" + }, + data = new + { + type = "recordCompanies", + id = existingCompany.StringId } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(existingTrack.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(existingTrack.Id); - trackInDatabase.OwnedBy.ShouldNotBeNull(); - trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); - }); - } + trackInDatabase.OwnedBy.ShouldNotBeNull(); + trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); + }); + } - [Fact] - public async Task Can_replace_OneToOne_relationship_from_principal_side() - { - // Arrange - Lyric existingLyric = _fakers.Lyric.Generate(); - existingLyric.Track = _fakers.MusicTrack.Generate(); + [Fact] + public async Task Can_replace_OneToOne_relationship_from_principal_side() + { + // Arrange + Lyric existingLyric = _fakers.Lyric.Generate(); + existingLyric.Track = _fakers.MusicTrack.Generate(); - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.AddInRange(existingLyric, existingTrack); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddInRange(existingLyric, existingTrack); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + @ref = new { - op = "update", - @ref = new - { - type = "lyrics", - id = existingLyric.StringId, - relationship = "track" - }, - data = new - { - type = "musicTracks", - id = existingTrack.StringId - } + type = "lyrics", + id = existingLyric.StringId, + relationship = "track" + }, + data = new + { + type = "musicTracks", + id = existingTrack.StringId } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Lyric lyricInDatabase = await dbContext.Lyrics.Include(lyric => lyric.Track).FirstWithIdAsync(existingLyric.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Lyric lyricInDatabase = await dbContext.Lyrics.Include(lyric => lyric.Track).FirstWithIdAsync(existingLyric.Id); - lyricInDatabase.Track.ShouldNotBeNull(); - lyricInDatabase.Track.Id.Should().Be(existingTrack.Id); + lyricInDatabase.Track.ShouldNotBeNull(); + lyricInDatabase.Track.Id.Should().Be(existingTrack.Id); - List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.ShouldHaveCount(2); - }); - } + List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); + tracksInDatabase.ShouldHaveCount(2); + }); + } - [Fact] - public async Task Can_replace_OneToOne_relationship_from_dependent_side() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.Lyric = _fakers.Lyric.Generate(); + [Fact] + public async Task Can_replace_OneToOne_relationship_from_dependent_side() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + existingTrack.Lyric = _fakers.Lyric.Generate(); - Lyric existingLyric = _fakers.Lyric.Generate(); + Lyric existingLyric = _fakers.Lyric.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.AddInRange(existingTrack, existingLyric); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddInRange(existingTrack, existingLyric); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + @ref = new { - op = "update", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "lyric" - }, - data = new - { - type = "lyrics", - id = existingLyric.StringId - } + type = "musicTracks", + id = existingTrack.StringId, + relationship = "lyric" + }, + data = new + { + type = "lyrics", + id = existingLyric.StringId } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Lyric).FirstWithIdAsync(existingTrack.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Lyric).FirstWithIdAsync(existingTrack.Id); - trackInDatabase.Lyric.ShouldNotBeNull(); - trackInDatabase.Lyric.Id.Should().Be(existingLyric.Id); + trackInDatabase.Lyric.ShouldNotBeNull(); + trackInDatabase.Lyric.Id.Should().Be(existingLyric.Id); - List lyricsInDatabase = await dbContext.Lyrics.ToListAsync(); - lyricsInDatabase.ShouldHaveCount(2); - }); - } + List lyricsInDatabase = await dbContext.Lyrics.ToListAsync(); + lyricsInDatabase.ShouldHaveCount(2); + }); + } - [Fact] - public async Task Can_replace_ManyToOne_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.OwnedBy = _fakers.RecordCompany.Generate(); + [Fact] + public async Task Can_replace_ManyToOne_relationship() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + existingTrack.OwnedBy = _fakers.RecordCompany.Generate(); - RecordCompany existingCompany = _fakers.RecordCompany.Generate(); + RecordCompany existingCompany = _fakers.RecordCompany.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.AddInRange(existingTrack, existingCompany); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddInRange(existingTrack, existingCompany); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + @ref = new { - op = "update", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "ownedBy" - }, - data = new - { - type = "recordCompanies", - id = existingCompany.StringId - } + type = "musicTracks", + id = existingTrack.StringId, + relationship = "ownedBy" + }, + data = new + { + type = "recordCompanies", + id = existingCompany.StringId } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(existingTrack.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(existingTrack.Id); - trackInDatabase.OwnedBy.ShouldNotBeNull(); - trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); + trackInDatabase.OwnedBy.ShouldNotBeNull(); + trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); - List companiesInDatabase = await dbContext.RecordCompanies.ToListAsync(); - companiesInDatabase.ShouldHaveCount(2); - }); - } + List companiesInDatabase = await dbContext.RecordCompanies.ToListAsync(); + companiesInDatabase.ShouldHaveCount(2); + }); + } - [Fact] - public async Task Cannot_create_for_href_element() + [Fact] + public async Task Cannot_create_for_href_element() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new - { - op = "update", - href = "/api/v1/musicTracks/1/relationships/ownedBy" - } + op = "update", + href = "/api/v1/musicTracks/1/relationships/ownedBy" } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'href' element is not supported."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/href"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'href' element is not supported."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/href"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_create_for_missing_type_in_ref() + [Fact] + public async Task Cannot_create_for_missing_type_in_ref() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + @ref = new { - op = "update", - @ref = new - { - id = Unknown.StringId.For(), - relationship = "track" - } + id = Unknown.StringId.For(), + relationship = "track" } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_create_for_unknown_type_in_ref() + [Fact] + public async Task Cannot_create_for_unknown_type_in_ref() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + @ref = new { - op = "update", - @ref = new - { - type = Unknown.ResourceType, - id = Unknown.StringId.For(), - relationship = "ownedBy" - } + type = Unknown.ResourceType, + id = Unknown.StringId.For(), + relationship = "ownedBy" } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); - error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); + error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_create_for_missing_ID_in_ref() + [Fact] + public async Task Cannot_create_for_missing_ID_in_ref() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + @ref = new { - op = "update", - @ref = new - { - type = "musicTracks", - relationship = "ownedBy" - } + type = "musicTracks", + relationship = "ownedBy" } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_create_for_unknown_ID_in_ref() - { - // Arrange - string trackId = Unknown.StringId.For(); + [Fact] + public async Task Cannot_create_for_unknown_ID_in_ref() + { + // Arrange + string trackId = Unknown.StringId.For(); - Lyric existingLyric = _fakers.Lyric.Generate(); + Lyric existingLyric = _fakers.Lyric.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Lyrics.Add(existingLyric); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Lyrics.Add(existingLyric); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + @ref = new { - op = "update", - @ref = new - { - type = "musicTracks", - id = trackId, - relationship = "lyric" - }, - data = new - { - type = "lyrics", - id = existingLyric.StringId - } + type = "musicTracks", + id = trackId, + relationship = "lyric" + }, + data = new + { + type = "lyrics", + id = existingLyric.StringId } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.NotFound); - error.Title.Should().Be("The requested resource does not exist."); - error.Detail.Should().Be($"Resource of type 'musicTracks' with ID '{trackId}' does not exist."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - error.Meta.Should().NotContainKey("requestBody"); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("The requested resource does not exist."); + error.Detail.Should().Be($"Resource of type 'musicTracks' with ID '{trackId}' does not exist."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Meta.Should().NotContainKey("requestBody"); + } - [Fact] - public async Task Cannot_create_for_incompatible_ID_in_ref() - { - // Arrange - Lyric existingLyric = _fakers.Lyric.Generate(); + [Fact] + public async Task Cannot_create_for_incompatible_ID_in_ref() + { + // Arrange + Lyric existingLyric = _fakers.Lyric.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Lyrics.Add(existingLyric); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Lyrics.Add(existingLyric); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + @ref = new { - op = "update", - @ref = new - { - type = "musicTracks", - id = "invalid-guid", - relationship = "lyric" - }, - data = new - { - type = "lyrics", - id = existingLyric.StringId - } + type = "musicTracks", + id = "invalid-guid", + relationship = "lyric" + }, + data = new + { + type = "lyrics", + id = existingLyric.StringId } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Incompatible 'id' value found."); - error.Detail.Should().Be("Failed to convert 'invalid-guid' of type 'String' to type 'Guid'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/id"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Incompatible 'id' value found."); + error.Detail.Should().Be("Failed to convert 'invalid-guid' of type 'String' to type 'Guid'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/id"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_create_for_ID_and_local_ID_in_ref() + [Fact] + public async Task Cannot_create_for_ID_and_local_ID_in_ref() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + @ref = new { - op = "update", - @ref = new - { - type = "musicTracks", - id = Unknown.StringId.For(), - lid = "local-1", - relationship = "ownedBy" - } + type = "musicTracks", + id = Unknown.StringId.For(), + lid = "local-1", + relationship = "ownedBy" } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_create_for_unknown_relationship_in_ref() + [Fact] + public async Task Cannot_create_for_unknown_relationship_in_ref() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + @ref = new { - op = "update", - @ref = new - { - type = "performers", - id = Unknown.StringId.For(), - relationship = Unknown.Relationship - } + type = "performers", + id = Unknown.StringId.For(), + relationship = Unknown.Relationship } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Unknown relationship found."); - error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' does not exist on resource type 'performers'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/relationship"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Unknown relationship found."); + error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' does not exist on resource type 'performers'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/relationship"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_create_for_missing_data() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + [Fact] + public async Task Cannot_create_for_missing_data() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + @ref = new { - op = "update", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "lyric" - } + type = "musicTracks", + id = existingTrack.StringId, + relationship = "lyric" } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'data' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_create_for_array_data() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + [Fact] + public async Task Cannot_create_for_array_data() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + @ref = new { - op = "update", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "lyric" - }, - data = new[] + type = "musicTracks", + id = existingTrack.StringId, + relationship = "lyric" + }, + data = new[] + { + new { - new - { - type = "lyrics", - id = Unknown.StringId.For() - } + type = "lyrics", + id = Unknown.StringId.For() } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected an object or 'null', instead of an array."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Expected an object or 'null', instead of an array."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_create_for_missing_type_in_data() + [Fact] + public async Task Cannot_create_for_missing_type_in_data() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + @ref = new { - op = "update", - @ref = new - { - type = "lyrics", - id = Unknown.StringId.For(), - relationship = "track" - }, - data = new - { - id = Unknown.StringId.For() - } + type = "lyrics", + id = Unknown.StringId.For(), + relationship = "track" + }, + data = new + { + id = Unknown.StringId.For() } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_create_for_unknown_type_in_data() + [Fact] + public async Task Cannot_create_for_unknown_type_in_data() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + @ref = new { - op = "update", - @ref = new - { - type = "musicTracks", - id = Unknown.StringId.For(), - relationship = "lyric" - }, - data = new - { - type = Unknown.ResourceType, - id = Unknown.StringId.For() - } + type = "musicTracks", + id = Unknown.StringId.For(), + relationship = "lyric" + }, + data = new + { + type = Unknown.ResourceType, + id = Unknown.StringId.For() } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); - error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); + error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_create_for_missing_ID_in_data() + [Fact] + public async Task Cannot_create_for_missing_ID_in_data() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + @ref = new { - op = "update", - @ref = new - { - type = "musicTracks", - id = Unknown.StringId.For(), - relationship = "lyric" - }, - data = new - { - type = "lyrics" - } + type = "musicTracks", + id = Unknown.StringId.For(), + relationship = "lyric" + }, + data = new + { + type = "lyrics" } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_create_for_ID_and_local_ID_in_data() + [Fact] + public async Task Cannot_create_for_ID_and_local_ID_in_data() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + @ref = new { - op = "update", - @ref = new - { - type = "musicTracks", - id = Unknown.StringId.For(), - relationship = "lyric" - }, - data = new - { - type = "lyrics", - id = Unknown.StringId.For(), - lid = "local-1" - } + type = "musicTracks", + id = Unknown.StringId.For(), + relationship = "lyric" + }, + data = new + { + type = "lyrics", + id = Unknown.StringId.For(), + lid = "local-1" } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_create_for_unknown_ID_in_data() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + [Fact] + public async Task Cannot_create_for_unknown_ID_in_data() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); - string lyricId = Unknown.StringId.For(); + string lyricId = Unknown.StringId.For(); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + @ref = new { - op = "update", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "lyric" - }, - data = new - { - type = "lyrics", - id = lyricId - } + type = "musicTracks", + id = existingTrack.StringId, + relationship = "lyric" + }, + data = new + { + type = "lyrics", + id = lyricId } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.NotFound); - error.Title.Should().Be("A related resource does not exist."); - error.Detail.Should().Be($"Related resource of type 'lyrics' with ID '{lyricId}' in relationship 'lyric' does not exist."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - error.Meta.Should().NotContainKey("requestBody"); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("A related resource does not exist."); + error.Detail.Should().Be($"Related resource of type 'lyrics' with ID '{lyricId}' in relationship 'lyric' does not exist."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Meta.Should().NotContainKey("requestBody"); + } - [Fact] - public async Task Cannot_create_for_incompatible_ID_in_data() - { - // Arrange - Lyric existingLyric = _fakers.Lyric.Generate(); + [Fact] + public async Task Cannot_create_for_incompatible_ID_in_data() + { + // Arrange + Lyric existingLyric = _fakers.Lyric.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Lyrics.Add(existingLyric); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Lyrics.Add(existingLyric); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + @ref = new { - op = "update", - @ref = new - { - type = "lyrics", - id = existingLyric.StringId, - relationship = "track" - }, - data = new - { - type = "musicTracks", - id = "invalid-guid" - } + type = "lyrics", + id = existingLyric.StringId, + relationship = "track" + }, + data = new + { + type = "musicTracks", + id = "invalid-guid" } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Incompatible 'id' value found."); - error.Detail.Should().Be("Failed to convert 'invalid-guid' of type 'String' to type 'Guid'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/id"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Incompatible 'id' value found."); + error.Detail.Should().Be("Failed to convert 'invalid-guid' of type 'String' to type 'Guid'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/id"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_create_for_relationship_mismatch_between_ref_and_data() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + [Fact] + public async Task Cannot_create_for_relationship_mismatch_between_ref_and_data() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + @ref = new { - op = "update", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "lyric" - }, - data = new - { - type = "playlists", - id = Unknown.StringId.For() - } + type = "musicTracks", + id = existingTrack.StringId, + relationship = "lyric" + }, + data = new + { + type = "playlists", + id = Unknown.StringId.For() } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.Conflict); - error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found."); - error.Detail.Should().Be("Type 'playlists' is incompatible with type 'lyrics' of relationship 'lyric'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Conflict); + error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found."); + error.Detail.Should().Be("Type 'playlists' is incompatible with type 'lyrics' of relationship 'lyric'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs index e942553a6d..98b8892800 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs @@ -1,807 +1,802 @@ -using System; -using System.Collections.Generic; using System.Net; -using System.Net.Http; -using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.EntityFrameworkCore; using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Updating.Resources +namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Updating.Resources; + +public sealed class AtomicReplaceToManyRelationshipTests : IClassFixture, OperationsDbContext>> { - public sealed class AtomicReplaceToManyRelationshipTests : IClassFixture, OperationsDbContext>> + private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new(); + + public AtomicReplaceToManyRelationshipTests(IntegrationTestContext, OperationsDbContext> testContext) { - private readonly IntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new(); + _testContext = testContext; - public AtomicReplaceToManyRelationshipTests(IntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; + testContext.UseController(); + } - testContext.UseController(); - } + [Fact] + public async Task Can_clear_OneToMany_relationship() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + existingTrack.Performers = _fakers.Performer.Generate(2); - [Fact] - public async Task Can_clear_OneToMany_relationship() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.Performers = _fakers.Performer.Generate(2); + await dbContext.ClearTableAsync(); + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = "musicTracks", + id = existingTrack.StringId, + relationships = new { - type = "musicTracks", - id = existingTrack.StringId, - relationships = new + performers = new { - performers = new - { - data = Array.Empty() - } + data = Array.Empty() } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(existingTrack.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(existingTrack.Id); - trackInDatabase.Performers.Should().BeEmpty(); + trackInDatabase.Performers.Should().BeEmpty(); - List performersInDatabase = await dbContext.Performers.ToListAsync(); - performersInDatabase.ShouldHaveCount(2); - }); - } + List performersInDatabase = await dbContext.Performers.ToListAsync(); + performersInDatabase.ShouldHaveCount(2); + }); + } - [Fact] - public async Task Can_clear_ManyToMany_relationship() - { - // Arrange - Playlist existingPlaylist = _fakers.Playlist.Generate(); - existingPlaylist.Tracks = _fakers.MusicTrack.Generate(2); + [Fact] + public async Task Can_clear_ManyToMany_relationship() + { + // Arrange + Playlist existingPlaylist = _fakers.Playlist.Generate(); + existingPlaylist.Tracks = _fakers.MusicTrack.Generate(2); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Playlists.Add(existingPlaylist); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Playlists.Add(existingPlaylist); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = "playlists", + id = existingPlaylist.StringId, + relationships = new { - type = "playlists", - id = existingPlaylist.StringId, - relationships = new + tracks = new { - tracks = new - { - data = Array.Empty() - } + data = Array.Empty() } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(existingPlaylist.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(existingPlaylist.Id); - playlistInDatabase.Tracks.Should().BeEmpty(); + playlistInDatabase.Tracks.Should().BeEmpty(); - List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); + List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.ShouldHaveCount(2); - }); - } + tracksInDatabase.ShouldHaveCount(2); + }); + } - [Fact] - public async Task Can_replace_OneToMany_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.Performers = _fakers.Performer.Generate(1); + [Fact] + public async Task Can_replace_OneToMany_relationship() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + existingTrack.Performers = _fakers.Performer.Generate(1); - List existingPerformers = _fakers.Performer.Generate(2); + List existingPerformers = _fakers.Performer.Generate(2); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.MusicTracks.Add(existingTrack); - dbContext.Performers.AddRange(existingPerformers); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.MusicTracks.Add(existingTrack); + dbContext.Performers.AddRange(existingPerformers); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = "musicTracks", + id = existingTrack.StringId, + relationships = new { - type = "musicTracks", - id = existingTrack.StringId, - relationships = new + performers = new { - performers = new + data = new[] { - data = new[] + new { - new - { - type = "performers", - id = existingPerformers[0].StringId - }, - new - { - type = "performers", - id = existingPerformers[1].StringId - } + type = "performers", + id = existingPerformers[0].StringId + }, + new + { + type = "performers", + id = existingPerformers[1].StringId } } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(existingTrack.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(existingTrack.Id); - trackInDatabase.Performers.ShouldHaveCount(2); - trackInDatabase.Performers.Should().ContainSingle(performer => performer.Id == existingPerformers[0].Id); - trackInDatabase.Performers.Should().ContainSingle(performer => performer.Id == existingPerformers[1].Id); + trackInDatabase.Performers.ShouldHaveCount(2); + trackInDatabase.Performers.Should().ContainSingle(performer => performer.Id == existingPerformers[0].Id); + trackInDatabase.Performers.Should().ContainSingle(performer => performer.Id == existingPerformers[1].Id); - List performersInDatabase = await dbContext.Performers.ToListAsync(); - performersInDatabase.ShouldHaveCount(3); - }); - } + List performersInDatabase = await dbContext.Performers.ToListAsync(); + performersInDatabase.ShouldHaveCount(3); + }); + } - [Fact] - public async Task Can_replace_ManyToMany_relationship() - { - // Arrange - Playlist existingPlaylist = _fakers.Playlist.Generate(); - existingPlaylist.Tracks = _fakers.MusicTrack.Generate(1); + [Fact] + public async Task Can_replace_ManyToMany_relationship() + { + // Arrange + Playlist existingPlaylist = _fakers.Playlist.Generate(); + existingPlaylist.Tracks = _fakers.MusicTrack.Generate(1); - List existingTracks = _fakers.MusicTrack.Generate(2); + List existingTracks = _fakers.MusicTrack.Generate(2); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Playlists.Add(existingPlaylist); - dbContext.MusicTracks.AddRange(existingTracks); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Playlists.Add(existingPlaylist); + dbContext.MusicTracks.AddRange(existingTracks); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = "playlists", + id = existingPlaylist.StringId, + relationships = new { - type = "playlists", - id = existingPlaylist.StringId, - relationships = new + tracks = new { - tracks = new + data = new[] { - data = new[] + new + { + type = "musicTracks", + id = existingTracks[0].StringId + }, + new { - new - { - type = "musicTracks", - id = existingTracks[0].StringId - }, - new - { - type = "musicTracks", - id = existingTracks[1].StringId - } + type = "musicTracks", + id = existingTracks[1].StringId } } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(existingPlaylist.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(existingPlaylist.Id); - playlistInDatabase.Tracks.ShouldHaveCount(2); - playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[0].Id); - playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[1].Id); + playlistInDatabase.Tracks.ShouldHaveCount(2); + playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[0].Id); + playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[1].Id); - List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); + List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.ShouldHaveCount(3); - }); - } + tracksInDatabase.ShouldHaveCount(3); + }); + } - [Fact] - public async Task Cannot_replace_for_missing_data_in_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + [Fact] + public async Task Cannot_replace_for_missing_data_in_relationship() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = "musicTracks", + id = existingTrack.StringId, + relationships = new { - type = "musicTracks", - id = existingTrack.StringId, - relationships = new + performers = new { - performers = new - { - } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'data' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_replace_for_null_data_in_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + [Fact] + public async Task Cannot_replace_for_null_data_in_relationship() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = "musicTracks", + id = existingTrack.StringId, + relationships = new { - type = "musicTracks", - id = existingTrack.StringId, - relationships = new + performers = new { - performers = new - { - data = (object?)null - } + data = (object?)null } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected an array, instead of 'null'."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Expected an array, instead of 'null'."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_replace_for_object_data_in_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + [Fact] + public async Task Cannot_replace_for_object_data_in_relationship() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = "musicTracks", + id = existingTrack.StringId, + relationships = new { - type = "musicTracks", - id = existingTrack.StringId, - relationships = new + performers = new { - performers = new + data = new { - data = new - { - } } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected an array, instead of an object."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Expected an array, instead of an object."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_replace_for_missing_type_in_relationship_data() + [Fact] + public async Task Cannot_replace_for_missing_type_in_relationship_data() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = "playlists", + id = Unknown.StringId.For(), + relationships = new { - type = "playlists", - id = Unknown.StringId.For(), - relationships = new + tracks = new { - tracks = new + data = new[] { - data = new[] + new { - new - { - id = Unknown.StringId.For() - } + id = Unknown.StringId.For() } } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/tracks/data[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/tracks/data[0]"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_replace_for_unknown_type_in_relationship_data() + [Fact] + public async Task Cannot_replace_for_unknown_type_in_relationship_data() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = "musicTracks", + id = Unknown.StringId.For(), + relationships = new { - type = "musicTracks", - id = Unknown.StringId.For(), - relationships = new + performers = new { - performers = new + data = new[] { - data = new[] + new { - new - { - type = Unknown.ResourceType, - id = Unknown.StringId.For() - } + type = Unknown.ResourceType, + id = Unknown.StringId.For() } } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); - error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data[0]/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); + error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data[0]/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_replace_for_missing_ID_in_relationship_data() + [Fact] + public async Task Cannot_replace_for_missing_ID_in_relationship_data() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = "musicTracks", + id = Unknown.StringId.For(), + relationships = new { - type = "musicTracks", - id = Unknown.StringId.For(), - relationships = new + performers = new { - performers = new + data = new[] { - data = new[] + new { - new - { - type = "performers" - } + type = "performers" } } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data[0]"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_replace_for_ID_and_local_ID_relationship_in_data() + [Fact] + public async Task Cannot_replace_for_ID_and_local_ID_relationship_in_data() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = "musicTracks", + id = Unknown.StringId.For(), + relationships = new { - type = "musicTracks", - id = Unknown.StringId.For(), - relationships = new + performers = new { - performers = new + data = new[] { - data = new[] + new { - new - { - type = "performers", - id = Unknown.StringId.For(), - lid = "local-1" - } + type = "performers", + id = Unknown.StringId.For(), + lid = "local-1" } } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data[0]"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_replace_for_unknown_IDs_in_relationship_data() - { - // Arrange - RecordCompany existingCompany = _fakers.RecordCompany.Generate(); + [Fact] + public async Task Cannot_replace_for_unknown_IDs_in_relationship_data() + { + // Arrange + RecordCompany existingCompany = _fakers.RecordCompany.Generate(); - string[] trackIds = - { - Unknown.StringId.For(), - Unknown.StringId.AltFor() - }; + string[] trackIds = + { + Unknown.StringId.For(), + Unknown.StringId.AltFor() + }; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.RecordCompanies.Add(existingCompany); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.RecordCompanies.Add(existingCompany); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = "recordCompanies", + id = existingCompany.StringId, + relationships = new { - type = "recordCompanies", - id = existingCompany.StringId, - relationships = new + tracks = new { - tracks = new + data = new[] { - data = new[] + new { - new - { - type = "musicTracks", - id = trackIds[0] - }, - new - { - type = "musicTracks", - id = trackIds[1] - } + type = "musicTracks", + id = trackIds[0] + }, + new + { + type = "musicTracks", + id = trackIds[1] } } } } } } - }; + } + }; + + const string route = "/operations"; - const string route = "/operations"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + responseDocument.Errors.ShouldHaveCount(2); - responseDocument.Errors.ShouldHaveCount(2); + ErrorObject error1 = responseDocument.Errors[0]; + error1.StatusCode.Should().Be(HttpStatusCode.NotFound); + error1.Title.Should().Be("A related resource does not exist."); + error1.Detail.Should().Be($"Related resource of type 'musicTracks' with ID '{trackIds[0]}' in relationship 'tracks' does not exist."); + error1.Source.ShouldNotBeNull(); + error1.Source.Pointer.Should().Be("/atomic:operations[0]"); - ErrorObject error1 = responseDocument.Errors[0]; - error1.StatusCode.Should().Be(HttpStatusCode.NotFound); - error1.Title.Should().Be("A related resource does not exist."); - error1.Detail.Should().Be($"Related resource of type 'musicTracks' with ID '{trackIds[0]}' in relationship 'tracks' does not exist."); - error1.Source.ShouldNotBeNull(); - error1.Source.Pointer.Should().Be("/atomic:operations[0]"); + ErrorObject error2 = responseDocument.Errors[1]; + error2.StatusCode.Should().Be(HttpStatusCode.NotFound); + error2.Title.Should().Be("A related resource does not exist."); + error2.Detail.Should().Be($"Related resource of type 'musicTracks' with ID '{trackIds[1]}' in relationship 'tracks' does not exist."); + error2.Source.ShouldNotBeNull(); + error2.Source.Pointer.Should().Be("/atomic:operations[0]"); + } - ErrorObject error2 = responseDocument.Errors[1]; - error2.StatusCode.Should().Be(HttpStatusCode.NotFound); - error2.Title.Should().Be("A related resource does not exist."); - error2.Detail.Should().Be($"Related resource of type 'musicTracks' with ID '{trackIds[1]}' in relationship 'tracks' does not exist."); - error2.Source.ShouldNotBeNull(); - error2.Source.Pointer.Should().Be("/atomic:operations[0]"); - } + [Fact] + public async Task Cannot_create_for_relationship_mismatch() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - [Fact] - public async Task Cannot_create_for_relationship_mismatch() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = "musicTracks", + id = existingTrack.StringId, + relationships = new { - type = "musicTracks", - id = existingTrack.StringId, - relationships = new + performers = new { - performers = new + data = new[] { - data = new[] + new { - new - { - type = "playlists", - id = Unknown.StringId.For() - } + type = "playlists", + id = Unknown.StringId.For() } } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.Conflict); - error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found."); - error.Detail.Should().Be("Type 'playlists' is incompatible with type 'performers' of relationship 'performers'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data[0]/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Conflict); + error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found."); + error.Detail.Should().Be("Type 'playlists' is incompatible with type 'performers' of relationship 'performers'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data[0]/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs index 783bacf575..f8051e369c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs @@ -1,9 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; using System.Net; -using System.Net.Http; -using System.Threading.Tasks; using FluentAssertions; using FluentAssertions.Extensions; using JsonApiDotNetCore.Configuration; @@ -13,1812 +8,1811 @@ using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Updating.Resources +namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Updating.Resources; + +public sealed class AtomicUpdateResourceTests : IClassFixture, OperationsDbContext>> { - public sealed class AtomicUpdateResourceTests : IClassFixture, OperationsDbContext>> - { - private readonly IntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new(); + private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new(); - public AtomicUpdateResourceTests(IntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; + public AtomicUpdateResourceTests(IntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; - testContext.UseController(); + testContext.UseController(); - // These routes need to be registered in ASP.NET for rendering links to resource/relationship endpoints. - testContext.UseController(); + // These routes need to be registered in ASP.NET for rendering links to resource/relationship endpoints. + testContext.UseController(); - testContext.ConfigureServicesAfterStartup(services => - { - services.AddResourceDefinition(); + testContext.ConfigureServicesAfterStartup(services => + { + services.AddResourceDefinition(); - services.AddSingleton(); - }); + services.AddSingleton(); + }); - var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); - options.AllowUnknownFieldsInRequestBody = false; - } + var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); + options.AllowUnknownFieldsInRequestBody = false; + } - [Fact] - public async Task Can_update_resources() - { - // Arrange - const int elementCount = 5; + [Fact] + public async Task Can_update_resources() + { + // Arrange + const int elementCount = 5; - List existingTracks = _fakers.MusicTrack.Generate(elementCount); - string[] newTrackTitles = _fakers.MusicTrack.Generate(elementCount).Select(musicTrack => musicTrack.Title).ToArray(); + List existingTracks = _fakers.MusicTrack.Generate(elementCount); + string[] newTrackTitles = _fakers.MusicTrack.Generate(elementCount).Select(musicTrack => musicTrack.Title).ToArray(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.MusicTracks.AddRange(existingTracks); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.MusicTracks.AddRange(existingTracks); + await dbContext.SaveChangesAsync(); + }); - var operationElements = new List(elementCount); + var operationElements = new List(elementCount); - for (int index = 0; index < elementCount; index++) + for (int index = 0; index < elementCount; index++) + { + operationElements.Add(new { - operationElements.Add(new + op = "update", + data = new { - op = "update", - data = new + type = "musicTracks", + id = existingTracks[index].StringId, + attributes = new { - type = "musicTracks", - id = existingTracks[index].StringId, - attributes = new - { - title = newTrackTitles[index] - } + title = newTrackTitles[index] } - }); - } + } + }); + } - var requestBody = new - { - atomic__operations = operationElements - }; + var requestBody = new + { + atomic__operations = operationElements + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.ShouldHaveCount(elementCount); + tracksInDatabase.ShouldHaveCount(elementCount); - for (int index = 0; index < elementCount; index++) - { - MusicTrack trackInDatabase = tracksInDatabase.Single(musicTrack => musicTrack.Id == existingTracks[index].Id); + for (int index = 0; index < elementCount; index++) + { + MusicTrack trackInDatabase = tracksInDatabase.Single(musicTrack => musicTrack.Id == existingTracks[index].Id); - trackInDatabase.Title.Should().Be(newTrackTitles[index]); - trackInDatabase.Genre.Should().Be(existingTracks[index].Genre); - } - }); - } + trackInDatabase.Title.Should().Be(newTrackTitles[index]); + trackInDatabase.Genre.Should().Be(existingTracks[index].Genre); + } + }); + } - [Fact] - public async Task Can_update_resource_without_attributes_or_relationships() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.OwnedBy = _fakers.RecordCompany.Generate(); + [Fact] + public async Task Can_update_resource_without_attributes_or_relationships() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + existingTrack.OwnedBy = _fakers.RecordCompany.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = "musicTracks", + id = existingTrack.StringId, + attributes = new + { + }, + relationships = new { - type = "musicTracks", - id = existingTrack.StringId, - attributes = new - { - }, - relationships = new - { - } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(existingTrack.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(existingTrack.Id); - trackInDatabase.Title.Should().Be(existingTrack.Title); - trackInDatabase.Genre.Should().Be(existingTrack.Genre); + trackInDatabase.Title.Should().Be(existingTrack.Title); + trackInDatabase.Genre.Should().Be(existingTrack.Genre); - trackInDatabase.OwnedBy.ShouldNotBeNull(); - trackInDatabase.OwnedBy.Id.Should().Be(existingTrack.OwnedBy.Id); - }); - } + trackInDatabase.OwnedBy.ShouldNotBeNull(); + trackInDatabase.OwnedBy.Id.Should().Be(existingTrack.OwnedBy.Id); + }); + } - [Fact] - public async Task Cannot_update_resource_with_unknown_attribute() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - string newTitle = _fakers.MusicTrack.Generate().Title; + [Fact] + public async Task Cannot_update_resource_with_unknown_attribute() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + string newTitle = _fakers.MusicTrack.Generate().Title; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = "musicTracks", + id = existingTrack.StringId, + attributes = new { - type = "musicTracks", - id = existingTrack.StringId, - attributes = new - { - title = newTitle, - doesNotExist = "Ignored" - } + title = newTitle, + doesNotExist = "Ignored" } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Unknown attribute found."); - error.Detail.Should().Be("Attribute 'doesNotExist' does not exist on resource type 'musicTracks'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/doesNotExist"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Unknown attribute found."); + error.Detail.Should().Be("Attribute 'doesNotExist' does not exist on resource type 'musicTracks'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/doesNotExist"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Can_update_resource_with_unknown_attribute() - { - // Arrange - var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); - options.AllowUnknownFieldsInRequestBody = true; + [Fact] + public async Task Can_update_resource_with_unknown_attribute() + { + // Arrange + var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); + options.AllowUnknownFieldsInRequestBody = true; - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - string newTitle = _fakers.MusicTrack.Generate().Title; + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + string newTitle = _fakers.MusicTrack.Generate().Title; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = "musicTracks", + id = existingTrack.StringId, + attributes = new { - type = "musicTracks", - id = existingTrack.StringId, - attributes = new - { - title = newTitle, - doesNotExist = "Ignored" - } + title = newTitle, + doesNotExist = "Ignored" } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.FirstWithIdAsync(existingTrack.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + MusicTrack trackInDatabase = await dbContext.MusicTracks.FirstWithIdAsync(existingTrack.Id); - trackInDatabase.Title.Should().Be(newTitle); - }); - } + trackInDatabase.Title.Should().Be(newTitle); + }); + } - [Fact] - public async Task Cannot_update_resource_with_unknown_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + [Fact] + public async Task Cannot_update_resource_with_unknown_relationship() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = "musicTracks", + id = existingTrack.StringId, + relationships = new { - type = "musicTracks", - id = existingTrack.StringId, - relationships = new + doesNotExist = new { - doesNotExist = new + data = new { - data = new - { - type = Unknown.ResourceType, - id = Unknown.StringId.Int32 - } + type = Unknown.ResourceType, + id = Unknown.StringId.Int32 } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Unknown relationship found."); - error.Detail.Should().Be("Relationship 'doesNotExist' does not exist on resource type 'musicTracks'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/doesNotExist"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Unknown relationship found."); + error.Detail.Should().Be("Relationship 'doesNotExist' does not exist on resource type 'musicTracks'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/doesNotExist"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Can_update_resource_with_unknown_relationship() - { - // Arrange - var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); - options.AllowUnknownFieldsInRequestBody = true; + [Fact] + public async Task Can_update_resource_with_unknown_relationship() + { + // Arrange + var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); + options.AllowUnknownFieldsInRequestBody = true; - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = "musicTracks", + id = existingTrack.StringId, + relationships = new { - type = "musicTracks", - id = existingTrack.StringId, - relationships = new + doesNotExist = new { - doesNotExist = new + data = new { - data = new - { - type = Unknown.ResourceType, - id = Unknown.StringId.Int32 - } + type = Unknown.ResourceType, + id = Unknown.StringId.Int32 } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); - } + responseDocument.Should().BeEmpty(); + } - [Fact] - public async Task Can_partially_update_resource_without_side_effects() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.OwnedBy = _fakers.RecordCompany.Generate(); + [Fact] + public async Task Can_partially_update_resource_without_side_effects() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + existingTrack.OwnedBy = _fakers.RecordCompany.Generate(); - string newGenre = _fakers.MusicTrack.Generate().Genre!; + string newGenre = _fakers.MusicTrack.Generate().Genre!; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = "musicTracks", + id = existingTrack.StringId, + attributes = new { - type = "musicTracks", - id = existingTrack.StringId, - attributes = new - { - genre = newGenre - } + genre = newGenre } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(existingTrack.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(existingTrack.Id); - trackInDatabase.Title.Should().Be(existingTrack.Title); - trackInDatabase.LengthInSeconds.Should().BeApproximately(existingTrack.LengthInSeconds); - trackInDatabase.Genre.Should().Be(newGenre); - trackInDatabase.ReleasedAt.Should().BeCloseTo(existingTrack.ReleasedAt); + trackInDatabase.Title.Should().Be(existingTrack.Title); + trackInDatabase.LengthInSeconds.Should().BeApproximately(existingTrack.LengthInSeconds); + trackInDatabase.Genre.Should().Be(newGenre); + trackInDatabase.ReleasedAt.Should().Be(existingTrack.ReleasedAt); - trackInDatabase.OwnedBy.ShouldNotBeNull(); - trackInDatabase.OwnedBy.Id.Should().Be(existingTrack.OwnedBy.Id); - }); - } + trackInDatabase.OwnedBy.ShouldNotBeNull(); + trackInDatabase.OwnedBy.Id.Should().Be(existingTrack.OwnedBy.Id); + }); + } - [Fact] - public async Task Can_completely_update_resource_without_side_effects() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.OwnedBy = _fakers.RecordCompany.Generate(); + [Fact] + public async Task Can_completely_update_resource_without_side_effects() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + existingTrack.OwnedBy = _fakers.RecordCompany.Generate(); - string newTitle = _fakers.MusicTrack.Generate().Title; - decimal? newLengthInSeconds = _fakers.MusicTrack.Generate().LengthInSeconds; - string newGenre = _fakers.MusicTrack.Generate().Genre!; - DateTimeOffset newReleasedAt = _fakers.MusicTrack.Generate().ReleasedAt; + string newTitle = _fakers.MusicTrack.Generate().Title; + decimal? newLengthInSeconds = _fakers.MusicTrack.Generate().LengthInSeconds; + string newGenre = _fakers.MusicTrack.Generate().Genre!; + DateTimeOffset newReleasedAt = _fakers.MusicTrack.Generate().ReleasedAt; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = "musicTracks", + id = existingTrack.StringId, + attributes = new { - type = "musicTracks", - id = existingTrack.StringId, - attributes = new - { - title = newTitle, - lengthInSeconds = newLengthInSeconds, - genre = newGenre, - releasedAt = newReleasedAt - } + title = newTitle, + lengthInSeconds = newLengthInSeconds, + genre = newGenre, + releasedAt = newReleasedAt } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(existingTrack.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(existingTrack.Id); - trackInDatabase.Title.Should().Be(newTitle); - trackInDatabase.LengthInSeconds.Should().BeApproximately(newLengthInSeconds); - trackInDatabase.Genre.Should().Be(newGenre); - trackInDatabase.ReleasedAt.Should().BeCloseTo(newReleasedAt); + trackInDatabase.Title.Should().Be(newTitle); + trackInDatabase.LengthInSeconds.Should().BeApproximately(newLengthInSeconds); + trackInDatabase.Genre.Should().Be(newGenre); + trackInDatabase.ReleasedAt.Should().Be(newReleasedAt); - trackInDatabase.OwnedBy.ShouldNotBeNull(); - trackInDatabase.OwnedBy.Id.Should().Be(existingTrack.OwnedBy.Id); - }); - } + trackInDatabase.OwnedBy.ShouldNotBeNull(); + trackInDatabase.OwnedBy.Id.Should().Be(existingTrack.OwnedBy.Id); + }); + } - [Fact] - public async Task Can_update_resource_with_side_effects() - { - // Arrange - TextLanguage existingLanguage = _fakers.TextLanguage.Generate(); - string newIsoCode = _fakers.TextLanguage.Generate().IsoCode!; + [Fact] + public async Task Can_update_resource_with_side_effects() + { + // Arrange + TextLanguage existingLanguage = _fakers.TextLanguage.Generate(); + string newIsoCode = _fakers.TextLanguage.Generate().IsoCode!; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.TextLanguages.Add(existingLanguage); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TextLanguages.Add(existingLanguage); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = "textLanguages", + id = existingLanguage.StringId, + attributes = new { - type = "textLanguages", - id = existingLanguage.StringId, - attributes = new - { - isoCode = newIsoCode - } + isoCode = newIsoCode } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(1); + responseDocument.Results.ShouldHaveCount(1); - string isoCode = $"{newIsoCode}{ImplicitlyChangingTextLanguageDefinition.Suffix}"; + string isoCode = $"{newIsoCode}{ImplicitlyChangingTextLanguageDefinition.Suffix}"; - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("textLanguages"); - resource.Attributes.ShouldContainKey("isoCode").With(value => value.Should().Be(isoCode)); - resource.Attributes.Should().NotContainKey("isRightToLeft"); - resource.Relationships.ShouldNotBeEmpty(); - }); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - TextLanguage languageInDatabase = await dbContext.TextLanguages.FirstWithIdAsync(existingLanguage.Id); - languageInDatabase.IsoCode.Should().Be(isoCode); - }); - } + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("textLanguages"); + resource.Attributes.ShouldContainKey("isoCode").With(value => value.Should().Be(isoCode)); + resource.Attributes.Should().NotContainKey("isRightToLeft"); + resource.Relationships.ShouldNotBeEmpty(); + }); - [Fact] - public async Task Update_resource_with_side_effects_hides_relationship_data_in_response() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - TextLanguage existingLanguage = _fakers.TextLanguage.Generate(); - existingLanguage.Lyrics = _fakers.Lyric.Generate(1); + TextLanguage languageInDatabase = await dbContext.TextLanguages.FirstWithIdAsync(existingLanguage.Id); + languageInDatabase.IsoCode.Should().Be(isoCode); + }); + } - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.TextLanguages.Add(existingLanguage); - await dbContext.SaveChangesAsync(); - }); + [Fact] + public async Task Update_resource_with_side_effects_hides_relationship_data_in_response() + { + // Arrange + TextLanguage existingLanguage = _fakers.TextLanguage.Generate(); + existingLanguage.Lyrics = _fakers.Lyric.Generate(1); - var requestBody = new + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TextLanguages.Add(existingLanguage); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + data = new { - op = "update", - data = new - { - type = "textLanguages", - id = existingLanguage.StringId - } + type = "textLanguages", + id = existingLanguage.StringId } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(1); + responseDocument.Results.ShouldHaveCount(1); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Relationships.ShouldNotBeEmpty(); - resource.Relationships.Values.Should().OnlyContain(value => value != null && value.Data.Value == null); - }); - } + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Relationships.ShouldNotBeEmpty(); + resource.Relationships.Values.Should().OnlyContain(value => value != null && value.Data.Value == null); + }); + } - [Fact] - public async Task Cannot_update_resource_for_href_element() + [Fact] + public async Task Cannot_update_resource_for_href_element() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new - { - op = "update", - href = "/api/v1/musicTracks/1" - } + op = "update", + href = "/api/v1/musicTracks/1" } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'href' element is not supported."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/href"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'href' element is not supported."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/href"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Can_update_resource_for_ref_element() - { - // Arrange - Performer existingPerformer = _fakers.Performer.Generate(); - string newArtistName = _fakers.Performer.Generate().ArtistName!; + [Fact] + public async Task Can_update_resource_for_ref_element() + { + // Arrange + Performer existingPerformer = _fakers.Performer.Generate(); + string newArtistName = _fakers.Performer.Generate().ArtistName!; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Performers.Add(existingPerformer); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Performers.Add(existingPerformer); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + @ref = new { - op = "update", - @ref = new - { - type = "performers", - id = existingPerformer.StringId - }, - data = new + type = "performers", + id = existingPerformer.StringId + }, + data = new + { + type = "performers", + id = existingPerformer.StringId, + attributes = new { - type = "performers", - id = existingPerformer.StringId, - attributes = new - { - artistName = newArtistName - } + artistName = newArtistName } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Performer performerInDatabase = await dbContext.Performers.FirstWithIdAsync(existingPerformer.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Performer performerInDatabase = await dbContext.Performers.FirstWithIdAsync(existingPerformer.Id); - performerInDatabase.ArtistName.Should().Be(newArtistName); - performerInDatabase.BornAt.Should().BeCloseTo(existingPerformer.BornAt); - }); - } + performerInDatabase.ArtistName.Should().Be(newArtistName); + performerInDatabase.BornAt.Should().Be(existingPerformer.BornAt); + }); + } - [Fact] - public async Task Cannot_update_resource_for_missing_type_in_ref() + [Fact] + public async Task Cannot_update_resource_for_missing_type_in_ref() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + @ref = new + { + id = Unknown.StringId.For() + }, + data = new { - op = "update", - @ref = new + type = "performers", + id = Unknown.StringId.For(), + attributes = new { - id = Unknown.StringId.For() }, - data = new + relationships = new { - type = "performers", - id = Unknown.StringId.For(), - attributes = new - { - }, - relationships = new - { - } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_update_resource_for_missing_ID_in_ref() + [Fact] + public async Task Cannot_update_resource_for_missing_ID_in_ref() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + @ref = new { - op = "update", - @ref = new + type = "performers" + }, + data = new + { + type = "performers", + id = Unknown.StringId.For(), + attributes = new { - type = "performers" }, - data = new + relationships = new { - type = "performers", - id = Unknown.StringId.For(), - attributes = new - { - }, - relationships = new - { - } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_update_resource_for_ID_and_local_ID_in_ref() + [Fact] + public async Task Cannot_update_resource_for_ID_and_local_ID_in_ref() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + @ref = new { - op = "update", - @ref = new + type = "performers", + id = Unknown.StringId.For(), + lid = "local-1" + }, + data = new + { + type = "performers", + id = Unknown.StringId.AltFor(), + attributes = new { - type = "performers", - id = Unknown.StringId.For(), - lid = "local-1" }, - data = new + relationships = new { - type = "performers", - id = Unknown.StringId.AltFor(), - attributes = new - { - }, - relationships = new - { - } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_update_resource_for_missing_data() + [Fact] + public async Task Cannot_update_resource_for_missing_data() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new - { - op = "update" - } + op = "update" } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'data' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_update_resource_for_null_data() + [Fact] + public async Task Cannot_update_resource_for_null_data() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new - { - op = "update", - data = (object?)null - } + op = "update", + data = (object?)null } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected an object, instead of 'null'."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Expected an object, instead of 'null'."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_update_resource_for_array_data() - { - // Arrange - Performer existingPerformer = _fakers.Performer.Generate(); + [Fact] + public async Task Cannot_update_resource_for_array_data() + { + // Arrange + Performer existingPerformer = _fakers.Performer.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Performers.Add(existingPerformer); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Performers.Add(existingPerformer); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + data = new[] { - op = "update", - data = new[] + new { - new + type = "performers", + id = existingPerformer.StringId, + attributes = new { - type = "performers", - id = existingPerformer.StringId, - attributes = new - { - artistName = existingPerformer.ArtistName - } + artistName = existingPerformer.ArtistName } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected an object, instead of an array."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Expected an object, instead of an array."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_update_resource_for_missing_type_in_data() + [Fact] + public async Task Cannot_update_resource_for_missing_type_in_data() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + data = new { - op = "update", - data = new + id = Unknown.StringId.Int32, + attributes = new + { + }, + relationships = new { - id = Unknown.StringId.Int32, - attributes = new - { - }, - relationships = new - { - } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_update_resource_for_missing_ID_in_data() + [Fact] + public async Task Cannot_update_resource_for_missing_ID_in_data() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = "performers", + attributes = new + { + }, + relationships = new { - type = "performers", - attributes = new - { - }, - relationships = new - { - } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_update_resource_for_ID_and_local_ID_in_data() + [Fact] + public async Task Cannot_update_resource_for_ID_and_local_ID_in_data() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = "performers", + id = Unknown.StringId.For(), + lid = "local-1", + attributes = new + { + }, + relationships = new { - type = "performers", - id = Unknown.StringId.For(), - lid = "local-1", - attributes = new - { - }, - relationships = new - { - } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_update_on_resource_type_mismatch_between_ref_and_data() + [Fact] + public async Task Cannot_update_on_resource_type_mismatch_between_ref_and_data() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + @ref = new { - op = "update", - @ref = new + type = "performers", + id = Unknown.StringId.For() + }, + data = new + { + type = "playlists", + id = Unknown.StringId.For(), + attributes = new { - type = "performers", - id = Unknown.StringId.For() }, - data = new + relationships = new { - type = "playlists", - id = Unknown.StringId.For(), - attributes = new - { - }, - relationships = new - { - } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.Conflict); - error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found."); - error.Detail.Should().Be("Type 'playlists' is incompatible with type 'performers'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Conflict); + error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found."); + error.Detail.Should().Be("Type 'playlists' is incompatible with type 'performers'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_update_on_resource_ID_mismatch_between_ref_and_data() - { - // Arrange - string performerId1 = Unknown.StringId.For(); - string performerId2 = Unknown.StringId.AltFor(); + [Fact] + public async Task Cannot_update_on_resource_ID_mismatch_between_ref_and_data() + { + // Arrange + string performerId1 = Unknown.StringId.For(); + string performerId2 = Unknown.StringId.AltFor(); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + @ref = new { - op = "update", - @ref = new + type = "performers", + id = performerId1 + }, + data = new + { + type = "performers", + id = performerId2, + attributes = new { - type = "performers", - id = performerId1 }, - data = new + relationships = new { - type = "performers", - id = performerId2, - attributes = new - { - }, - relationships = new - { - } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.Conflict); - error.Title.Should().Be("Failed to deserialize request body: Conflicting 'id' values found."); - error.Detail.Should().Be($"Expected '{performerId1}' instead of '{performerId2}'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/id"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Conflict); + error.Title.Should().Be("Failed to deserialize request body: Conflicting 'id' values found."); + error.Detail.Should().Be($"Expected '{performerId1}' instead of '{performerId2}'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/id"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_update_on_resource_local_ID_mismatch_between_ref_and_data() + [Fact] + public async Task Cannot_update_on_resource_local_ID_mismatch_between_ref_and_data() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + @ref = new + { + type = "performers", + lid = "local-1" + }, + data = new { - op = "update", - @ref = new + type = "performers", + lid = "local-2", + attributes = new { - type = "performers", - lid = "local-1" }, - data = new + relationships = new { - type = "performers", - lid = "local-2", - attributes = new - { - }, - relationships = new - { - } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.Conflict); - error.Title.Should().Be("Failed to deserialize request body: Conflicting 'lid' values found."); - error.Detail.Should().Be("Expected 'local-1' instead of 'local-2'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/lid"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Conflict); + error.Title.Should().Be("Failed to deserialize request body: Conflicting 'lid' values found."); + error.Detail.Should().Be("Expected 'local-1' instead of 'local-2'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/lid"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_update_on_mixture_of_ID_and_local_ID_between_ref_and_data() - { - // Arrange - string performerId = Unknown.StringId.For(); + [Fact] + public async Task Cannot_update_on_mixture_of_ID_and_local_ID_between_ref_and_data() + { + // Arrange + string performerId = Unknown.StringId.For(); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + @ref = new { - op = "update", - @ref = new + type = "performers", + id = performerId + }, + data = new + { + type = "performers", + lid = "local-1", + attributes = new { - type = "performers", - id = performerId }, - data = new + relationships = new { - type = "performers", - lid = "local-1", - attributes = new - { - }, - relationships = new - { - } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'id' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_update_on_mixture_of_local_ID_and_ID_between_ref_and_data() - { - // Arrange - string performerId = Unknown.StringId.For(); + [Fact] + public async Task Cannot_update_on_mixture_of_local_ID_and_ID_between_ref_and_data() + { + // Arrange + string performerId = Unknown.StringId.For(); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + @ref = new + { + type = "performers", + lid = "local-1" + }, + data = new { - op = "update", - @ref = new + type = "performers", + id = performerId, + attributes = new { - type = "performers", - lid = "local-1" }, - data = new + relationships = new { - type = "performers", - id = performerId, - attributes = new - { - }, - relationships = new - { - } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'lid' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'lid' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_update_resource_for_unknown_type() + [Fact] + public async Task Cannot_update_resource_for_unknown_type() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = Unknown.ResourceType, + id = Unknown.StringId.Int32, + attributes = new + { + }, + relationships = new { - type = Unknown.ResourceType, - id = Unknown.StringId.Int32, - attributes = new - { - }, - relationships = new - { - } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); - error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); + error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_update_resource_for_unknown_ID() - { - // Arrange - string performerId = Unknown.StringId.For(); + [Fact] + public async Task Cannot_update_resource_for_unknown_ID() + { + // Arrange + string performerId = Unknown.StringId.For(); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = "performers", + id = performerId, + attributes = new + { + }, + relationships = new { - type = "performers", - id = performerId, - attributes = new - { - }, - relationships = new - { - } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.NotFound); - error.Title.Should().Be("The requested resource does not exist."); - error.Detail.Should().Be($"Resource of type 'performers' with ID '{performerId}' does not exist."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - error.Meta.Should().NotContainKey("requestBody"); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("The requested resource does not exist."); + error.Detail.Should().Be($"Resource of type 'performers' with ID '{performerId}' does not exist."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Meta.Should().NotContainKey("requestBody"); + } - [Fact] - public async Task Cannot_update_resource_for_incompatible_ID() - { - // Arrange - string guid = Unknown.StringId.Guid; + [Fact] + public async Task Cannot_update_resource_for_incompatible_ID() + { + // Arrange + string guid = Unknown.StringId.Guid; - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + @ref = new { - op = "update", - @ref = new - { - type = "performers", - id = guid - }, - data = new + type = "performers", + id = guid + }, + data = new + { + type = "performers", + id = guid, + attributes = new { - type = "performers", - id = guid, - attributes = new - { - } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Incompatible 'id' value found."); - error.Detail.Should().Be($"Failed to convert '{guid}' of type 'String' to type 'Int32'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/id"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Incompatible 'id' value found."); + error.Detail.Should().Be($"Failed to convert '{guid}' of type 'String' to type 'Int32'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/id"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_update_resource_attribute_with_blocked_capability() - { - // Arrange - Lyric existingLyric = _fakers.Lyric.Generate(); + [Fact] + public async Task Cannot_update_resource_attribute_with_blocked_capability() + { + // Arrange + Lyric existingLyric = _fakers.Lyric.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Lyrics.Add(existingLyric); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Lyrics.Add(existingLyric); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = "lyrics", + id = existingLyric.StringId, + attributes = new { - type = "lyrics", - id = existingLyric.StringId, - attributes = new - { - createdAt = 12.July(1980) - } + createdAt = 12.July(1980) } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Attribute value cannot be assigned when updating resource."); - error.Detail.Should().Be("The attribute 'createdAt' on resource type 'lyrics' cannot be assigned to."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/createdAt"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Attribute value cannot be assigned when updating resource."); + error.Detail.Should().Be("The attribute 'createdAt' on resource type 'lyrics' cannot be assigned to."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/createdAt"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_update_resource_with_readonly_attribute() - { - // Arrange - Playlist existingPlaylist = _fakers.Playlist.Generate(); + [Fact] + public async Task Cannot_update_resource_with_readonly_attribute() + { + // Arrange + Playlist existingPlaylist = _fakers.Playlist.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Playlists.Add(existingPlaylist); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Playlists.Add(existingPlaylist); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = "playlists", + id = existingPlaylist.StringId, + attributes = new { - type = "playlists", - id = existingPlaylist.StringId, - attributes = new - { - isArchived = true - } + isArchived = true } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Attribute is read-only."); - error.Detail.Should().Be("Attribute 'isArchived' on resource type 'playlists' is read-only."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/isArchived"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Attribute is read-only."); + error.Detail.Should().Be("Attribute 'isArchived' on resource type 'playlists' is read-only."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/isArchived"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_change_ID_of_existing_resource() - { - // Arrange - RecordCompany existingCompany = _fakers.RecordCompany.Generate(); + [Fact] + public async Task Cannot_change_ID_of_existing_resource() + { + // Arrange + RecordCompany existingCompany = _fakers.RecordCompany.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.RecordCompanies.Add(existingCompany); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.RecordCompanies.Add(existingCompany); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = "recordCompanies", + id = existingCompany.StringId, + attributes = new { - type = "recordCompanies", - id = existingCompany.StringId, - attributes = new - { - id = (existingCompany.Id + 1).ToString() - } + id = (existingCompany.Id + 1).ToString() } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Resource ID is read-only."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/id"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Resource ID is read-only."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/id"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_update_resource_with_incompatible_attribute_value() - { - // Arrange - Performer existingPerformer = _fakers.Performer.Generate(); + [Fact] + public async Task Cannot_update_resource_with_incompatible_attribute_value() + { + // Arrange + Performer existingPerformer = _fakers.Performer.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Performers.Add(existingPerformer); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Performers.Add(existingPerformer); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = "performers", + id = existingPerformer.StringId, + attributes = new { - type = "performers", - id = existingPerformer.StringId, - attributes = new - { - bornAt = 123.45 - } + bornAt = 123.45 } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Incompatible attribute value found."); - error.Detail.Should().Be("Failed to convert attribute 'bornAt' with value '123.45' of type 'Number' to type 'DateTimeOffset'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/bornAt"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Incompatible attribute value found."); + error.Detail.Should().Be("Failed to convert attribute 'bornAt' with value '123.45' of type 'Number' to type 'DateTimeOffset'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/bornAt"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Can_update_resource_with_attributes_and_multiple_relationship_types() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.Lyric = _fakers.Lyric.Generate(); - existingTrack.OwnedBy = _fakers.RecordCompany.Generate(); - existingTrack.Performers = _fakers.Performer.Generate(1); + [Fact] + public async Task Can_update_resource_with_attributes_and_multiple_relationship_types() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + existingTrack.Lyric = _fakers.Lyric.Generate(); + existingTrack.OwnedBy = _fakers.RecordCompany.Generate(); + existingTrack.Performers = _fakers.Performer.Generate(1); - string newGenre = _fakers.MusicTrack.Generate().Genre!; + string newGenre = _fakers.MusicTrack.Generate().Genre!; - Lyric existingLyric = _fakers.Lyric.Generate(); - RecordCompany existingCompany = _fakers.RecordCompany.Generate(); - Performer existingPerformer = _fakers.Performer.Generate(); + Lyric existingLyric = _fakers.Lyric.Generate(); + RecordCompany existingCompany = _fakers.RecordCompany.Generate(); + Performer existingPerformer = _fakers.Performer.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddInRange(existingTrack, existingLyric, existingCompany, existingPerformer); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingTrack, existingLyric, existingCompany, existingPerformer); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = "musicTracks", + id = existingTrack.StringId, + attributes = new { - type = "musicTracks", - id = existingTrack.StringId, - attributes = new + genre = newGenre + }, + relationships = new + { + lyric = new { - genre = newGenre + data = new + { + type = "lyrics", + id = existingLyric.StringId + } }, - relationships = new + ownedBy = new { - lyric = new - { - data = new - { - type = "lyrics", - id = existingLyric.StringId - } - }, - ownedBy = new + data = new { - data = new - { - type = "recordCompanies", - id = existingCompany.StringId - } - }, - performers = new + type = "recordCompanies", + id = existingCompany.StringId + } + }, + performers = new + { + data = new[] { - data = new[] + new { - new - { - type = "performers", - id = existingPerformer.StringId - } + type = "performers", + id = existingPerformer.StringId } } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true + await _testContext.RunOnDatabaseAsync(async dbContext => + { + // @formatter:wrap_chained_method_calls chop_always + // @formatter:keep_existing_linebreaks true - MusicTrack trackInDatabase = await dbContext.MusicTracks - .Include(musicTrack => musicTrack.Lyric) - .Include(musicTrack => musicTrack.OwnedBy) - .Include(musicTrack => musicTrack.Performers) - .FirstWithIdAsync(existingTrack.Id); + MusicTrack trackInDatabase = await dbContext.MusicTracks + .Include(musicTrack => musicTrack.Lyric) + .Include(musicTrack => musicTrack.OwnedBy) + .Include(musicTrack => musicTrack.Performers) + .FirstWithIdAsync(existingTrack.Id); - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore + // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_chained_method_calls restore - trackInDatabase.Title.Should().Be(existingTrack.Title); - trackInDatabase.Genre.Should().Be(newGenre); + trackInDatabase.Title.Should().Be(existingTrack.Title); + trackInDatabase.Genre.Should().Be(newGenre); - trackInDatabase.Lyric.ShouldNotBeNull(); - trackInDatabase.Lyric.Id.Should().Be(existingLyric.Id); + trackInDatabase.Lyric.ShouldNotBeNull(); + trackInDatabase.Lyric.Id.Should().Be(existingLyric.Id); - trackInDatabase.OwnedBy.ShouldNotBeNull(); - trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); + trackInDatabase.OwnedBy.ShouldNotBeNull(); + trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); - trackInDatabase.Performers.ShouldHaveCount(1); - trackInDatabase.Performers[0].Id.Should().Be(existingPerformer.Id); - }); - } + trackInDatabase.Performers.ShouldHaveCount(1); + trackInDatabase.Performers[0].Id.Should().Be(existingPerformer.Id); + }); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateToOneRelationshipTests.cs index 3f7c089e44..efa3813d74 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateToOneRelationshipTests.cs @@ -1,1054 +1,1049 @@ -using System; -using System.Collections.Generic; using System.Net; -using System.Net.Http; -using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.EntityFrameworkCore; using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Updating.Resources +namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Updating.Resources; + +public sealed class AtomicUpdateToOneRelationshipTests : IClassFixture, OperationsDbContext>> { - public sealed class AtomicUpdateToOneRelationshipTests : IClassFixture, OperationsDbContext>> + private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new(); + + public AtomicUpdateToOneRelationshipTests(IntegrationTestContext, OperationsDbContext> testContext) { - private readonly IntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new(); + _testContext = testContext; - public AtomicUpdateToOneRelationshipTests(IntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; + testContext.UseController(); + } - testContext.UseController(); - } + [Fact] + public async Task Can_clear_OneToOne_relationship_from_principal_side() + { + // Arrange + Lyric existingLyric = _fakers.Lyric.Generate(); + existingLyric.Track = _fakers.MusicTrack.Generate(); - [Fact] - public async Task Can_clear_OneToOne_relationship_from_principal_side() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - Lyric existingLyric = _fakers.Lyric.Generate(); - existingLyric.Track = _fakers.MusicTrack.Generate(); + await dbContext.ClearTableAsync(); + dbContext.Lyrics.Add(existingLyric); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Lyrics.Add(existingLyric); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = "lyrics", + id = existingLyric.StringId, + relationships = new { - type = "lyrics", - id = existingLyric.StringId, - relationships = new + track = new { - track = new - { - data = (object?)null - } + data = (object?)null } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Lyric lyricInDatabase = await dbContext.Lyrics.Include(lyric => lyric.Track).FirstWithIdAsync(existingLyric.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Lyric lyricInDatabase = await dbContext.Lyrics.Include(lyric => lyric.Track).FirstWithIdAsync(existingLyric.Id); - lyricInDatabase.Track.Should().BeNull(); + lyricInDatabase.Track.Should().BeNull(); - List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.ShouldHaveCount(1); - }); - } + List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); + tracksInDatabase.ShouldHaveCount(1); + }); + } - [Fact] - public async Task Can_clear_OneToOne_relationship_from_dependent_side() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.Lyric = _fakers.Lyric.Generate(); + [Fact] + public async Task Can_clear_OneToOne_relationship_from_dependent_side() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + existingTrack.Lyric = _fakers.Lyric.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = "musicTracks", + id = existingTrack.StringId, + relationships = new { - type = "musicTracks", - id = existingTrack.StringId, - relationships = new + lyric = new { - lyric = new - { - data = (object?)null - } + data = (object?)null } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Lyric).FirstWithIdAsync(existingTrack.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Lyric).FirstWithIdAsync(existingTrack.Id); - trackInDatabase.Lyric.Should().BeNull(); + trackInDatabase.Lyric.Should().BeNull(); - List lyricsInDatabase = await dbContext.Lyrics.ToListAsync(); - lyricsInDatabase.ShouldHaveCount(1); - }); - } + List lyricsInDatabase = await dbContext.Lyrics.ToListAsync(); + lyricsInDatabase.ShouldHaveCount(1); + }); + } - [Fact] - public async Task Can_clear_ManyToOne_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.OwnedBy = _fakers.RecordCompany.Generate(); + [Fact] + public async Task Can_clear_ManyToOne_relationship() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + existingTrack.OwnedBy = _fakers.RecordCompany.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = "musicTracks", + id = existingTrack.StringId, + relationships = new { - type = "musicTracks", - id = existingTrack.StringId, - relationships = new + ownedBy = new { - ownedBy = new - { - data = (object?)null - } + data = (object?)null } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(existingTrack.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(existingTrack.Id); - trackInDatabase.OwnedBy.Should().BeNull(); + trackInDatabase.OwnedBy.Should().BeNull(); - List companiesInDatabase = await dbContext.RecordCompanies.ToListAsync(); - companiesInDatabase.ShouldHaveCount(1); - }); - } + List companiesInDatabase = await dbContext.RecordCompanies.ToListAsync(); + companiesInDatabase.ShouldHaveCount(1); + }); + } - [Fact] - public async Task Can_create_OneToOne_relationship_from_principal_side() - { - // Arrange - Lyric existingLyric = _fakers.Lyric.Generate(); - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + [Fact] + public async Task Can_create_OneToOne_relationship_from_principal_side() + { + // Arrange + Lyric existingLyric = _fakers.Lyric.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddInRange(existingLyric, existingTrack); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingLyric, existingTrack); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = "lyrics", + id = existingLyric.StringId, + relationships = new { - type = "lyrics", - id = existingLyric.StringId, - relationships = new + track = new { - track = new + data = new { - data = new - { - type = "musicTracks", - id = existingTrack.StringId - } + type = "musicTracks", + id = existingTrack.StringId } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Lyric lyricInDatabase = await dbContext.Lyrics.Include(lyric => lyric.Track).FirstWithIdAsync(existingLyric.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Lyric lyricInDatabase = await dbContext.Lyrics.Include(lyric => lyric.Track).FirstWithIdAsync(existingLyric.Id); - lyricInDatabase.Track.ShouldNotBeNull(); - lyricInDatabase.Track.Id.Should().Be(existingTrack.Id); - }); - } + lyricInDatabase.Track.ShouldNotBeNull(); + lyricInDatabase.Track.Id.Should().Be(existingTrack.Id); + }); + } - [Fact] - public async Task Can_create_OneToOne_relationship_from_dependent_side() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - Lyric existingLyric = _fakers.Lyric.Generate(); + [Fact] + public async Task Can_create_OneToOne_relationship_from_dependent_side() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + Lyric existingLyric = _fakers.Lyric.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddInRange(existingTrack, existingLyric); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingTrack, existingLyric); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = "musicTracks", + id = existingTrack.StringId, + relationships = new { - type = "musicTracks", - id = existingTrack.StringId, - relationships = new + lyric = new { - lyric = new + data = new { - data = new - { - type = "lyrics", - id = existingLyric.StringId - } + type = "lyrics", + id = existingLyric.StringId } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Lyric).FirstWithIdAsync(existingTrack.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Lyric).FirstWithIdAsync(existingTrack.Id); - trackInDatabase.Lyric.ShouldNotBeNull(); - trackInDatabase.Lyric.Id.Should().Be(existingLyric.Id); - }); - } + trackInDatabase.Lyric.ShouldNotBeNull(); + trackInDatabase.Lyric.Id.Should().Be(existingLyric.Id); + }); + } - [Fact] - public async Task Can_create_ManyToOne_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - RecordCompany existingCompany = _fakers.RecordCompany.Generate(); + [Fact] + public async Task Can_create_ManyToOne_relationship() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + RecordCompany existingCompany = _fakers.RecordCompany.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddInRange(existingTrack, existingCompany); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingTrack, existingCompany); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = "musicTracks", + id = existingTrack.StringId, + relationships = new { - type = "musicTracks", - id = existingTrack.StringId, - relationships = new + ownedBy = new { - ownedBy = new + data = new { - data = new - { - type = "recordCompanies", - id = existingCompany.StringId - } + type = "recordCompanies", + id = existingCompany.StringId } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(existingTrack.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(existingTrack.Id); - trackInDatabase.OwnedBy.ShouldNotBeNull(); - trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); - }); - } + trackInDatabase.OwnedBy.ShouldNotBeNull(); + trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); + }); + } - [Fact] - public async Task Can_replace_OneToOne_relationship_from_principal_side() - { - // Arrange - Lyric existingLyric = _fakers.Lyric.Generate(); - existingLyric.Track = _fakers.MusicTrack.Generate(); + [Fact] + public async Task Can_replace_OneToOne_relationship_from_principal_side() + { + // Arrange + Lyric existingLyric = _fakers.Lyric.Generate(); + existingLyric.Track = _fakers.MusicTrack.Generate(); - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.AddInRange(existingLyric, existingTrack); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddInRange(existingLyric, existingTrack); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = "lyrics", + id = existingLyric.StringId, + relationships = new { - type = "lyrics", - id = existingLyric.StringId, - relationships = new + track = new { - track = new + data = new { - data = new - { - type = "musicTracks", - id = existingTrack.StringId - } + type = "musicTracks", + id = existingTrack.StringId } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Lyric lyricInDatabase = await dbContext.Lyrics.Include(lyric => lyric.Track).FirstWithIdAsync(existingLyric.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Lyric lyricInDatabase = await dbContext.Lyrics.Include(lyric => lyric.Track).FirstWithIdAsync(existingLyric.Id); - lyricInDatabase.Track.ShouldNotBeNull(); - lyricInDatabase.Track.Id.Should().Be(existingTrack.Id); + lyricInDatabase.Track.ShouldNotBeNull(); + lyricInDatabase.Track.Id.Should().Be(existingTrack.Id); - List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.ShouldHaveCount(2); - }); - } + List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); + tracksInDatabase.ShouldHaveCount(2); + }); + } - [Fact] - public async Task Can_replace_OneToOne_relationship_from_dependent_side() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.Lyric = _fakers.Lyric.Generate(); + [Fact] + public async Task Can_replace_OneToOne_relationship_from_dependent_side() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + existingTrack.Lyric = _fakers.Lyric.Generate(); - Lyric existingLyric = _fakers.Lyric.Generate(); + Lyric existingLyric = _fakers.Lyric.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.AddInRange(existingTrack, existingLyric); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddInRange(existingTrack, existingLyric); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = "musicTracks", + id = existingTrack.StringId, + relationships = new { - type = "musicTracks", - id = existingTrack.StringId, - relationships = new + lyric = new { - lyric = new + data = new { - data = new - { - type = "lyrics", - id = existingLyric.StringId - } + type = "lyrics", + id = existingLyric.StringId } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Lyric).FirstWithIdAsync(existingTrack.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Lyric).FirstWithIdAsync(existingTrack.Id); - trackInDatabase.Lyric.ShouldNotBeNull(); - trackInDatabase.Lyric.Id.Should().Be(existingLyric.Id); + trackInDatabase.Lyric.ShouldNotBeNull(); + trackInDatabase.Lyric.Id.Should().Be(existingLyric.Id); - List lyricsInDatabase = await dbContext.Lyrics.ToListAsync(); - lyricsInDatabase.ShouldHaveCount(2); - }); - } + List lyricsInDatabase = await dbContext.Lyrics.ToListAsync(); + lyricsInDatabase.ShouldHaveCount(2); + }); + } - [Fact] - public async Task Can_replace_ManyToOne_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.OwnedBy = _fakers.RecordCompany.Generate(); + [Fact] + public async Task Can_replace_ManyToOne_relationship() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + existingTrack.OwnedBy = _fakers.RecordCompany.Generate(); - RecordCompany existingCompany = _fakers.RecordCompany.Generate(); + RecordCompany existingCompany = _fakers.RecordCompany.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.AddInRange(existingTrack, existingCompany); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddInRange(existingTrack, existingCompany); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = "musicTracks", + id = existingTrack.StringId, + relationships = new { - type = "musicTracks", - id = existingTrack.StringId, - relationships = new + ownedBy = new { - ownedBy = new + data = new { - data = new - { - type = "recordCompanies", - id = existingCompany.StringId - } + type = "recordCompanies", + id = existingCompany.StringId } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(existingTrack.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(existingTrack.Id); - trackInDatabase.OwnedBy.ShouldNotBeNull(); - trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); + trackInDatabase.OwnedBy.ShouldNotBeNull(); + trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); - List companiesInDatabase = await dbContext.RecordCompanies.ToListAsync(); - companiesInDatabase.ShouldHaveCount(2); - }); - } + List companiesInDatabase = await dbContext.RecordCompanies.ToListAsync(); + companiesInDatabase.ShouldHaveCount(2); + }); + } - [Fact] - public async Task Cannot_create_for_null_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + [Fact] + public async Task Cannot_create_for_null_relationship() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = "musicTracks", + id = existingTrack.StringId, + relationships = new { - type = "musicTracks", - id = existingTrack.StringId, - relationships = new - { - lyric = (object?)null - } + lyric = (object?)null } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected an object, instead of 'null'."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Expected an object, instead of 'null'."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_create_for_missing_data_in_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + [Fact] + public async Task Cannot_create_for_missing_data_in_relationship() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = "musicTracks", + id = existingTrack.StringId, + relationships = new { - type = "musicTracks", - id = existingTrack.StringId, - relationships = new + lyric = new { - lyric = new - { - } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'data' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_create_for_array_data_in_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + [Fact] + public async Task Cannot_create_for_array_data_in_relationship() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = "musicTracks", + id = existingTrack.StringId, + relationships = new { - type = "musicTracks", - id = existingTrack.StringId, - relationships = new + lyric = new { - lyric = new + data = new[] { - data = new[] + new { - new - { - type = "lyrics", - id = Unknown.StringId.For() - } + type = "lyrics", + id = Unknown.StringId.For() } } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected an object or 'null', instead of an array."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Expected an object or 'null', instead of an array."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_create_for_missing_type_in_relationship_data() + [Fact] + public async Task Cannot_create_for_missing_type_in_relationship_data() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = "lyrics", + id = Unknown.StringId.For(), + relationships = new { - type = "lyrics", - id = Unknown.StringId.For(), - relationships = new + track = new { - track = new + data = new { - data = new - { - id = Unknown.StringId.For() - } + id = Unknown.StringId.For() } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/track/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/track/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_create_for_unknown_type_in_relationship_data() + [Fact] + public async Task Cannot_create_for_unknown_type_in_relationship_data() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = "musicTracks", + id = Unknown.StringId.For(), + relationships = new { - type = "musicTracks", - id = Unknown.StringId.For(), - relationships = new + lyric = new { - lyric = new + data = new { - data = new - { - type = Unknown.ResourceType, - id = Unknown.StringId.For() - } + type = Unknown.ResourceType, + id = Unknown.StringId.For() } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); - error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); + error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_create_for_missing_ID_in_relationship_data() + [Fact] + public async Task Cannot_create_for_missing_ID_in_relationship_data() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = "musicTracks", + id = Unknown.StringId.For(), + relationships = new { - type = "musicTracks", - id = Unknown.StringId.For(), - relationships = new + lyric = new { - lyric = new + data = new { - data = new - { - type = "lyrics" - } + type = "lyrics" } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_create_for_ID_and_local_ID_in_relationship_data() + [Fact] + public async Task Cannot_create_for_ID_and_local_ID_in_relationship_data() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = "musicTracks", + id = Unknown.StringId.For(), + relationships = new { - type = "musicTracks", - id = Unknown.StringId.For(), - relationships = new + lyric = new { - lyric = new + data = new { - data = new - { - type = "lyrics", - id = Unknown.StringId.For(), - lid = "local-1" - } + type = "lyrics", + id = Unknown.StringId.For(), + lid = "local-1" } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - [Fact] - public async Task Cannot_create_for_unknown_ID_in_relationship_data() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + [Fact] + public async Task Cannot_create_for_unknown_ID_in_relationship_data() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); - string lyricId = Unknown.StringId.For(); + string lyricId = Unknown.StringId.For(); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = "musicTracks", + id = existingTrack.StringId, + relationships = new { - type = "musicTracks", - id = existingTrack.StringId, - relationships = new + lyric = new { - lyric = new + data = new { - data = new - { - type = "lyrics", - id = lyricId - } + type = "lyrics", + id = lyricId } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.NotFound); - error.Title.Should().Be("A related resource does not exist."); - error.Detail.Should().Be($"Related resource of type 'lyrics' with ID '{lyricId}' in relationship 'lyric' does not exist."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - error.Meta.Should().NotContainKey("requestBody"); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("A related resource does not exist."); + error.Detail.Should().Be($"Related resource of type 'lyrics' with ID '{lyricId}' in relationship 'lyric' does not exist."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Meta.Should().NotContainKey("requestBody"); + } - [Fact] - public async Task Cannot_create_for_relationship_mismatch() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + [Fact] + public async Task Cannot_create_for_relationship_mismatch() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "update", + data = new { - op = "update", - data = new + type = "musicTracks", + id = existingTrack.StringId, + relationships = new { - type = "musicTracks", - id = existingTrack.StringId, - relationships = new + lyric = new { - lyric = new + data = new { - data = new - { - type = "playlists", - id = Unknown.StringId.For() - } + type = "playlists", + id = Unknown.StringId.For() } } } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.Conflict); - error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found."); - error.Detail.Should().Be("Type 'playlists' is incompatible with type 'lyrics' of relationship 'lyric'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Conflict); + error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found."); + error.Detail.Should().Be("Type 'playlists' is incompatible with type 'lyrics' of relationship 'lyric'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/Car.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/Car.cs index 5e7b5cc9ad..29213f5e69 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/Car.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/Car.cs @@ -1,52 +1,50 @@ -using System; using System.ComponentModel.DataAnnotations.Schema; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.CompositeKeys +namespace JsonApiDotNetCoreTests.IntegrationTests.CompositeKeys; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.CompositeKeys")] +public sealed class Car : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - [Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.CompositeKeys")] - public sealed class Car : Identifiable + [NotMapped] + public override string? Id { - [NotMapped] - public override string? Id + get => RegionId == default && LicensePlate == default ? null : $"{RegionId}:{LicensePlate}"; + set { - get => RegionId == default && LicensePlate == default ? null : $"{RegionId}:{LicensePlate}"; - set + if (value == null) + { + RegionId = default; + LicensePlate = default; + return; + } + + string[] elements = value.Split(':'); + + if (elements.Length == 2 && long.TryParse(elements[0], out long regionId)) { - if (value == null) - { - RegionId = default; - LicensePlate = default; - return; - } - - string[] elements = value.Split(':'); - - if (elements.Length == 2 && long.TryParse(elements[0], out long regionId)) - { - RegionId = regionId; - LicensePlate = elements[1]; - } - else - { - throw new InvalidOperationException($"Failed to convert ID '{value}'."); - } + RegionId = regionId; + LicensePlate = elements[1]; + } + else + { + throw new InvalidOperationException($"Failed to convert ID '{value}'."); } } + } - [Attr] - public string? LicensePlate { get; set; } + [Attr] + public string? LicensePlate { get; set; } - [Attr] - public long RegionId { get; set; } + [Attr] + public long RegionId { get; set; } - [HasOne] - public Engine Engine { get; set; } = null!; + [HasOne] + public Engine Engine { get; set; } = null!; - [HasOne] - public Dealership? Dealership { get; set; } - } + [HasOne] + public Dealership? Dealership { get; set; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarCompositeKeyAwareRepository.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarCompositeKeyAwareRepository.cs index 5f3e5e01d9..4009f1dd82 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarCompositeKeyAwareRepository.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarCompositeKeyAwareRepository.cs @@ -1,5 +1,3 @@ -using System.Collections.Generic; -using System.Linq; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries; @@ -8,47 +6,46 @@ using JsonApiDotNetCore.Resources; using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCoreTests.IntegrationTests.CompositeKeys +namespace JsonApiDotNetCoreTests.IntegrationTests.CompositeKeys; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public class CarCompositeKeyAwareRepository : EntityFrameworkCoreRepository + where TResource : class, IIdentifiable { - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public class CarCompositeKeyAwareRepository : EntityFrameworkCoreRepository - where TResource : class, IIdentifiable + private readonly CarExpressionRewriter _writer; + + public CarCompositeKeyAwareRepository(ITargetedFields targetedFields, IDbContextResolver dbContextResolver, IResourceGraph resourceGraph, + IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory, + IResourceDefinitionAccessor resourceDefinitionAccessor) + : base(targetedFields, dbContextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor) + { + _writer = new CarExpressionRewriter(resourceGraph); + } + + protected override IQueryable ApplyQueryLayer(QueryLayer queryLayer) { - private readonly CarExpressionRewriter _writer; + RecursiveRewriteFilterInLayer(queryLayer); + + return base.ApplyQueryLayer(queryLayer); + } - public CarCompositeKeyAwareRepository(ITargetedFields targetedFields, IDbContextResolver dbContextResolver, IResourceGraph resourceGraph, - IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory, - IResourceDefinitionAccessor resourceDefinitionAccessor) - : base(targetedFields, dbContextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor) + private void RecursiveRewriteFilterInLayer(QueryLayer queryLayer) + { + if (queryLayer.Filter != null) { - _writer = new CarExpressionRewriter(resourceGraph); + queryLayer.Filter = (FilterExpression?)_writer.Visit(queryLayer.Filter, null); } - protected override IQueryable ApplyQueryLayer(QueryLayer queryLayer) + if (queryLayer.Sort != null) { - RecursiveRewriteFilterInLayer(queryLayer); - - return base.ApplyQueryLayer(queryLayer); + queryLayer.Sort = (SortExpression?)_writer.Visit(queryLayer.Sort, null); } - private void RecursiveRewriteFilterInLayer(QueryLayer queryLayer) + if (queryLayer.Projection != null) { - if (queryLayer.Filter != null) - { - queryLayer.Filter = (FilterExpression?)_writer.Visit(queryLayer.Filter, null); - } - - if (queryLayer.Sort != null) - { - queryLayer.Sort = (SortExpression?)_writer.Visit(queryLayer.Sort, null); - } - - if (queryLayer.Projection != null) + foreach (QueryLayer? nextLayer in queryLayer.Projection.Values.Where(layer => layer != null)) { - foreach (QueryLayer? nextLayer in queryLayer.Projection.Values.Where(layer => layer != null)) - { - RecursiveRewriteFilterInLayer(nextLayer!); - } + RecursiveRewriteFilterInLayer(nextLayer!); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarExpressionRewriter.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarExpressionRewriter.cs index 301de2901f..d5b421850e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarExpressionRewriter.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarExpressionRewriter.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; using System.Collections.Immutable; -using System.Linq; using System.Reflection; using JsonApiDotNetCore; using JsonApiDotNetCore.Configuration; @@ -9,154 +6,149 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.CompositeKeys +namespace JsonApiDotNetCoreTests.IntegrationTests.CompositeKeys; + +/// +/// Rewrites an expression tree, updating all references to with the combination of and +/// . +/// +/// +/// This enables queries to use , which is not mapped in the database. +/// +internal sealed class CarExpressionRewriter : QueryExpressionRewriter { - /// - /// Rewrites an expression tree, updating all references to with the combination of and - /// . - /// - /// - /// This enables queries to use , which is not mapped in the database. - /// - internal sealed class CarExpressionRewriter : QueryExpressionRewriter - { - private readonly AttrAttribute _regionIdAttribute; - private readonly AttrAttribute _licensePlateAttribute; + private readonly AttrAttribute _regionIdAttribute; + private readonly AttrAttribute _licensePlateAttribute; - public CarExpressionRewriter(IResourceGraph resourceGraph) - { - ResourceType carType = resourceGraph.GetResourceType(); + public CarExpressionRewriter(IResourceGraph resourceGraph) + { + ResourceType carType = resourceGraph.GetResourceType(); - _regionIdAttribute = carType.GetAttributeByPropertyName(nameof(Car.RegionId)); - _licensePlateAttribute = carType.GetAttributeByPropertyName(nameof(Car.LicensePlate)); - } + _regionIdAttribute = carType.GetAttributeByPropertyName(nameof(Car.RegionId)); + _licensePlateAttribute = carType.GetAttributeByPropertyName(nameof(Car.LicensePlate)); + } - public override QueryExpression? VisitComparison(ComparisonExpression expression, object? argument) + public override QueryExpression? VisitComparison(ComparisonExpression expression, object? argument) + { + if (expression.Left is ResourceFieldChainExpression leftChain && expression.Right is LiteralConstantExpression rightConstant) { - if (expression.Left is ResourceFieldChainExpression leftChain && expression.Right is LiteralConstantExpression rightConstant) - { - PropertyInfo leftProperty = leftChain.Fields[^1].Property; + PropertyInfo leftProperty = leftChain.Fields[^1].Property; - if (IsCarId(leftProperty)) + if (IsCarId(leftProperty)) + { + if (expression.Operator != ComparisonOperator.Equals) { - if (expression.Operator != ComparisonOperator.Equals) - { - throw new NotSupportedException("Only equality comparisons are possible on Car IDs."); - } - - return RewriteFilterOnCarStringIds(leftChain, rightConstant.Value.AsEnumerable()); + throw new NotSupportedException("Only equality comparisons are possible on Car IDs."); } - } - return base.VisitComparison(expression, argument); + return RewriteFilterOnCarStringIds(leftChain, rightConstant.Value.AsEnumerable()); + } } - public override QueryExpression? VisitAny(AnyExpression expression, object? argument) - { - PropertyInfo property = expression.TargetAttribute.Fields[^1].Property; - - if (IsCarId(property)) - { - string[] carStringIds = expression.Constants.Select(constant => constant.Value).ToArray(); - return RewriteFilterOnCarStringIds(expression.TargetAttribute, carStringIds); - } + return base.VisitComparison(expression, argument); + } - return base.VisitAny(expression, argument); - } + public override QueryExpression? VisitAny(AnyExpression expression, object? argument) + { + PropertyInfo property = expression.TargetAttribute.Fields[^1].Property; - public override QueryExpression? VisitMatchText(MatchTextExpression expression, object? argument) + if (IsCarId(property)) { - PropertyInfo property = expression.TargetAttribute.Fields[^1].Property; + string[] carStringIds = expression.Constants.Select(constant => constant.Value).ToArray(); + return RewriteFilterOnCarStringIds(expression.TargetAttribute, carStringIds); + } - if (IsCarId(property)) - { - throw new NotSupportedException("Partial text matching on Car IDs is not possible."); - } + return base.VisitAny(expression, argument); + } - return base.VisitMatchText(expression, argument); - } + public override QueryExpression? VisitMatchText(MatchTextExpression expression, object? argument) + { + PropertyInfo property = expression.TargetAttribute.Fields[^1].Property; - private static bool IsCarId(PropertyInfo property) + if (IsCarId(property)) { - return property.Name == nameof(Identifiable.Id) && property.DeclaringType == typeof(Car); + throw new NotSupportedException("Partial text matching on Car IDs is not possible."); } - private QueryExpression RewriteFilterOnCarStringIds(ResourceFieldChainExpression existingCarIdChain, IEnumerable carStringIds) - { - ImmutableArray.Builder outerTermsBuilder = ImmutableArray.CreateBuilder(); + return base.VisitMatchText(expression, argument); + } - foreach (string carStringId in carStringIds) - { - var tempCar = new Car - { - StringId = carStringId - }; + private static bool IsCarId(PropertyInfo property) + { + return property.Name == nameof(Identifiable.Id) && property.DeclaringType == typeof(Car); + } - FilterExpression keyComparison = CreateEqualityComparisonOnCompositeKey(existingCarIdChain, tempCar.RegionId, tempCar.LicensePlate!); - outerTermsBuilder.Add(keyComparison); - } + private QueryExpression RewriteFilterOnCarStringIds(ResourceFieldChainExpression existingCarIdChain, IEnumerable carStringIds) + { + ImmutableArray.Builder outerTermsBuilder = ImmutableArray.CreateBuilder(); + + foreach (string carStringId in carStringIds) + { + var tempCar = new Car + { + StringId = carStringId + }; - return outerTermsBuilder.Count == 1 ? outerTermsBuilder[0] : new LogicalExpression(LogicalOperator.Or, outerTermsBuilder.ToImmutable()); + FilterExpression keyComparison = CreateEqualityComparisonOnCompositeKey(existingCarIdChain, tempCar.RegionId, tempCar.LicensePlate!); + outerTermsBuilder.Add(keyComparison); } - private FilterExpression CreateEqualityComparisonOnCompositeKey(ResourceFieldChainExpression existingCarIdChain, long regionIdValue, - string licensePlateValue) - { - ResourceFieldChainExpression regionIdChain = ReplaceLastAttributeInChain(existingCarIdChain, _regionIdAttribute); + return outerTermsBuilder.Count == 1 ? outerTermsBuilder[0] : new LogicalExpression(LogicalOperator.Or, outerTermsBuilder.ToImmutable()); + } - var regionIdComparison = new ComparisonExpression(ComparisonOperator.Equals, regionIdChain, - new LiteralConstantExpression(regionIdValue.ToString())); + private FilterExpression CreateEqualityComparisonOnCompositeKey(ResourceFieldChainExpression existingCarIdChain, long regionIdValue, + string licensePlateValue) + { + ResourceFieldChainExpression regionIdChain = ReplaceLastAttributeInChain(existingCarIdChain, _regionIdAttribute); + var regionIdComparison = new ComparisonExpression(ComparisonOperator.Equals, regionIdChain, new LiteralConstantExpression(regionIdValue.ToString())); - ResourceFieldChainExpression licensePlateChain = ReplaceLastAttributeInChain(existingCarIdChain, _licensePlateAttribute); + ResourceFieldChainExpression licensePlateChain = ReplaceLastAttributeInChain(existingCarIdChain, _licensePlateAttribute); + var licensePlateComparison = new ComparisonExpression(ComparisonOperator.Equals, licensePlateChain, new LiteralConstantExpression(licensePlateValue)); - var licensePlateComparison = new ComparisonExpression(ComparisonOperator.Equals, licensePlateChain, - new LiteralConstantExpression(licensePlateValue)); + return new LogicalExpression(LogicalOperator.And, regionIdComparison, licensePlateComparison); + } - return new LogicalExpression(LogicalOperator.And, regionIdComparison, licensePlateComparison); - } + public override QueryExpression VisitSort(SortExpression expression, object? argument) + { + ImmutableArray.Builder elementsBuilder = ImmutableArray.CreateBuilder(expression.Elements.Count); - public override QueryExpression VisitSort(SortExpression expression, object? argument) + foreach (SortElementExpression sortElement in expression.Elements) { - ImmutableArray.Builder elementsBuilder = ImmutableArray.CreateBuilder(expression.Elements.Count); - - foreach (SortElementExpression sortElement in expression.Elements) + if (IsSortOnCarId(sortElement)) { - if (IsSortOnCarId(sortElement)) - { - ResourceFieldChainExpression regionIdSort = ReplaceLastAttributeInChain(sortElement.TargetAttribute!, _regionIdAttribute); - elementsBuilder.Add(new SortElementExpression(regionIdSort, sortElement.IsAscending)); + ResourceFieldChainExpression regionIdSort = ReplaceLastAttributeInChain(sortElement.TargetAttribute!, _regionIdAttribute); + elementsBuilder.Add(new SortElementExpression(regionIdSort, sortElement.IsAscending)); - ResourceFieldChainExpression licensePlateSort = ReplaceLastAttributeInChain(sortElement.TargetAttribute!, _licensePlateAttribute); - elementsBuilder.Add(new SortElementExpression(licensePlateSort, sortElement.IsAscending)); - } - else - { - elementsBuilder.Add(sortElement); - } + ResourceFieldChainExpression licensePlateSort = ReplaceLastAttributeInChain(sortElement.TargetAttribute!, _licensePlateAttribute); + elementsBuilder.Add(new SortElementExpression(licensePlateSort, sortElement.IsAscending)); + } + else + { + elementsBuilder.Add(sortElement); } - - return new SortExpression(elementsBuilder.ToImmutable()); } - private static bool IsSortOnCarId(SortElementExpression sortElement) + return new SortExpression(elementsBuilder.ToImmutable()); + } + + private static bool IsSortOnCarId(SortElementExpression sortElement) + { + if (sortElement.TargetAttribute != null) { - if (sortElement.TargetAttribute != null) - { - PropertyInfo property = sortElement.TargetAttribute.Fields[^1].Property; + PropertyInfo property = sortElement.TargetAttribute.Fields[^1].Property; - if (IsCarId(property)) - { - return true; - } + if (IsCarId(property)) + { + return true; } - - return false; } - private static ResourceFieldChainExpression ReplaceLastAttributeInChain(ResourceFieldChainExpression resourceFieldChain, AttrAttribute attribute) - { - IImmutableList fields = resourceFieldChain.Fields.SetItem(resourceFieldChain.Fields.Count - 1, attribute); - return new ResourceFieldChainExpression(fields); - } + return false; + } + + private static ResourceFieldChainExpression ReplaceLastAttributeInChain(ResourceFieldChainExpression resourceFieldChain, AttrAttribute attribute) + { + IImmutableList fields = resourceFieldChain.Fields.SetItem(resourceFieldChain.Fields.Count - 1, attribute); + return new ResourceFieldChainExpression(fields); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeDbContext.cs index 25a8a04201..67213057b7 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeDbContext.cs @@ -3,37 +3,36 @@ // @formatter:wrap_chained_method_calls chop_always -namespace JsonApiDotNetCoreTests.IntegrationTests.CompositeKeys +namespace JsonApiDotNetCoreTests.IntegrationTests.CompositeKeys; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class CompositeDbContext : DbContext { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class CompositeDbContext : DbContext - { - public DbSet Cars => Set(); - public DbSet Engines => Set(); - public DbSet Dealerships => Set(); + public DbSet Cars => Set(); + public DbSet Engines => Set(); + public DbSet Dealerships => Set(); - public CompositeDbContext(DbContextOptions options) - : base(options) - { - } + public CompositeDbContext(DbContextOptions options) + : base(options) + { + } - protected override void OnModelCreating(ModelBuilder builder) - { - builder.Entity() - .HasKey(car => new - { - car.RegionId, - car.LicensePlate - }); + protected override void OnModelCreating(ModelBuilder builder) + { + builder.Entity() + .HasKey(car => new + { + car.RegionId, + car.LicensePlate + }); - builder.Entity() - .HasOne(engine => engine.Car) - .WithOne(car => car!.Engine) - .HasForeignKey(); + builder.Entity() + .HasOne(engine => engine.Car) + .WithOne(car => car.Engine) + .HasForeignKey(); - builder.Entity() - .HasMany(dealership => dealership.Inventory) - .WithOne(car => car.Dealership!); - } + builder.Entity() + .HasMany(dealership => dealership.Inventory) + .WithOne(car => car.Dealership!); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeKeyFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeKeyFakers.cs index a470a32b6b..ce3a4455d1 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeKeyFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeKeyFakers.cs @@ -1,32 +1,30 @@ -using System; using Bogus; using TestBuildingBlocks; // @formatter:wrap_chained_method_calls chop_always // @formatter:keep_existing_linebreaks true -namespace JsonApiDotNetCoreTests.IntegrationTests.CompositeKeys +namespace JsonApiDotNetCoreTests.IntegrationTests.CompositeKeys; + +internal sealed class CompositeKeyFakers : FakerContainer { - internal sealed class CompositeKeyFakers : FakerContainer - { - private readonly Lazy> _lazyCarFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(car => car.LicensePlate, faker => faker.Random.Replace("??-??-##")) - .RuleFor(car => car.RegionId, faker => faker.Random.Long(100, 999))); + private readonly Lazy> _lazyCarFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(car => car.LicensePlate, faker => faker.Random.Replace("??-??-##")) + .RuleFor(car => car.RegionId, faker => faker.Random.Long(100, 999))); - private readonly Lazy> _lazyEngineFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(engine => engine.SerialCode, faker => faker.Random.Replace("????-????"))); + private readonly Lazy> _lazyEngineFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(engine => engine.SerialCode, faker => faker.Random.Replace("????-????"))); - private readonly Lazy> _lazyDealershipFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(dealership => dealership.Address, faker => faker.Address.FullAddress())); + private readonly Lazy> _lazyDealershipFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(dealership => dealership.Address, faker => faker.Address.FullAddress())); - public Faker Car => _lazyCarFaker.Value; - public Faker Engine => _lazyEngineFaker.Value; - public Faker Dealership => _lazyDealershipFaker.Value; - } + public Faker Car => _lazyCarFaker.Value; + public Faker Engine => _lazyEngineFaker.Value; + public Faker Dealership => _lazyDealershipFaker.Value; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs index 7ad93bdec6..981190dc72 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs @@ -1,7 +1,4 @@ -using System.Linq; using System.Net; -using System.Net.Http; -using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; @@ -10,510 +7,505 @@ using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.CompositeKeys +namespace JsonApiDotNetCoreTests.IntegrationTests.CompositeKeys; + +public sealed class CompositeKeyTests : IClassFixture, CompositeDbContext>> { - public sealed class CompositeKeyTests : IClassFixture, CompositeDbContext>> + private readonly IntegrationTestContext, CompositeDbContext> _testContext; + private readonly CompositeKeyFakers _fakers = new(); + + public CompositeKeyTests(IntegrationTestContext, CompositeDbContext> testContext) { - private readonly IntegrationTestContext, CompositeDbContext> _testContext; - private readonly CompositeKeyFakers _fakers = new(); + _testContext = testContext; - public CompositeKeyTests(IntegrationTestContext, CompositeDbContext> testContext) - { - _testContext = testContext; + testContext.UseController(); + testContext.UseController(); + testContext.UseController(); - testContext.UseController(); - testContext.UseController(); - testContext.UseController(); + testContext.ConfigureServicesAfterStartup(services => + { + services.AddResourceRepository>(); + services.AddResourceRepository>(); + }); - testContext.ConfigureServicesAfterStartup(services => - { - services.AddResourceRepository>(); - services.AddResourceRepository>(); - }); + var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); + options.AllowClientGeneratedIds = true; + } - var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); - options.AllowClientGeneratedIds = true; - } + [Fact] + public async Task Can_filter_on_ID_in_primary_resources() + { + // Arrange + Car car = _fakers.Car.Generate(); - [Fact] - public async Task Can_filter_on_ID_in_primary_resources() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - Car car = _fakers.Car.Generate(); + await dbContext.ClearTableAsync(); + dbContext.Cars.Add(car); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Cars.Add(car); - await dbContext.SaveChangesAsync(); - }); + string route = $"/cars?filter=any(id,'{car.RegionId}:{car.LicensePlate}','999:XX-YY-22')"; - string route = $"/cars?filter=any(id,'{car.RegionId}:{car.LicensePlate}','999:XX-YY-22')"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be(car.StringId); + } - responseDocument.Data.ManyValue.ShouldHaveCount(1); - responseDocument.Data.ManyValue[0].Id.Should().Be(car.StringId); - } + [Fact] + public async Task Can_get_primary_resource_by_ID() + { + // Arrange + Car car = _fakers.Car.Generate(); - [Fact] - public async Task Can_get_primary_resource_by_ID() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - Car car = _fakers.Car.Generate(); + await dbContext.ClearTableAsync(); + dbContext.Cars.Add(car); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Cars.Add(car); - await dbContext.SaveChangesAsync(); - }); + string route = $"/cars/{car.StringId}"; - string route = $"/cars/{car.StringId}"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Id.Should().Be(car.StringId); + } - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Id.Should().Be(car.StringId); - } + [Fact] + public async Task Can_sort_on_ID() + { + // Arrange + Car car = _fakers.Car.Generate(); - [Fact] - public async Task Can_sort_on_ID() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - Car car = _fakers.Car.Generate(); + await dbContext.ClearTableAsync(); + dbContext.Cars.Add(car); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Cars.Add(car); - await dbContext.SaveChangesAsync(); - }); + const string route = "/cars?sort=id"; - const string route = "/cars?sort=id"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be(car.StringId); + } - responseDocument.Data.ManyValue.ShouldHaveCount(1); - responseDocument.Data.ManyValue[0].Id.Should().Be(car.StringId); - } + [Fact] + public async Task Can_select_ID() + { + // Arrange + Car car = _fakers.Car.Generate(); - [Fact] - public async Task Can_select_ID() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - Car car = _fakers.Car.Generate(); + await dbContext.ClearTableAsync(); + dbContext.Cars.Add(car); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Cars.Add(car); - await dbContext.SaveChangesAsync(); - }); - - const string route = "/cars?fields[cars]=id"; + const string route = "/cars?fields[cars]=id"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); - responseDocument.Data.ManyValue[0].Id.Should().Be(car.StringId); - } + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be(car.StringId); + } - [Fact] - public async Task Can_create_resource() - { - // Arrange - Engine existingEngine = _fakers.Engine.Generate(); + [Fact] + public async Task Can_create_resource() + { + // Arrange + Engine existingEngine = _fakers.Engine.Generate(); - Car newCar = _fakers.Car.Generate(); + Car newCar = _fakers.Car.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Engines.Add(existingEngine); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Engines.Add(existingEngine); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new { - data = new + type = "cars", + attributes = new { - type = "cars", - attributes = new - { - regionId = newCar.RegionId, - licensePlate = newCar.LicensePlate - }, - relationships = new + regionId = newCar.RegionId, + licensePlate = newCar.LicensePlate + }, + relationships = new + { + engine = new { - engine = new + data = new { - data = new - { - type = "engines", - id = existingEngine.StringId - } + type = "engines", + id = existingEngine.StringId } } } - }; + } + }; - const string route = "/cars"; + const string route = "/cars"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Car? carInDatabase = - await dbContext.Cars.FirstOrDefaultAsync(car => car.RegionId == newCar.RegionId && car.LicensePlate == newCar.LicensePlate); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Car? carInDatabase = await dbContext.Cars.FirstOrDefaultAsync(car => car.RegionId == newCar.RegionId && car.LicensePlate == newCar.LicensePlate); - carInDatabase.ShouldNotBeNull(); - carInDatabase.Id.Should().Be($"{newCar.RegionId}:{newCar.LicensePlate}"); - }); - } + carInDatabase.ShouldNotBeNull(); + carInDatabase.Id.Should().Be($"{newCar.RegionId}:{newCar.LicensePlate}"); + }); + } - [Fact] - public async Task Can_create_OneToOne_relationship() - { - // Arrange - Car existingCar = _fakers.Car.Generate(); - Engine existingEngine = _fakers.Engine.Generate(); + [Fact] + public async Task Can_create_OneToOne_relationship() + { + // Arrange + Car existingCar = _fakers.Car.Generate(); + Engine existingEngine = _fakers.Engine.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.AddInRange(existingCar, existingEngine); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddInRange(existingCar, existingEngine); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new { - data = new + type = "engines", + id = existingEngine.StringId, + relationships = new { - type = "engines", - id = existingEngine.StringId, - relationships = new + car = new { - car = new + data = new { - data = new - { - type = "cars", - id = existingCar.StringId - } + type = "cars", + id = existingCar.StringId } } } - }; + } + }; - string route = $"/engines/{existingEngine.StringId}"; + string route = $"/engines/{existingEngine.StringId}"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Engine engineInDatabase = await dbContext.Engines.Include(engine => engine.Car).FirstWithIdAsync(existingEngine.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Engine engineInDatabase = await dbContext.Engines.Include(engine => engine.Car).FirstWithIdAsync(existingEngine.Id); - engineInDatabase.Car.ShouldNotBeNull(); - engineInDatabase.Car.Id.Should().Be(existingCar.StringId); - }); - } + engineInDatabase.Car.ShouldNotBeNull(); + engineInDatabase.Car.Id.Should().Be(existingCar.StringId); + }); + } - [Fact] - public async Task Can_clear_OneToOne_relationship() - { - // Arrange - Engine existingEngine = _fakers.Engine.Generate(); - existingEngine.Car = _fakers.Car.Generate(); + [Fact] + public async Task Can_clear_OneToOne_relationship() + { + // Arrange + Engine existingEngine = _fakers.Engine.Generate(); + existingEngine.Car = _fakers.Car.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Engines.Add(existingEngine); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Engines.Add(existingEngine); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new { - data = new + type = "engines", + id = existingEngine.StringId, + relationships = new { - type = "engines", - id = existingEngine.StringId, - relationships = new + car = new { - car = new - { - data = (object?)null - } + data = (object?)null } } - }; + } + }; - string route = $"/engines/{existingEngine.StringId}"; + string route = $"/engines/{existingEngine.StringId}"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Engine engineInDatabase = await dbContext.Engines.Include(engine => engine.Car).FirstWithIdAsync(existingEngine.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Engine engineInDatabase = await dbContext.Engines.Include(engine => engine.Car).FirstWithIdAsync(existingEngine.Id); - engineInDatabase.Car.Should().BeNull(); - }); - } + engineInDatabase.Car.Should().BeNull(); + }); + } - [Fact] - public async Task Can_remove_from_OneToMany_relationship() - { - // Arrange - Dealership existingDealership = _fakers.Dealership.Generate(); - existingDealership.Inventory = _fakers.Car.Generate(2).ToHashSet(); + [Fact] + public async Task Can_remove_from_OneToMany_relationship() + { + // Arrange + Dealership existingDealership = _fakers.Dealership.Generate(); + existingDealership.Inventory = _fakers.Car.Generate(2).ToHashSet(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Dealerships.Add(existingDealership); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Dealerships.Add(existingDealership); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new[] { - data = new[] + new { - new - { - type = "cars", - id = existingDealership.Inventory.ElementAt(0).StringId - } + type = "cars", + id = existingDealership.Inventory.ElementAt(0).StringId } - }; + } + }; - string route = $"/dealerships/{existingDealership.StringId}/relationships/inventory"; + string route = $"/dealerships/{existingDealership.StringId}/relationships/inventory"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Dealership dealershipInDatabase = - await dbContext.Dealerships.Include(dealership => dealership.Inventory).FirstWithIdAsync(existingDealership.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Dealership dealershipInDatabase = await dbContext.Dealerships.Include(dealership => dealership.Inventory).FirstWithIdAsync(existingDealership.Id); - dealershipInDatabase.Inventory.ShouldHaveCount(1); - dealershipInDatabase.Inventory.Should().ContainSingle(car => car.Id == existingDealership.Inventory.ElementAt(1).Id); - }); - } + dealershipInDatabase.Inventory.ShouldHaveCount(1); + dealershipInDatabase.Inventory.Should().ContainSingle(car => car.Id == existingDealership.Inventory.ElementAt(1).Id); + }); + } - [Fact] - public async Task Can_add_to_OneToMany_relationship() - { - // Arrange - Dealership existingDealership = _fakers.Dealership.Generate(); - Car existingCar = _fakers.Car.Generate(); + [Fact] + public async Task Can_add_to_OneToMany_relationship() + { + // Arrange + Dealership existingDealership = _fakers.Dealership.Generate(); + Car existingCar = _fakers.Car.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.AddInRange(existingDealership, existingCar); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddInRange(existingDealership, existingCar); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new[] { - data = new[] + new { - new - { - type = "cars", - id = existingCar.StringId - } + type = "cars", + id = existingCar.StringId } - }; + } + }; - string route = $"/dealerships/{existingDealership.StringId}/relationships/inventory"; + string route = $"/dealerships/{existingDealership.StringId}/relationships/inventory"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Dealership dealershipInDatabase = - await dbContext.Dealerships.Include(dealership => dealership.Inventory).FirstWithIdAsync(existingDealership.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Dealership dealershipInDatabase = await dbContext.Dealerships.Include(dealership => dealership.Inventory).FirstWithIdAsync(existingDealership.Id); - dealershipInDatabase.Inventory.ShouldHaveCount(1); - dealershipInDatabase.Inventory.Should().ContainSingle(car => car.Id == existingCar.Id); - }); - } + dealershipInDatabase.Inventory.ShouldHaveCount(1); + dealershipInDatabase.Inventory.Should().ContainSingle(car => car.Id == existingCar.Id); + }); + } - [Fact] - public async Task Can_replace_OneToMany_relationship() - { - // Arrange - Dealership existingDealership = _fakers.Dealership.Generate(); - existingDealership.Inventory = _fakers.Car.Generate(2).ToHashSet(); + [Fact] + public async Task Can_replace_OneToMany_relationship() + { + // Arrange + Dealership existingDealership = _fakers.Dealership.Generate(); + existingDealership.Inventory = _fakers.Car.Generate(2).ToHashSet(); - Car existingCar = _fakers.Car.Generate(); + Car existingCar = _fakers.Car.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.AddInRange(existingDealership, existingCar); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddInRange(existingDealership, existingCar); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new[] { - data = new[] + new { - new - { - type = "cars", - id = existingDealership.Inventory.ElementAt(0).StringId - }, - new - { - type = "cars", - id = existingCar.StringId - } + type = "cars", + id = existingDealership.Inventory.ElementAt(0).StringId + }, + new + { + type = "cars", + id = existingCar.StringId } - }; + } + }; - string route = $"/dealerships/{existingDealership.StringId}/relationships/inventory"; + string route = $"/dealerships/{existingDealership.StringId}/relationships/inventory"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Dealership dealershipInDatabase = - await dbContext.Dealerships.Include(dealership => dealership.Inventory).FirstWithIdAsync(existingDealership.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Dealership dealershipInDatabase = await dbContext.Dealerships.Include(dealership => dealership.Inventory).FirstWithIdAsync(existingDealership.Id); - dealershipInDatabase.Inventory.ShouldHaveCount(2); - dealershipInDatabase.Inventory.Should().ContainSingle(car => car.Id == existingCar.Id); - dealershipInDatabase.Inventory.Should().ContainSingle(car => car.Id == existingDealership.Inventory.ElementAt(0).Id); - }); - } + dealershipInDatabase.Inventory.ShouldHaveCount(2); + dealershipInDatabase.Inventory.Should().ContainSingle(car => car.Id == existingCar.Id); + dealershipInDatabase.Inventory.Should().ContainSingle(car => car.Id == existingDealership.Inventory.ElementAt(0).Id); + }); + } - [Fact] - public async Task Cannot_remove_from_ManyToOne_relationship_for_unknown_relationship_ID() - { - // Arrange - Dealership existingDealership = _fakers.Dealership.Generate(); + [Fact] + public async Task Cannot_remove_from_ManyToOne_relationship_for_unknown_relationship_ID() + { + // Arrange + Dealership existingDealership = _fakers.Dealership.Generate(); - string unknownCarId = _fakers.Car.Generate().StringId!; + string unknownCarId = _fakers.Car.Generate().StringId!; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Dealerships.Add(existingDealership); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Dealerships.Add(existingDealership); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new[] { - data = new[] + new { - new - { - type = "cars", - id = unknownCarId - } + type = "cars", + id = unknownCarId } - }; + } + }; + + string route = $"/dealerships/{existingDealership.StringId}/relationships/inventory"; - string route = $"/dealerships/{existingDealership.StringId}/relationships/inventory"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + responseDocument.Errors.ShouldHaveCount(1); - responseDocument.Errors.ShouldHaveCount(1); + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("A related resource does not exist."); + error.Detail.Should().Be($"Related resource of type 'cars' with ID '{unknownCarId}' in relationship 'inventory' does not exist."); + } - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.NotFound); - error.Title.Should().Be("A related resource does not exist."); - error.Detail.Should().Be($"Related resource of type 'cars' with ID '{unknownCarId}' in relationship 'inventory' does not exist."); - } + [Fact] + public async Task Can_delete_resource() + { + // Arrange + Car existingCar = _fakers.Car.Generate(); - [Fact] - public async Task Can_delete_resource() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - Car existingCar = _fakers.Car.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Cars.Add(existingCar); - await dbContext.SaveChangesAsync(); - }); + await dbContext.ClearTableAsync(); + dbContext.Cars.Add(existingCar); + await dbContext.SaveChangesAsync(); + }); - string route = $"/cars/{existingCar.StringId}"; + string route = $"/cars/{existingCar.StringId}"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Car? carInDatabase = - await dbContext.Cars.FirstOrDefaultAsync(car => car.RegionId == existingCar.RegionId && car.LicensePlate == existingCar.LicensePlate); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Car? carInDatabase = + await dbContext.Cars.FirstOrDefaultAsync(car => car.RegionId == existingCar.RegionId && car.LicensePlate == existingCar.LicensePlate); - carInDatabase.Should().BeNull(); - }); - } + carInDatabase.Should().BeNull(); + }); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/Dealership.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/Dealership.cs index 651eeb692c..14784cb438 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/Dealership.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/Dealership.cs @@ -1,18 +1,16 @@ -using System.Collections.Generic; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.CompositeKeys +namespace JsonApiDotNetCoreTests.IntegrationTests.CompositeKeys; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.CompositeKeys")] +public sealed class Dealership : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - [Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.CompositeKeys")] - public sealed class Dealership : Identifiable - { - [Attr] - public string Address { get; set; } = null!; + [Attr] + public string Address { get; set; } = null!; - [HasMany] - public ISet Inventory { get; set; } = new HashSet(); - } + [HasMany] + public ISet Inventory { get; set; } = new HashSet(); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/Engine.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/Engine.cs index 87e207c0a6..56421610aa 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/Engine.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/Engine.cs @@ -2,16 +2,15 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.CompositeKeys +namespace JsonApiDotNetCoreTests.IntegrationTests.CompositeKeys; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.CompositeKeys")] +public sealed class Engine : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - [Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.CompositeKeys")] - public sealed class Engine : Identifiable - { - [Attr] - public string SerialCode { get; set; } = null!; + [Attr] + public string SerialCode { get; set; } = null!; - [HasOne] - public Car? Car { get; set; } - } + [HasOne] + public Car? Car { get; set; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs index 35b24149f6..caa29722c5 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs @@ -1,253 +1,249 @@ -using System; using System.Net; -using System.Net.Http; using System.Net.Http.Headers; -using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Serialization.Objects; using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.ContentNegotiation +namespace JsonApiDotNetCoreTests.IntegrationTests.ContentNegotiation; + +public sealed class AcceptHeaderTests : IClassFixture, PolicyDbContext>> { - public sealed class AcceptHeaderTests : IClassFixture, PolicyDbContext>> - { - private readonly IntegrationTestContext, PolicyDbContext> _testContext; + private readonly IntegrationTestContext, PolicyDbContext> _testContext; - public AcceptHeaderTests(IntegrationTestContext, PolicyDbContext> testContext) - { - _testContext = testContext; + public AcceptHeaderTests(IntegrationTestContext, PolicyDbContext> testContext) + { + _testContext = testContext; - testContext.UseController(); - testContext.UseController(); - } + testContext.UseController(); + testContext.UseController(); + } - [Fact] - public async Task Permits_no_Accept_headers() - { - // Arrange - const string route = "/policies"; + [Fact] + public async Task Permits_no_Accept_headers() + { + // Arrange + const string route = "/policies"; - // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteGetAsync(route); + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteGetAsync(route); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - } + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + } - [Fact] - public async Task Permits_no_Accept_headers_at_operations_endpoint() + [Fact] + public async Task Permits_no_Accept_headers_at_operations_endpoint() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "policies", + attributes = new { - type = "policies", - attributes = new - { - name = "some" - } + name = "some" } } } - }; + } + }; + + const string route = "/operations"; + const string contentType = HeaderConstants.AtomicOperationsMediaType; - const string route = "/operations"; - const string contentType = HeaderConstants.AtomicOperationsMediaType; + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody, contentType); - // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody, contentType); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + } - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - } + [Fact] + public async Task Permits_global_wildcard_in_Accept_headers() + { + // Arrange + const string route = "/policies"; - [Fact] - public async Task Permits_global_wildcard_in_Accept_headers() + Action setRequestHeaders = headers => { - // Arrange - const string route = "/policies"; + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("text/html")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("*/*")); + }; - Action setRequestHeaders = headers => - { - headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("text/html")); - headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("*/*")); - }; + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteGetAsync(route, setRequestHeaders); - // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteGetAsync(route, setRequestHeaders); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + } - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - } + [Fact] + public async Task Permits_application_wildcard_in_Accept_headers() + { + // Arrange + const string route = "/policies"; - [Fact] - public async Task Permits_application_wildcard_in_Accept_headers() + Action setRequestHeaders = headers => { - // Arrange - const string route = "/policies"; + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("text/html;q=0.8")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/*;q=0.2")); + }; - Action setRequestHeaders = headers => - { - headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("text/html;q=0.8")); - headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/*;q=0.2")); - }; + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteGetAsync(route, setRequestHeaders); - // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteGetAsync(route, setRequestHeaders); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + } - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - } + [Fact] + public async Task Permits_JsonApi_without_parameters_in_Accept_headers() + { + // Arrange + const string route = "/policies"; - [Fact] - public async Task Permits_JsonApi_without_parameters_in_Accept_headers() + Action setRequestHeaders = headers => { - // Arrange - const string route = "/policies"; + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("text/html")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType}; profile=some")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType}; ext=other")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType}; unknown=unexpected")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType}; q=0.3")); + }; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteGetAsync(route, setRequestHeaders); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + } - Action setRequestHeaders = headers => - { - headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("text/html")); - headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType}; profile=some")); - headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType}; ext=other")); - headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType}; unknown=unexpected")); - headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType}; q=0.3")); - }; - - // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteGetAsync(route, setRequestHeaders); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - } - - [Fact] - public async Task Permits_JsonApi_with_AtomicOperations_extension_in_Accept_headers_at_operations_endpoint() + [Fact] + public async Task Permits_JsonApi_with_AtomicOperations_extension_in_Accept_headers_at_operations_endpoint() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "policies", + attributes = new { - type = "policies", - attributes = new - { - name = "some" - } + name = "some" } } } - }; + } + }; - const string route = "/operations"; - const string contentType = HeaderConstants.AtomicOperationsMediaType; + const string route = "/operations"; + const string contentType = HeaderConstants.AtomicOperationsMediaType; - Action setRequestHeaders = headers => - { - headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("text/html")); - headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType}; profile=some")); - headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.MediaType)); - headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType}; unknown=unexpected")); - headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType};ext=\"https://jsonapi.org/ext/atomic\"; q=0.2")); - }; - - // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody, contentType, setRequestHeaders); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - } - - [Fact] - public async Task Denies_JsonApi_with_parameters_in_Accept_headers() + Action setRequestHeaders = headers => { - // Arrange - const string route = "/policies"; + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("text/html")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType}; profile=some")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.MediaType)); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType}; unknown=unexpected")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType};ext=\"https://jsonapi.org/ext/atomic\"; q=0.2")); + }; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody, contentType, setRequestHeaders); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + } - Action setRequestHeaders = headers => - { - headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("text/html")); - headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType}; profile=some")); - headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType}; ext=other")); - headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType}; unknown=unexpected")); - headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.AtomicOperationsMediaType)); - }; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route, setRequestHeaders); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotAcceptable); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.NotAcceptable); - error.Title.Should().Be("The specified Accept header value does not contain any supported media types."); - error.Detail.Should().Be("Please include 'application/vnd.api+json' in the Accept header values."); - error.Source.ShouldNotBeNull(); - error.Source.Header.Should().Be("Accept"); - } - - [Fact] - public async Task Denies_JsonApi_in_Accept_headers_at_operations_endpoint() + [Fact] + public async Task Denies_JsonApi_with_parameters_in_Accept_headers() + { + // Arrange + const string route = "/policies"; + + Action setRequestHeaders = headers => + { + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("text/html")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType}; profile=some")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType}; ext=other")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType}; unknown=unexpected")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.AtomicOperationsMediaType)); + }; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route, setRequestHeaders); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotAcceptable); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotAcceptable); + error.Title.Should().Be("The specified Accept header value does not contain any supported media types."); + error.Detail.Should().Be("Please include 'application/vnd.api+json' in the Accept header values."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be("Accept"); + } + + [Fact] + public async Task Denies_JsonApi_in_Accept_headers_at_operations_endpoint() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "policies", + attributes = new { - type = "policies", - attributes = new - { - name = "some" - } + name = "some" } } } - }; + } + }; - const string route = "/operations"; - const string contentType = HeaderConstants.AtomicOperationsMediaType; + const string route = "/operations"; + const string contentType = HeaderConstants.AtomicOperationsMediaType; - Action setRequestHeaders = headers => - { - headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.MediaType)); - }; + Action setRequestHeaders = headers => + { + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.MediaType)); + }; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = - await _testContext.ExecutePostAsync(route, requestBody, contentType, setRequestHeaders); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = + await _testContext.ExecutePostAsync(route, requestBody, contentType, setRequestHeaders); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotAcceptable); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotAcceptable); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.NotAcceptable); - error.Title.Should().Be("The specified Accept header value does not contain any supported media types."); - error.Detail.Should().Be("Please include 'application/vnd.api+json; ext=\"https://jsonapi.org/ext/atomic\"' in the Accept header values."); - error.Source.ShouldNotBeNull(); - error.Source.Header.Should().Be("Accept"); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotAcceptable); + error.Title.Should().Be("The specified Accept header value does not contain any supported media types."); + error.Detail.Should().Be("Please include 'application/vnd.api+json; ext=\"https://jsonapi.org/ext/atomic\"' in the Accept header values."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be("Accept"); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs index 9c96bf0a1d..70fd03c453 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs @@ -1,388 +1,385 @@ using System.Net; -using System.Net.Http; -using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Serialization.Objects; using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.ContentNegotiation +namespace JsonApiDotNetCoreTests.IntegrationTests.ContentNegotiation; + +public sealed class ContentTypeHeaderTests : IClassFixture, PolicyDbContext>> { - public sealed class ContentTypeHeaderTests : IClassFixture, PolicyDbContext>> - { - private readonly IntegrationTestContext, PolicyDbContext> _testContext; + private readonly IntegrationTestContext, PolicyDbContext> _testContext; - public ContentTypeHeaderTests(IntegrationTestContext, PolicyDbContext> testContext) - { - _testContext = testContext; + public ContentTypeHeaderTests(IntegrationTestContext, PolicyDbContext> testContext) + { + _testContext = testContext; - testContext.UseController(); - testContext.UseController(); - } + testContext.UseController(); + testContext.UseController(); + } - [Fact] - public async Task Returns_JsonApi_ContentType_header() - { - // Arrange - const string route = "/policies"; + [Fact] + public async Task Returns_JsonApi_ContentType_header() + { + // Arrange + const string route = "/policies"; - // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteGetAsync(route); + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteGetAsync(route); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); - httpResponse.Content.Headers.ContentType.ToString().Should().Be(HeaderConstants.MediaType); - } + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(HeaderConstants.MediaType); + } - [Fact] - public async Task Returns_JsonApi_ContentType_header_with_AtomicOperations_extension() + [Fact] + public async Task Returns_JsonApi_ContentType_header_with_AtomicOperations_extension() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "policies", + attributes = new { - type = "policies", - attributes = new - { - name = "some" - } + name = "some" } } } - }; + } + }; - const string route = "/operations"; + const string route = "/operations"; - // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); - httpResponse.Content.Headers.ContentType.ToString().Should().Be(HeaderConstants.AtomicOperationsMediaType); - } + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(HeaderConstants.AtomicOperationsMediaType); + } - [Fact] - public async Task Denies_unknown_ContentType_header() + [Fact] + public async Task Denies_unknown_ContentType_header() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + data = new { - data = new + type = "policies", + attributes = new { - type = "policies", - attributes = new - { - name = "some" - } + name = "some" } - }; + } + }; - const string route = "/policies"; - const string contentType = "text/html"; + const string route = "/policies"; + const string contentType = "text/html"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody, contentType); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody, contentType); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnsupportedMediaType); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnsupportedMediaType); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnsupportedMediaType); - error.Title.Should().Be("The specified Content-Type header value is not supported."); - error.Detail.Should().Be("Please specify 'application/vnd.api+json' instead of 'text/html' for the Content-Type header value."); - error.Source.ShouldNotBeNull(); - error.Source.Header.Should().Be("Content-Type"); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnsupportedMediaType); + error.Title.Should().Be("The specified Content-Type header value is not supported."); + error.Detail.Should().Be("Please specify 'application/vnd.api+json' instead of 'text/html' for the Content-Type header value."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be("Content-Type"); + } - [Fact] - public async Task Permits_JsonApi_ContentType_header() + [Fact] + public async Task Permits_JsonApi_ContentType_header() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + data = new { - data = new + type = "policies", + attributes = new { - type = "policies", - attributes = new - { - name = "some" - } + name = "some" } - }; + } + }; - const string route = "/policies"; - const string contentType = HeaderConstants.MediaType; + const string route = "/policies"; + const string contentType = HeaderConstants.MediaType; - // Act - // ReSharper disable once RedundantArgumentDefaultValue - (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody, contentType); + // Act + // ReSharper disable once RedundantArgumentDefaultValue + (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody, contentType); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - } + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + } - [Fact] - public async Task Permits_JsonApi_ContentType_header_with_AtomicOperations_extension_at_operations_endpoint() + [Fact] + public async Task Permits_JsonApi_ContentType_header_with_AtomicOperations_extension_at_operations_endpoint() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "policies", + attributes = new { - type = "policies", - attributes = new - { - name = "some" - } + name = "some" } } } - }; + } + }; - const string route = "/operations"; - const string contentType = HeaderConstants.AtomicOperationsMediaType; + const string route = "/operations"; + const string contentType = HeaderConstants.AtomicOperationsMediaType; - // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody, contentType); + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody, contentType); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - } + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + } - [Fact] - public async Task Denies_JsonApi_ContentType_header_with_profile() + [Fact] + public async Task Denies_JsonApi_ContentType_header_with_profile() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + data = new { - data = new + type = "policies", + attributes = new { - type = "policies", - attributes = new - { - name = "some" - } + name = "some" } - }; + } + }; - const string route = "/policies"; - string contentType = $"{HeaderConstants.MediaType}; profile=something"; + const string route = "/policies"; + const string contentType = $"{HeaderConstants.MediaType}; profile=something"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody, contentType); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody, contentType); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnsupportedMediaType); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnsupportedMediaType); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnsupportedMediaType); - error.Title.Should().Be("The specified Content-Type header value is not supported."); - error.Detail.Should().Be($"Please specify 'application/vnd.api+json' instead of '{contentType}' for the Content-Type header value."); - error.Source.ShouldNotBeNull(); - error.Source.Header.Should().Be("Content-Type"); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnsupportedMediaType); + error.Title.Should().Be("The specified Content-Type header value is not supported."); + error.Detail.Should().Be($"Please specify 'application/vnd.api+json' instead of '{contentType}' for the Content-Type header value."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be("Content-Type"); + } - [Fact] - public async Task Denies_JsonApi_ContentType_header_with_extension() + [Fact] + public async Task Denies_JsonApi_ContentType_header_with_extension() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + data = new { - data = new + type = "policies", + attributes = new { - type = "policies", - attributes = new - { - name = "some" - } + name = "some" } - }; + } + }; - const string route = "/policies"; - string contentType = $"{HeaderConstants.MediaType}; ext=something"; + const string route = "/policies"; + const string contentType = $"{HeaderConstants.MediaType}; ext=something"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody, contentType); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody, contentType); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnsupportedMediaType); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnsupportedMediaType); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnsupportedMediaType); - error.Title.Should().Be("The specified Content-Type header value is not supported."); - error.Detail.Should().Be($"Please specify 'application/vnd.api+json' instead of '{contentType}' for the Content-Type header value."); - error.Source.ShouldNotBeNull(); - error.Source.Header.Should().Be("Content-Type"); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnsupportedMediaType); + error.Title.Should().Be("The specified Content-Type header value is not supported."); + error.Detail.Should().Be($"Please specify 'application/vnd.api+json' instead of '{contentType}' for the Content-Type header value."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be("Content-Type"); + } - [Fact] - public async Task Denies_JsonApi_ContentType_header_with_AtomicOperations_extension_at_resource_endpoint() + [Fact] + public async Task Denies_JsonApi_ContentType_header_with_AtomicOperations_extension_at_resource_endpoint() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + data = new { - data = new + type = "policies", + attributes = new { - type = "policies", - attributes = new - { - name = "some" - } + name = "some" } - }; + } + }; - const string route = "/policies"; - const string contentType = HeaderConstants.AtomicOperationsMediaType; + const string route = "/policies"; + const string contentType = HeaderConstants.AtomicOperationsMediaType; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody, contentType); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody, contentType); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnsupportedMediaType); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnsupportedMediaType); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnsupportedMediaType); - error.Title.Should().Be("The specified Content-Type header value is not supported."); - error.Detail.Should().Be($"Please specify 'application/vnd.api+json' instead of '{contentType}' for the Content-Type header value."); - error.Source.ShouldNotBeNull(); - error.Source.Header.Should().Be("Content-Type"); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnsupportedMediaType); + error.Title.Should().Be("The specified Content-Type header value is not supported."); + error.Detail.Should().Be($"Please specify 'application/vnd.api+json' instead of '{contentType}' for the Content-Type header value."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be("Content-Type"); + } - [Fact] - public async Task Denies_JsonApi_ContentType_header_with_CharSet() + [Fact] + public async Task Denies_JsonApi_ContentType_header_with_CharSet() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + data = new { - data = new + type = "policies", + attributes = new { - type = "policies", - attributes = new - { - name = "some" - } + name = "some" } - }; + } + }; - const string route = "/policies"; - string contentType = $"{HeaderConstants.MediaType}; charset=ISO-8859-4"; + const string route = "/policies"; + const string contentType = $"{HeaderConstants.MediaType}; charset=ISO-8859-4"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody, contentType); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody, contentType); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnsupportedMediaType); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnsupportedMediaType); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnsupportedMediaType); - error.Title.Should().Be("The specified Content-Type header value is not supported."); - error.Detail.Should().Be($"Please specify 'application/vnd.api+json' instead of '{contentType}' for the Content-Type header value."); - error.Source.ShouldNotBeNull(); - error.Source.Header.Should().Be("Content-Type"); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnsupportedMediaType); + error.Title.Should().Be("The specified Content-Type header value is not supported."); + error.Detail.Should().Be($"Please specify 'application/vnd.api+json' instead of '{contentType}' for the Content-Type header value."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be("Content-Type"); + } - [Fact] - public async Task Denies_JsonApi_ContentType_header_with_unknown_parameter() + [Fact] + public async Task Denies_JsonApi_ContentType_header_with_unknown_parameter() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + data = new { - data = new + type = "policies", + attributes = new { - type = "policies", - attributes = new - { - name = "some" - } + name = "some" } - }; + } + }; - const string route = "/policies"; - string contentType = $"{HeaderConstants.MediaType}; unknown=unexpected"; + const string route = "/policies"; + const string contentType = $"{HeaderConstants.MediaType}; unknown=unexpected"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody, contentType); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody, contentType); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnsupportedMediaType); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnsupportedMediaType); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnsupportedMediaType); - error.Title.Should().Be("The specified Content-Type header value is not supported."); - error.Detail.Should().Be($"Please specify 'application/vnd.api+json' instead of '{contentType}' for the Content-Type header value."); - error.Source.ShouldNotBeNull(); - error.Source.Header.Should().Be("Content-Type"); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnsupportedMediaType); + error.Title.Should().Be("The specified Content-Type header value is not supported."); + error.Detail.Should().Be($"Please specify 'application/vnd.api+json' instead of '{contentType}' for the Content-Type header value."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be("Content-Type"); + } - [Fact] - public async Task Denies_JsonApi_ContentType_header_at_operations_endpoint() + [Fact] + public async Task Denies_JsonApi_ContentType_header_at_operations_endpoint() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + atomic__operations = new[] { - atomic__operations = new[] + new { - new + op = "add", + data = new { - op = "add", - data = new + type = "policies", + attributes = new { - type = "policies", - attributes = new - { - name = "some" - } + name = "some" } } } - }; + } + }; - const string route = "/operations"; - const string contentType = HeaderConstants.MediaType; + const string route = "/operations"; + const string contentType = HeaderConstants.MediaType; - // Act - // ReSharper disable once RedundantArgumentDefaultValue - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody, contentType); + // Act + // ReSharper disable once RedundantArgumentDefaultValue + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody, contentType); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnsupportedMediaType); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnsupportedMediaType); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - string detail = $"Please specify '{HeaderConstants.AtomicOperationsMediaType}' instead of '{contentType}' for the Content-Type header value."; + const string detail = $"Please specify '{HeaderConstants.AtomicOperationsMediaType}' instead of '{contentType}' for the Content-Type header value."; - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnsupportedMediaType); - error.Title.Should().Be("The specified Content-Type header value is not supported."); - error.Detail.Should().Be(detail); - error.Source.ShouldNotBeNull(); - error.Source.Header.Should().Be("Content-Type"); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnsupportedMediaType); + error.Title.Should().Be("The specified Content-Type header value is not supported."); + error.Detail.Should().Be(detail); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be("Content-Type"); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/OperationsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/OperationsController.cs index 06cf328d23..d24a0f29d1 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/OperationsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/OperationsController.cs @@ -5,14 +5,13 @@ using JsonApiDotNetCore.Resources; using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCoreTests.IntegrationTests.ContentNegotiation +namespace JsonApiDotNetCoreTests.IntegrationTests.ContentNegotiation; + +public sealed class OperationsController : JsonApiOperationsController { - public sealed class OperationsController : JsonApiOperationsController + public OperationsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IOperationsProcessor processor, + IJsonApiRequest request, ITargetedFields targetedFields) + : base(options, resourceGraph, loggerFactory, processor, request, targetedFields) { - public OperationsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IOperationsProcessor processor, - IJsonApiRequest request, ITargetedFields targetedFields) - : base(options, resourceGraph, loggerFactory, processor, request, targetedFields) - { - } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/Policy.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/Policy.cs index 9f8e8b2af9..ab2339e9fd 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/Policy.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/Policy.cs @@ -2,13 +2,12 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.ContentNegotiation +namespace JsonApiDotNetCoreTests.IntegrationTests.ContentNegotiation; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.ContentNegotiation")] +public sealed class Policy : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - [Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.ContentNegotiation")] - public sealed class Policy : Identifiable - { - [Attr] - public string Name { get; set; } = null!; - } + [Attr] + public string Name { get; set; } = null!; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/PolicyDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/PolicyDbContext.cs index 3e952ff6ba..61063269ce 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/PolicyDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/PolicyDbContext.cs @@ -1,16 +1,15 @@ using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; -namespace JsonApiDotNetCoreTests.IntegrationTests.ContentNegotiation +namespace JsonApiDotNetCoreTests.IntegrationTests.ContentNegotiation; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class PolicyDbContext : DbContext { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class PolicyDbContext : DbContext - { - public DbSet Policies => Set(); + public DbSet Policies => Set(); - public PolicyDbContext(DbContextOptions options) - : base(options) - { - } + public PolicyDbContext(DbContextOptions options) + : base(options) + { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/ActionResultDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/ActionResultDbContext.cs index c8219d8dd6..63b748ffab 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/ActionResultDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/ActionResultDbContext.cs @@ -1,16 +1,15 @@ using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; -namespace JsonApiDotNetCoreTests.IntegrationTests.ControllerActionResults +namespace JsonApiDotNetCoreTests.IntegrationTests.ControllerActionResults; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class ActionResultDbContext : DbContext { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class ActionResultDbContext : DbContext - { - public DbSet Toothbrushes => Set(); + public DbSet Toothbrushes => Set(); - public ActionResultDbContext(DbContextOptions options) - : base(options) - { - } + public ActionResultDbContext(DbContextOptions options) + : base(options) + { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/ActionResultTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/ActionResultTests.cs index 915b2020fc..94e9a12b85 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/ActionResultTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/ActionResultTests.cs @@ -1,156 +1,153 @@ using System.Net; -using System.Net.Http; -using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.ControllerActionResults +namespace JsonApiDotNetCoreTests.IntegrationTests.ControllerActionResults; + +public sealed class ActionResultTests : IClassFixture, ActionResultDbContext>> { - public sealed class ActionResultTests : IClassFixture, ActionResultDbContext>> + private readonly IntegrationTestContext, ActionResultDbContext> _testContext; + + public ActionResultTests(IntegrationTestContext, ActionResultDbContext> testContext) { - private readonly IntegrationTestContext, ActionResultDbContext> _testContext; + _testContext = testContext; - public ActionResultTests(IntegrationTestContext, ActionResultDbContext> testContext) - { - _testContext = testContext; + testContext.UseController(); + } - testContext.UseController(); - } + [Fact] + public async Task Can_get_resource_by_ID() + { + // Arrange + var toothbrush = new Toothbrush(); - [Fact] - public async Task Can_get_resource_by_ID() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - var toothbrush = new Toothbrush(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Toothbrushes.Add(toothbrush); - await dbContext.SaveChangesAsync(); - }); + dbContext.Toothbrushes.Add(toothbrush); + await dbContext.SaveChangesAsync(); + }); - string route = $"/toothbrushes/{toothbrush.StringId}"; + string route = $"/toothbrushes/{toothbrush.StringId}"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Id.Should().Be(toothbrush.StringId); - } + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Id.Should().Be(toothbrush.StringId); + } - [Fact] - public async Task Converts_empty_ActionResult_to_error_collection() - { - // Arrange - string route = $"/toothbrushes/{ToothbrushesController.EmptyActionResultId}"; + [Fact] + public async Task Converts_empty_ActionResult_to_error_collection() + { + // Arrange + string route = $"/toothbrushes/{ToothbrushesController.EmptyActionResultId}"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.NotFound); - error.Title.Should().Be("NotFound"); - error.Detail.Should().BeNull(); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("NotFound"); + error.Detail.Should().BeNull(); + } - [Fact] - public async Task Converts_ActionResult_with_error_object_to_error_collection() - { - // Arrange - string route = $"/toothbrushes/{ToothbrushesController.ActionResultWithErrorObjectId}"; + [Fact] + public async Task Converts_ActionResult_with_error_object_to_error_collection() + { + // Arrange + string route = $"/toothbrushes/{ToothbrushesController.ActionResultWithErrorObjectId}"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.NotFound); - error.Title.Should().Be("No toothbrush with that ID exists."); - error.Detail.Should().BeNull(); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("No toothbrush with that ID exists."); + error.Detail.Should().BeNull(); + } - [Fact] - public async Task Cannot_convert_ActionResult_with_string_parameter_to_error_collection() - { - // Arrange - string route = $"/toothbrushes/{ToothbrushesController.ActionResultWithStringParameter}"; + [Fact] + public async Task Cannot_convert_ActionResult_with_string_parameter_to_error_collection() + { + // Arrange + string route = $"/toothbrushes/{ToothbrushesController.ActionResultWithStringParameter}"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.InternalServerError); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.InternalServerError); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.InternalServerError); - error.Title.Should().Be("An unhandled error occurred while processing this request."); - error.Detail.Should().Be("Data being returned must be resources, operations, errors or null."); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.InternalServerError); + error.Title.Should().Be("An unhandled error occurred while processing this request."); + error.Detail.Should().Be("Data being returned must be resources, operations, errors or null."); + } - [Fact] - public async Task Converts_ObjectResult_with_error_object_to_error_collection() - { - // Arrange - string route = $"/toothbrushes/{ToothbrushesController.ObjectResultWithErrorObjectId}"; + [Fact] + public async Task Converts_ObjectResult_with_error_object_to_error_collection() + { + // Arrange + string route = $"/toothbrushes/{ToothbrushesController.ObjectResultWithErrorObjectId}"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadGateway); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadGateway); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadGateway); - error.Title.Should().BeNull(); - error.Detail.Should().BeNull(); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadGateway); + error.Title.Should().BeNull(); + error.Detail.Should().BeNull(); + } - [Fact] - public async Task Converts_ObjectResult_with_error_objects_to_error_collection() - { - // Arrange - string route = $"/toothbrushes/{ToothbrushesController.ObjectResultWithErrorCollectionId}"; + [Fact] + public async Task Converts_ObjectResult_with_error_objects_to_error_collection() + { + // Arrange + string route = $"/toothbrushes/{ToothbrushesController.ObjectResultWithErrorCollectionId}"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(3); + responseDocument.Errors.ShouldHaveCount(3); - ErrorObject error1 = responseDocument.Errors[0]; - error1.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); - error1.Title.Should().BeNull(); - error1.Detail.Should().BeNull(); + ErrorObject error1 = responseDocument.Errors[0]; + error1.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + error1.Title.Should().BeNull(); + error1.Detail.Should().BeNull(); - ErrorObject error2 = responseDocument.Errors[1]; - error2.StatusCode.Should().Be(HttpStatusCode.Unauthorized); - error2.Title.Should().BeNull(); - error2.Detail.Should().BeNull(); + ErrorObject error2 = responseDocument.Errors[1]; + error2.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + error2.Title.Should().BeNull(); + error2.Detail.Should().BeNull(); - ErrorObject error3 = responseDocument.Errors[2]; - error3.StatusCode.Should().Be(HttpStatusCode.ExpectationFailed); - error3.Title.Should().Be("This is not a very great request."); - error3.Detail.Should().BeNull(); - } + ErrorObject error3 = responseDocument.Errors[2]; + error3.StatusCode.Should().Be(HttpStatusCode.ExpectationFailed); + error3.Title.Should().Be("This is not a very great request."); + error3.Detail.Should().BeNull(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/Toothbrush.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/Toothbrush.cs index e79ba4c1c4..4e6769756a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/Toothbrush.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/Toothbrush.cs @@ -2,13 +2,12 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.ControllerActionResults +namespace JsonApiDotNetCoreTests.IntegrationTests.ControllerActionResults; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.ControllerActionResults")] +public sealed class Toothbrush : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - [Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.ControllerActionResults")] - public sealed class Toothbrush : Identifiable - { - [Attr] - public bool IsElectric { get; set; } - } + [Attr] + public bool IsElectric { get; set; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/ToothbrushesController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/ToothbrushesController.cs index fbebfa42b5..2170d113ce 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/ToothbrushesController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/ToothbrushesController.cs @@ -1,66 +1,63 @@ using System.Net; -using System.Threading; -using System.Threading.Tasks; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.AspNetCore.Mvc; -namespace JsonApiDotNetCoreTests.IntegrationTests.ControllerActionResults +namespace JsonApiDotNetCoreTests.IntegrationTests.ControllerActionResults; + +// Workaround for https://youtrack.jetbrains.com/issue/RSRP-487028 +public partial class ToothbrushesController { - // Workaround for https://youtrack.jetbrains.com/issue/RSRP-487028 - public partial class ToothbrushesController - { - } +} - partial class ToothbrushesController +partial class ToothbrushesController +{ + internal const int EmptyActionResultId = 11111111; + internal const int ActionResultWithErrorObjectId = 22222222; + internal const int ActionResultWithStringParameter = 33333333; + internal const int ObjectResultWithErrorObjectId = 44444444; + internal const int ObjectResultWithErrorCollectionId = 55555555; + + [HttpGet("{id}")] + public override async Task GetAsync(int id, CancellationToken cancellationToken) { - internal const int EmptyActionResultId = 11111111; - internal const int ActionResultWithErrorObjectId = 22222222; - internal const int ActionResultWithStringParameter = 33333333; - internal const int ObjectResultWithErrorObjectId = 44444444; - internal const int ObjectResultWithErrorCollectionId = 55555555; - - [HttpGet("{id}")] - public override async Task GetAsync(int id, CancellationToken cancellationToken) + if (id == EmptyActionResultId) { - if (id == EmptyActionResultId) - { - return NotFound(); - } + return NotFound(); + } - if (id == ActionResultWithErrorObjectId) + if (id == ActionResultWithErrorObjectId) + { + return NotFound(new ErrorObject(HttpStatusCode.NotFound) { - return NotFound(new ErrorObject(HttpStatusCode.NotFound) - { - Title = "No toothbrush with that ID exists." - }); - } + Title = "No toothbrush with that ID exists." + }); + } - if (id == ActionResultWithStringParameter) - { - return Conflict("Something went wrong."); - } + if (id == ActionResultWithStringParameter) + { + return Conflict("Something went wrong."); + } - if (id == ObjectResultWithErrorObjectId) - { - return Error(new ErrorObject(HttpStatusCode.BadGateway)); - } + if (id == ObjectResultWithErrorObjectId) + { + return Error(new ErrorObject(HttpStatusCode.BadGateway)); + } - if (id == ObjectResultWithErrorCollectionId) + if (id == ObjectResultWithErrorCollectionId) + { + var errors = new[] { - var errors = new[] + new ErrorObject(HttpStatusCode.PreconditionFailed), + new ErrorObject(HttpStatusCode.Unauthorized), + new ErrorObject(HttpStatusCode.ExpectationFailed) { - new ErrorObject(HttpStatusCode.PreconditionFailed), - new ErrorObject(HttpStatusCode.Unauthorized), - new ErrorObject(HttpStatusCode.ExpectationFailed) - { - Title = "This is not a very great request." - } - }; + Title = "This is not a very great request." + } + }; - return Error(errors); - } - - return await base.GetAsync(id, cancellationToken); + return Error(errors); } + + return await base.GetAsync(id, cancellationToken); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/ApiControllerAttributeTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/ApiControllerAttributeTests.cs index 2d6fcca6fb..ee5c6c2da7 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/ApiControllerAttributeTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/ApiControllerAttributeTests.cs @@ -1,41 +1,38 @@ using System.Net; -using System.Net.Http; -using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.CustomRoutes +namespace JsonApiDotNetCoreTests.IntegrationTests.CustomRoutes; + +public sealed class ApiControllerAttributeTests : IClassFixture, CustomRouteDbContext>> { - public sealed class ApiControllerAttributeTests : IClassFixture, CustomRouteDbContext>> - { - private readonly IntegrationTestContext, CustomRouteDbContext> _testContext; + private readonly IntegrationTestContext, CustomRouteDbContext> _testContext; - public ApiControllerAttributeTests(IntegrationTestContext, CustomRouteDbContext> testContext) - { - _testContext = testContext; + public ApiControllerAttributeTests(IntegrationTestContext, CustomRouteDbContext> testContext) + { + _testContext = testContext; - testContext.UseController(); - } + testContext.UseController(); + } - [Fact] - public async Task ApiController_attribute_transforms_NotFound_action_result_without_arguments_into_ProblemDetails() - { - // Arrange - const string route = "/world-civilians/missing"; + [Fact] + public async Task ApiController_attribute_transforms_NotFound_action_result_without_arguments_into_ProblemDetails() + { + // Arrange + const string route = "/world-civilians/missing"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.Links.ShouldNotBeNull(); - error.Links.About.Should().Be("https://tools.ietf.org/html/rfc7231#section-6.5.4"); - } + ErrorObject error = responseDocument.Errors[0]; + error.Links.ShouldNotBeNull(); + error.Links.About.Should().Be("https://tools.ietf.org/html/rfc7231#section-6.5.4"); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/Civilian.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/Civilian.cs index f0c67c80a0..09fceeca60 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/Civilian.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/Civilian.cs @@ -2,13 +2,12 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.CustomRoutes +namespace JsonApiDotNetCoreTests.IntegrationTests.CustomRoutes; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.CustomRoutes")] +public sealed class Civilian : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - [Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.CustomRoutes")] - public sealed class Civilian : Identifiable - { - [Attr] - public string Name { get; set; } = null!; - } + [Attr] + public string Name { get; set; } = null!; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CiviliansController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CiviliansController.cs index b94f7b8989..313a8e8849 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CiviliansController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CiviliansController.cs @@ -1,24 +1,22 @@ -using System.Threading.Tasks; using JsonApiDotNetCore.Controllers.Annotations; using Microsoft.AspNetCore.Mvc; -namespace JsonApiDotNetCoreTests.IntegrationTests.CustomRoutes +namespace JsonApiDotNetCoreTests.IntegrationTests.CustomRoutes; + +// Workaround for https://youtrack.jetbrains.com/issue/RSRP-487028 +public partial class CiviliansController { - // Workaround for https://youtrack.jetbrains.com/issue/RSRP-487028 - public partial class CiviliansController - { - } +} - [ApiController] - [DisableRoutingConvention] - [Route("world-civilians")] - partial class CiviliansController +[ApiController] +[DisableRoutingConvention] +[Route("world-civilians")] +partial class CiviliansController +{ + [HttpGet("missing")] + public async Task GetMissingAsync() { - [HttpGet("missing")] - public async Task GetMissingAsync() - { - await Task.Yield(); - return NotFound(); - } + await Task.Yield(); + return NotFound(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CustomRouteDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CustomRouteDbContext.cs index fc24c83f45..784e07bb4b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CustomRouteDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CustomRouteDbContext.cs @@ -1,17 +1,16 @@ using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; -namespace JsonApiDotNetCoreTests.IntegrationTests.CustomRoutes +namespace JsonApiDotNetCoreTests.IntegrationTests.CustomRoutes; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class CustomRouteDbContext : DbContext { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class CustomRouteDbContext : DbContext - { - public DbSet Towns => Set(); - public DbSet Civilians => Set(); + public DbSet Towns => Set(); + public DbSet Civilians => Set(); - public CustomRouteDbContext(DbContextOptions options) - : base(options) - { - } + public CustomRouteDbContext(DbContextOptions options) + : base(options) + { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CustomRouteFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CustomRouteFakers.cs index 6d32e44da8..d9416d99ab 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CustomRouteFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CustomRouteFakers.cs @@ -1,27 +1,25 @@ -using System; using Bogus; using TestBuildingBlocks; // @formatter:wrap_chained_method_calls chop_always // @formatter:keep_existing_linebreaks true -namespace JsonApiDotNetCoreTests.IntegrationTests.CustomRoutes +namespace JsonApiDotNetCoreTests.IntegrationTests.CustomRoutes; + +internal sealed class CustomRouteFakers : FakerContainer { - internal sealed class CustomRouteFakers : FakerContainer - { - private readonly Lazy> _lazyTownFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(town => town.Name, faker => faker.Address.City()) - .RuleFor(town => town.Latitude, faker => faker.Address.Latitude()) - .RuleFor(town => town.Longitude, faker => faker.Address.Longitude())); + private readonly Lazy> _lazyTownFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(town => town.Name, faker => faker.Address.City()) + .RuleFor(town => town.Latitude, faker => faker.Address.Latitude()) + .RuleFor(town => town.Longitude, faker => faker.Address.Longitude())); - private readonly Lazy> _lazyCivilianFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(civilian => civilian.Name, faker => faker.Person.FullName)); + private readonly Lazy> _lazyCivilianFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(civilian => civilian.Name, faker => faker.Person.FullName)); - public Faker Town => _lazyTownFaker.Value; - public Faker Civilian => _lazyCivilianFaker.Value; - } + public Faker Town => _lazyTownFaker.Value; + public Faker Civilian => _lazyCivilianFaker.Value; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CustomRouteTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CustomRouteTests.cs index 4c313aa2c3..a4a98592d9 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CustomRouteTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CustomRouteTests.cs @@ -1,97 +1,92 @@ -using System.Collections.Generic; -using System.Linq; using System.Net; -using System.Net.Http; -using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.CustomRoutes +namespace JsonApiDotNetCoreTests.IntegrationTests.CustomRoutes; + +public sealed class CustomRouteTests : IClassFixture, CustomRouteDbContext>> { - public sealed class CustomRouteTests : IClassFixture, CustomRouteDbContext>> + private const string HostPrefix = "http://localhost"; + + private readonly IntegrationTestContext, CustomRouteDbContext> _testContext; + private readonly CustomRouteFakers _fakers = new(); + + public CustomRouteTests(IntegrationTestContext, CustomRouteDbContext> testContext) { - private const string HostPrefix = "http://localhost"; + _testContext = testContext; - private readonly IntegrationTestContext, CustomRouteDbContext> _testContext; - private readonly CustomRouteFakers _fakers = new(); + testContext.UseController(); + testContext.UseController(); + } - public CustomRouteTests(IntegrationTestContext, CustomRouteDbContext> testContext) + [Fact] + public async Task Can_get_resource_at_custom_route() + { + // Arrange + Town town = _fakers.Town.Generate(); + town.Civilians = _fakers.Civilian.Generate(1).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => { - _testContext = testContext; + dbContext.Towns.Add(town); + await dbContext.SaveChangesAsync(); + }); - testContext.UseController(); - testContext.UseController(); - } + string route = $"/world-api/civilization/popular/towns/{town.StringId}"; - [Fact] - public async Task Can_get_resource_at_custom_route() - { - // Arrange - Town town = _fakers.Town.Generate(); - town.Civilians = _fakers.Civilian.Generate(1).ToHashSet(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Towns.Add(town); - await dbContext.SaveChangesAsync(); - }); - - string route = $"/world-api/civilization/popular/towns/{town.StringId}"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Type.Should().Be("towns"); - responseDocument.Data.SingleValue.Id.Should().Be(town.StringId); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("name").With(value => value.Should().Be(town.Name)); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("latitude").With(value => value.Should().Be(town.Latitude)); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("longitude").With(value => value.Should().Be(town.Longitude)); - - responseDocument.Data.SingleValue.Relationships.ShouldContainKey("civilians").With(value => - { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - value.Links.Self.Should().Be($"{HostPrefix}{route}/relationships/civilians"); - value.Links.Related.Should().Be($"{HostPrefix}{route}/civilians"); - }); - - responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.ShouldNotBeNull(); - responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); - } - - [Fact] - public async Task Can_get_resources_at_custom_action_method() + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("towns"); + responseDocument.Data.SingleValue.Id.Should().Be(town.StringId); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("name").With(value => value.Should().Be(town.Name)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("latitude").With(value => value.Should().Be(town.Latitude)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("longitude").With(value => value.Should().Be(town.Longitude)); + + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("civilians").With(value => { - // Arrange - List town = _fakers.Town.Generate(7); + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{HostPrefix}{route}/relationships/civilians"); + value.Links.Related.Should().Be($"{HostPrefix}{route}/civilians"); + }); + + responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.Self.Should().Be($"{HostPrefix}{route}"); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); + } - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Towns.AddRange(town); - await dbContext.SaveChangesAsync(); - }); + [Fact] + public async Task Can_get_resources_at_custom_action_method() + { + // Arrange + List town = _fakers.Town.Generate(7); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Towns.AddRange(town); + await dbContext.SaveChangesAsync(); + }); - const string route = "/world-api/civilization/popular/towns/largest-5"; + const string route = "/world-api/civilization/popular/towns/largest-5"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(5); - responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Type == "towns"); - responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Attributes.ShouldNotBeNull().Any()); - responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Relationships.ShouldNotBeNull().Any()); - } + responseDocument.Data.ManyValue.ShouldHaveCount(5); + responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Type == "towns"); + responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Attributes.ShouldNotBeNull().Any()); + responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Relationships.ShouldNotBeNull().Any()); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/Town.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/Town.cs index fd98d4c0c9..bbb7e6909a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/Town.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/Town.cs @@ -1,24 +1,22 @@ -using System.Collections.Generic; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.CustomRoutes +namespace JsonApiDotNetCoreTests.IntegrationTests.CustomRoutes; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.CustomRoutes")] +public sealed class Town : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - [Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.CustomRoutes")] - public sealed class Town : Identifiable - { - [Attr] - public string Name { get; set; } = null!; + [Attr] + public string Name { get; set; } = null!; - [Attr] - public double Latitude { get; set; } + [Attr] + public double Latitude { get; set; } - [Attr] - public double Longitude { get; set; } + [Attr] + public double Longitude { get; set; } - [HasMany] - public ISet Civilians { get; set; } = new HashSet(); - } + [HasMany] + public ISet Civilians { get; set; } = new HashSet(); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/TownsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/TownsController.cs index 3369ac7643..e3fab1ff3c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/TownsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/TownsController.cs @@ -1,7 +1,3 @@ -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers.Annotations; using JsonApiDotNetCore.Services; @@ -10,34 +6,33 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCoreTests.IntegrationTests.CustomRoutes +namespace JsonApiDotNetCoreTests.IntegrationTests.CustomRoutes; + +// Workaround for https://youtrack.jetbrains.com/issue/RSRP-487028 +public partial class TownsController +{ +} + +[DisableRoutingConvention] +[Route("world-api/civilization/popular/towns")] +partial class TownsController { - // Workaround for https://youtrack.jetbrains.com/issue/RSRP-487028 - public partial class TownsController + private readonly CustomRouteDbContext _dbContext; + + [ActivatorUtilitiesConstructor] + public TownsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IResourceService resourceService, + CustomRouteDbContext dbContext) + : base(options, resourceGraph, loggerFactory, resourceService) { + _dbContext = dbContext; } - [DisableRoutingConvention] - [Route("world-api/civilization/popular/towns")] - partial class TownsController + [HttpGet("largest-{count}")] + public async Task GetLargestTownsAsync(int count, CancellationToken cancellationToken) { - private readonly CustomRouteDbContext _dbContext; - - [ActivatorUtilitiesConstructor] - public TownsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IResourceService resourceService, - CustomRouteDbContext dbContext) - : base(options, resourceGraph, loggerFactory, resourceService) - { - _dbContext = dbContext; - } - - [HttpGet("largest-{count}")] - public async Task GetLargestTownsAsync(int count, CancellationToken cancellationToken) - { - IQueryable query = _dbContext.Towns.OrderByDescending(town => town.Civilians.Count).Take(count); + IQueryable query = _dbContext.Towns.OrderByDescending(town => town.Civilians.Count).Take(count); - List results = await query.ToListAsync(cancellationToken); - return Ok(results); - } + List results = await query.ToListAsync(cancellationToken); + return Ok(results); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/Building.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/Building.cs index 519d62886c..ea3805cb22 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/Building.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/Building.cs @@ -1,67 +1,65 @@ -using System.Collections.Generic; using System.ComponentModel.DataAnnotations.Schema; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.EagerLoading +namespace JsonApiDotNetCoreTests.IntegrationTests.EagerLoading; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.EagerLoading")] +public sealed class Building : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - [Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.EagerLoading")] - public sealed class Building : Identifiable - { - private string? _tempPrimaryDoorColor; + private string? _tempPrimaryDoorColor; - [Attr] - public string Number { get; set; } = null!; + [Attr] + public string Number { get; set; } = null!; - [NotMapped] - [Attr] - public int WindowCount => Windows.Count; + [NotMapped] + [Attr] + public int WindowCount => Windows.Count; - [NotMapped] - [Attr(Capabilities = AttrCapabilities.AllowView | AttrCapabilities.AllowChange)] - public string PrimaryDoorColor + [NotMapped] + [Attr(Capabilities = AttrCapabilities.AllowView | AttrCapabilities.AllowChange)] + public string PrimaryDoorColor + { + get { - get + if (_tempPrimaryDoorColor == null && PrimaryDoor == null) { - if (_tempPrimaryDoorColor == null && PrimaryDoor == null) - { - // The ASP.NET model validator reads the value of this required property, to ensure it is not null. - // When creating a resource, BuildingDefinition ensures a value is assigned. But when updating a resource - // and PrimaryDoorColor is explicitly set to null in the request body and ModelState validation is enabled, - // we want it to produce a validation error, so return null here. - return null!; - } + // The ASP.NET model validator reads the value of this required property, to ensure it is not null. + // When creating a resource, BuildingDefinition ensures a value is assigned. But when updating a resource + // and PrimaryDoorColor is explicitly set to null in the request body and ModelState validation is enabled, + // we want it to produce a validation error, so return null here. + return null!; + } - return _tempPrimaryDoorColor ?? PrimaryDoor!.Color; + return _tempPrimaryDoorColor ?? PrimaryDoor!.Color; + } + set + { + if (PrimaryDoor == null) + { + // A request body is being deserialized. At this time, related entities have not been loaded yet. + // We cache the assigned value in a private field, so it can be used later. + _tempPrimaryDoorColor = value; } - set + else { - if (PrimaryDoor == null) - { - // A request body is being deserialized. At this time, related entities have not been loaded yet. - // We cache the assigned value in a private field, so it can be used later. - _tempPrimaryDoorColor = value; - } - else - { - PrimaryDoor.Color = value; - } + PrimaryDoor.Color = value; } } + } - [NotMapped] - [Attr(Capabilities = AttrCapabilities.AllowView)] - public string? SecondaryDoorColor => SecondaryDoor?.Color; + [NotMapped] + [Attr(Capabilities = AttrCapabilities.AllowView)] + public string? SecondaryDoorColor => SecondaryDoor?.Color; - [EagerLoad] - public IList Windows { get; set; } = new List(); + [EagerLoad] + public IList Windows { get; set; } = new List(); - [EagerLoad] - public Door? PrimaryDoor { get; set; } + [EagerLoad] + public Door? PrimaryDoor { get; set; } - [EagerLoad] - public Door? SecondaryDoor { get; set; } - } + [EagerLoad] + public Door? SecondaryDoor { get; set; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/BuildingDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/BuildingDefinition.cs index a6fc726a19..a1ebb2c87f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/BuildingDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/BuildingDefinition.cs @@ -4,32 +4,31 @@ using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources; -namespace JsonApiDotNetCoreTests.IntegrationTests.EagerLoading +namespace JsonApiDotNetCoreTests.IntegrationTests.EagerLoading; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class BuildingDefinition : JsonApiResourceDefinition { - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class BuildingDefinition : JsonApiResourceDefinition - { - private readonly IJsonApiRequest _request; + private readonly IJsonApiRequest _request; - public BuildingDefinition(IResourceGraph resourceGraph, IJsonApiRequest request) - : base(resourceGraph) - { - ArgumentGuard.NotNull(request, nameof(request)); + public BuildingDefinition(IResourceGraph resourceGraph, IJsonApiRequest request) + : base(resourceGraph) + { + ArgumentGuard.NotNull(request, nameof(request)); - _request = request; - } + _request = request; + } - public override void OnDeserialize(Building resource) + public override void OnDeserialize(Building resource) + { + if (_request.WriteOperation == WriteOperationKind.CreateResource) { - if (_request.WriteOperation == WriteOperationKind.CreateResource) + // Must ensure that an instance exists for this required relationship, + // so that ASP.NET ModelState validation does not produce a validation error. + resource.PrimaryDoor = new Door { - // Must ensure that an instance exists for this required relationship, - // so that ASP.NET ModelState validation does not produce a validation error. - resource.PrimaryDoor = new Door - { - Color = "(unspecified)" - }; - } + Color = "(unspecified)" + }; } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/BuildingRepository.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/BuildingRepository.cs index 88a4f6d714..572a7bd3e6 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/BuildingRepository.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/BuildingRepository.cs @@ -1,6 +1,3 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries; @@ -8,29 +5,28 @@ using JsonApiDotNetCore.Resources; using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCoreTests.IntegrationTests.EagerLoading +namespace JsonApiDotNetCoreTests.IntegrationTests.EagerLoading; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class BuildingRepository : EntityFrameworkCoreRepository { - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class BuildingRepository : EntityFrameworkCoreRepository + public BuildingRepository(ITargetedFields targetedFields, IDbContextResolver dbContextResolver, IResourceGraph resourceGraph, + IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory, + IResourceDefinitionAccessor resourceDefinitionAccessor) + : base(targetedFields, dbContextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor) { - public BuildingRepository(ITargetedFields targetedFields, IDbContextResolver dbContextResolver, IResourceGraph resourceGraph, - IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory, - IResourceDefinitionAccessor resourceDefinitionAccessor) - : base(targetedFields, dbContextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor) - { - } + } - public override async Task GetForCreateAsync(int id, CancellationToken cancellationToken) - { - Building building = await base.GetForCreateAsync(id, cancellationToken); + public override async Task GetForCreateAsync(int id, CancellationToken cancellationToken) + { + Building building = await base.GetForCreateAsync(id, cancellationToken); - // Must ensure that an instance exists for this required relationship, so that POST Resource succeeds. - building.PrimaryDoor = new Door - { - Color = "(unspecified)" - }; + // Must ensure that an instance exists for this required relationship, so that POST Resource succeeds. + building.PrimaryDoor = new Door + { + Color = "(unspecified)" + }; - return building; - } + return building; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/City.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/City.cs index ce9788abb7..fea280724b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/City.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/City.cs @@ -1,17 +1,15 @@ -using System.Collections.Generic; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.EagerLoading +namespace JsonApiDotNetCoreTests.IntegrationTests.EagerLoading; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class City : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class City : Identifiable - { - [Attr] - public string Name { get; set; } = null!; + [Attr] + public string Name { get; set; } = null!; - [HasMany] - public IList Streets { get; set; } = new List(); - } + [HasMany] + public IList Streets { get; set; } = new List(); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/Door.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/Door.cs index c42235ea23..1e60b1bd71 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/Door.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/Door.cs @@ -1,11 +1,12 @@ using JetBrains.Annotations; +using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.EagerLoading +namespace JsonApiDotNetCoreTests.IntegrationTests.EagerLoading; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[NoResource] +public sealed class Door { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Door - { - public int Id { get; set; } - public string Color { get; set; } = null!; - } + public int Id { get; set; } + public string Color { get; set; } = null!; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/EagerLoadingDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/EagerLoadingDbContext.cs index aec0207c25..01f81cd319 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/EagerLoadingDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/EagerLoadingDbContext.cs @@ -3,36 +3,35 @@ // @formatter:wrap_chained_method_calls chop_always -namespace JsonApiDotNetCoreTests.IntegrationTests.EagerLoading +namespace JsonApiDotNetCoreTests.IntegrationTests.EagerLoading; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class EagerLoadingDbContext : DbContext { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class EagerLoadingDbContext : DbContext - { - public DbSet States => Set(); - public DbSet Streets => Set(); - public DbSet Buildings => Set(); - public DbSet Doors => Set(); + public DbSet States => Set(); + public DbSet Streets => Set(); + public DbSet Buildings => Set(); + public DbSet Doors => Set(); - public EagerLoadingDbContext(DbContextOptions options) - : base(options) - { - } + public EagerLoadingDbContext(DbContextOptions options) + : base(options) + { + } - protected override void OnModelCreating(ModelBuilder builder) - { - builder.Entity() - .HasOne(building => building.PrimaryDoor) - .WithOne() - .HasForeignKey("PrimaryDoorId") - // The PrimaryDoor relationship property is declared as nullable, because the Door type is not publicly exposed, - // so we don't want ModelState validation to fail when it isn't provided by the client. But because - // BuildingRepository ensures a value is assigned on Create, we can make it a required relationship in the database. - .IsRequired(); + protected override void OnModelCreating(ModelBuilder builder) + { + builder.Entity() + .HasOne(building => building.PrimaryDoor) + .WithOne() + .HasForeignKey("PrimaryDoorId") + // The PrimaryDoor relationship property is declared as nullable, because the Door type is not publicly exposed, + // so we don't want ModelState validation to fail when it isn't provided by the client. But because + // BuildingRepository ensures a value is assigned on Create, we can make it a required relationship in the database. + .IsRequired(); - builder.Entity() - .HasOne(building => building.SecondaryDoor) - .WithOne() - .HasForeignKey("SecondaryDoorId"); - } + builder.Entity() + .HasOne(building => building.SecondaryDoor) + .WithOne() + .HasForeignKey("SecondaryDoorId"); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/EagerLoadingFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/EagerLoadingFakers.cs index 608ae1c034..0ac7eb6804 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/EagerLoadingFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/EagerLoadingFakers.cs @@ -1,50 +1,48 @@ -using System; using Bogus; using TestBuildingBlocks; // @formatter:wrap_chained_method_calls chop_always // @formatter:keep_existing_linebreaks true -namespace JsonApiDotNetCoreTests.IntegrationTests.EagerLoading +namespace JsonApiDotNetCoreTests.IntegrationTests.EagerLoading; + +internal sealed class EagerLoadingFakers : FakerContainer { - internal sealed class EagerLoadingFakers : FakerContainer - { - private readonly Lazy> _lazyStateFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(state => state.Name, faker => faker.Address.City())); - - private readonly Lazy> _lazyCityFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(city => city.Name, faker => faker.Address.City())); - - private readonly Lazy> _lazyStreetFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(street => street.Name, faker => faker.Address.StreetName())); - - private readonly Lazy> _lazyBuildingFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(building => building.Number, faker => faker.Address.BuildingNumber())); - - private readonly Lazy> _lazyWindowFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(window => window.HeightInCentimeters, faker => faker.Random.Number(30, 199)) - .RuleFor(window => window.WidthInCentimeters, faker => faker.Random.Number(30, 199))); - - private readonly Lazy> _lazyDoorFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(door => door.Color, faker => faker.Commerce.Color())); - - public Faker State => _lazyStateFaker.Value; - public Faker City => _lazyCityFaker.Value; - public Faker Street => _lazyStreetFaker.Value; - public Faker Building => _lazyBuildingFaker.Value; - public Faker Window => _lazyWindowFaker.Value; - public Faker Door => _lazyDoorFaker.Value; - } + private readonly Lazy> _lazyStateFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(state => state.Name, faker => faker.Address.City())); + + private readonly Lazy> _lazyCityFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(city => city.Name, faker => faker.Address.City())); + + private readonly Lazy> _lazyStreetFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(street => street.Name, faker => faker.Address.StreetName())); + + private readonly Lazy> _lazyBuildingFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(building => building.Number, faker => faker.Address.BuildingNumber())); + + private readonly Lazy> _lazyWindowFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(window => window.HeightInCentimeters, faker => faker.Random.Number(30, 199)) + .RuleFor(window => window.WidthInCentimeters, faker => faker.Random.Number(30, 199))); + + private readonly Lazy> _lazyDoorFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(door => door.Color, faker => faker.Commerce.Color())); + + public Faker State => _lazyStateFaker.Value; + public Faker City => _lazyCityFaker.Value; + public Faker Street => _lazyStreetFaker.Value; + public Faker Building => _lazyBuildingFaker.Value; + public Faker Window => _lazyWindowFaker.Value; + public Faker Door => _lazyDoorFaker.Value; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/EagerLoadingTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/EagerLoadingTests.cs index b0c22d0bff..e12167ad3a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/EagerLoadingTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/EagerLoadingTests.cs @@ -1,6 +1,4 @@ using System.Net; -using System.Net.Http; -using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; @@ -8,403 +6,402 @@ using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.EagerLoading +namespace JsonApiDotNetCoreTests.IntegrationTests.EagerLoading; + +public sealed class EagerLoadingTests : IClassFixture, EagerLoadingDbContext>> { - public sealed class EagerLoadingTests : IClassFixture, EagerLoadingDbContext>> + private readonly IntegrationTestContext, EagerLoadingDbContext> _testContext; + private readonly EagerLoadingFakers _fakers = new(); + + public EagerLoadingTests(IntegrationTestContext, EagerLoadingDbContext> testContext) { - private readonly IntegrationTestContext, EagerLoadingDbContext> _testContext; - private readonly EagerLoadingFakers _fakers = new(); + _testContext = testContext; - public EagerLoadingTests(IntegrationTestContext, EagerLoadingDbContext> testContext) - { - _testContext = testContext; + testContext.UseController(); + testContext.UseController(); + testContext.UseController(); - testContext.UseController(); - testContext.UseController(); - testContext.UseController(); + testContext.ConfigureServicesAfterStartup(services => + { + services.AddResourceDefinition(); + services.AddResourceRepository(); + }); + } - testContext.ConfigureServicesAfterStartup(services => - { - services.AddResourceDefinition(); - services.AddResourceRepository(); - }); - } + [Fact] + public async Task Can_get_primary_resource_with_eager_loads() + { + // Arrange + Building building = _fakers.Building.Generate(); + building.Windows = _fakers.Window.Generate(4); + building.PrimaryDoor = _fakers.Door.Generate(); + building.SecondaryDoor = _fakers.Door.Generate(); - [Fact] - public async Task Can_get_primary_resource_with_eager_loads() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - Building building = _fakers.Building.Generate(); - building.Windows = _fakers.Window.Generate(4); - building.PrimaryDoor = _fakers.Door.Generate(); - building.SecondaryDoor = _fakers.Door.Generate(); + dbContext.Buildings.Add(building); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Buildings.Add(building); - await dbContext.SaveChangesAsync(); - }); + string route = $"/buildings/{building.StringId}"; - string route = $"/buildings/{building.StringId}"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Id.Should().Be(building.StringId); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("number").With(value => value.Should().Be(building.Number)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("windowCount").With(value => value.Should().Be(4)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("primaryDoorColor").With(value => value.Should().Be(building.PrimaryDoor.Color)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("secondaryDoorColor").With(value => value.Should().Be(building.SecondaryDoor.Color)); + } - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Id.Should().Be(building.StringId); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("number").With(value => value.Should().Be(building.Number)); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("windowCount").With(value => value.Should().Be(4)); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("primaryDoorColor").With(value => value.Should().Be(building.PrimaryDoor.Color)); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("secondaryDoorColor").With(value => value.Should().Be(building.SecondaryDoor.Color)); - } + [Fact] + public async Task Can_get_primary_resource_with_nested_eager_loads() + { + // Arrange + Street street = _fakers.Street.Generate(); + street.Buildings = _fakers.Building.Generate(2); - [Fact] - public async Task Can_get_primary_resource_with_nested_eager_loads() - { - // Arrange - Street street = _fakers.Street.Generate(); - street.Buildings = _fakers.Building.Generate(2); + street.Buildings[0].Windows = _fakers.Window.Generate(2); + street.Buildings[0].PrimaryDoor = _fakers.Door.Generate(); - street.Buildings[0].Windows = _fakers.Window.Generate(2); - street.Buildings[0].PrimaryDoor = _fakers.Door.Generate(); + street.Buildings[1].Windows = _fakers.Window.Generate(3); + street.Buildings[1].PrimaryDoor = _fakers.Door.Generate(); + street.Buildings[1].SecondaryDoor = _fakers.Door.Generate(); - street.Buildings[1].Windows = _fakers.Window.Generate(3); - street.Buildings[1].PrimaryDoor = _fakers.Door.Generate(); - street.Buildings[1].SecondaryDoor = _fakers.Door.Generate(); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Streets.Add(street); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Streets.Add(street); - await dbContext.SaveChangesAsync(); - }); + string route = $"/streets/{street.StringId}"; - string route = $"/streets/{street.StringId}"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Id.Should().Be(street.StringId); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("name").With(value => value.Should().Be(street.Name)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("buildingCount").With(value => value.Should().Be(2)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("doorTotalCount").With(value => value.Should().Be(3)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("windowTotalCount").With(value => value.Should().Be(5)); + } - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Id.Should().Be(street.StringId); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("name").With(value => value.Should().Be(street.Name)); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("buildingCount").With(value => value.Should().Be(2)); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("doorTotalCount").With(value => value.Should().Be(3)); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("windowTotalCount").With(value => value.Should().Be(5)); - } + [Fact] + public async Task Can_get_primary_resource_with_fieldset() + { + // Arrange + Street street = _fakers.Street.Generate(); + street.Buildings = _fakers.Building.Generate(1); + street.Buildings[0].Windows = _fakers.Window.Generate(3); + street.Buildings[0].PrimaryDoor = _fakers.Door.Generate(); - [Fact] - public async Task Can_get_primary_resource_with_fieldset() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - Street street = _fakers.Street.Generate(); - street.Buildings = _fakers.Building.Generate(1); - street.Buildings[0].Windows = _fakers.Window.Generate(3); - street.Buildings[0].PrimaryDoor = _fakers.Door.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Streets.Add(street); - await dbContext.SaveChangesAsync(); - }); + dbContext.Streets.Add(street); + await dbContext.SaveChangesAsync(); + }); - string route = $"/streets/{street.StringId}?fields[streets]=windowTotalCount"; + string route = $"/streets/{street.StringId}?fields[streets]=windowTotalCount"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Id.Should().Be(street.StringId); - responseDocument.Data.SingleValue.Attributes.ShouldHaveCount(1); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("windowTotalCount").With(value => value.Should().Be(3)); - responseDocument.Data.SingleValue.Relationships.Should().BeNull(); - } + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Id.Should().Be(street.StringId); + responseDocument.Data.SingleValue.Attributes.ShouldHaveCount(1); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("windowTotalCount").With(value => value.Should().Be(3)); + responseDocument.Data.SingleValue.Relationships.Should().BeNull(); + } - [Fact] - public async Task Can_get_primary_resource_with_includes() + [Fact] + public async Task Can_get_primary_resource_with_includes() + { + // Arrange + State state = _fakers.State.Generate(); + state.Cities = _fakers.City.Generate(1); + state.Cities[0].Streets = _fakers.Street.Generate(1); + state.Cities[0].Streets[0].Buildings = _fakers.Building.Generate(1); + state.Cities[0].Streets[0].Buildings[0].PrimaryDoor = _fakers.Door.Generate(); + state.Cities[0].Streets[0].Buildings[0].Windows = _fakers.Window.Generate(3); + + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - State state = _fakers.State.Generate(); - state.Cities = _fakers.City.Generate(1); - state.Cities[0].Streets = _fakers.Street.Generate(1); - state.Cities[0].Streets[0].Buildings = _fakers.Building.Generate(1); - state.Cities[0].Streets[0].Buildings[0].PrimaryDoor = _fakers.Door.Generate(); - state.Cities[0].Streets[0].Buildings[0].Windows = _fakers.Window.Generate(3); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.States.Add(state); - await dbContext.SaveChangesAsync(); - }); + dbContext.States.Add(state); + await dbContext.SaveChangesAsync(); + }); - string route = $"/states/{state.StringId}?include=cities.streets"; + string route = $"/states/{state.StringId}?include=cities.streets"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Id.Should().Be(state.StringId); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("name").With(value => value.Should().Be(state.Name)); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Id.Should().Be(state.StringId); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("name").With(value => value.Should().Be(state.Name)); - responseDocument.Included.ShouldHaveCount(2); + responseDocument.Included.ShouldHaveCount(2); - responseDocument.Included[0].Type.Should().Be("cities"); - responseDocument.Included[0].Id.Should().Be(state.Cities[0].StringId); - responseDocument.Included[0].Attributes.ShouldContainKey("name").With(value => value.Should().Be(state.Cities[0].Name)); + responseDocument.Included[0].Type.Should().Be("cities"); + responseDocument.Included[0].Id.Should().Be(state.Cities[0].StringId); + responseDocument.Included[0].Attributes.ShouldContainKey("name").With(value => value.Should().Be(state.Cities[0].Name)); - responseDocument.Included[1].Type.Should().Be("streets"); - responseDocument.Included[1].Id.Should().Be(state.Cities[0].Streets[0].StringId); - responseDocument.Included[1].Attributes.ShouldContainKey("buildingCount").With(value => value.Should().Be(1)); - responseDocument.Included[1].Attributes.ShouldContainKey("doorTotalCount").With(value => value.Should().Be(1)); - responseDocument.Included[1].Attributes.ShouldContainKey("windowTotalCount").With(value => value.Should().Be(3)); - } + responseDocument.Included[1].Type.Should().Be("streets"); + responseDocument.Included[1].Id.Should().Be(state.Cities[0].Streets[0].StringId); + responseDocument.Included[1].Attributes.ShouldContainKey("buildingCount").With(value => value.Should().Be(1)); + responseDocument.Included[1].Attributes.ShouldContainKey("doorTotalCount").With(value => value.Should().Be(1)); + responseDocument.Included[1].Attributes.ShouldContainKey("windowTotalCount").With(value => value.Should().Be(3)); + } - [Fact] - public async Task Can_get_secondary_resources_with_include_and_fieldsets() - { - // Arrange - State state = _fakers.State.Generate(); - state.Cities = _fakers.City.Generate(1); - state.Cities[0].Streets = _fakers.Street.Generate(1); - state.Cities[0].Streets[0].Buildings = _fakers.Building.Generate(1); - state.Cities[0].Streets[0].Buildings[0].PrimaryDoor = _fakers.Door.Generate(); - state.Cities[0].Streets[0].Buildings[0].SecondaryDoor = _fakers.Door.Generate(); - state.Cities[0].Streets[0].Buildings[0].Windows = _fakers.Window.Generate(1); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.States.Add(state); - await dbContext.SaveChangesAsync(); - }); - - string route = $"/states/{state.StringId}/cities?include=streets&fields[cities]=name&fields[streets]=doorTotalCount,windowTotalCount"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Data.ManyValue.ShouldHaveCount(1); - responseDocument.Data.ManyValue[0].Id.Should().Be(state.Cities[0].StringId); - responseDocument.Data.ManyValue[0].Attributes.ShouldHaveCount(1); - responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("name").With(value => value.Should().Be(state.Cities[0].Name)); - responseDocument.Data.ManyValue[0].Relationships.Should().BeNull(); - - responseDocument.Included.ShouldHaveCount(1); - responseDocument.Included[0].Type.Should().Be("streets"); - responseDocument.Included[0].Id.Should().Be(state.Cities[0].Streets[0].StringId); - responseDocument.Included[0].Attributes.ShouldHaveCount(2); - responseDocument.Included[0].Attributes.ShouldContainKey("doorTotalCount").With(value => value.Should().Be(2)); - responseDocument.Included[0].Attributes.ShouldContainKey("windowTotalCount").With(value => value.Should().Be(1)); - responseDocument.Included[0].Relationships.Should().BeNull(); - } - - [Fact] - public async Task Can_create_resource() + [Fact] + public async Task Can_get_secondary_resources_with_include_and_fieldsets() + { + // Arrange + State state = _fakers.State.Generate(); + state.Cities = _fakers.City.Generate(1); + state.Cities[0].Streets = _fakers.Street.Generate(1); + state.Cities[0].Streets[0].Buildings = _fakers.Building.Generate(1); + state.Cities[0].Streets[0].Buildings[0].PrimaryDoor = _fakers.Door.Generate(); + state.Cities[0].Streets[0].Buildings[0].SecondaryDoor = _fakers.Door.Generate(); + state.Cities[0].Streets[0].Buildings[0].Windows = _fakers.Window.Generate(1); + + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - Building newBuilding = _fakers.Building.Generate(); + dbContext.States.Add(state); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/states/{state.StringId}/cities?include=streets&fields[cities]=name&fields[streets]=doorTotalCount,windowTotalCount"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be(state.Cities[0].StringId); + responseDocument.Data.ManyValue[0].Attributes.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("name").With(value => value.Should().Be(state.Cities[0].Name)); + responseDocument.Data.ManyValue[0].Relationships.Should().BeNull(); + + responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included[0].Type.Should().Be("streets"); + responseDocument.Included[0].Id.Should().Be(state.Cities[0].Streets[0].StringId); + responseDocument.Included[0].Attributes.ShouldHaveCount(2); + responseDocument.Included[0].Attributes.ShouldContainKey("doorTotalCount").With(value => value.Should().Be(2)); + responseDocument.Included[0].Attributes.ShouldContainKey("windowTotalCount").With(value => value.Should().Be(1)); + responseDocument.Included[0].Relationships.Should().BeNull(); + } - var requestBody = new + [Fact] + public async Task Can_create_resource() + { + // Arrange + Building newBuilding = _fakers.Building.Generate(); + + var requestBody = new + { + data = new { - data = new + type = "buildings", + attributes = new { - type = "buildings", - attributes = new - { - number = newBuilding.Number - } + number = newBuilding.Number } - }; + } + }; - const string route = "/buildings"; + const string route = "/buildings"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("number").With(value => value.Should().Be(newBuilding.Number)); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("windowCount").With(value => value.Should().Be(0)); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("primaryDoorColor").With(value => value.Should().Be("(unspecified)")); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("secondaryDoorColor").With(value => value.Should().BeNull()); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("number").With(value => value.Should().Be(newBuilding.Number)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("windowCount").With(value => value.Should().Be(0)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("primaryDoorColor").With(value => value.Should().Be("(unspecified)")); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("secondaryDoorColor").With(value => value.Should().BeNull()); - int newBuildingId = int.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); + int newBuildingId = int.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true - - Building? buildingInDatabase = await dbContext.Buildings - .Include(building => building.PrimaryDoor) - .Include(building => building.SecondaryDoor) - .Include(building => building.Windows) - .FirstWithIdOrDefaultAsync(newBuildingId); - - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore - - buildingInDatabase.ShouldNotBeNull(); - buildingInDatabase.Number.Should().Be(newBuilding.Number); - buildingInDatabase.PrimaryDoor.ShouldNotBeNull(); - buildingInDatabase.PrimaryDoor.Color.Should().Be("(unspecified)"); - buildingInDatabase.SecondaryDoor.Should().BeNull(); - buildingInDatabase.Windows.Should().BeEmpty(); - }); - } - - [Fact] - public async Task Can_update_resource() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - Building existingBuilding = _fakers.Building.Generate(); - existingBuilding.PrimaryDoor = _fakers.Door.Generate(); - existingBuilding.SecondaryDoor = _fakers.Door.Generate(); - existingBuilding.Windows = _fakers.Window.Generate(2); + // @formatter:wrap_chained_method_calls chop_always + // @formatter:keep_existing_linebreaks true + + Building? buildingInDatabase = await dbContext.Buildings + .Include(building => building.PrimaryDoor) + .Include(building => building.SecondaryDoor) + .Include(building => building.Windows) + .FirstWithIdOrDefaultAsync(newBuildingId); + + // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_chained_method_calls restore + + buildingInDatabase.ShouldNotBeNull(); + buildingInDatabase.Number.Should().Be(newBuilding.Number); + buildingInDatabase.PrimaryDoor.ShouldNotBeNull(); + buildingInDatabase.PrimaryDoor.Color.Should().Be("(unspecified)"); + buildingInDatabase.SecondaryDoor.Should().BeNull(); + buildingInDatabase.Windows.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Can_update_resource() + { + // Arrange + Building existingBuilding = _fakers.Building.Generate(); + existingBuilding.PrimaryDoor = _fakers.Door.Generate(); + existingBuilding.SecondaryDoor = _fakers.Door.Generate(); + existingBuilding.Windows = _fakers.Window.Generate(2); - string newBuildingNumber = _fakers.Building.Generate().Number; - string newPrimaryDoorColor = _fakers.Door.Generate().Color; + string newBuildingNumber = _fakers.Building.Generate().Number; + string newPrimaryDoorColor = _fakers.Door.Generate().Color; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Buildings.Add(existingBuilding); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Buildings.Add(existingBuilding); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new { - data = new + type = "buildings", + id = existingBuilding.StringId, + attributes = new { - type = "buildings", - id = existingBuilding.StringId, - attributes = new - { - number = newBuildingNumber, - primaryDoorColor = newPrimaryDoorColor - } + number = newBuildingNumber, + primaryDoorColor = newPrimaryDoorColor } - }; + } + }; - string route = $"/buildings/{existingBuilding.StringId}"; + string route = $"/buildings/{existingBuilding.StringId}"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true - - Building? buildingInDatabase = await dbContext.Buildings - .Include(building => building.PrimaryDoor) - .Include(building => building.SecondaryDoor) - .Include(building => building.Windows) - .FirstWithIdOrDefaultAsync(existingBuilding.Id); - - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore - - buildingInDatabase.ShouldNotBeNull(); - buildingInDatabase.Number.Should().Be(newBuildingNumber); - buildingInDatabase.PrimaryDoor.ShouldNotBeNull(); - buildingInDatabase.PrimaryDoor.Color.Should().Be(newPrimaryDoorColor); - buildingInDatabase.SecondaryDoor.ShouldNotBeNull(); - buildingInDatabase.Windows.ShouldHaveCount(2); - }); - } - - [Fact] - public async Task Cannot_update_resource_when_primaryDoorColor_is_set_to_null() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - Building existingBuilding = _fakers.Building.Generate(); - existingBuilding.PrimaryDoor = _fakers.Door.Generate(); + // @formatter:wrap_chained_method_calls chop_always + // @formatter:keep_existing_linebreaks true + + Building? buildingInDatabase = await dbContext.Buildings + .Include(building => building.PrimaryDoor) + .Include(building => building.SecondaryDoor) + .Include(building => building.Windows) + .FirstWithIdOrDefaultAsync(existingBuilding.Id); + + // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_chained_method_calls restore + + buildingInDatabase.ShouldNotBeNull(); + buildingInDatabase.Number.Should().Be(newBuildingNumber); + buildingInDatabase.PrimaryDoor.ShouldNotBeNull(); + buildingInDatabase.PrimaryDoor.Color.Should().Be(newPrimaryDoorColor); + buildingInDatabase.SecondaryDoor.ShouldNotBeNull(); + buildingInDatabase.Windows.ShouldHaveCount(2); + }); + } - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Buildings.Add(existingBuilding); - await dbContext.SaveChangesAsync(); - }); + [Fact] + public async Task Cannot_update_resource_when_primaryDoorColor_is_set_to_null() + { + // Arrange + Building existingBuilding = _fakers.Building.Generate(); + existingBuilding.PrimaryDoor = _fakers.Door.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Buildings.Add(existingBuilding); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new { - data = new + type = "buildings", + id = existingBuilding.StringId, + attributes = new { - type = "buildings", - id = existingBuilding.StringId, - attributes = new - { - primaryDoorColor = (string?)null - } + primaryDoorColor = (string?)null } - }; + } + }; + + string route = $"/buildings/{existingBuilding.StringId}"; - string route = $"/buildings/{existingBuilding.StringId}"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + responseDocument.Errors.ShouldHaveCount(1); - responseDocument.Errors.ShouldHaveCount(1); + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Input validation failed."); + error.Detail.Should().Be("The PrimaryDoorColor field is required."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data/attributes/primaryDoorColor"); + } - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Input validation failed."); - error.Detail.Should().Be("The PrimaryDoorColor field is required."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/data/attributes/primaryDoorColor"); - } + [Fact] + public async Task Can_delete_resource() + { + // Arrange + Building existingBuilding = _fakers.Building.Generate(); + existingBuilding.PrimaryDoor = _fakers.Door.Generate(); - [Fact] - public async Task Can_delete_resource() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - Building existingBuilding = _fakers.Building.Generate(); - existingBuilding.PrimaryDoor = _fakers.Door.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Buildings.Add(existingBuilding); - await dbContext.SaveChangesAsync(); - }); + dbContext.Buildings.Add(existingBuilding); + await dbContext.SaveChangesAsync(); + }); - string route = $"/buildings/{existingBuilding.StringId}"; + string route = $"/buildings/{existingBuilding.StringId}"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Building? buildingInDatabase = await dbContext.Buildings.FirstWithIdOrDefaultAsync(existingBuilding.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Building? buildingInDatabase = await dbContext.Buildings.FirstWithIdOrDefaultAsync(existingBuilding.Id); - buildingInDatabase.Should().BeNull(); - }); - } + buildingInDatabase.Should().BeNull(); + }); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/State.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/State.cs index afd641b839..6f41815618 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/State.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/State.cs @@ -1,18 +1,16 @@ -using System.Collections.Generic; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.EagerLoading +namespace JsonApiDotNetCoreTests.IntegrationTests.EagerLoading; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.EagerLoading")] +public sealed class State : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - [Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.EagerLoading")] - public sealed class State : Identifiable - { - [Attr] - public string Name { get; set; } = null!; + [Attr] + public string Name { get; set; } = null!; - [HasMany] - public IList Cities { get; set; } = new List(); - } + [HasMany] + public IList Cities { get; set; } = new List(); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/Street.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/Street.cs index 736b0535f5..db440ad85d 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/Street.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/Street.cs @@ -1,32 +1,29 @@ -using System.Collections.Generic; using System.ComponentModel.DataAnnotations.Schema; -using System.Linq; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.EagerLoading +namespace JsonApiDotNetCoreTests.IntegrationTests.EagerLoading; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.EagerLoading")] +public sealed class Street : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - [Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.EagerLoading")] - public sealed class Street : Identifiable - { - [Attr] - public string Name { get; set; } = null!; + [Attr] + public string Name { get; set; } = null!; - [NotMapped] - [Attr(Capabilities = AttrCapabilities.AllowView)] - public int BuildingCount => Buildings.Count; + [NotMapped] + [Attr(Capabilities = AttrCapabilities.AllowView)] + public int BuildingCount => Buildings.Count; - [NotMapped] - [Attr(Capabilities = AttrCapabilities.AllowView)] - public int DoorTotalCount => Buildings.Sum(building => building.SecondaryDoor == null ? 1 : 2); + [NotMapped] + [Attr(Capabilities = AttrCapabilities.AllowView)] + public int DoorTotalCount => Buildings.Sum(building => building.SecondaryDoor == null ? 1 : 2); - [NotMapped] - [Attr(Capabilities = AttrCapabilities.AllowView)] - public int WindowTotalCount => Buildings.Sum(building => building.WindowCount); + [NotMapped] + [Attr(Capabilities = AttrCapabilities.AllowView)] + public int WindowTotalCount => Buildings.Sum(building => building.WindowCount); - [EagerLoad] - public IList Buildings { get; set; } = new List(); - } + [EagerLoad] + public IList Buildings { get; set; } = new List(); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/Window.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/Window.cs index 2e447e5d59..9727327216 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/Window.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/Window.cs @@ -1,12 +1,13 @@ using JetBrains.Annotations; +using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.EagerLoading +namespace JsonApiDotNetCoreTests.IntegrationTests.EagerLoading; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[NoResource] +public sealed class Window { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Window - { - public int Id { get; set; } - public int HeightInCentimeters { get; set; } - public int WidthInCentimeters { get; set; } - } + public int Id { get; set; } + public int HeightInCentimeters { get; set; } + public int WidthInCentimeters { get; set; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/AlternateExceptionHandler.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/AlternateExceptionHandler.cs index 46f81275f9..4af459fb71 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/AlternateExceptionHandler.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/AlternateExceptionHandler.cs @@ -1,40 +1,37 @@ -using System; -using System.Collections.Generic; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCoreTests.IntegrationTests.ExceptionHandling +namespace JsonApiDotNetCoreTests.IntegrationTests.ExceptionHandling; + +public sealed class AlternateExceptionHandler : ExceptionHandler { - public sealed class AlternateExceptionHandler : ExceptionHandler + public AlternateExceptionHandler(ILoggerFactory loggerFactory, IJsonApiOptions options) + : base(loggerFactory, options) { - public AlternateExceptionHandler(ILoggerFactory loggerFactory, IJsonApiOptions options) - : base(loggerFactory, options) - { - } + } - protected override LogLevel GetLogLevel(Exception exception) + protected override LogLevel GetLogLevel(Exception exception) + { + if (exception is ConsumerArticleIsNoLongerAvailableException) { - if (exception is ConsumerArticleIsNoLongerAvailableException) - { - return LogLevel.Warning; - } - - return base.GetLogLevel(exception); + return LogLevel.Warning; } - protected override IReadOnlyList CreateErrorResponse(Exception exception) + return base.GetLogLevel(exception); + } + + protected override IReadOnlyList CreateErrorResponse(Exception exception) + { + if (exception is ConsumerArticleIsNoLongerAvailableException articleException) { - if (exception is ConsumerArticleIsNoLongerAvailableException articleException) + articleException.Errors[0].Meta = new Dictionary { - articleException.Errors[0].Meta = new Dictionary - { - ["Support"] = $"Please contact us for info about similar articles at {articleException.SupportEmailAddress}." - }; - } - - return base.CreateErrorResponse(exception); + ["Support"] = $"Please contact us for info about similar articles at {articleException.SupportEmailAddress}." + }; } + + return base.CreateErrorResponse(exception); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ConsumerArticle.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ConsumerArticle.cs index f000a79217..c508e7aa17 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ConsumerArticle.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ConsumerArticle.cs @@ -2,13 +2,12 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.ExceptionHandling +namespace JsonApiDotNetCoreTests.IntegrationTests.ExceptionHandling; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.ExceptionHandling")] +public sealed class ConsumerArticle : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - [Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.ExceptionHandling")] - public sealed class ConsumerArticle : Identifiable - { - [Attr] - public string Code { get; set; } = null!; - } + [Attr] + public string Code { get; set; } = null!; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ConsumerArticleIsNoLongerAvailableException.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ConsumerArticleIsNoLongerAvailableException.cs index 78260204d7..6c913ac04b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ConsumerArticleIsNoLongerAvailableException.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ConsumerArticleIsNoLongerAvailableException.cs @@ -2,20 +2,19 @@ using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCoreTests.IntegrationTests.ExceptionHandling +namespace JsonApiDotNetCoreTests.IntegrationTests.ExceptionHandling; + +internal sealed class ConsumerArticleIsNoLongerAvailableException : JsonApiException { - internal sealed class ConsumerArticleIsNoLongerAvailableException : JsonApiException - { - public string SupportEmailAddress { get; } + public string SupportEmailAddress { get; } - public ConsumerArticleIsNoLongerAvailableException(string articleCode, string supportEmailAddress) - : base(new ErrorObject(HttpStatusCode.Gone) - { - Title = "The requested article is no longer available.", - Detail = $"Article with code '{articleCode}' is no longer available." - }) + public ConsumerArticleIsNoLongerAvailableException(string articleCode, string supportEmailAddress) + : base(new ErrorObject(HttpStatusCode.Gone) { - SupportEmailAddress = supportEmailAddress; - } + Title = "The requested article is no longer available.", + Detail = $"Article with code '{articleCode}' is no longer available." + }) + { + SupportEmailAddress = supportEmailAddress; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ConsumerArticleService.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ConsumerArticleService.cs index ce66e0575f..335bf7e9fb 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ConsumerArticleService.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ConsumerArticleService.cs @@ -1,6 +1,3 @@ -using System; -using System.Threading; -using System.Threading.Tasks; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; @@ -10,32 +7,30 @@ using JsonApiDotNetCore.Services; using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCoreTests.IntegrationTests.ExceptionHandling +namespace JsonApiDotNetCoreTests.IntegrationTests.ExceptionHandling; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class ConsumerArticleService : JsonApiResourceService { - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class ConsumerArticleService : JsonApiResourceService + private const string SupportEmailAddress = "company@email.com"; + internal const string UnavailableArticlePrefix = "X"; + + public ConsumerArticleService(IResourceRepositoryAccessor repositoryAccessor, IQueryLayerComposer queryLayerComposer, IPaginationContext paginationContext, + IJsonApiOptions options, ILoggerFactory loggerFactory, IJsonApiRequest request, IResourceChangeTracker resourceChangeTracker, + IResourceDefinitionAccessor resourceDefinitionAccessor) + : base(repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, resourceChangeTracker, resourceDefinitionAccessor) { - private const string SupportEmailAddress = "company@email.com"; - internal const string UnavailableArticlePrefix = "X"; + } - public ConsumerArticleService(IResourceRepositoryAccessor repositoryAccessor, IQueryLayerComposer queryLayerComposer, - IPaginationContext paginationContext, IJsonApiOptions options, ILoggerFactory loggerFactory, IJsonApiRequest request, - IResourceChangeTracker resourceChangeTracker, IResourceDefinitionAccessor resourceDefinitionAccessor) - : base(repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, resourceChangeTracker, - resourceDefinitionAccessor) - { - } + public override async Task GetAsync(int id, CancellationToken cancellationToken) + { + ConsumerArticle consumerArticle = await base.GetAsync(id, cancellationToken); - public override async Task GetAsync(int id, CancellationToken cancellationToken) + if (consumerArticle.Code.StartsWith(UnavailableArticlePrefix, StringComparison.Ordinal)) { - ConsumerArticle consumerArticle = await base.GetAsync(id, cancellationToken); - - if (consumerArticle.Code.StartsWith(UnavailableArticlePrefix, StringComparison.Ordinal)) - { - throw new ConsumerArticleIsNoLongerAvailableException(consumerArticle.Code, SupportEmailAddress); - } - - return consumerArticle; + throw new ConsumerArticleIsNoLongerAvailableException(consumerArticle.Code, SupportEmailAddress); } + + return consumerArticle; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ErrorDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ErrorDbContext.cs index 70141a7998..141dfc4f71 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ErrorDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ErrorDbContext.cs @@ -1,17 +1,16 @@ using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; -namespace JsonApiDotNetCoreTests.IntegrationTests.ExceptionHandling +namespace JsonApiDotNetCoreTests.IntegrationTests.ExceptionHandling; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class ErrorDbContext : DbContext { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class ErrorDbContext : DbContext - { - public DbSet ConsumerArticles => Set(); - public DbSet ThrowingArticles => Set(); + public DbSet ConsumerArticles => Set(); + public DbSet ThrowingArticles => Set(); - public ErrorDbContext(DbContextOptions options) - : base(options) - { - } + public ErrorDbContext(DbContextOptions options) + : base(options) + { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs index 5622f87280..13f69d94b4 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs @@ -1,9 +1,5 @@ -using System.Collections.Generic; -using System.Linq; using System.Net; -using System.Net.Http; using System.Text.Json; -using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; @@ -13,169 +9,168 @@ using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.ExceptionHandling +namespace JsonApiDotNetCoreTests.IntegrationTests.ExceptionHandling; + +public sealed class ExceptionHandlerTests : IClassFixture, ErrorDbContext>> { - public sealed class ExceptionHandlerTests : IClassFixture, ErrorDbContext>> + private readonly IntegrationTestContext, ErrorDbContext> _testContext; + + public ExceptionHandlerTests(IntegrationTestContext, ErrorDbContext> testContext) { - private readonly IntegrationTestContext, ErrorDbContext> _testContext; + _testContext = testContext; - public ExceptionHandlerTests(IntegrationTestContext, ErrorDbContext> testContext) - { - _testContext = testContext; + testContext.UseController(); + testContext.UseController(); - testContext.UseController(); - testContext.UseController(); + var loggerFactory = new FakeLoggerFactory(LogLevel.Warning); - var loggerFactory = new FakeLoggerFactory(LogLevel.Warning); + testContext.ConfigureLogging(options => + { + options.ClearProviders(); + options.AddProvider(loggerFactory); + }); - testContext.ConfigureLogging(options => - { - options.ClearProviders(); - options.AddProvider(loggerFactory); - }); + testContext.ConfigureServicesBeforeStartup(services => + { + services.AddSingleton(loggerFactory); + }); - testContext.ConfigureServicesBeforeStartup(services => - { - services.AddSingleton(loggerFactory); - }); + testContext.ConfigureServicesAfterStartup(services => + { + services.AddResourceService(); + services.AddScoped(); + }); + } - testContext.ConfigureServicesAfterStartup(services => - { - services.AddResourceService(); - services.AddScoped(); - }); - } + [Fact] + public async Task Logs_and_produces_error_response_for_custom_exception() + { + // Arrange + var loggerFactory = _testContext.Factory.Services.GetRequiredService(); + loggerFactory.Logger.Clear(); - [Fact] - public async Task Logs_and_produces_error_response_for_custom_exception() + var consumerArticle = new ConsumerArticle { - // Arrange - var loggerFactory = _testContext.Factory.Services.GetRequiredService(); - loggerFactory.Logger.Clear(); - - var consumerArticle = new ConsumerArticle - { - Code = $"{ConsumerArticleService.UnavailableArticlePrefix}123" - }; + Code = $"{ConsumerArticleService.UnavailableArticlePrefix}123" + }; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.ConsumerArticles.Add(consumerArticle); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.ConsumerArticles.Add(consumerArticle); + await dbContext.SaveChangesAsync(); + }); - string route = $"/consumerArticles/{consumerArticle.StringId}"; + string route = $"/consumerArticles/{consumerArticle.StringId}"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Gone); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Gone); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.Gone); - error.Title.Should().Be("The requested article is no longer available."); - error.Detail.Should().Be("Article with code 'X123' is no longer available."); + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Gone); + error.Title.Should().Be("The requested article is no longer available."); + error.Detail.Should().Be("Article with code 'X123' is no longer available."); - error.Meta.ShouldContainKey("support").With(value => - { - JsonElement element = value.Should().BeOfType().Subject; - element.GetString().Should().Be("Please contact us for info about similar articles at company@email.com."); - }); + error.Meta.ShouldContainKey("support").With(value => + { + JsonElement element = value.Should().BeOfType().Subject; + element.GetString().Should().Be("Please contact us for info about similar articles at company@email.com."); + }); - responseDocument.Meta.Should().BeNull(); + responseDocument.Meta.Should().BeNull(); - loggerFactory.Logger.Messages.ShouldHaveCount(1); - loggerFactory.Logger.Messages.Single().LogLevel.Should().Be(LogLevel.Warning); - loggerFactory.Logger.Messages.Single().Text.Should().Contain("Article with code 'X123' is no longer available."); - } + loggerFactory.Logger.Messages.ShouldHaveCount(1); + loggerFactory.Logger.Messages.Single().LogLevel.Should().Be(LogLevel.Warning); + loggerFactory.Logger.Messages.Single().Text.Should().Contain("Article with code 'X123' is no longer available."); + } - [Fact] - public async Task Logs_and_produces_error_response_on_deserialization_failure() - { - // Arrange - var loggerFactory = _testContext.Factory.Services.GetRequiredService(); - loggerFactory.Logger.Clear(); + [Fact] + public async Task Logs_and_produces_error_response_on_deserialization_failure() + { + // Arrange + var loggerFactory = _testContext.Factory.Services.GetRequiredService(); + loggerFactory.Logger.Clear(); - const string requestBody = @"{ ""data"": { ""type"": """" } }"; + const string requestBody = @"{ ""data"": { ""type"": """" } }"; - const string route = "/consumerArticles"; + const string route = "/consumerArticles"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); - error.Detail.Should().Be("Resource type '' does not exist."); + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); + error.Detail.Should().Be("Resource type '' does not exist."); - error.Meta.ShouldContainKey("requestBody").With(value => - { - JsonElement element = value.Should().BeOfType().Subject; - element.GetString().Should().Be(requestBody); - }); + error.Meta.ShouldContainKey("requestBody").With(value => + { + JsonElement element = value.Should().BeOfType().Subject; + element.GetString().Should().Be(requestBody); + }); - error.Meta.ShouldContainKey("stackTrace").With(value => - { - JsonElement element = value.Should().BeOfType().Subject; - IEnumerable stackTraceLines = element.EnumerateArray().Select(token => token.GetString()); + error.Meta.ShouldContainKey("stackTrace").With(value => + { + JsonElement element = value.Should().BeOfType().Subject; + IEnumerable stackTraceLines = element.EnumerateArray().Select(token => token.GetString()); - stackTraceLines.ShouldNotBeEmpty(); - }); + stackTraceLines.ShouldNotBeEmpty(); + }); - loggerFactory.Logger.Messages.Should().BeEmpty(); - } + loggerFactory.Logger.Messages.Should().BeEmpty(); + } - [Fact] - public async Task Logs_and_produces_error_response_on_serialization_failure() - { - // Arrange - var loggerFactory = _testContext.Factory.Services.GetRequiredService(); - loggerFactory.Logger.Clear(); + [Fact] + public async Task Logs_and_produces_error_response_on_serialization_failure() + { + // Arrange + var loggerFactory = _testContext.Factory.Services.GetRequiredService(); + loggerFactory.Logger.Clear(); - var throwingArticle = new ThrowingArticle(); + var throwingArticle = new ThrowingArticle(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.ThrowingArticles.Add(throwingArticle); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.ThrowingArticles.Add(throwingArticle); + await dbContext.SaveChangesAsync(); + }); - string route = $"/throwingArticles/{throwingArticle.StringId}"; + string route = $"/throwingArticles/{throwingArticle.StringId}"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.InternalServerError); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.InternalServerError); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.InternalServerError); - error.Title.Should().Be("An unhandled error occurred while processing this request."); - error.Detail.Should().Be("Exception has been thrown by the target of an invocation."); + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.InternalServerError); + error.Title.Should().Be("An unhandled error occurred while processing this request."); + error.Detail.Should().Be("Exception has been thrown by the target of an invocation."); - error.Meta.ShouldContainKey("stackTrace").With(value => - { - JsonElement element = value.Should().BeOfType().Subject; - IEnumerable stackTraceLines = element.EnumerateArray().Select(token => token.GetString()); + error.Meta.ShouldContainKey("stackTrace").With(value => + { + JsonElement element = value.Should().BeOfType().Subject; + IEnumerable stackTraceLines = element.EnumerateArray().Select(token => token.GetString()); - stackTraceLines.Should().ContainMatch("*ThrowingArticle*"); - }); + stackTraceLines.Should().ContainMatch("*ThrowingArticle*"); + }); - responseDocument.Meta.Should().BeNull(); + responseDocument.Meta.Should().BeNull(); - loggerFactory.Logger.Messages.ShouldHaveCount(1); - loggerFactory.Logger.Messages.Single().LogLevel.Should().Be(LogLevel.Error); - loggerFactory.Logger.Messages.Single().Text.Should().Contain("Exception has been thrown by the target of an invocation."); - } + loggerFactory.Logger.Messages.ShouldHaveCount(1); + loggerFactory.Logger.Messages.Single().LogLevel.Should().Be(LogLevel.Error); + loggerFactory.Logger.Messages.Single().Text.Should().Contain("Exception has been thrown by the target of an invocation."); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ThrowingArticle.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ThrowingArticle.cs index 210a2bb569..ae63c0f158 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ThrowingArticle.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ThrowingArticle.cs @@ -1,17 +1,15 @@ -using System; using System.ComponentModel.DataAnnotations.Schema; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.ExceptionHandling +namespace JsonApiDotNetCoreTests.IntegrationTests.ExceptionHandling; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.ExceptionHandling")] +public sealed class ThrowingArticle : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - [Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.ExceptionHandling")] - public sealed class ThrowingArticle : Identifiable - { - [Attr] - [NotMapped] - public string Status => throw new InvalidOperationException("Article status could not be determined."); - } + [Attr] + [NotMapped] + public string Status => throw new InvalidOperationException("Article status could not be determined."); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/HitCountingResourceDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/HitCountingResourceDefinition.cs index 58cb641e49..57098822a1 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/HitCountingResourceDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/HitCountingResourceDefinition.cs @@ -1,7 +1,4 @@ -using System.Collections.Generic; using System.Collections.Immutable; -using System.Threading; -using System.Threading.Tasks; using JsonApiDotNetCore; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; @@ -9,189 +6,188 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests +namespace JsonApiDotNetCoreTests.IntegrationTests; + +/// +/// Tracks invocations on callback methods. This is used solely in our tests, so we can assert which +/// calls were made, and in which order. +/// +public abstract class HitCountingResourceDefinition : JsonApiResourceDefinition + where TResource : class, IIdentifiable { - /// - /// Tracks invocations on callback methods. This is used solely in our tests, so we can assert which - /// calls were made, and in which order. - /// - public abstract class HitCountingResourceDefinition : JsonApiResourceDefinition - where TResource : class, IIdentifiable - { - private readonly ResourceDefinitionHitCounter _hitCounter; + private readonly ResourceDefinitionHitCounter _hitCounter; - protected virtual ResourceDefinitionExtensibilityPoints ExtensibilityPointsToTrack => ResourceDefinitionExtensibilityPoints.All; + protected virtual ResourceDefinitionExtensibilityPoints ExtensibilityPointsToTrack => ResourceDefinitionExtensibilityPoints.All; - protected HitCountingResourceDefinition(IResourceGraph resourceGraph, ResourceDefinitionHitCounter hitCounter) - : base(resourceGraph) - { - ArgumentGuard.NotNull(hitCounter, nameof(hitCounter)); + protected HitCountingResourceDefinition(IResourceGraph resourceGraph, ResourceDefinitionHitCounter hitCounter) + : base(resourceGraph) + { + ArgumentGuard.NotNull(hitCounter, nameof(hitCounter)); - _hitCounter = hitCounter; - } + _hitCounter = hitCounter; + } - public override IImmutableSet OnApplyIncludes(IImmutableSet existingIncludes) + public override IImmutableSet OnApplyIncludes(IImmutableSet existingIncludes) + { + if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.OnApplyIncludes)) { - if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.OnApplyIncludes)) - { - _hitCounter.TrackInvocation(ResourceDefinitionExtensibilityPoints.OnApplyIncludes); - } - - return base.OnApplyIncludes(existingIncludes); + _hitCounter.TrackInvocation(ResourceDefinitionExtensibilityPoints.OnApplyIncludes); } - public override FilterExpression? OnApplyFilter(FilterExpression? existingFilter) - { - if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.OnApplyFilter)) - { - _hitCounter.TrackInvocation(ResourceDefinitionExtensibilityPoints.OnApplyFilter); - } + return base.OnApplyIncludes(existingIncludes); + } - return base.OnApplyFilter(existingFilter); + public override FilterExpression? OnApplyFilter(FilterExpression? existingFilter) + { + if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.OnApplyFilter)) + { + _hitCounter.TrackInvocation(ResourceDefinitionExtensibilityPoints.OnApplyFilter); } - public override SortExpression? OnApplySort(SortExpression? existingSort) - { - if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.OnApplySort)) - { - _hitCounter.TrackInvocation(ResourceDefinitionExtensibilityPoints.OnApplySort); - } + return base.OnApplyFilter(existingFilter); + } - return base.OnApplySort(existingSort); + public override SortExpression? OnApplySort(SortExpression? existingSort) + { + if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.OnApplySort)) + { + _hitCounter.TrackInvocation(ResourceDefinitionExtensibilityPoints.OnApplySort); } - public override PaginationExpression? OnApplyPagination(PaginationExpression? existingPagination) - { - if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.OnApplyPagination)) - { - _hitCounter.TrackInvocation(ResourceDefinitionExtensibilityPoints.OnApplyPagination); - } + return base.OnApplySort(existingSort); + } - return base.OnApplyPagination(existingPagination); + public override PaginationExpression? OnApplyPagination(PaginationExpression? existingPagination) + { + if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.OnApplyPagination)) + { + _hitCounter.TrackInvocation(ResourceDefinitionExtensibilityPoints.OnApplyPagination); } - public override SparseFieldSetExpression? OnApplySparseFieldSet(SparseFieldSetExpression? existingSparseFieldSet) - { - if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet)) - { - _hitCounter.TrackInvocation(ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet); - } + return base.OnApplyPagination(existingPagination); + } - return base.OnApplySparseFieldSet(existingSparseFieldSet); + public override SparseFieldSetExpression? OnApplySparseFieldSet(SparseFieldSetExpression? existingSparseFieldSet) + { + if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet)) + { + _hitCounter.TrackInvocation(ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet); } - public override QueryStringParameterHandlers? OnRegisterQueryableHandlersForQueryStringParameters() - { - if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.OnRegisterQueryableHandlersForQueryStringParameters)) - { - _hitCounter.TrackInvocation(ResourceDefinitionExtensibilityPoints.OnRegisterQueryableHandlersForQueryStringParameters); - } + return base.OnApplySparseFieldSet(existingSparseFieldSet); + } - return base.OnRegisterQueryableHandlersForQueryStringParameters(); + public override QueryStringParameterHandlers? OnRegisterQueryableHandlersForQueryStringParameters() + { + if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.OnRegisterQueryableHandlersForQueryStringParameters)) + { + _hitCounter.TrackInvocation(ResourceDefinitionExtensibilityPoints.OnRegisterQueryableHandlersForQueryStringParameters); } - public override IDictionary? GetMeta(TResource resource) - { - if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.GetMeta)) - { - _hitCounter.TrackInvocation(ResourceDefinitionExtensibilityPoints.GetMeta); - } + return base.OnRegisterQueryableHandlersForQueryStringParameters(); + } - return base.GetMeta(resource); + public override IDictionary? GetMeta(TResource resource) + { + if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.GetMeta)) + { + _hitCounter.TrackInvocation(ResourceDefinitionExtensibilityPoints.GetMeta); } - public override Task OnPrepareWriteAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) - { - if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync)) - { - _hitCounter.TrackInvocation(ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync); - } + return base.GetMeta(resource); + } - return base.OnPrepareWriteAsync(resource, writeOperation, cancellationToken); + public override Task OnPrepareWriteAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync)) + { + _hitCounter.TrackInvocation(ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync); } - public override Task OnSetToOneRelationshipAsync(TResource leftResource, HasOneAttribute hasOneRelationship, - IIdentifiable? rightResourceId, WriteOperationKind writeOperation, CancellationToken cancellationToken) - { - if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.OnSetToOneRelationshipAsync)) - { - _hitCounter.TrackInvocation(ResourceDefinitionExtensibilityPoints.OnSetToOneRelationshipAsync); - } + return base.OnPrepareWriteAsync(resource, writeOperation, cancellationToken); + } - return base.OnSetToOneRelationshipAsync(leftResource, hasOneRelationship, rightResourceId, writeOperation, cancellationToken); + public override Task OnSetToOneRelationshipAsync(TResource leftResource, HasOneAttribute hasOneRelationship, IIdentifiable? rightResourceId, + WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.OnSetToOneRelationshipAsync)) + { + _hitCounter.TrackInvocation(ResourceDefinitionExtensibilityPoints.OnSetToOneRelationshipAsync); } - public override Task OnSetToManyRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, - WriteOperationKind writeOperation, CancellationToken cancellationToken) - { - if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.OnSetToManyRelationshipAsync)) - { - _hitCounter.TrackInvocation(ResourceDefinitionExtensibilityPoints.OnSetToManyRelationshipAsync); - } + return base.OnSetToOneRelationshipAsync(leftResource, hasOneRelationship, rightResourceId, writeOperation, cancellationToken); + } - return base.OnSetToManyRelationshipAsync(leftResource, hasManyRelationship, rightResourceIds, writeOperation, cancellationToken); + public override Task OnSetToManyRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.OnSetToManyRelationshipAsync)) + { + _hitCounter.TrackInvocation(ResourceDefinitionExtensibilityPoints.OnSetToManyRelationshipAsync); } - public override Task OnAddToRelationshipAsync(TId leftResourceId, HasManyAttribute hasManyRelationship, ISet rightResourceIds, - CancellationToken cancellationToken) - { - if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.OnAddToRelationshipAsync)) - { - _hitCounter.TrackInvocation(ResourceDefinitionExtensibilityPoints.OnAddToRelationshipAsync); - } + return base.OnSetToManyRelationshipAsync(leftResource, hasManyRelationship, rightResourceIds, writeOperation, cancellationToken); + } - return base.OnAddToRelationshipAsync(leftResourceId, hasManyRelationship, rightResourceIds, cancellationToken); + public override Task OnAddToRelationshipAsync(TId leftResourceId, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + CancellationToken cancellationToken) + { + if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.OnAddToRelationshipAsync)) + { + _hitCounter.TrackInvocation(ResourceDefinitionExtensibilityPoints.OnAddToRelationshipAsync); } - public override Task OnRemoveFromRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, - CancellationToken cancellationToken) - { - if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.OnRemoveFromRelationshipAsync)) - { - _hitCounter.TrackInvocation(ResourceDefinitionExtensibilityPoints.OnRemoveFromRelationshipAsync); - } + return base.OnAddToRelationshipAsync(leftResourceId, hasManyRelationship, rightResourceIds, cancellationToken); + } - return base.OnRemoveFromRelationshipAsync(leftResource, hasManyRelationship, rightResourceIds, cancellationToken); + public override Task OnRemoveFromRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + CancellationToken cancellationToken) + { + if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.OnRemoveFromRelationshipAsync)) + { + _hitCounter.TrackInvocation(ResourceDefinitionExtensibilityPoints.OnRemoveFromRelationshipAsync); } - public override Task OnWritingAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) - { - if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.OnWritingAsync)) - { - _hitCounter.TrackInvocation(ResourceDefinitionExtensibilityPoints.OnWritingAsync); - } + return base.OnRemoveFromRelationshipAsync(leftResource, hasManyRelationship, rightResourceIds, cancellationToken); + } - return base.OnWritingAsync(resource, writeOperation, cancellationToken); + public override Task OnWritingAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.OnWritingAsync)) + { + _hitCounter.TrackInvocation(ResourceDefinitionExtensibilityPoints.OnWritingAsync); } - public override Task OnWriteSucceededAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) - { - if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync)) - { - _hitCounter.TrackInvocation(ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync); - } + return base.OnWritingAsync(resource, writeOperation, cancellationToken); + } - return base.OnWriteSucceededAsync(resource, writeOperation, cancellationToken); + public override Task OnWriteSucceededAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync)) + { + _hitCounter.TrackInvocation(ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync); } - public override void OnDeserialize(TResource resource) - { - if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.OnDeserialize)) - { - _hitCounter.TrackInvocation(ResourceDefinitionExtensibilityPoints.OnDeserialize); - } + return base.OnWriteSucceededAsync(resource, writeOperation, cancellationToken); + } - base.OnDeserialize(resource); + public override void OnDeserialize(TResource resource) + { + if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.OnDeserialize)) + { + _hitCounter.TrackInvocation(ResourceDefinitionExtensibilityPoints.OnDeserialize); } - public override void OnSerialize(TResource resource) - { - if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.OnSerialize)) - { - _hitCounter.TrackInvocation(ResourceDefinitionExtensibilityPoints.OnSerialize); - } + base.OnDeserialize(resource); + } - base.OnSerialize(resource); + public override void OnSerialize(TResource resource) + { + if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.OnSerialize)) + { + _hitCounter.TrackInvocation(ResourceDefinitionExtensibilityPoints.OnSerialize); } + + base.OnSerialize(resource); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/ArtGallery.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/ArtGallery.cs index d6131d07c2..f73865f589 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/ArtGallery.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/ArtGallery.cs @@ -1,18 +1,16 @@ -using System.Collections.Generic; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.HostingInIIS +namespace JsonApiDotNetCoreTests.IntegrationTests.HostingInIIS; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.HostingInIIS")] +public sealed class ArtGallery : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - [Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.HostingInIIS")] - public sealed class ArtGallery : Identifiable - { - [Attr] - public string Theme { get; set; } = null!; + [Attr] + public string Theme { get; set; } = null!; - [HasMany] - public ISet Paintings { get; set; } = new HashSet(); - } + [HasMany] + public ISet Paintings { get; set; } = new HashSet(); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/HostingDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/HostingDbContext.cs index 81e9ab0a1a..e92dae1318 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/HostingDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/HostingDbContext.cs @@ -1,17 +1,16 @@ using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; -namespace JsonApiDotNetCoreTests.IntegrationTests.HostingInIIS +namespace JsonApiDotNetCoreTests.IntegrationTests.HostingInIIS; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class HostingDbContext : DbContext { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class HostingDbContext : DbContext - { - public DbSet ArtGalleries => Set(); - public DbSet Paintings => Set(); + public DbSet ArtGalleries => Set(); + public DbSet Paintings => Set(); - public HostingDbContext(DbContextOptions options) - : base(options) - { - } + public HostingDbContext(DbContextOptions options) + : base(options) + { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/HostingFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/HostingFakers.cs index 027ba2d61e..93734a6f08 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/HostingFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/HostingFakers.cs @@ -1,25 +1,23 @@ -using System; using Bogus; using TestBuildingBlocks; // @formatter:wrap_chained_method_calls chop_always // @formatter:keep_existing_linebreaks true -namespace JsonApiDotNetCoreTests.IntegrationTests.HostingInIIS +namespace JsonApiDotNetCoreTests.IntegrationTests.HostingInIIS; + +internal sealed class HostingFakers : FakerContainer { - internal sealed class HostingFakers : FakerContainer - { - private readonly Lazy> _lazyArtGalleryFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(artGallery => artGallery.Theme, faker => faker.Lorem.Word())); + private readonly Lazy> _lazyArtGalleryFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(artGallery => artGallery.Theme, faker => faker.Lorem.Word())); - private readonly Lazy> _lazyPaintingFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(painting => painting.Title, faker => faker.Lorem.Sentence())); + private readonly Lazy> _lazyPaintingFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(painting => painting.Title, faker => faker.Lorem.Sentence())); - public Faker ArtGallery => _lazyArtGalleryFaker.Value; - public Faker Painting => _lazyPaintingFaker.Value; - } + public Faker ArtGallery => _lazyArtGalleryFaker.Value; + public Faker Painting => _lazyPaintingFaker.Value; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/HostingStartup.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/HostingStartup.cs index 07ff57f515..24546c2062 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/HostingStartup.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/HostingStartup.cs @@ -6,25 +6,24 @@ using Microsoft.Extensions.Logging; using TestBuildingBlocks; -namespace JsonApiDotNetCoreTests.IntegrationTests.HostingInIIS +namespace JsonApiDotNetCoreTests.IntegrationTests.HostingInIIS; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class HostingStartup : TestableStartup + where TDbContext : DbContext { - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class HostingStartup : TestableStartup - where TDbContext : DbContext + protected override void SetJsonApiOptions(JsonApiOptions options) { - protected override void SetJsonApiOptions(JsonApiOptions options) - { - base.SetJsonApiOptions(options); + base.SetJsonApiOptions(options); - options.Namespace = "public-api"; - options.IncludeTotalResourceCount = true; - } + options.Namespace = "public-api"; + options.IncludeTotalResourceCount = true; + } - public override void Configure(IApplicationBuilder app, IWebHostEnvironment environment, ILoggerFactory loggerFactory) - { - app.UsePathBase("/iis-application-virtual-directory"); + public override void Configure(IApplicationBuilder app, IWebHostEnvironment environment, ILoggerFactory loggerFactory) + { + app.UsePathBase("/iis-application-virtual-directory"); - base.Configure(app, environment, loggerFactory); - } + base.Configure(app, environment, loggerFactory); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/HostingTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/HostingTests.cs index 009cb406ee..c6e4ea363b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/HostingTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/HostingTests.cs @@ -1,162 +1,157 @@ -using System.Linq; using System.Net; -using System.Net.Http; -using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.HostingInIIS +namespace JsonApiDotNetCoreTests.IntegrationTests.HostingInIIS; + +public sealed class HostingTests : IClassFixture, HostingDbContext>> { - public sealed class HostingTests : IClassFixture, HostingDbContext>> - { - private const string HostPrefix = "http://localhost"; + private const string HostPrefix = "http://localhost"; - private readonly IntegrationTestContext, HostingDbContext> _testContext; - private readonly HostingFakers _fakers = new(); + private readonly IntegrationTestContext, HostingDbContext> _testContext; + private readonly HostingFakers _fakers = new(); - public HostingTests(IntegrationTestContext, HostingDbContext> testContext) - { - _testContext = testContext; + public HostingTests(IntegrationTestContext, HostingDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + testContext.UseController(); + } - testContext.UseController(); - testContext.UseController(); - } + [Fact] + public async Task Get_primary_resources_with_include_returns_links() + { + // Arrange + ArtGallery gallery = _fakers.ArtGallery.Generate(); + gallery.Paintings = _fakers.Painting.Generate(1).ToHashSet(); - [Fact] - public async Task Get_primary_resources_with_include_returns_links() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - ArtGallery gallery = _fakers.ArtGallery.Generate(); - gallery.Paintings = _fakers.Painting.Generate(1).ToHashSet(); + await dbContext.ClearTableAsync(); + dbContext.ArtGalleries.Add(gallery); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.ArtGalleries.Add(gallery); - await dbContext.SaveChangesAsync(); - }); + const string route = "/iis-application-virtual-directory/public-api/artGalleries?include=paintings"; - const string route = "/iis-application-virtual-directory/public-api/artGalleries?include=paintings"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().Be(responseDocument.Links.Self); + responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); - responseDocument.Links.ShouldNotBeNull(); - responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.Related.Should().BeNull(); - responseDocument.Links.First.Should().Be(responseDocument.Links.Self); - responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); - responseDocument.Links.Prev.Should().BeNull(); - responseDocument.Links.Next.Should().BeNull(); + responseDocument.Data.ManyValue.ShouldHaveCount(1); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].With(resource => + { + string galleryLink = $"{HostPrefix}/iis-application-virtual-directory/public-api/artGalleries/{gallery.StringId}"; + + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(galleryLink); - responseDocument.Data.ManyValue[0].With(resource => + resource.Relationships.ShouldContainKey("paintings").With(value => { - string galleryLink = $"{HostPrefix}/iis-application-virtual-directory/public-api/artGalleries/{gallery.StringId}"; - - resource.Links.ShouldNotBeNull(); - resource.Links.Self.Should().Be(galleryLink); - - resource.Relationships.ShouldContainKey("paintings").With(value => - { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - value.Links.Self.Should().Be($"{galleryLink}/relationships/paintings"); - value.Links.Related.Should().Be($"{galleryLink}/paintings"); - }); + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{galleryLink}/relationships/paintings"); + value.Links.Related.Should().Be($"{galleryLink}/paintings"); }); + }); - string paintingLink = - $"{HostPrefix}/iis-application-virtual-directory/custom/path/to/paintings-of-the-world/{gallery.Paintings.ElementAt(0).StringId}"; + string paintingLink = $"{HostPrefix}/iis-application-virtual-directory/custom/path/to/paintings-of-the-world/{gallery.Paintings.ElementAt(0).StringId}"; - responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included.ShouldHaveCount(1); - responseDocument.Included[0].With(resource => + responseDocument.Included[0].With(resource => + { + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(paintingLink); + + resource.Relationships.ShouldContainKey("exposedAt").With(value => { - resource.Links.ShouldNotBeNull(); - resource.Links.Self.Should().Be(paintingLink); - - resource.Relationships.ShouldContainKey("exposedAt").With(value => - { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - value.Links.Self.Should().Be($"{paintingLink}/relationships/exposedAt"); - value.Links.Related.Should().Be($"{paintingLink}/exposedAt"); - }); + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{paintingLink}/relationships/exposedAt"); + value.Links.Related.Should().Be($"{paintingLink}/exposedAt"); }); - } + }); + } + + [Fact] + public async Task Get_primary_resources_with_include_on_custom_route_returns_links() + { + // Arrange + Painting painting = _fakers.Painting.Generate(); + painting.ExposedAt = _fakers.ArtGallery.Generate(); - [Fact] - public async Task Get_primary_resources_with_include_on_custom_route_returns_links() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - Painting painting = _fakers.Painting.Generate(); - painting.ExposedAt = _fakers.ArtGallery.Generate(); + await dbContext.ClearTableAsync(); + dbContext.Paintings.Add(painting); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Paintings.Add(painting); - await dbContext.SaveChangesAsync(); - }); + const string route = "/iis-application-virtual-directory/custom/path/to/paintings-of-the-world?include=exposedAt"; - const string route = "/iis-application-virtual-directory/custom/path/to/paintings-of-the-world?include=exposedAt"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().Be(responseDocument.Links.Self); + responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); - responseDocument.Links.ShouldNotBeNull(); - responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.Related.Should().BeNull(); - responseDocument.Links.First.Should().Be(responseDocument.Links.Self); - responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); - responseDocument.Links.Prev.Should().BeNull(); - responseDocument.Links.Next.Should().BeNull(); + responseDocument.Data.ManyValue.ShouldHaveCount(1); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].With(resource => + { + string paintingLink = $"{HostPrefix}/iis-application-virtual-directory/custom/path/to/paintings-of-the-world/{painting.StringId}"; + + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(paintingLink); - responseDocument.Data.ManyValue[0].With(resource => + resource.Relationships.ShouldContainKey("exposedAt").With(value => { - string paintingLink = $"{HostPrefix}/iis-application-virtual-directory/custom/path/to/paintings-of-the-world/{painting.StringId}"; - - resource.Links.ShouldNotBeNull(); - resource.Links.Self.Should().Be(paintingLink); - - resource.Relationships.ShouldContainKey("exposedAt").With(value => - { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - value.Links.Self.Should().Be($"{paintingLink}/relationships/exposedAt"); - value.Links.Related.Should().Be($"{paintingLink}/exposedAt"); - }); + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{paintingLink}/relationships/exposedAt"); + value.Links.Related.Should().Be($"{paintingLink}/exposedAt"); }); + }); + + responseDocument.Included.ShouldHaveCount(1); + + responseDocument.Included[0].With(resource => + { + string galleryLink = $"{HostPrefix}/iis-application-virtual-directory/public-api/artGalleries/{painting.ExposedAt.StringId}"; - responseDocument.Included.ShouldHaveCount(1); + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(galleryLink); - responseDocument.Included[0].With(resource => + resource.Relationships.ShouldContainKey("paintings").With(value => { - string galleryLink = $"{HostPrefix}/iis-application-virtual-directory/public-api/artGalleries/{painting.ExposedAt.StringId}"; - - resource.Links.ShouldNotBeNull(); - resource.Links.Self.Should().Be(galleryLink); - - resource.Relationships.ShouldContainKey("paintings").With(value => - { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - value.Links.Self.Should().Be($"{galleryLink}/relationships/paintings"); - value.Links.Related.Should().Be($"{galleryLink}/paintings"); - }); + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{galleryLink}/relationships/paintings"); + value.Links.Related.Should().Be($"{galleryLink}/paintings"); }); - } + }); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/Painting.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/Painting.cs index 52508dbdba..97c04ac55e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/Painting.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/Painting.cs @@ -2,16 +2,15 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.HostingInIIS +namespace JsonApiDotNetCoreTests.IntegrationTests.HostingInIIS; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.HostingInIIS")] +public sealed class Painting : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - [Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.HostingInIIS")] - public sealed class Painting : Identifiable - { - [Attr] - public string Title { get; set; } = null!; + [Attr] + public string Title { get; set; } = null!; - [HasOne] - public ArtGallery? ExposedAt { get; set; } - } + [HasOne] + public ArtGallery? ExposedAt { get; set; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/PaintingsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/PaintingsController.cs index 3217f34511..bf7b915f03 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/PaintingsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/PaintingsController.cs @@ -1,16 +1,15 @@ using JsonApiDotNetCore.Controllers.Annotations; using Microsoft.AspNetCore.Mvc; -namespace JsonApiDotNetCoreTests.IntegrationTests.HostingInIIS +namespace JsonApiDotNetCoreTests.IntegrationTests.HostingInIIS; + +// Workaround for https://youtrack.jetbrains.com/issue/RSRP-487028 +public partial class PaintingsController { - // Workaround for https://youtrack.jetbrains.com/issue/RSRP-487028 - public partial class PaintingsController - { - } +} - [DisableRoutingConvention] - [Route("custom/path/to/paintings-of-the-world")] - partial class PaintingsController - { - } +[DisableRoutingConvention] +[Route("custom/path/to/paintings-of-the-world")] +partial class PaintingsController +{ } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/BankAccount.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/BankAccount.cs index 197cee35c6..911054f291 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/BankAccount.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/BankAccount.cs @@ -1,16 +1,14 @@ -using System.Collections.Generic; using JetBrains.Annotations; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.IdObfuscation +namespace JsonApiDotNetCoreTests.IntegrationTests.IdObfuscation; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class BankAccount : ObfuscatedIdentifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class BankAccount : ObfuscatedIdentifiable - { - [Attr] - public string Iban { get; set; } = null!; + [Attr] + public string Iban { get; set; } = null!; - [HasMany] - public IList Cards { get; set; } = new List(); - } + [HasMany] + public IList Cards { get; set; } = new List(); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/BankAccountsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/BankAccountsController.cs index d064824979..eedc36ff87 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/BankAccountsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/BankAccountsController.cs @@ -2,14 +2,13 @@ using JsonApiDotNetCore.Services; using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCoreTests.IntegrationTests.IdObfuscation +namespace JsonApiDotNetCoreTests.IntegrationTests.IdObfuscation; + +public sealed class BankAccountsController : ObfuscatedIdentifiableController { - public sealed class BankAccountsController : ObfuscatedIdentifiableController + public BankAccountsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { - public BankAccountsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, - IResourceService resourceService) - : base(options, resourceGraph, loggerFactory, resourceService) - { - } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/DebitCard.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/DebitCard.cs index 13e8cb583f..323d1bd1e3 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/DebitCard.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/DebitCard.cs @@ -1,18 +1,17 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.IdObfuscation +namespace JsonApiDotNetCoreTests.IntegrationTests.IdObfuscation; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class DebitCard : ObfuscatedIdentifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class DebitCard : ObfuscatedIdentifiable - { - [Attr] - public string OwnerName { get; set; } = null!; + [Attr] + public string OwnerName { get; set; } = null!; - [Attr] - public short PinCode { get; set; } + [Attr] + public short PinCode { get; set; } - [HasOne] - public BankAccount Account { get; set; } = null!; - } + [HasOne] + public BankAccount Account { get; set; } = null!; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/DebitCardsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/DebitCardsController.cs index 2d733f0a34..e8c426572a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/DebitCardsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/DebitCardsController.cs @@ -2,14 +2,13 @@ using JsonApiDotNetCore.Services; using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCoreTests.IntegrationTests.IdObfuscation +namespace JsonApiDotNetCoreTests.IntegrationTests.IdObfuscation; + +public sealed class DebitCardsController : ObfuscatedIdentifiableController { - public sealed class DebitCardsController : ObfuscatedIdentifiableController + public DebitCardsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { - public DebitCardsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, - IResourceService resourceService) - : base(options, resourceGraph, loggerFactory, resourceService) - { - } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/HexadecimalCodec.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/HexadecimalCodec.cs index 7796ec4ed5..1e95efa717 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/HexadecimalCodec.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/HexadecimalCodec.cs @@ -1,71 +1,68 @@ -using System; -using System.Collections.Generic; using System.Globalization; using System.Net; using System.Text; using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCoreTests.IntegrationTests.IdObfuscation +namespace JsonApiDotNetCoreTests.IntegrationTests.IdObfuscation; + +internal sealed class HexadecimalCodec { - internal sealed class HexadecimalCodec + public int Decode(string? value) { - public int Decode(string? value) + if (value == null) { - if (value == null) - { - return 0; - } + return 0; + } - if (!value.StartsWith("x", StringComparison.Ordinal)) + if (!value.StartsWith("x", StringComparison.Ordinal)) + { + throw new JsonApiException(new ErrorObject(HttpStatusCode.BadRequest) { - throw new JsonApiException(new ErrorObject(HttpStatusCode.BadRequest) - { - Title = "Invalid ID value.", - Detail = $"The value '{value}' is not a valid hexadecimal value." - }); - } - - string stringValue = FromHexString(value[1..]); - return int.Parse(stringValue); + Title = "Invalid ID value.", + Detail = $"The value '{value}' is not a valid hexadecimal value." + }); } - private static string FromHexString(string hexString) - { - var bytes = new List(hexString.Length / 2); + string stringValue = FromHexString(value[1..]); + return int.Parse(stringValue); + } - for (int index = 0; index < hexString.Length; index += 2) - { - string hexChar = hexString.Substring(index, 2); - byte bt = byte.Parse(hexChar, NumberStyles.HexNumber); - bytes.Add(bt); - } + private static string FromHexString(string hexString) + { + var bytes = new List(hexString.Length / 2); - char[] chars = Encoding.ASCII.GetChars(bytes.ToArray()); - return new string(chars); + for (int index = 0; index < hexString.Length; index += 2) + { + string hexChar = hexString.Substring(index, 2); + byte bt = byte.Parse(hexChar, NumberStyles.HexNumber); + bytes.Add(bt); } - public string? Encode(int value) - { - if (value == 0) - { - return null; - } + char[] chars = Encoding.ASCII.GetChars(bytes.ToArray()); + return new string(chars); + } - string stringValue = value.ToString(); - return $"x{ToHexString(stringValue)}"; + public string? Encode(int value) + { + if (value == 0) + { + return null; } - private static string ToHexString(string value) - { - var builder = new StringBuilder(); + string stringValue = value.ToString(); + return $"x{ToHexString(stringValue)}"; + } - foreach (byte bt in Encoding.ASCII.GetBytes(value)) - { - builder.Append(bt.ToString("X2")); - } + private static string ToHexString(string value) + { + var builder = new StringBuilder(); - return builder.ToString(); + foreach (byte bt in Encoding.ASCII.GetBytes(value)) + { + builder.Append(bt.ToString("X2")); } + + return builder.ToString(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs index bbe61c5059..e14f5c616e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs @@ -1,478 +1,474 @@ -using System.Collections.Generic; using System.Net; -using System.Net.Http; -using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.EntityFrameworkCore; using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.IdObfuscation +namespace JsonApiDotNetCoreTests.IntegrationTests.IdObfuscation; + +public sealed class IdObfuscationTests : IClassFixture, ObfuscationDbContext>> { - public sealed class IdObfuscationTests : IClassFixture, ObfuscationDbContext>> + private readonly IntegrationTestContext, ObfuscationDbContext> _testContext; + private readonly ObfuscationFakers _fakers = new(); + + public IdObfuscationTests(IntegrationTestContext, ObfuscationDbContext> testContext) { - private readonly IntegrationTestContext, ObfuscationDbContext> _testContext; - private readonly ObfuscationFakers _fakers = new(); + _testContext = testContext; - public IdObfuscationTests(IntegrationTestContext, ObfuscationDbContext> testContext) - { - _testContext = testContext; + testContext.UseController(); + testContext.UseController(); + } - testContext.UseController(); - testContext.UseController(); - } + [Fact] + public async Task Can_filter_equality_in_primary_resources() + { + // Arrange + List accounts = _fakers.BankAccount.Generate(2); - [Fact] - public async Task Can_filter_equality_in_primary_resources() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - List accounts = _fakers.BankAccount.Generate(2); + await dbContext.ClearTableAsync(); + dbContext.BankAccounts.AddRange(accounts); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.BankAccounts.AddRange(accounts); - await dbContext.SaveChangesAsync(); - }); + string route = $"/bankAccounts?filter=equals(id,'{accounts[1].StringId}')"; - string route = $"/bankAccounts?filter=equals(id,'{accounts[1].StringId}')"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be(accounts[1].StringId); + } - responseDocument.Data.ManyValue.ShouldHaveCount(1); - responseDocument.Data.ManyValue[0].Id.Should().Be(accounts[1].StringId); - } + [Fact] + public async Task Can_filter_any_in_primary_resources() + { + // Arrange + List accounts = _fakers.BankAccount.Generate(2); - [Fact] - public async Task Can_filter_any_in_primary_resources() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - List accounts = _fakers.BankAccount.Generate(2); + await dbContext.ClearTableAsync(); + dbContext.BankAccounts.AddRange(accounts); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.BankAccounts.AddRange(accounts); - await dbContext.SaveChangesAsync(); - }); + var codec = new HexadecimalCodec(); + string route = $"/bankAccounts?filter=any(id,'{accounts[1].StringId}','{codec.Encode(Unknown.TypedId.Int32)}')"; - var codec = new HexadecimalCodec(); - string route = $"/bankAccounts?filter=any(id,'{accounts[1].StringId}','{codec.Encode(Unknown.TypedId.Int32)}')"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be(accounts[1].StringId); + } - responseDocument.Data.ManyValue.ShouldHaveCount(1); - responseDocument.Data.ManyValue[0].Id.Should().Be(accounts[1].StringId); - } + [Fact] + public async Task Cannot_get_primary_resource_for_invalid_ID() + { + // Arrange + const string route = "/bankAccounts/not-a-hex-value"; - [Fact] - public async Task Cannot_get_primary_resource_for_invalid_ID() - { - // Arrange - const string route = "/bankAccounts/not-a-hex-value"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + responseDocument.Errors.ShouldHaveCount(1); - responseDocument.Errors.ShouldHaveCount(1); + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("Invalid ID value."); + error.Detail.Should().Be("The value 'not-a-hex-value' is not a valid hexadecimal value."); + } - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Invalid ID value."); - error.Detail.Should().Be("The value 'not-a-hex-value' is not a valid hexadecimal value."); - } + [Fact] + public async Task Can_get_primary_resource_by_ID() + { + // Arrange + DebitCard card = _fakers.DebitCard.Generate(); + card.Account = _fakers.BankAccount.Generate(); - [Fact] - public async Task Can_get_primary_resource_by_ID() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - DebitCard card = _fakers.DebitCard.Generate(); - card.Account = _fakers.BankAccount.Generate(); + dbContext.DebitCards.Add(card); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.DebitCards.Add(card); - await dbContext.SaveChangesAsync(); - }); + string route = $"/debitCards/{card.StringId}"; - string route = $"/debitCards/{card.StringId}"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Id.Should().Be(card.StringId); + } - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Id.Should().Be(card.StringId); - } + [Fact] + public async Task Can_get_secondary_resources() + { + // Arrange + BankAccount account = _fakers.BankAccount.Generate(); + account.Cards = _fakers.DebitCard.Generate(2); - [Fact] - public async Task Can_get_secondary_resources() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - BankAccount account = _fakers.BankAccount.Generate(); - account.Cards = _fakers.DebitCard.Generate(2); + dbContext.BankAccounts.Add(account); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.BankAccounts.Add(account); - await dbContext.SaveChangesAsync(); - }); + string route = $"/bankAccounts/{account.StringId}/cards"; - string route = $"/bankAccounts/{account.StringId}/cards"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.ManyValue.ShouldHaveCount(2); + responseDocument.Data.ManyValue[0].Id.Should().Be(account.Cards[0].StringId); + responseDocument.Data.ManyValue[1].Id.Should().Be(account.Cards[1].StringId); + } - responseDocument.Data.ManyValue.ShouldHaveCount(2); - responseDocument.Data.ManyValue[0].Id.Should().Be(account.Cards[0].StringId); - responseDocument.Data.ManyValue[1].Id.Should().Be(account.Cards[1].StringId); - } + [Fact] + public async Task Can_include_resource_with_sparse_fieldset() + { + // Arrange + BankAccount account = _fakers.BankAccount.Generate(); + account.Cards = _fakers.DebitCard.Generate(1); - [Fact] - public async Task Can_include_resource_with_sparse_fieldset() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - BankAccount account = _fakers.BankAccount.Generate(); - account.Cards = _fakers.DebitCard.Generate(1); + dbContext.BankAccounts.Add(account); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.BankAccounts.Add(account); - await dbContext.SaveChangesAsync(); - }); + string route = $"/bankAccounts/{account.StringId}?include=cards&fields[debitCards]=ownerName"; - string route = $"/bankAccounts/{account.StringId}?include=cards&fields[debitCards]=ownerName"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Id.Should().Be(account.StringId); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Id.Should().Be(account.StringId); + responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included[0].Id.Should().Be(account.Cards[0].StringId); + responseDocument.Included[0].Attributes.ShouldHaveCount(1); + responseDocument.Included[0].Relationships.Should().BeNull(); + } - responseDocument.Included.ShouldHaveCount(1); - responseDocument.Included[0].Id.Should().Be(account.Cards[0].StringId); - responseDocument.Included[0].Attributes.ShouldHaveCount(1); - responseDocument.Included[0].Relationships.Should().BeNull(); - } + [Fact] + public async Task Can_get_relationship() + { + // Arrange + BankAccount account = _fakers.BankAccount.Generate(); + account.Cards = _fakers.DebitCard.Generate(1); - [Fact] - public async Task Can_get_relationship() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - BankAccount account = _fakers.BankAccount.Generate(); - account.Cards = _fakers.DebitCard.Generate(1); + dbContext.BankAccounts.Add(account); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.BankAccounts.Add(account); - await dbContext.SaveChangesAsync(); - }); + string route = $"/bankAccounts/{account.StringId}/relationships/cards"; - string route = $"/bankAccounts/{account.StringId}/relationships/cards"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be(account.Cards[0].StringId); + } - responseDocument.Data.ManyValue.ShouldHaveCount(1); - responseDocument.Data.ManyValue[0].Id.Should().Be(account.Cards[0].StringId); - } + [Fact] + public async Task Can_create_resource_with_relationship() + { + // Arrange + BankAccount existingAccount = _fakers.BankAccount.Generate(); + DebitCard newCard = _fakers.DebitCard.Generate(); - [Fact] - public async Task Can_create_resource_with_relationship() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - BankAccount existingAccount = _fakers.BankAccount.Generate(); - DebitCard newCard = _fakers.DebitCard.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.BankAccounts.Add(existingAccount); - await dbContext.SaveChangesAsync(); - }); + dbContext.BankAccounts.Add(existingAccount); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new { - data = new + type = "debitCards", + attributes = new { - type = "debitCards", - attributes = new - { - ownerName = newCard.OwnerName, - pinCode = newCard.PinCode - }, - relationships = new + ownerName = newCard.OwnerName, + pinCode = newCard.PinCode + }, + relationships = new + { + account = new { - account = new + data = new { - data = new - { - type = "bankAccounts", - id = existingAccount.StringId - } + type = "bankAccounts", + id = existingAccount.StringId } } } - }; + } + }; - const string route = "/debitCards"; + const string route = "/debitCards"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("ownerName").With(value => value.Should().Be(newCard.OwnerName)); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("pinCode").With(value => value.Should().Be(newCard.PinCode)); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("ownerName").With(value => value.Should().Be(newCard.OwnerName)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("pinCode").With(value => value.Should().Be(newCard.PinCode)); - var codec = new HexadecimalCodec(); - int newCardId = codec.Decode(responseDocument.Data.SingleValue.Id); + var codec = new HexadecimalCodec(); + int newCardId = codec.Decode(responseDocument.Data.SingleValue.Id); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - DebitCard cardInDatabase = await dbContext.DebitCards.Include(card => card.Account).FirstWithIdAsync(newCardId); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + DebitCard cardInDatabase = await dbContext.DebitCards.Include(card => card.Account).FirstWithIdAsync(newCardId); - cardInDatabase.OwnerName.Should().Be(newCard.OwnerName); - cardInDatabase.PinCode.Should().Be(newCard.PinCode); + cardInDatabase.OwnerName.Should().Be(newCard.OwnerName); + cardInDatabase.PinCode.Should().Be(newCard.PinCode); - cardInDatabase.Account.ShouldNotBeNull(); - cardInDatabase.Account.Id.Should().Be(existingAccount.Id); - cardInDatabase.Account.StringId.Should().Be(existingAccount.StringId); - }); - } + cardInDatabase.Account.ShouldNotBeNull(); + cardInDatabase.Account.Id.Should().Be(existingAccount.Id); + cardInDatabase.Account.StringId.Should().Be(existingAccount.StringId); + }); + } - [Fact] - public async Task Can_update_resource_with_relationship() - { - // Arrange - BankAccount existingAccount = _fakers.BankAccount.Generate(); - existingAccount.Cards = _fakers.DebitCard.Generate(1); + [Fact] + public async Task Can_update_resource_with_relationship() + { + // Arrange + BankAccount existingAccount = _fakers.BankAccount.Generate(); + existingAccount.Cards = _fakers.DebitCard.Generate(1); - DebitCard existingCard = _fakers.DebitCard.Generate(); - existingCard.Account = _fakers.BankAccount.Generate(); + DebitCard existingCard = _fakers.DebitCard.Generate(); + existingCard.Account = _fakers.BankAccount.Generate(); - string newIban = _fakers.BankAccount.Generate().Iban; + string newIban = _fakers.BankAccount.Generate().Iban; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddInRange(existingAccount, existingCard); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingAccount, existingCard); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new { - data = new + type = "bankAccounts", + id = existingAccount.StringId, + attributes = new { - type = "bankAccounts", - id = existingAccount.StringId, - attributes = new - { - iban = newIban - }, - relationships = new + iban = newIban + }, + relationships = new + { + cards = new { - cards = new + data = new[] { - data = new[] + new { - new - { - type = "debitCards", - id = existingCard.StringId - } + type = "debitCards", + id = existingCard.StringId } } } } - }; + } + }; - string route = $"/bankAccounts/{existingAccount.StringId}"; + string route = $"/bankAccounts/{existingAccount.StringId}"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - BankAccount accountInDatabase = await dbContext.BankAccounts.Include(account => account.Cards).FirstWithIdAsync(existingAccount.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + BankAccount accountInDatabase = await dbContext.BankAccounts.Include(account => account.Cards).FirstWithIdAsync(existingAccount.Id); - accountInDatabase.Iban.Should().Be(newIban); + accountInDatabase.Iban.Should().Be(newIban); - accountInDatabase.Cards.ShouldHaveCount(1); - accountInDatabase.Cards[0].Id.Should().Be(existingCard.Id); - accountInDatabase.Cards[0].StringId.Should().Be(existingCard.StringId); - }); - } + accountInDatabase.Cards.ShouldHaveCount(1); + accountInDatabase.Cards[0].Id.Should().Be(existingCard.Id); + accountInDatabase.Cards[0].StringId.Should().Be(existingCard.StringId); + }); + } - [Fact] - public async Task Can_add_to_ToMany_relationship() - { - // Arrange - BankAccount existingAccount = _fakers.BankAccount.Generate(); - existingAccount.Cards = _fakers.DebitCard.Generate(1); + [Fact] + public async Task Can_add_to_ToMany_relationship() + { + // Arrange + BankAccount existingAccount = _fakers.BankAccount.Generate(); + existingAccount.Cards = _fakers.DebitCard.Generate(1); - DebitCard existingDebitCard = _fakers.DebitCard.Generate(); - existingDebitCard.Account = _fakers.BankAccount.Generate(); + DebitCard existingDebitCard = _fakers.DebitCard.Generate(); + existingDebitCard.Account = _fakers.BankAccount.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddInRange(existingAccount, existingDebitCard); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingAccount, existingDebitCard); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new[] { - data = new[] + new { - new - { - type = "debitCards", - id = existingDebitCard.StringId - } + type = "debitCards", + id = existingDebitCard.StringId } - }; + } + }; - string route = $"/bankAccounts/{existingAccount.StringId}/relationships/cards"; + string route = $"/bankAccounts/{existingAccount.StringId}/relationships/cards"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - BankAccount accountInDatabase = await dbContext.BankAccounts.Include(account => account.Cards).FirstWithIdAsync(existingAccount.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + BankAccount accountInDatabase = await dbContext.BankAccounts.Include(account => account.Cards).FirstWithIdAsync(existingAccount.Id); - accountInDatabase.Cards.ShouldHaveCount(2); - }); - } + accountInDatabase.Cards.ShouldHaveCount(2); + }); + } - [Fact] - public async Task Can_remove_from_ToMany_relationship() - { - // Arrange - BankAccount existingAccount = _fakers.BankAccount.Generate(); - existingAccount.Cards = _fakers.DebitCard.Generate(2); + [Fact] + public async Task Can_remove_from_ToMany_relationship() + { + // Arrange + BankAccount existingAccount = _fakers.BankAccount.Generate(); + existingAccount.Cards = _fakers.DebitCard.Generate(2); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.BankAccounts.Add(existingAccount); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.BankAccounts.Add(existingAccount); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new[] { - data = new[] + new { - new - { - type = "debitCards", - id = existingAccount.Cards[0].StringId - } + type = "debitCards", + id = existingAccount.Cards[0].StringId } - }; - - string route = $"/bankAccounts/{existingAccount.StringId}/relationships/cards"; + } + }; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + string route = $"/bankAccounts/{existingAccount.StringId}/relationships/cards"; - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - BankAccount accountInDatabase = await dbContext.BankAccounts.Include(account => account.Cards).FirstWithIdAsync(existingAccount.Id); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - accountInDatabase.Cards.ShouldHaveCount(1); - }); - } + responseDocument.Should().BeEmpty(); - [Fact] - public async Task Can_delete_resource() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - BankAccount existingAccount = _fakers.BankAccount.Generate(); - existingAccount.Cards = _fakers.DebitCard.Generate(1); + BankAccount accountInDatabase = await dbContext.BankAccounts.Include(account => account.Cards).FirstWithIdAsync(existingAccount.Id); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.BankAccounts.Add(existingAccount); - await dbContext.SaveChangesAsync(); - }); + accountInDatabase.Cards.ShouldHaveCount(1); + }); + } - string route = $"/bankAccounts/{existingAccount.StringId}"; + [Fact] + public async Task Can_delete_resource() + { + // Arrange + BankAccount existingAccount = _fakers.BankAccount.Generate(); + existingAccount.Cards = _fakers.DebitCard.Generate(1); - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.BankAccounts.Add(existingAccount); + await dbContext.SaveChangesAsync(); + }); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + string route = $"/bankAccounts/{existingAccount.StringId}"; - responseDocument.Should().BeEmpty(); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - BankAccount? accountInDatabase = await dbContext.BankAccounts.Include(account => account.Cards).FirstWithIdOrDefaultAsync(existingAccount.Id); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - accountInDatabase.Should().BeNull(); - }); - } + responseDocument.Should().BeEmpty(); - [Fact] - public async Task Cannot_delete_unknown_resource() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - var codec = new HexadecimalCodec(); - string? stringId = codec.Encode(Unknown.TypedId.Int32); + BankAccount? accountInDatabase = await dbContext.BankAccounts.Include(account => account.Cards).FirstWithIdOrDefaultAsync(existingAccount.Id); + + accountInDatabase.Should().BeNull(); + }); + } + + [Fact] + public async Task Cannot_delete_unknown_resource() + { + // Arrange + var codec = new HexadecimalCodec(); + string? stringId = codec.Encode(Unknown.TypedId.Int32); - string route = $"/bankAccounts/{stringId}"; + string route = $"/bankAccounts/{stringId}"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteDeleteAsync(route); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteDeleteAsync(route); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.NotFound); - error.Title.Should().Be("The requested resource does not exist."); - error.Detail.Should().Be($"Resource of type 'bankAccounts' with ID '{stringId}' does not exist."); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("The requested resource does not exist."); + error.Detail.Should().Be($"Resource of type 'bankAccounts' with ID '{stringId}' does not exist."); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiable.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiable.cs index 223a29229f..0aa2203d8a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiable.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiable.cs @@ -1,19 +1,18 @@ using JsonApiDotNetCore.Resources; -namespace JsonApiDotNetCoreTests.IntegrationTests.IdObfuscation +namespace JsonApiDotNetCoreTests.IntegrationTests.IdObfuscation; + +public abstract class ObfuscatedIdentifiable : Identifiable { - public abstract class ObfuscatedIdentifiable : Identifiable - { - private static readonly HexadecimalCodec Codec = new(); + private static readonly HexadecimalCodec Codec = new(); - protected override string? GetStringId(int value) - { - return value == default ? null : Codec.Encode(value); - } + protected override string? GetStringId(int value) + { + return value == default ? null : Codec.Encode(value); + } - protected override int GetTypedId(string? value) - { - return value == null ? default : Codec.Decode(value); - } + protected override int GetTypedId(string? value) + { + return value == null ? default : Codec.Decode(value); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiableController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiableController.cs index 0eb276db99..cded455259 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiableController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiableController.cs @@ -1,6 +1,3 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Resources; @@ -8,87 +5,86 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCoreTests.IntegrationTests.IdObfuscation +namespace JsonApiDotNetCoreTests.IntegrationTests.IdObfuscation; + +public abstract class ObfuscatedIdentifiableController : BaseJsonApiController + where TResource : class, IIdentifiable { - public abstract class ObfuscatedIdentifiableController : BaseJsonApiController - where TResource : class, IIdentifiable - { - private readonly HexadecimalCodec _codec = new(); + private readonly HexadecimalCodec _codec = new(); - protected ObfuscatedIdentifiableController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, - IResourceService resourceService) - : base(options, resourceGraph, loggerFactory, resourceService) - { - } + protected ObfuscatedIdentifiableController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) + { + } - [HttpGet] - public override Task GetAsync(CancellationToken cancellationToken) - { - return base.GetAsync(cancellationToken); - } + [HttpGet] + public override Task GetAsync(CancellationToken cancellationToken) + { + return base.GetAsync(cancellationToken); + } - [HttpGet("{id}")] - public Task GetAsync(string id, CancellationToken cancellationToken) - { - int idValue = _codec.Decode(id); - return base.GetAsync(idValue, cancellationToken); - } + [HttpGet("{id}")] + public Task GetAsync(string id, CancellationToken cancellationToken) + { + int idValue = _codec.Decode(id); + return base.GetAsync(idValue, cancellationToken); + } - [HttpGet("{id}/{relationshipName}")] - public Task GetSecondaryAsync(string id, string relationshipName, CancellationToken cancellationToken) - { - int idValue = _codec.Decode(id); - return base.GetSecondaryAsync(idValue, relationshipName, cancellationToken); - } + [HttpGet("{id}/{relationshipName}")] + public Task GetSecondaryAsync(string id, string relationshipName, CancellationToken cancellationToken) + { + int idValue = _codec.Decode(id); + return base.GetSecondaryAsync(idValue, relationshipName, cancellationToken); + } - [HttpGet("{id}/relationships/{relationshipName}")] - public Task GetRelationshipAsync(string id, string relationshipName, CancellationToken cancellationToken) - { - int idValue = _codec.Decode(id); - return base.GetRelationshipAsync(idValue, relationshipName, cancellationToken); - } + [HttpGet("{id}/relationships/{relationshipName}")] + public Task GetRelationshipAsync(string id, string relationshipName, CancellationToken cancellationToken) + { + int idValue = _codec.Decode(id); + return base.GetRelationshipAsync(idValue, relationshipName, cancellationToken); + } - [HttpPost] - public override Task PostAsync([FromBody] TResource resource, CancellationToken cancellationToken) - { - return base.PostAsync(resource, cancellationToken); - } + [HttpPost] + public override Task PostAsync([FromBody] TResource resource, CancellationToken cancellationToken) + { + return base.PostAsync(resource, cancellationToken); + } - [HttpPost("{id}/relationships/{relationshipName}")] - public Task PostRelationshipAsync(string id, string relationshipName, [FromBody] ISet rightResourceIds, - CancellationToken cancellationToken) - { - int idValue = _codec.Decode(id); - return base.PostRelationshipAsync(idValue, relationshipName, rightResourceIds, cancellationToken); - } + [HttpPost("{id}/relationships/{relationshipName}")] + public Task PostRelationshipAsync(string id, string relationshipName, [FromBody] ISet rightResourceIds, + CancellationToken cancellationToken) + { + int idValue = _codec.Decode(id); + return base.PostRelationshipAsync(idValue, relationshipName, rightResourceIds, cancellationToken); + } - [HttpPatch("{id}")] - public Task PatchAsync(string id, [FromBody] TResource resource, CancellationToken cancellationToken) - { - int idValue = _codec.Decode(id); - return base.PatchAsync(idValue, resource, cancellationToken); - } + [HttpPatch("{id}")] + public Task PatchAsync(string id, [FromBody] TResource resource, CancellationToken cancellationToken) + { + int idValue = _codec.Decode(id); + return base.PatchAsync(idValue, resource, cancellationToken); + } - [HttpPatch("{id}/relationships/{relationshipName}")] - public Task PatchRelationshipAsync(string id, string relationshipName, [FromBody] object rightValue, CancellationToken cancellationToken) - { - int idValue = _codec.Decode(id); - return base.PatchRelationshipAsync(idValue, relationshipName, rightValue, cancellationToken); - } + [HttpPatch("{id}/relationships/{relationshipName}")] + public Task PatchRelationshipAsync(string id, string relationshipName, [FromBody] object rightValue, CancellationToken cancellationToken) + { + int idValue = _codec.Decode(id); + return base.PatchRelationshipAsync(idValue, relationshipName, rightValue, cancellationToken); + } - [HttpDelete("{id}")] - public Task DeleteAsync(string id, CancellationToken cancellationToken) - { - int idValue = _codec.Decode(id); - return base.DeleteAsync(idValue, cancellationToken); - } + [HttpDelete("{id}")] + public Task DeleteAsync(string id, CancellationToken cancellationToken) + { + int idValue = _codec.Decode(id); + return base.DeleteAsync(idValue, cancellationToken); + } - [HttpDelete("{id}/relationships/{relationshipName}")] - public Task DeleteRelationshipAsync(string id, string relationshipName, [FromBody] ISet rightResourceIds, - CancellationToken cancellationToken) - { - int idValue = _codec.Decode(id); - return base.DeleteRelationshipAsync(idValue, relationshipName, rightResourceIds, cancellationToken); - } + [HttpDelete("{id}/relationships/{relationshipName}")] + public Task DeleteRelationshipAsync(string id, string relationshipName, [FromBody] ISet rightResourceIds, + CancellationToken cancellationToken) + { + int idValue = _codec.Decode(id); + return base.DeleteRelationshipAsync(idValue, relationshipName, rightResourceIds, cancellationToken); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscationDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscationDbContext.cs index 4cf9791745..94921ea800 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscationDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscationDbContext.cs @@ -1,17 +1,16 @@ using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; -namespace JsonApiDotNetCoreTests.IntegrationTests.IdObfuscation +namespace JsonApiDotNetCoreTests.IntegrationTests.IdObfuscation; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class ObfuscationDbContext : DbContext { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class ObfuscationDbContext : DbContext - { - public DbSet BankAccounts => Set(); - public DbSet DebitCards => Set(); + public DbSet BankAccounts => Set(); + public DbSet DebitCards => Set(); - public ObfuscationDbContext(DbContextOptions options) - : base(options) - { - } + public ObfuscationDbContext(DbContextOptions options) + : base(options) + { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscationFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscationFakers.cs index 3b82da0e5d..201089d15b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscationFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscationFakers.cs @@ -1,26 +1,24 @@ -using System; using Bogus; using TestBuildingBlocks; // @formatter:wrap_chained_method_calls chop_always // @formatter:keep_existing_linebreaks true -namespace JsonApiDotNetCoreTests.IntegrationTests.IdObfuscation +namespace JsonApiDotNetCoreTests.IntegrationTests.IdObfuscation; + +internal sealed class ObfuscationFakers : FakerContainer { - internal sealed class ObfuscationFakers : FakerContainer - { - private readonly Lazy> _lazyBankAccountFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(bankAccount => bankAccount.Iban, faker => faker.Finance.Iban())); + private readonly Lazy> _lazyBankAccountFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(bankAccount => bankAccount.Iban, faker => faker.Finance.Iban())); - private readonly Lazy> _lazyDebitCardFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(debitCard => debitCard.OwnerName, faker => faker.Name.FullName()) - .RuleFor(debitCard => debitCard.PinCode, faker => (short)faker.Random.Number(1000, 9999))); + private readonly Lazy> _lazyDebitCardFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(debitCard => debitCard.OwnerName, faker => faker.Name.FullName()) + .RuleFor(debitCard => debitCard.PinCode, faker => (short)faker.Random.Number(1000, 9999))); - public Faker BankAccount => _lazyBankAccountFaker.Value; - public Faker DebitCard => _lazyDebitCardFaker.Value; - } + public Faker BankAccount => _lazyBankAccountFaker.Value; + public Faker DebitCard => _lazyDebitCardFaker.Value; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateDbContext.cs index a67e9f2647..2f1b56cf6f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateDbContext.cs @@ -3,39 +3,38 @@ // @formatter:wrap_chained_method_calls chop_always -namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.ModelState +namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.ModelState; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class ModelStateDbContext : DbContext { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class ModelStateDbContext : DbContext + public DbSet Volumes => Set(); + public DbSet Directories => Set(); + public DbSet Files => Set(); + + public ModelStateDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder builder) { - public DbSet Volumes => Set(); - public DbSet Directories => Set(); - public DbSet Files => Set(); - - public ModelStateDbContext(DbContextOptions options) - : base(options) - { - } - - protected override void OnModelCreating(ModelBuilder builder) - { - builder.Entity() - .HasOne(systemVolume => systemVolume.RootDirectory) - .WithOne() - .HasForeignKey("RootDirectoryId") - .IsRequired(); - - builder.Entity() - .HasMany(systemDirectory => systemDirectory.Subdirectories) - .WithOne(systemDirectory => systemDirectory.Parent!); - - builder.Entity() - .HasOne(systemDirectory => systemDirectory.Self) - .WithOne(); - - builder.Entity() - .HasOne(systemDirectory => systemDirectory.AlsoSelf) - .WithOne(); - } + builder.Entity() + .HasOne(systemVolume => systemVolume.RootDirectory) + .WithOne() + .HasForeignKey("RootDirectoryId") + .IsRequired(); + + builder.Entity() + .HasMany(systemDirectory => systemDirectory.Subdirectories) + .WithOne(systemDirectory => systemDirectory.Parent!); + + builder.Entity() + .HasOne(systemDirectory => systemDirectory.Self) + .WithOne(); + + builder.Entity() + .HasOne(systemDirectory => systemDirectory.AlsoSelf) + .WithOne(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateFakers.cs index 06b8829ebe..75183eaf9f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateFakers.cs @@ -1,34 +1,32 @@ -using System; using Bogus; using TestBuildingBlocks; // @formatter:wrap_chained_method_calls chop_always // @formatter:keep_existing_linebreaks true -namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.ModelState +namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.ModelState; + +internal sealed class ModelStateFakers : FakerContainer { - internal sealed class ModelStateFakers : FakerContainer - { - private readonly Lazy> _lazySystemVolumeFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(systemVolume => systemVolume.Name, faker => faker.Lorem.Word())); + private readonly Lazy> _lazySystemVolumeFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(systemVolume => systemVolume.Name, faker => faker.Lorem.Word())); - private readonly Lazy> _lazySystemFileFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(systemFile => systemFile.FileName, faker => faker.System.FileName()) - .RuleFor(systemFile => systemFile.SizeInBytes, faker => faker.Random.Long(0, 1_000_000))); + private readonly Lazy> _lazySystemFileFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(systemFile => systemFile.FileName, faker => faker.System.FileName()) + .RuleFor(systemFile => systemFile.SizeInBytes, faker => faker.Random.Long(0, 1_000_000))); - private readonly Lazy> _lazySystemDirectoryFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(systemDirectory => systemDirectory.Name, faker => faker.Address.City()) - .RuleFor(systemDirectory => systemDirectory.IsCaseSensitive, faker => faker.Random.Bool()) - .RuleFor(systemDirectory => systemDirectory.SizeInBytes, faker => faker.Random.Long(0, 1_000_000))); + private readonly Lazy> _lazySystemDirectoryFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(systemDirectory => systemDirectory.Name, faker => faker.Address.City()) + .RuleFor(systemDirectory => systemDirectory.IsCaseSensitive, faker => faker.Random.Bool()) + .RuleFor(systemDirectory => systemDirectory.SizeInBytes, faker => faker.Random.Long(0, 1_000_000))); - public Faker SystemVolume => _lazySystemVolumeFaker.Value; - public Faker SystemFile => _lazySystemFileFaker.Value; - public Faker SystemDirectory => _lazySystemDirectoryFaker.Value; - } + public Faker SystemVolume => _lazySystemVolumeFaker.Value; + public Faker SystemFile => _lazySystemFileFaker.Value; + public Faker SystemDirectory => _lazySystemDirectoryFaker.Value; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateValidationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateValidationTests.cs index e314bf5461..5a07c9ec99 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateValidationTests.cs @@ -1,869 +1,865 @@ -using System.Linq; using System.Net; -using System.Net.Http; -using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.ModelState +namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.ModelState; + +public sealed class ModelStateValidationTests : IClassFixture, ModelStateDbContext>> { - public sealed class ModelStateValidationTests : IClassFixture, ModelStateDbContext>> - { - private readonly IntegrationTestContext, ModelStateDbContext> _testContext; - private readonly ModelStateFakers _fakers = new(); + private readonly IntegrationTestContext, ModelStateDbContext> _testContext; + private readonly ModelStateFakers _fakers = new(); - public ModelStateValidationTests(IntegrationTestContext, ModelStateDbContext> testContext) - { - _testContext = testContext; + public ModelStateValidationTests(IntegrationTestContext, ModelStateDbContext> testContext) + { + _testContext = testContext; - testContext.UseController(); - testContext.UseController(); - } + testContext.UseController(); + testContext.UseController(); + } - [Fact] - public async Task Cannot_create_resource_with_omitted_required_attribute() + [Fact] + public async Task Cannot_create_resource_with_omitted_required_attribute() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + data = new { - data = new + type = "systemDirectories", + attributes = new { - type = "systemDirectories", - attributes = new - { - isCaseSensitive = true - } + isCaseSensitive = true } - }; + } + }; - const string route = "/systemDirectories"; + const string route = "/systemDirectories"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Input validation failed."); - error.Detail.Should().Be("The Name field is required."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/data/attributes/directoryName"); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Input validation failed."); + error.Detail.Should().Be("The Name field is required."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data/attributes/directoryName"); + } - [Fact] - public async Task Cannot_create_resource_with_null_for_required_attribute_value() + [Fact] + public async Task Cannot_create_resource_with_null_for_required_attribute_value() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + data = new { - data = new + type = "systemDirectories", + attributes = new { - type = "systemDirectories", - attributes = new - { - directoryName = (string?)null, - isCaseSensitive = true - } + directoryName = (string?)null, + isCaseSensitive = true } - }; + } + }; - const string route = "/systemDirectories"; + const string route = "/systemDirectories"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Input validation failed."); - error.Detail.Should().Be("The Name field is required."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/data/attributes/directoryName"); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Input validation failed."); + error.Detail.Should().Be("The Name field is required."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data/attributes/directoryName"); + } - [Fact] - public async Task Cannot_create_resource_with_invalid_attribute_value() + [Fact] + public async Task Cannot_create_resource_with_invalid_attribute_value() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + data = new { - data = new + type = "systemDirectories", + attributes = new { - type = "systemDirectories", - attributes = new - { - directoryName = "!@#$%^&*().-", - isCaseSensitive = true - } + directoryName = "!@#$%^&*().-", + isCaseSensitive = true } - }; + } + }; - const string route = "/systemDirectories"; + const string route = "/systemDirectories"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Input validation failed."); - error.Detail.Should().Be("The field Name must match the regular expression '^[\\w\\s]+$'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/data/attributes/directoryName"); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Input validation failed."); + error.Detail.Should().Be("The field Name must match the regular expression '^[\\w\\s]+$'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data/attributes/directoryName"); + } - [Fact] - public async Task Can_create_resource_with_valid_attribute_value() - { - // Arrange - SystemDirectory newDirectory = _fakers.SystemDirectory.Generate(); + [Fact] + public async Task Can_create_resource_with_valid_attribute_value() + { + // Arrange + SystemDirectory newDirectory = _fakers.SystemDirectory.Generate(); - var requestBody = new + var requestBody = new + { + data = new { - data = new + type = "systemDirectories", + attributes = new { - type = "systemDirectories", - attributes = new - { - directoryName = newDirectory.Name, - isCaseSensitive = newDirectory.IsCaseSensitive - } + directoryName = newDirectory.Name, + isCaseSensitive = newDirectory.IsCaseSensitive } - }; + } + }; - const string route = "/systemDirectories"; + const string route = "/systemDirectories"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("directoryName").With(value => value.Should().Be(newDirectory.Name)); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("isCaseSensitive").With(value => value.Should().Be(newDirectory.IsCaseSensitive)); - } + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("directoryName").With(value => value.Should().Be(newDirectory.Name)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("isCaseSensitive").With(value => value.Should().Be(newDirectory.IsCaseSensitive)); + } - [Fact] - public async Task Cannot_create_resource_with_multiple_violations() + [Fact] + public async Task Cannot_create_resource_with_multiple_violations() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + data = new { - data = new + type = "systemDirectories", + attributes = new { - type = "systemDirectories", - attributes = new - { - isCaseSensitive = false, - sizeInBytes = -1 - } + isCaseSensitive = false, + sizeInBytes = -1 } - }; + } + }; - const string route = "/systemDirectories"; + const string route = "/systemDirectories"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(2); + responseDocument.Errors.ShouldHaveCount(2); - ErrorObject error1 = responseDocument.Errors[0]; - error1.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error1.Title.Should().Be("Input validation failed."); - error1.Detail.Should().Be("The Name field is required."); - error1.Source.ShouldNotBeNull(); - error1.Source.Pointer.Should().Be("/data/attributes/directoryName"); + ErrorObject error1 = responseDocument.Errors[0]; + error1.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error1.Title.Should().Be("Input validation failed."); + error1.Detail.Should().Be("The Name field is required."); + error1.Source.ShouldNotBeNull(); + error1.Source.Pointer.Should().Be("/data/attributes/directoryName"); - ErrorObject error2 = responseDocument.Errors[1]; - error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error2.Title.Should().Be("Input validation failed."); - error2.Detail.Should().Be("The field SizeInBytes must be between 0 and 9223372036854775807."); - error2.Source.ShouldNotBeNull(); - error2.Source.Pointer.Should().Be("/data/attributes/sizeInBytes"); - } + ErrorObject error2 = responseDocument.Errors[1]; + error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error2.Title.Should().Be("Input validation failed."); + error2.Detail.Should().Be("The field SizeInBytes must be between 0 and 9223372036854775807."); + error2.Source.ShouldNotBeNull(); + error2.Source.Pointer.Should().Be("/data/attributes/sizeInBytes"); + } - [Fact] - public async Task Does_not_exceed_MaxModelValidationErrors() + [Fact] + public async Task Does_not_exceed_MaxModelValidationErrors() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + data = new { - data = new + type = "systemDirectories", + attributes = new { - type = "systemDirectories", - attributes = new - { - sizeInBytes = -1 - } + sizeInBytes = -1 } - }; - - const string route = "/systemDirectories"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(3); - - ErrorObject error1 = responseDocument.Errors[0]; - error1.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error1.Title.Should().Be("Input validation failed."); - error1.Detail.Should().Be("The maximum number of allowed model errors has been reached."); - error1.Source.Should().BeNull(); + } + }; + + const string route = "/systemDirectories"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.ShouldHaveCount(3); + + ErrorObject error1 = responseDocument.Errors[0]; + error1.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error1.Title.Should().Be("Input validation failed."); + error1.Detail.Should().Be("The maximum number of allowed model errors has been reached."); + error1.Source.Should().BeNull(); + + ErrorObject error2 = responseDocument.Errors[1]; + error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error2.Title.Should().Be("Input validation failed."); + error2.Detail.Should().Be("The Name field is required."); + error2.Source.ShouldNotBeNull(); + error2.Source.Pointer.Should().Be("/data/attributes/directoryName"); + + ErrorObject error3 = responseDocument.Errors[2]; + error3.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error3.Title.Should().Be("Input validation failed."); + error3.Detail.Should().Be("The IsCaseSensitive field is required."); + error3.Source.ShouldNotBeNull(); + error3.Source.Pointer.Should().Be("/data/attributes/isCaseSensitive"); + } - ErrorObject error2 = responseDocument.Errors[1]; - error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error2.Title.Should().Be("Input validation failed."); - error2.Detail.Should().Be("The Name field is required."); - error2.Source.ShouldNotBeNull(); - error2.Source.Pointer.Should().Be("/data/attributes/directoryName"); + [Fact] + public async Task Can_create_resource_with_annotated_relationships() + { + // Arrange + SystemDirectory existingParentDirectory = _fakers.SystemDirectory.Generate(); + SystemDirectory existingSubdirectory = _fakers.SystemDirectory.Generate(); + SystemFile existingFile = _fakers.SystemFile.Generate(); - ErrorObject error3 = responseDocument.Errors[2]; - error3.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error3.Title.Should().Be("Input validation failed."); - error3.Detail.Should().Be("The IsCaseSensitive field is required."); - error3.Source.ShouldNotBeNull(); - error3.Source.Pointer.Should().Be("/data/attributes/isCaseSensitive"); - } + SystemDirectory newDirectory = _fakers.SystemDirectory.Generate(); - [Fact] - public async Task Can_create_resource_with_annotated_relationships() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - SystemDirectory existingParentDirectory = _fakers.SystemDirectory.Generate(); - SystemDirectory existingSubdirectory = _fakers.SystemDirectory.Generate(); - SystemFile existingFile = _fakers.SystemFile.Generate(); + dbContext.Directories.AddRange(existingParentDirectory, existingSubdirectory); + dbContext.Files.Add(existingFile); + await dbContext.SaveChangesAsync(); + }); - SystemDirectory newDirectory = _fakers.SystemDirectory.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Directories.AddRange(existingParentDirectory, existingSubdirectory); - dbContext.Files.Add(existingFile); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new + var requestBody = new + { + data = new { - data = new + type = "systemDirectories", + attributes = new { - type = "systemDirectories", - attributes = new - { - directoryName = newDirectory.Name, - isCaseSensitive = newDirectory.IsCaseSensitive - }, - relationships = new + directoryName = newDirectory.Name, + isCaseSensitive = newDirectory.IsCaseSensitive + }, + relationships = new + { + subdirectories = new { - subdirectories = new + data = new[] { - data = new[] + new { - new - { - type = "systemDirectories", - id = existingSubdirectory.StringId - } + type = "systemDirectories", + id = existingSubdirectory.StringId } - }, - files = new + } + }, + files = new + { + data = new[] { - data = new[] + new { - new - { - type = "systemFiles", - id = existingFile.StringId - } + type = "systemFiles", + id = existingFile.StringId } - }, - parent = new + } + }, + parent = new + { + data = new { - data = new - { - type = "systemDirectories", - id = existingParentDirectory.StringId - } + type = "systemDirectories", + id = existingParentDirectory.StringId } } } - }; + } + }; - const string route = "/systemDirectories"; + const string route = "/systemDirectories"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("directoryName").With(value => value.Should().Be(newDirectory.Name)); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("isCaseSensitive").With(value => value.Should().Be(newDirectory.IsCaseSensitive)); - } + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("directoryName").With(value => value.Should().Be(newDirectory.Name)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("isCaseSensitive").With(value => value.Should().Be(newDirectory.IsCaseSensitive)); + } - [Fact] - public async Task Can_add_to_annotated_ToMany_relationship() - { - // Arrange - SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate(); - SystemFile existingFile = _fakers.SystemFile.Generate(); + [Fact] + public async Task Can_add_to_annotated_ToMany_relationship() + { + // Arrange + SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate(); + SystemFile existingFile = _fakers.SystemFile.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddInRange(existingDirectory, existingFile); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingDirectory, existingFile); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new[] { - data = new[] + new { - new - { - type = "systemFiles", - id = existingFile.StringId - } + type = "systemFiles", + id = existingFile.StringId } - }; + } + }; - string route = $"/systemDirectories/{existingDirectory.StringId}/relationships/files"; + string route = $"/systemDirectories/{existingDirectory.StringId}/relationships/files"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); - } + responseDocument.Should().BeEmpty(); + } - [Fact] - public async Task Can_update_resource_with_omitted_required_attribute_value() - { - // Arrange - SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate(); + [Fact] + public async Task Can_update_resource_with_omitted_required_attribute_value() + { + // Arrange + SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate(); - long newSizeInBytes = _fakers.SystemDirectory.Generate().SizeInBytes; + long newSizeInBytes = _fakers.SystemDirectory.Generate().SizeInBytes; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Directories.Add(existingDirectory); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Directories.Add(existingDirectory); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new { - data = new + type = "systemDirectories", + id = existingDirectory.StringId, + attributes = new { - type = "systemDirectories", - id = existingDirectory.StringId, - attributes = new - { - sizeInBytes = newSizeInBytes - } + sizeInBytes = newSizeInBytes } - }; + } + }; - string route = $"/systemDirectories/{existingDirectory.StringId}"; + string route = $"/systemDirectories/{existingDirectory.StringId}"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); - } + responseDocument.Should().BeEmpty(); + } - [Fact] - public async Task Cannot_update_resource_with_null_for_required_attribute_values() - { - // Arrange - SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate(); + [Fact] + public async Task Cannot_update_resource_with_null_for_required_attribute_values() + { + // Arrange + SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Directories.Add(existingDirectory); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Directories.Add(existingDirectory); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new { - data = new + type = "systemDirectories", + id = existingDirectory.StringId, + attributes = new { - type = "systemDirectories", - id = existingDirectory.StringId, - attributes = new - { - directoryName = (string?)null, - isCaseSensitive = (bool?)null - } + directoryName = (string?)null, + isCaseSensitive = (bool?)null } - }; + } + }; - string route = $"/systemDirectories/{existingDirectory.StringId}"; + string route = $"/systemDirectories/{existingDirectory.StringId}"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(2); + responseDocument.Errors.ShouldHaveCount(2); - ErrorObject error1 = responseDocument.Errors[0]; - error1.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error1.Title.Should().Be("Input validation failed."); - error1.Detail.Should().Be("The Name field is required."); - error1.Source.ShouldNotBeNull(); - error1.Source.Pointer.Should().Be("/data/attributes/directoryName"); + ErrorObject error1 = responseDocument.Errors[0]; + error1.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error1.Title.Should().Be("Input validation failed."); + error1.Detail.Should().Be("The Name field is required."); + error1.Source.ShouldNotBeNull(); + error1.Source.Pointer.Should().Be("/data/attributes/directoryName"); - ErrorObject error2 = responseDocument.Errors[1]; - error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error2.Title.Should().Be("Input validation failed."); - error2.Detail.Should().Be("The IsCaseSensitive field is required."); - error2.Source.ShouldNotBeNull(); - error2.Source.Pointer.Should().Be("/data/attributes/isCaseSensitive"); - } + ErrorObject error2 = responseDocument.Errors[1]; + error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error2.Title.Should().Be("Input validation failed."); + error2.Detail.Should().Be("The IsCaseSensitive field is required."); + error2.Source.ShouldNotBeNull(); + error2.Source.Pointer.Should().Be("/data/attributes/isCaseSensitive"); + } - [Fact] - public async Task Cannot_update_resource_with_invalid_attribute_value() - { - // Arrange - SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate(); + [Fact] + public async Task Cannot_update_resource_with_invalid_attribute_value() + { + // Arrange + SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Directories.Add(existingDirectory); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Directories.Add(existingDirectory); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new { - data = new + type = "systemDirectories", + id = existingDirectory.StringId, + attributes = new { - type = "systemDirectories", - id = existingDirectory.StringId, - attributes = new - { - directoryName = "!@#$%^&*().-" - } + directoryName = "!@#$%^&*().-" } - }; + } + }; - string route = $"/systemDirectories/{existingDirectory.StringId}"; + string route = $"/systemDirectories/{existingDirectory.StringId}"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Input validation failed."); - error.Detail.Should().Be("The field Name must match the regular expression '^[\\w\\s]+$'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/data/attributes/directoryName"); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Input validation failed."); + error.Detail.Should().Be("The field Name must match the regular expression '^[\\w\\s]+$'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data/attributes/directoryName"); + } - [Fact] - public async Task Cannot_update_resource_with_invalid_ID() + [Fact] + public async Task Cannot_update_resource_with_invalid_ID() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + data = new { - data = new + type = "systemDirectories", + id = "-1", + relationships = new { - type = "systemDirectories", - id = "-1", - relationships = new + subdirectories = new { - subdirectories = new + data = new[] { - data = new[] + new { - new - { - type = "systemDirectories", - id = "-1" - } + type = "systemDirectories", + id = "-1" } } } } - }; + } + }; - const string route = "/systemDirectories/-1"; + const string route = "/systemDirectories/-1"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(2); + responseDocument.Errors.ShouldHaveCount(2); - ErrorObject error1 = responseDocument.Errors[0]; - error1.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error1.Title.Should().Be("Input validation failed."); - error1.Detail.Should().Be("The field Id must match the regular expression '^[0-9]+$'."); - error1.Source.ShouldNotBeNull(); - error1.Source.Pointer.Should().Be("/data/id"); + ErrorObject error1 = responseDocument.Errors[0]; + error1.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error1.Title.Should().Be("Input validation failed."); + error1.Detail.Should().Be("The field Id must match the regular expression '^[0-9]+$'."); + error1.Source.ShouldNotBeNull(); + error1.Source.Pointer.Should().Be("/data/id"); - ErrorObject error2 = responseDocument.Errors[1]; - error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error2.Title.Should().Be("Input validation failed."); - error2.Detail.Should().Be("The field Id must match the regular expression '^[0-9]+$'."); - error2.Source.ShouldNotBeNull(); - error2.Source.Pointer.Should().Be("/data/relationships/subdirectories/data[0]/id"); - } + ErrorObject error2 = responseDocument.Errors[1]; + error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error2.Title.Should().Be("Input validation failed."); + error2.Detail.Should().Be("The field Id must match the regular expression '^[0-9]+$'."); + error2.Source.ShouldNotBeNull(); + error2.Source.Pointer.Should().Be("/data/relationships/subdirectories/data[0]/id"); + } - [Fact] - public async Task Can_update_resource_with_valid_attribute_value() - { - // Arrange - SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate(); + [Fact] + public async Task Can_update_resource_with_valid_attribute_value() + { + // Arrange + SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate(); - string newDirectoryName = _fakers.SystemDirectory.Generate().Name; + string newDirectoryName = _fakers.SystemDirectory.Generate().Name; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Directories.Add(existingDirectory); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Directories.Add(existingDirectory); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new { - data = new + type = "systemDirectories", + id = existingDirectory.StringId, + attributes = new { - type = "systemDirectories", - id = existingDirectory.StringId, - attributes = new - { - directoryName = newDirectoryName - } + directoryName = newDirectoryName } - }; + } + }; - string route = $"/systemDirectories/{existingDirectory.StringId}"; + string route = $"/systemDirectories/{existingDirectory.StringId}"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); - } + responseDocument.Should().BeEmpty(); + } - [Fact] - public async Task Can_update_resource_with_annotated_relationships() - { - // Arrange - SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate(); - existingDirectory.Subdirectories = _fakers.SystemDirectory.Generate(1); - existingDirectory.Files = _fakers.SystemFile.Generate(1); - existingDirectory.Parent = _fakers.SystemDirectory.Generate(); + [Fact] + public async Task Can_update_resource_with_annotated_relationships() + { + // Arrange + SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate(); + existingDirectory.Subdirectories = _fakers.SystemDirectory.Generate(1); + existingDirectory.Files = _fakers.SystemFile.Generate(1); + existingDirectory.Parent = _fakers.SystemDirectory.Generate(); - SystemDirectory existingParent = _fakers.SystemDirectory.Generate(); - SystemDirectory existingSubdirectory = _fakers.SystemDirectory.Generate(); - SystemFile existingFile = _fakers.SystemFile.Generate(); + SystemDirectory existingParent = _fakers.SystemDirectory.Generate(); + SystemDirectory existingSubdirectory = _fakers.SystemDirectory.Generate(); + SystemFile existingFile = _fakers.SystemFile.Generate(); - string newDirectoryName = _fakers.SystemDirectory.Generate().Name; + string newDirectoryName = _fakers.SystemDirectory.Generate().Name; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Directories.AddRange(existingDirectory, existingParent, existingSubdirectory); - dbContext.Files.Add(existingFile); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Directories.AddRange(existingDirectory, existingParent, existingSubdirectory); + dbContext.Files.Add(existingFile); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new { - data = new + type = "systemDirectories", + id = existingDirectory.StringId, + attributes = new { - type = "systemDirectories", - id = existingDirectory.StringId, - attributes = new - { - directoryName = newDirectoryName - }, - relationships = new + directoryName = newDirectoryName + }, + relationships = new + { + subdirectories = new { - subdirectories = new + data = new[] { - data = new[] + new { - new - { - type = "systemDirectories", - id = existingSubdirectory.StringId - } + type = "systemDirectories", + id = existingSubdirectory.StringId } - }, - files = new + } + }, + files = new + { + data = new[] { - data = new[] + new { - new - { - type = "systemFiles", - id = existingFile.StringId - } + type = "systemFiles", + id = existingFile.StringId } - }, - parent = new + } + }, + parent = new + { + data = new { - data = new - { - type = "systemDirectories", - id = existingParent.StringId - } + type = "systemDirectories", + id = existingParent.StringId } } } - }; + } + }; - string route = $"/systemDirectories/{existingDirectory.StringId}"; + string route = $"/systemDirectories/{existingDirectory.StringId}"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); - } + responseDocument.Should().BeEmpty(); + } - [Fact] - public async Task Can_update_resource_with_multiple_self_references() - { - // Arrange - SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate(); + [Fact] + public async Task Can_update_resource_with_multiple_self_references() + { + // Arrange + SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Directories.Add(existingDirectory); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Directories.Add(existingDirectory); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new { - data = new + type = "systemDirectories", + id = existingDirectory.StringId, + relationships = new { - type = "systemDirectories", - id = existingDirectory.StringId, - relationships = new + self = new { - self = new + data = new { - data = new - { - type = "systemDirectories", - id = existingDirectory.StringId - } - }, - alsoSelf = new + type = "systemDirectories", + id = existingDirectory.StringId + } + }, + alsoSelf = new + { + data = new { - data = new - { - type = "systemDirectories", - id = existingDirectory.StringId - } + type = "systemDirectories", + id = existingDirectory.StringId } } } - }; + } + }; - string route = $"/systemDirectories/{existingDirectory.StringId}"; + string route = $"/systemDirectories/{existingDirectory.StringId}"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); - } + responseDocument.Should().BeEmpty(); + } - [Fact] - public async Task Can_update_resource_with_collection_of_self_references() - { - // Arrange - SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate(); + [Fact] + public async Task Can_update_resource_with_collection_of_self_references() + { + // Arrange + SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Directories.Add(existingDirectory); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Directories.Add(existingDirectory); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new { - data = new + type = "systemDirectories", + id = existingDirectory.StringId, + relationships = new { - type = "systemDirectories", - id = existingDirectory.StringId, - relationships = new + subdirectories = new { - subdirectories = new + data = new[] { - data = new[] + new { - new - { - type = "systemDirectories", - id = existingDirectory.StringId - } + type = "systemDirectories", + id = existingDirectory.StringId } } } } - }; + } + }; - string route = $"/systemDirectories/{existingDirectory.StringId}"; + string route = $"/systemDirectories/{existingDirectory.StringId}"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); - } + responseDocument.Should().BeEmpty(); + } - [Fact] - public async Task Can_replace_annotated_ToOne_relationship() - { - // Arrange - SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate(); - existingDirectory.Parent = _fakers.SystemDirectory.Generate(); + [Fact] + public async Task Can_replace_annotated_ToOne_relationship() + { + // Arrange + SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate(); + existingDirectory.Parent = _fakers.SystemDirectory.Generate(); - SystemDirectory otherExistingDirectory = _fakers.SystemDirectory.Generate(); + SystemDirectory otherExistingDirectory = _fakers.SystemDirectory.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Directories.AddRange(existingDirectory, otherExistingDirectory); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Directories.AddRange(existingDirectory, otherExistingDirectory); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new { - data = new - { - type = "systemDirectories", - id = otherExistingDirectory.StringId - } - }; + type = "systemDirectories", + id = otherExistingDirectory.StringId + } + }; - string route = $"/systemDirectories/{existingDirectory.StringId}/relationships/parent"; + string route = $"/systemDirectories/{existingDirectory.StringId}/relationships/parent"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); - } + responseDocument.Should().BeEmpty(); + } - [Fact] - public async Task Can_replace_annotated_ToMany_relationship() - { - // Arrange - SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate(); - existingDirectory.Files = _fakers.SystemFile.Generate(2); + [Fact] + public async Task Can_replace_annotated_ToMany_relationship() + { + // Arrange + SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate(); + existingDirectory.Files = _fakers.SystemFile.Generate(2); - SystemFile existingFile = _fakers.SystemFile.Generate(); + SystemFile existingFile = _fakers.SystemFile.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddInRange(existingDirectory, existingFile); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingDirectory, existingFile); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new[] { - data = new[] + new { - new - { - type = "systemFiles", - id = existingFile.StringId - } + type = "systemFiles", + id = existingFile.StringId } - }; + } + }; - string route = $"/systemDirectories/{existingDirectory.StringId}/relationships/files"; + string route = $"/systemDirectories/{existingDirectory.StringId}/relationships/files"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); - } + responseDocument.Should().BeEmpty(); + } - [Fact] - public async Task Can_remove_from_annotated_ToMany_relationship() - { - // Arrange - SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate(); - existingDirectory.Files = _fakers.SystemFile.Generate(1); + [Fact] + public async Task Can_remove_from_annotated_ToMany_relationship() + { + // Arrange + SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate(); + existingDirectory.Files = _fakers.SystemFile.Generate(1); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Directories.Add(existingDirectory); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Directories.Add(existingDirectory); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new[] { - data = new[] + new { - new - { - type = "systemFiles", - id = existingDirectory.Files.ElementAt(0).StringId - } + type = "systemFiles", + id = existingDirectory.Files.ElementAt(0).StringId } - }; + } + }; - string route = $"/systemDirectories/{existingDirectory.StringId}/relationships/files"; + string route = $"/systemDirectories/{existingDirectory.StringId}/relationships/files"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); - } + responseDocument.Should().BeEmpty(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/NoModelStateValidationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/NoModelStateValidationTests.cs index 4a7968e68c..bef4748d69 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/NoModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/NoModelStateValidationTests.cs @@ -1,139 +1,135 @@ using System.Net; -using System.Net.Http; -using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreTests.Startups; using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.ModelState +namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.ModelState; + +public sealed class NoModelStateValidationTests : IClassFixture, ModelStateDbContext>> { - public sealed class NoModelStateValidationTests - : IClassFixture, ModelStateDbContext>> - { - private readonly IntegrationTestContext, ModelStateDbContext> _testContext; - private readonly ModelStateFakers _fakers = new(); + private readonly IntegrationTestContext, ModelStateDbContext> _testContext; + private readonly ModelStateFakers _fakers = new(); - public NoModelStateValidationTests(IntegrationTestContext, ModelStateDbContext> testContext) - { - _testContext = testContext; + public NoModelStateValidationTests(IntegrationTestContext, ModelStateDbContext> testContext) + { + _testContext = testContext; - testContext.UseController(); - testContext.UseController(); - testContext.UseController(); - } + testContext.UseController(); + testContext.UseController(); + testContext.UseController(); + } - [Fact] - public async Task Can_create_resource_with_invalid_attribute_value() + [Fact] + public async Task Can_create_resource_with_invalid_attribute_value() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + data = new { - data = new + type = "systemDirectories", + attributes = new { - type = "systemDirectories", - attributes = new - { - directoryName = "!@#$%^&*().-", - isCaseSensitive = false - } + directoryName = "!@#$%^&*().-", + isCaseSensitive = false } - }; + } + }; - const string route = "/systemDirectories"; + const string route = "/systemDirectories"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("directoryName").With(value => value.Should().Be("!@#$%^&*().-")); - } + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("directoryName").With(value => value.Should().Be("!@#$%^&*().-")); + } - [Fact] - public async Task Can_update_resource_with_invalid_attribute_value() - { - // Arrange - SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate(); + [Fact] + public async Task Can_update_resource_with_invalid_attribute_value() + { + // Arrange + SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Directories.Add(existingDirectory); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Directories.Add(existingDirectory); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new { - data = new + type = "systemDirectories", + id = existingDirectory.StringId, + attributes = new { - type = "systemDirectories", - id = existingDirectory.StringId, - attributes = new - { - directoryName = "!@#$%^&*().-" - } + directoryName = "!@#$%^&*().-" } - }; + } + }; - string route = $"/systemDirectories/{existingDirectory.StringId}"; + string route = $"/systemDirectories/{existingDirectory.StringId}"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); - } + responseDocument.Should().BeEmpty(); + } - [Fact] - public async Task Cannot_clear_required_OneToOne_relationship_through_primary_endpoint() - { - // Arrange - SystemVolume existingVolume = _fakers.SystemVolume.Generate(); - existingVolume.RootDirectory = _fakers.SystemDirectory.Generate(); + [Fact] + public async Task Cannot_clear_required_OneToOne_relationship_through_primary_endpoint() + { + // Arrange + SystemVolume existingVolume = _fakers.SystemVolume.Generate(); + existingVolume.RootDirectory = _fakers.SystemDirectory.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Volumes.Add(existingVolume); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Volumes.Add(existingVolume); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new { - data = new + type = "systemVolumes", + id = existingVolume.StringId, + relationships = new { - type = "systemVolumes", - id = existingVolume.StringId, - relationships = new + rootDirectory = new { - rootDirectory = new - { - data = (object?)null - } + data = (object?)null } } - }; + } + }; - string route = $"/systemVolumes/{existingVolume.StringId}"; + string route = $"/systemVolumes/{existingVolume.StringId}"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Failed to clear a required relationship."); + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("Failed to clear a required relationship."); - error.Detail.Should().Be($"The relationship 'rootDirectory' on resource type 'systemVolumes' with ID '{existingVolume.StringId}' " + - "cannot be cleared because it is a required relationship."); - } + error.Detail.Should().Be($"The relationship 'rootDirectory' on resource type 'systemVolumes' with ID '{existingVolume.StringId}' " + + "cannot be cleared because it is a required relationship."); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemDirectory.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemDirectory.cs index caafbb66c1..1712ad103e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemDirectory.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemDirectory.cs @@ -1,43 +1,41 @@ -using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.ModelState +namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.ModelState; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.InputValidation.ModelState")] +public sealed class SystemDirectory : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - [Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.InputValidation.ModelState")] - public sealed class SystemDirectory : Identifiable - { - [RegularExpression("^[0-9]+$")] - public override int Id { get; set; } + [RegularExpression("^[0-9]+$")] + public override int Id { get; set; } - [Attr(PublicName = "directoryName")] - [RegularExpression(@"^[\w\s]+$")] - public string Name { get; set; } = null!; + [Attr(PublicName = "directoryName")] + [RegularExpression(@"^[\w\s]+$")] + public string Name { get; set; } = null!; - [Attr] - [Required] - public bool? IsCaseSensitive { get; set; } + [Attr] + [Required] + public bool? IsCaseSensitive { get; set; } - [Attr] - [Range(typeof(long), "0", "9223372036854775807")] - public long SizeInBytes { get; set; } + [Attr] + [Range(typeof(long), "0", "9223372036854775807")] + public long SizeInBytes { get; set; } - [HasMany] - public ICollection Subdirectories { get; set; } = new List(); + [HasMany] + public ICollection Subdirectories { get; set; } = new List(); - [HasMany] - public ICollection Files { get; set; } = new List(); + [HasMany] + public ICollection Files { get; set; } = new List(); - [HasOne] - public SystemDirectory? Self { get; set; } + [HasOne] + public SystemDirectory? Self { get; set; } - [HasOne] - public SystemDirectory? AlsoSelf { get; set; } + [HasOne] + public SystemDirectory? AlsoSelf { get; set; } - [HasOne] - public SystemDirectory? Parent { get; set; } - } + [HasOne] + public SystemDirectory? Parent { get; set; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemFile.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemFile.cs index c5143f344e..56bd50d1e7 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemFile.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemFile.cs @@ -3,19 +3,18 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.ModelState +namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.ModelState; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.InputValidation.ModelState")] +public sealed class SystemFile : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - [Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.InputValidation.ModelState")] - public sealed class SystemFile : Identifiable - { - [Attr] - [MinLength(1)] - public string FileName { get; set; } = null!; + [Attr] + [MinLength(1)] + public string FileName { get; set; } = null!; - [Attr] - [Required] - [Range(typeof(long), "0", "9223372036854775807")] - public long? SizeInBytes { get; set; } - } + [Attr] + [Required] + [Range(typeof(long), "0", "9223372036854775807")] + public long? SizeInBytes { get; set; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemVolume.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemVolume.cs index 2f1453cb13..08e4e58937 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemVolume.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemVolume.cs @@ -2,16 +2,15 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.ModelState +namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.ModelState; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.InputValidation.ModelState")] +public sealed class SystemVolume : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - [Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.InputValidation.ModelState")] - public sealed class SystemVolume : Identifiable - { - [Attr] - public string? Name { get; set; } + [Attr] + public string? Name { get; set; } - [HasOne] - public SystemDirectory RootDirectory { get; set; } = null!; - } + [HasOne] + public SystemDirectory RootDirectory { get; set; } = null!; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/Workflow.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/Workflow.cs index 042fca1878..486d4f96b3 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/Workflow.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/Workflow.cs @@ -1,15 +1,13 @@ -using System; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.RequestBody +namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.RequestBody; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.InputValidation.RequestBody")] +public sealed class Workflow : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - [Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.InputValidation.RequestBody")] - public sealed class Workflow : Identifiable - { - [Attr] - public WorkflowStage Stage { get; set; } - } + [Attr] + public WorkflowStage Stage { get; set; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowDbContext.cs index ab1442ea05..decc09bdc6 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowDbContext.cs @@ -3,16 +3,15 @@ // @formatter:wrap_chained_method_calls chop_always -namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.RequestBody +namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.RequestBody; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class WorkflowDbContext : DbContext { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class WorkflowDbContext : DbContext - { - public DbSet Workflows => Set(); + public DbSet Workflows => Set(); - public WorkflowDbContext(DbContextOptions options) - : base(options) - { - } + public WorkflowDbContext(DbContextOptions options) + : base(options) + { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowDefinition.cs index cebebb9fda..f63b793e5b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowDefinition.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Generic; using System.Net; -using System.Threading; -using System.Threading.Tasks; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Errors; @@ -10,104 +6,103 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.RequestBody +namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.RequestBody; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class WorkflowDefinition : JsonApiResourceDefinition { - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class WorkflowDefinition : JsonApiResourceDefinition + private static readonly Dictionary> StageTransitionTable = new() { - private static readonly Dictionary> StageTransitionTable = new() + [WorkflowStage.Created] = new[] { - [WorkflowStage.Created] = new[] - { - WorkflowStage.InProgress - }, - [WorkflowStage.InProgress] = new[] - { - WorkflowStage.OnHold, - WorkflowStage.Succeeded, - WorkflowStage.Failed, - WorkflowStage.Canceled - }, - [WorkflowStage.OnHold] = new[] - { - WorkflowStage.InProgress, - WorkflowStage.Canceled - } - }; + WorkflowStage.InProgress + }, + [WorkflowStage.InProgress] = new[] + { + WorkflowStage.OnHold, + WorkflowStage.Succeeded, + WorkflowStage.Failed, + WorkflowStage.Canceled + }, + [WorkflowStage.OnHold] = new[] + { + WorkflowStage.InProgress, + WorkflowStage.Canceled + } + }; - private WorkflowStage _previousStage; + private WorkflowStage _previousStage; - public WorkflowDefinition(IResourceGraph resourceGraph) - : base(resourceGraph) + public WorkflowDefinition(IResourceGraph resourceGraph) + : base(resourceGraph) + { + } + + public override Task OnPrepareWriteAsync(Workflow resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + if (writeOperation == WriteOperationKind.UpdateResource) { + _previousStage = resource.Stage; } - public override Task OnPrepareWriteAsync(Workflow resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) - { - if (writeOperation == WriteOperationKind.UpdateResource) - { - _previousStage = resource.Stage; - } + return Task.CompletedTask; + } - return Task.CompletedTask; + public override Task OnWritingAsync(Workflow resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + if (writeOperation == WriteOperationKind.CreateResource) + { + AssertHasValidInitialStage(resource); } - - public override Task OnWritingAsync(Workflow resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) + else if (writeOperation == WriteOperationKind.UpdateResource && resource.Stage != _previousStage) { - if (writeOperation == WriteOperationKind.CreateResource) - { - AssertHasValidInitialStage(resource); - } - else if (writeOperation == WriteOperationKind.UpdateResource && resource.Stage != _previousStage) - { - AssertCanTransitionToStage(_previousStage, resource.Stage); - } - - return Task.CompletedTask; + AssertCanTransitionToStage(_previousStage, resource.Stage); } - [AssertionMethod] - private static void AssertHasValidInitialStage(Workflow resource) + return Task.CompletedTask; + } + + [AssertionMethod] + private static void AssertHasValidInitialStage(Workflow resource) + { + if (resource.Stage != WorkflowStage.Created) { - if (resource.Stage != WorkflowStage.Created) + throw new JsonApiException(new ErrorObject(HttpStatusCode.UnprocessableEntity) { - throw new JsonApiException(new ErrorObject(HttpStatusCode.UnprocessableEntity) + Title = "Invalid workflow stage.", + Detail = $"Initial stage of workflow must be '{WorkflowStage.Created}'.", + Source = new ErrorSource { - Title = "Invalid workflow stage.", - Detail = $"Initial stage of workflow must be '{WorkflowStage.Created}'.", - Source = new ErrorSource - { - Pointer = "/data/attributes/stage" - } - }); - } + Pointer = "/data/attributes/stage" + } + }); } + } - [AssertionMethod] - private static void AssertCanTransitionToStage(WorkflowStage fromStage, WorkflowStage toStage) + [AssertionMethod] + private static void AssertCanTransitionToStage(WorkflowStage fromStage, WorkflowStage toStage) + { + if (!CanTransitionToStage(fromStage, toStage)) { - if (!CanTransitionToStage(fromStage, toStage)) + throw new JsonApiException(new ErrorObject(HttpStatusCode.UnprocessableEntity) { - throw new JsonApiException(new ErrorObject(HttpStatusCode.UnprocessableEntity) + Title = "Invalid workflow stage.", + Detail = $"Cannot transition from '{fromStage}' to '{toStage}'.", + Source = new ErrorSource { - Title = "Invalid workflow stage.", - Detail = $"Cannot transition from '{fromStage}' to '{toStage}'.", - Source = new ErrorSource - { - Pointer = "/data/attributes/stage" - } - }); - } + Pointer = "/data/attributes/stage" + } + }); } + } - private static bool CanTransitionToStage(WorkflowStage fromStage, WorkflowStage toStage) + private static bool CanTransitionToStage(WorkflowStage fromStage, WorkflowStage toStage) + { + if (StageTransitionTable.TryGetValue(fromStage, out ICollection? possibleNextStages)) { - if (StageTransitionTable.TryGetValue(fromStage, out ICollection? possibleNextStages)) - { - return possibleNextStages.Contains(toStage); - } - - return false; + return possibleNextStages.Contains(toStage); } + + return false; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowStage.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowStage.cs index 07c9dbe4ca..60baadeadd 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowStage.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowStage.cs @@ -1,12 +1,11 @@ -namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.RequestBody +namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.RequestBody; + +public enum WorkflowStage { - public enum WorkflowStage - { - Created, - InProgress, - OnHold, - Succeeded, - Failed, - Canceled - } + Created, + InProgress, + OnHold, + Succeeded, + Failed, + Canceled } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowTests.cs index 3b7d0f9f16..002d598003 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowTests.cs @@ -1,174 +1,171 @@ using System.Net; -using System.Net.Http; -using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.RequestBody +namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.RequestBody; + +public sealed class WorkflowTests : IClassFixture, WorkflowDbContext>> { - public sealed class WorkflowTests : IClassFixture, WorkflowDbContext>> - { - private readonly IntegrationTestContext, WorkflowDbContext> _testContext; + private readonly IntegrationTestContext, WorkflowDbContext> _testContext; - public WorkflowTests(IntegrationTestContext, WorkflowDbContext> testContext) - { - _testContext = testContext; + public WorkflowTests(IntegrationTestContext, WorkflowDbContext> testContext) + { + _testContext = testContext; - testContext.UseController(); + testContext.UseController(); - testContext.ConfigureServicesAfterStartup(services => - { - services.AddResourceDefinition(); - }); - } + testContext.ConfigureServicesAfterStartup(services => + { + services.AddResourceDefinition(); + }); + } - [Fact] - public async Task Can_create_in_valid_stage() + [Fact] + public async Task Can_create_in_valid_stage() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + data = new { - data = new + type = "workflows", + attributes = new { - type = "workflows", - attributes = new - { - stage = WorkflowStage.Created - } + stage = WorkflowStage.Created } - }; + } + }; - const string route = "/workflows"; + const string route = "/workflows"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - } + responseDocument.Data.SingleValue.ShouldNotBeNull(); + } - [Fact] - public async Task Cannot_create_in_invalid_stage() + [Fact] + public async Task Cannot_create_in_invalid_stage() + { + // Arrange + var requestBody = new { - // Arrange - var requestBody = new + data = new { - data = new + type = "workflows", + attributes = new { - type = "workflows", - attributes = new - { - stage = WorkflowStage.Canceled - } + stage = WorkflowStage.Canceled } - }; + } + }; - const string route = "/workflows"; + const string route = "/workflows"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Invalid workflow stage."); - error.Detail.Should().Be("Initial stage of workflow must be 'Created'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/data/attributes/stage"); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Invalid workflow stage."); + error.Detail.Should().Be("Initial stage of workflow must be 'Created'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data/attributes/stage"); + } - [Fact] - public async Task Cannot_transition_to_invalid_stage() + [Fact] + public async Task Cannot_transition_to_invalid_stage() + { + // Arrange + var existingWorkflow = new Workflow { - // Arrange - var existingWorkflow = new Workflow - { - Stage = WorkflowStage.OnHold - }; + Stage = WorkflowStage.OnHold + }; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Workflows.Add(existingWorkflow); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Workflows.Add(existingWorkflow); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new { - data = new + type = "workflows", + id = existingWorkflow.StringId, + attributes = new { - type = "workflows", - id = existingWorkflow.StringId, - attributes = new - { - stage = WorkflowStage.Succeeded - } + stage = WorkflowStage.Succeeded } - }; + } + }; - string route = $"/workflows/{existingWorkflow.StringId}"; + string route = $"/workflows/{existingWorkflow.StringId}"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Invalid workflow stage."); - error.Detail.Should().Be("Cannot transition from 'OnHold' to 'Succeeded'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/data/attributes/stage"); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Invalid workflow stage."); + error.Detail.Should().Be("Cannot transition from 'OnHold' to 'Succeeded'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data/attributes/stage"); + } - [Fact] - public async Task Can_transition_to_valid_stage() + [Fact] + public async Task Can_transition_to_valid_stage() + { + // Arrange + var existingWorkflow = new Workflow { - // Arrange - var existingWorkflow = new Workflow - { - Stage = WorkflowStage.InProgress - }; + Stage = WorkflowStage.InProgress + }; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Workflows.Add(existingWorkflow); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Workflows.Add(existingWorkflow); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new { - data = new + type = "workflows", + id = existingWorkflow.StringId, + attributes = new { - type = "workflows", - id = existingWorkflow.StringId, - attributes = new - { - stage = WorkflowStage.Failed - } + stage = WorkflowStage.Failed } - }; + } + }; - string route = $"/workflows/{existingWorkflow.StringId}"; + string route = $"/workflows/{existingWorkflow.StringId}"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); - } + responseDocument.Should().BeEmpty(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithNamespaceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithNamespaceTests.cs index d9a628af06..799942bea9 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithNamespaceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithNamespaceTests.cs @@ -1,7 +1,4 @@ -using System.Linq; using System.Net; -using System.Net.Http; -using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; @@ -11,471 +8,469 @@ using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.Links +namespace JsonApiDotNetCoreTests.IntegrationTests.Links; + +public sealed class AbsoluteLinksWithNamespaceTests : IClassFixture, LinksDbContext>> { - public sealed class AbsoluteLinksWithNamespaceTests - : IClassFixture, LinksDbContext>> + private const string HostPrefix = "http://localhost"; + private const string PathPrefix = "/api"; + + private readonly IntegrationTestContext, LinksDbContext> _testContext; + private readonly LinksFakers _fakers = new(); + + public AbsoluteLinksWithNamespaceTests(IntegrationTestContext, LinksDbContext> testContext) { - private const string HostPrefix = "http://localhost"; - private const string PathPrefix = "/api"; + _testContext = testContext; - private readonly IntegrationTestContext, LinksDbContext> _testContext; - private readonly LinksFakers _fakers = new(); + testContext.UseController(); + testContext.UseController(); - public AbsoluteLinksWithNamespaceTests(IntegrationTestContext, LinksDbContext> testContext) + testContext.ConfigureServicesAfterStartup(services => { - _testContext = testContext; - - testContext.UseController(); - testContext.UseController(); + services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); + }); - testContext.ConfigureServicesAfterStartup(services => - { - services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); - }); + var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); + options.IncludeTotalResourceCount = true; + } - var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); - options.IncludeTotalResourceCount = true; - } + [Fact] + public async Task Get_primary_resource_by_ID_returns_absolute_links() + { + // Arrange + PhotoAlbum album = _fakers.PhotoAlbum.Generate(); - [Fact] - public async Task Get_primary_resource_by_ID_returns_absolute_links() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - PhotoAlbum album = _fakers.PhotoAlbum.Generate(); + dbContext.PhotoAlbums.Add(album); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.PhotoAlbums.Add(album); - await dbContext.SaveChangesAsync(); - }); + string route = $"{PathPrefix}/photoAlbums/{album.StringId}"; - string route = $"{PathPrefix}/photoAlbums/{album.StringId}"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); - responseDocument.Links.ShouldNotBeNull(); - responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.Related.Should().BeNull(); - responseDocument.Links.First.Should().BeNull(); - responseDocument.Links.Last.Should().BeNull(); - responseDocument.Links.Prev.Should().BeNull(); - responseDocument.Links.Next.Should().BeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Links.Self.Should().Be($"{HostPrefix}{route}"); + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photos").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{HostPrefix}{route}/relationships/photos"); + value.Links.Related.Should().Be($"{HostPrefix}{route}/photos"); + }); + } - responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photos").With(value => - { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - value.Links.Self.Should().Be($"{HostPrefix}{route}/relationships/photos"); - value.Links.Related.Should().Be($"{HostPrefix}{route}/photos"); - }); - } + [Fact] + public async Task Get_primary_resources_with_include_returns_absolute_links() + { + // Arrange + PhotoAlbum album = _fakers.PhotoAlbum.Generate(); + album.Photos = _fakers.Photo.Generate(1).ToHashSet(); - [Fact] - public async Task Get_primary_resources_with_include_returns_absolute_links() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - PhotoAlbum album = _fakers.PhotoAlbum.Generate(); - album.Photos = _fakers.Photo.Generate(1).ToHashSet(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.PhotoAlbums.Add(album); - await dbContext.SaveChangesAsync(); - }); + await dbContext.ClearTableAsync(); + dbContext.PhotoAlbums.Add(album); + await dbContext.SaveChangesAsync(); + }); - string route = $"{PathPrefix}/photoAlbums?include=photos"; + const string route = $"{PathPrefix}/photoAlbums?include=photos"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.ShouldNotBeNull(); - responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.Related.Should().BeNull(); - responseDocument.Links.First.Should().Be(responseDocument.Links.Self); - responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); - responseDocument.Links.Prev.Should().BeNull(); - responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().Be(responseDocument.Links.Self); + responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.ShouldHaveCount(1); - responseDocument.Data.ManyValue[0].With(resource => - { - string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{album.StringId}"; + responseDocument.Data.ManyValue[0].With(resource => + { + string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{album.StringId}"; - resource.Links.ShouldNotBeNull(); - resource.Links.Self.Should().Be(albumLink); + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(albumLink); - resource.Relationships.ShouldContainKey("photos").With(value => - { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); - value.Links.Related.Should().Be($"{albumLink}/photos"); - }); + resource.Relationships.ShouldContainKey("photos").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); + value.Links.Related.Should().Be($"{albumLink}/photos"); }); + }); - responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included.ShouldHaveCount(1); - responseDocument.Included[0].With(resource => - { - string photoLink = $"{HostPrefix}{PathPrefix}/photos/{album.Photos.ElementAt(0).StringId}"; + responseDocument.Included[0].With(resource => + { + string photoLink = $"{HostPrefix}{PathPrefix}/photos/{album.Photos.ElementAt(0).StringId}"; - resource.Links.ShouldNotBeNull(); - resource.Links.Self.Should().Be(photoLink); + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(photoLink); - resource.Relationships.ShouldContainKey("album").With(value => - { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - value.Links.Self.Should().Be($"{photoLink}/relationships/album"); - value.Links.Related.Should().Be($"{photoLink}/album"); - }); + resource.Relationships.ShouldContainKey("album").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{photoLink}/relationships/album"); + value.Links.Related.Should().Be($"{photoLink}/album"); }); - } + }); + } + + [Fact] + public async Task Get_secondary_resource_returns_absolute_links() + { + // Arrange + Photo photo = _fakers.Photo.Generate(); + photo.Album = _fakers.PhotoAlbum.Generate(); - [Fact] - public async Task Get_secondary_resource_returns_absolute_links() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - Photo photo = _fakers.Photo.Generate(); - photo.Album = _fakers.PhotoAlbum.Generate(); + dbContext.Photos.Add(photo); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Photos.Add(photo); - await dbContext.SaveChangesAsync(); - }); + string route = $"{PathPrefix}/photos/{photo.StringId}/album"; - string route = $"{PathPrefix}/photos/{photo.StringId}/album"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); - responseDocument.Links.ShouldNotBeNull(); - responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.Related.Should().BeNull(); - responseDocument.Links.First.Should().BeNull(); - responseDocument.Links.Last.Should().BeNull(); - responseDocument.Links.Prev.Should().BeNull(); - responseDocument.Links.Next.Should().BeNull(); + string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{photo.Album.StringId}"; - string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{photo.Album.StringId}"; + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.Self.Should().Be(albumLink); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Links.Self.Should().Be(albumLink); + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photos").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); + value.Links.Related.Should().Be($"{albumLink}/photos"); + }); + } - responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photos").With(value => - { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); - value.Links.Related.Should().Be($"{albumLink}/photos"); - }); - } + [Fact] + public async Task Get_secondary_resources_returns_absolute_links() + { + // Arrange + PhotoAlbum album = _fakers.PhotoAlbum.Generate(); + album.Photos = _fakers.Photo.Generate(1).ToHashSet(); - [Fact] - public async Task Get_secondary_resources_returns_absolute_links() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - PhotoAlbum album = _fakers.PhotoAlbum.Generate(); - album.Photos = _fakers.Photo.Generate(1).ToHashSet(); + dbContext.PhotoAlbums.Add(album); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.PhotoAlbums.Add(album); - await dbContext.SaveChangesAsync(); - }); + string route = $"{PathPrefix}/photoAlbums/{album.StringId}/photos"; - string route = $"{PathPrefix}/photoAlbums/{album.StringId}/photos"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().Be(responseDocument.Links.Self); + responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); - responseDocument.Links.ShouldNotBeNull(); - responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.Related.Should().BeNull(); - responseDocument.Links.First.Should().Be(responseDocument.Links.Self); - responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); - responseDocument.Links.Prev.Should().BeNull(); - responseDocument.Links.Next.Should().BeNull(); + responseDocument.Data.ManyValue.ShouldHaveCount(1); - responseDocument.Data.ManyValue.ShouldHaveCount(1); - - responseDocument.Data.ManyValue[0].With(resource => - { - string photoLink = $"{HostPrefix}{PathPrefix}/photos/{album.Photos.ElementAt(0).StringId}"; + responseDocument.Data.ManyValue[0].With(resource => + { + string photoLink = $"{HostPrefix}{PathPrefix}/photos/{album.Photos.ElementAt(0).StringId}"; - resource.Links.ShouldNotBeNull(); - resource.Links.Self.Should().Be(photoLink); + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(photoLink); - resource.Relationships.ShouldContainKey("album").With(value => - { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - value.Links.Self.Should().Be($"{photoLink}/relationships/album"); - value.Links.Related.Should().Be($"{photoLink}/album"); - }); + resource.Relationships.ShouldContainKey("album").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{photoLink}/relationships/album"); + value.Links.Related.Should().Be($"{photoLink}/album"); }); - } + }); + } + + [Fact] + public async Task Get_ToOne_relationship_returns_absolute_links() + { + // Arrange + Photo photo = _fakers.Photo.Generate(); + photo.Album = _fakers.PhotoAlbum.Generate(); - [Fact] - public async Task Get_ToOne_relationship_returns_absolute_links() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - Photo photo = _fakers.Photo.Generate(); - photo.Album = _fakers.PhotoAlbum.Generate(); + dbContext.Photos.Add(photo); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Photos.Add(photo); - await dbContext.SaveChangesAsync(); - }); + string route = $"{PathPrefix}/photos/{photo.StringId}/relationships/album"; - string route = $"{PathPrefix}/photos/{photo.StringId}/relationships/album"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); + responseDocument.Links.Related.Should().Be($"{HostPrefix}{PathPrefix}/photos/{photo.StringId}/album"); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); - responseDocument.Links.ShouldNotBeNull(); - responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.Related.Should().Be($"{HostPrefix}{PathPrefix}/photos/{photo.StringId}/album"); - responseDocument.Links.First.Should().BeNull(); - responseDocument.Links.Last.Should().BeNull(); - responseDocument.Links.Prev.Should().BeNull(); - responseDocument.Links.Next.Should().BeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.Should().BeNull(); + responseDocument.Data.SingleValue.Relationships.Should().BeNull(); + } - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Links.Should().BeNull(); - responseDocument.Data.SingleValue.Relationships.Should().BeNull(); - } + [Fact] + public async Task Get_ToMany_relationship_returns_absolute_links() + { + // Arrange + PhotoAlbum album = _fakers.PhotoAlbum.Generate(); + album.Photos = _fakers.Photo.Generate(1).ToHashSet(); - [Fact] - public async Task Get_ToMany_relationship_returns_absolute_links() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - PhotoAlbum album = _fakers.PhotoAlbum.Generate(); - album.Photos = _fakers.Photo.Generate(1).ToHashSet(); + dbContext.PhotoAlbums.Add(album); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.PhotoAlbums.Add(album); - await dbContext.SaveChangesAsync(); - }); - - string route = $"{PathPrefix}/photoAlbums/{album.StringId}/relationships/photos"; + string route = $"{PathPrefix}/photoAlbums/{album.StringId}/relationships/photos"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.ShouldNotBeNull(); - responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.Related.Should().Be($"{HostPrefix}{PathPrefix}/photoAlbums/{album.StringId}/photos"); - responseDocument.Links.First.Should().Be(responseDocument.Links.Self); - responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); - responseDocument.Links.Prev.Should().BeNull(); - responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); + responseDocument.Links.Related.Should().Be($"{HostPrefix}{PathPrefix}/photoAlbums/{album.StringId}/photos"); + responseDocument.Links.First.Should().Be(responseDocument.Links.Self); + responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); - responseDocument.Data.ManyValue.ShouldHaveCount(1); - responseDocument.Data.ManyValue[0].Links.Should().BeNull(); - responseDocument.Data.ManyValue[0].Relationships.Should().BeNull(); - } + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Links.Should().BeNull(); + responseDocument.Data.ManyValue[0].Relationships.Should().BeNull(); + } - [Fact] - public async Task Create_resource_with_side_effects_and_include_returns_absolute_links() - { - // Arrange - Photo existingPhoto = _fakers.Photo.Generate(); + [Fact] + public async Task Create_resource_with_side_effects_and_include_returns_absolute_links() + { + // Arrange + Photo existingPhoto = _fakers.Photo.Generate(); - string newAlbumName = _fakers.PhotoAlbum.Generate().Name; + string newAlbumName = _fakers.PhotoAlbum.Generate().Name; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Photos.Add(existingPhoto); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Photos.Add(existingPhoto); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new { - data = new + type = "photoAlbums", + attributes = new { - type = "photoAlbums", - attributes = new - { - name = newAlbumName - }, - relationships = new + name = newAlbumName + }, + relationships = new + { + photos = new { - photos = new + data = new[] { - data = new[] + new { - new - { - type = "photos", - id = existingPhoto.StringId - } + type = "photos", + id = existingPhoto.StringId } } } } - }; + } + }; - string route = $"{PathPrefix}/photoAlbums?include=photos"; + const string route = $"{PathPrefix}/photoAlbums?include=photos"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Links.ShouldNotBeNull(); - responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.Related.Should().BeNull(); - responseDocument.Links.First.Should().BeNull(); - responseDocument.Links.Last.Should().BeNull(); - responseDocument.Links.Prev.Should().BeNull(); - responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); - string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{responseDocument.Data.SingleValue.Id}"; + string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{responseDocument.Data.SingleValue.Id}"; - responseDocument.Data.SingleValue.Links.Self.Should().Be(albumLink); + responseDocument.Data.SingleValue.Links.Self.Should().Be(albumLink); - responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photos").With(value => - { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); - value.Links.Related.Should().Be($"{albumLink}/photos"); - }); + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photos").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); + value.Links.Related.Should().Be($"{albumLink}/photos"); + }); - responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included.ShouldHaveCount(1); - responseDocument.Included[0].With(resource => - { - string photoLink = $"{HostPrefix}{PathPrefix}/photos/{existingPhoto.StringId}"; + responseDocument.Included[0].With(resource => + { + string photoLink = $"{HostPrefix}{PathPrefix}/photos/{existingPhoto.StringId}"; - resource.Links.ShouldNotBeNull(); - resource.Links.Self.Should().Be(photoLink); + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(photoLink); - resource.Relationships.ShouldContainKey("album").With(value => - { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - value.Links.Self.Should().Be($"{photoLink}/relationships/album"); - value.Links.Related.Should().Be($"{photoLink}/album"); - }); + resource.Relationships.ShouldContainKey("album").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{photoLink}/relationships/album"); + value.Links.Related.Should().Be($"{photoLink}/album"); }); - } + }); + } - [Fact] - public async Task Update_resource_with_side_effects_and_include_returns_absolute_links() - { - // Arrange - Photo existingPhoto = _fakers.Photo.Generate(); - PhotoAlbum existingAlbum = _fakers.PhotoAlbum.Generate(); + [Fact] + public async Task Update_resource_with_side_effects_and_include_returns_absolute_links() + { + // Arrange + Photo existingPhoto = _fakers.Photo.Generate(); + PhotoAlbum existingAlbum = _fakers.PhotoAlbum.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddInRange(existingPhoto, existingAlbum); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingPhoto, existingAlbum); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new { - data = new + type = "photos", + id = existingPhoto.StringId, + relationships = new { - type = "photos", - id = existingPhoto.StringId, - relationships = new + album = new { - album = new + data = new { - data = new - { - type = "photoAlbums", - id = existingAlbum.StringId - } + type = "photoAlbums", + id = existingAlbum.StringId } } } - }; + } + }; - string route = $"{PathPrefix}/photos/{existingPhoto.StringId}?include=album"; + string route = $"{PathPrefix}/photos/{existingPhoto.StringId}?include=album"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.ShouldNotBeNull(); - responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.Related.Should().BeNull(); - responseDocument.Links.First.Should().BeNull(); - responseDocument.Links.Last.Should().BeNull(); - responseDocument.Links.Prev.Should().BeNull(); - responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); - string photoLink = $"{HostPrefix}{PathPrefix}/photos/{existingPhoto.StringId}"; + string photoLink = $"{HostPrefix}{PathPrefix}/photos/{existingPhoto.StringId}"; - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Links.Self.Should().Be(photoLink); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.Self.Should().Be(photoLink); - responseDocument.Data.SingleValue.Relationships.ShouldContainKey("album").With(value => - { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - value.Links.Self.Should().Be($"{photoLink}/relationships/album"); - value.Links.Related.Should().Be($"{photoLink}/album"); - }); + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("album").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{photoLink}/relationships/album"); + value.Links.Related.Should().Be($"{photoLink}/album"); + }); - responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included.ShouldHaveCount(1); - responseDocument.Included[0].With(resource => - { - string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{existingAlbum.StringId}"; + responseDocument.Included[0].With(resource => + { + string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{existingAlbum.StringId}"; - resource.Links.ShouldNotBeNull(); - resource.Links.Self.Should().Be(albumLink); + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(albumLink); - resource.Relationships.ShouldContainKey("photos").With(value => - { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); - value.Links.Related.Should().Be($"{albumLink}/photos"); - }); + resource.Relationships.ShouldContainKey("photos").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); + value.Links.Related.Should().Be($"{albumLink}/photos"); }); - } + }); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithoutNamespaceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithoutNamespaceTests.cs index 2bf54c153c..6fa391fd22 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithoutNamespaceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithoutNamespaceTests.cs @@ -1,7 +1,4 @@ -using System.Linq; using System.Net; -using System.Net.Http; -using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; @@ -11,471 +8,469 @@ using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.Links +namespace JsonApiDotNetCoreTests.IntegrationTests.Links; + +public sealed class AbsoluteLinksWithoutNamespaceTests : IClassFixture, LinksDbContext>> { - public sealed class AbsoluteLinksWithoutNamespaceTests - : IClassFixture, LinksDbContext>> + private const string HostPrefix = "http://localhost"; + private const string PathPrefix = ""; + + private readonly IntegrationTestContext, LinksDbContext> _testContext; + private readonly LinksFakers _fakers = new(); + + public AbsoluteLinksWithoutNamespaceTests(IntegrationTestContext, LinksDbContext> testContext) { - private const string HostPrefix = "http://localhost"; - private const string PathPrefix = ""; + _testContext = testContext; - private readonly IntegrationTestContext, LinksDbContext> _testContext; - private readonly LinksFakers _fakers = new(); + testContext.UseController(); + testContext.UseController(); - public AbsoluteLinksWithoutNamespaceTests(IntegrationTestContext, LinksDbContext> testContext) + testContext.ConfigureServicesAfterStartup(services => { - _testContext = testContext; - - testContext.UseController(); - testContext.UseController(); + services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); + }); - testContext.ConfigureServicesAfterStartup(services => - { - services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); - }); + var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); + options.IncludeTotalResourceCount = true; + } - var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); - options.IncludeTotalResourceCount = true; - } + [Fact] + public async Task Get_primary_resource_by_ID_returns_absolute_links() + { + // Arrange + PhotoAlbum album = _fakers.PhotoAlbum.Generate(); - [Fact] - public async Task Get_primary_resource_by_ID_returns_absolute_links() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - PhotoAlbum album = _fakers.PhotoAlbum.Generate(); + dbContext.PhotoAlbums.Add(album); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.PhotoAlbums.Add(album); - await dbContext.SaveChangesAsync(); - }); + string route = $"{PathPrefix}/photoAlbums/{album.StringId}"; - string route = $"{PathPrefix}/photoAlbums/{album.StringId}"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); - responseDocument.Links.ShouldNotBeNull(); - responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.Related.Should().BeNull(); - responseDocument.Links.First.Should().BeNull(); - responseDocument.Links.Last.Should().BeNull(); - responseDocument.Links.Prev.Should().BeNull(); - responseDocument.Links.Next.Should().BeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Links.Self.Should().Be($"{HostPrefix}{route}"); + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photos").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{HostPrefix}{route}/relationships/photos"); + value.Links.Related.Should().Be($"{HostPrefix}{route}/photos"); + }); + } - responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photos").With(value => - { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - value.Links.Self.Should().Be($"{HostPrefix}{route}/relationships/photos"); - value.Links.Related.Should().Be($"{HostPrefix}{route}/photos"); - }); - } + [Fact] + public async Task Get_primary_resources_with_include_returns_absolute_links() + { + // Arrange + PhotoAlbum album = _fakers.PhotoAlbum.Generate(); + album.Photos = _fakers.Photo.Generate(1).ToHashSet(); - [Fact] - public async Task Get_primary_resources_with_include_returns_absolute_links() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - PhotoAlbum album = _fakers.PhotoAlbum.Generate(); - album.Photos = _fakers.Photo.Generate(1).ToHashSet(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.PhotoAlbums.Add(album); - await dbContext.SaveChangesAsync(); - }); + await dbContext.ClearTableAsync(); + dbContext.PhotoAlbums.Add(album); + await dbContext.SaveChangesAsync(); + }); - string route = $"{PathPrefix}/photoAlbums?include=photos"; + const string route = $"{PathPrefix}/photoAlbums?include=photos"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.ShouldNotBeNull(); - responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.Related.Should().BeNull(); - responseDocument.Links.First.Should().Be(responseDocument.Links.Self); - responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); - responseDocument.Links.Prev.Should().BeNull(); - responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().Be(responseDocument.Links.Self); + responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.ShouldHaveCount(1); - responseDocument.Data.ManyValue[0].With(resource => - { - string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{album.StringId}"; + responseDocument.Data.ManyValue[0].With(resource => + { + string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{album.StringId}"; - resource.Links.ShouldNotBeNull(); - resource.Links.Self.Should().Be(albumLink); + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(albumLink); - resource.Relationships.ShouldContainKey("photos").With(value => - { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); - value.Links.Related.Should().Be($"{albumLink}/photos"); - }); + resource.Relationships.ShouldContainKey("photos").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); + value.Links.Related.Should().Be($"{albumLink}/photos"); }); + }); - responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included.ShouldHaveCount(1); - responseDocument.Included[0].With(resource => - { - string photoLink = $"{HostPrefix}{PathPrefix}/photos/{album.Photos.ElementAt(0).StringId}"; + responseDocument.Included[0].With(resource => + { + string photoLink = $"{HostPrefix}{PathPrefix}/photos/{album.Photos.ElementAt(0).StringId}"; - resource.Links.ShouldNotBeNull(); - resource.Links.Self.Should().Be(photoLink); + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(photoLink); - resource.Relationships.ShouldContainKey("album").With(value => - { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - value.Links.Self.Should().Be($"{photoLink}/relationships/album"); - value.Links.Related.Should().Be($"{photoLink}/album"); - }); + resource.Relationships.ShouldContainKey("album").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{photoLink}/relationships/album"); + value.Links.Related.Should().Be($"{photoLink}/album"); }); - } + }); + } + + [Fact] + public async Task Get_secondary_resource_returns_absolute_links() + { + // Arrange + Photo photo = _fakers.Photo.Generate(); + photo.Album = _fakers.PhotoAlbum.Generate(); - [Fact] - public async Task Get_secondary_resource_returns_absolute_links() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - Photo photo = _fakers.Photo.Generate(); - photo.Album = _fakers.PhotoAlbum.Generate(); + dbContext.Photos.Add(photo); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Photos.Add(photo); - await dbContext.SaveChangesAsync(); - }); + string route = $"{PathPrefix}/photos/{photo.StringId}/album"; - string route = $"{PathPrefix}/photos/{photo.StringId}/album"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); - responseDocument.Links.ShouldNotBeNull(); - responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.Related.Should().BeNull(); - responseDocument.Links.First.Should().BeNull(); - responseDocument.Links.Last.Should().BeNull(); - responseDocument.Links.Prev.Should().BeNull(); - responseDocument.Links.Next.Should().BeNull(); + string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{photo.Album.StringId}"; - string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{photo.Album.StringId}"; + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.Self.Should().Be(albumLink); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Links.Self.Should().Be(albumLink); + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photos").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); + value.Links.Related.Should().Be($"{albumLink}/photos"); + }); + } - responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photos").With(value => - { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); - value.Links.Related.Should().Be($"{albumLink}/photos"); - }); - } + [Fact] + public async Task Get_secondary_resources_returns_absolute_links() + { + // Arrange + PhotoAlbum album = _fakers.PhotoAlbum.Generate(); + album.Photos = _fakers.Photo.Generate(1).ToHashSet(); - [Fact] - public async Task Get_secondary_resources_returns_absolute_links() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - PhotoAlbum album = _fakers.PhotoAlbum.Generate(); - album.Photos = _fakers.Photo.Generate(1).ToHashSet(); + dbContext.PhotoAlbums.Add(album); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.PhotoAlbums.Add(album); - await dbContext.SaveChangesAsync(); - }); + string route = $"{PathPrefix}/photoAlbums/{album.StringId}/photos"; - string route = $"{PathPrefix}/photoAlbums/{album.StringId}/photos"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().Be(responseDocument.Links.Self); + responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); - responseDocument.Links.ShouldNotBeNull(); - responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.Related.Should().BeNull(); - responseDocument.Links.First.Should().Be(responseDocument.Links.Self); - responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); - responseDocument.Links.Prev.Should().BeNull(); - responseDocument.Links.Next.Should().BeNull(); + responseDocument.Data.ManyValue.ShouldHaveCount(1); - responseDocument.Data.ManyValue.ShouldHaveCount(1); - - responseDocument.Data.ManyValue[0].With(resource => - { - string photoLink = $"{HostPrefix}{PathPrefix}/photos/{album.Photos.ElementAt(0).StringId}"; + responseDocument.Data.ManyValue[0].With(resource => + { + string photoLink = $"{HostPrefix}{PathPrefix}/photos/{album.Photos.ElementAt(0).StringId}"; - resource.Links.ShouldNotBeNull(); - resource.Links.Self.Should().Be(photoLink); + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(photoLink); - resource.Relationships.ShouldContainKey("album").With(value => - { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - value.Links.Self.Should().Be($"{photoLink}/relationships/album"); - value.Links.Related.Should().Be($"{photoLink}/album"); - }); + resource.Relationships.ShouldContainKey("album").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{photoLink}/relationships/album"); + value.Links.Related.Should().Be($"{photoLink}/album"); }); - } + }); + } + + [Fact] + public async Task Get_ToOne_relationship_returns_absolute_links() + { + // Arrange + Photo photo = _fakers.Photo.Generate(); + photo.Album = _fakers.PhotoAlbum.Generate(); - [Fact] - public async Task Get_ToOne_relationship_returns_absolute_links() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - Photo photo = _fakers.Photo.Generate(); - photo.Album = _fakers.PhotoAlbum.Generate(); + dbContext.Photos.Add(photo); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Photos.Add(photo); - await dbContext.SaveChangesAsync(); - }); + string route = $"{PathPrefix}/photos/{photo.StringId}/relationships/album"; - string route = $"{PathPrefix}/photos/{photo.StringId}/relationships/album"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); + responseDocument.Links.Related.Should().Be($"{HostPrefix}{PathPrefix}/photos/{photo.StringId}/album"); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); - responseDocument.Links.ShouldNotBeNull(); - responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.Related.Should().Be($"{HostPrefix}{PathPrefix}/photos/{photo.StringId}/album"); - responseDocument.Links.First.Should().BeNull(); - responseDocument.Links.Last.Should().BeNull(); - responseDocument.Links.Prev.Should().BeNull(); - responseDocument.Links.Next.Should().BeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.Should().BeNull(); + responseDocument.Data.SingleValue.Relationships.Should().BeNull(); + } - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Links.Should().BeNull(); - responseDocument.Data.SingleValue.Relationships.Should().BeNull(); - } + [Fact] + public async Task Get_ToMany_relationship_returns_absolute_links() + { + // Arrange + PhotoAlbum album = _fakers.PhotoAlbum.Generate(); + album.Photos = _fakers.Photo.Generate(1).ToHashSet(); - [Fact] - public async Task Get_ToMany_relationship_returns_absolute_links() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - PhotoAlbum album = _fakers.PhotoAlbum.Generate(); - album.Photos = _fakers.Photo.Generate(1).ToHashSet(); + dbContext.PhotoAlbums.Add(album); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.PhotoAlbums.Add(album); - await dbContext.SaveChangesAsync(); - }); - - string route = $"{PathPrefix}/photoAlbums/{album.StringId}/relationships/photos"; + string route = $"{PathPrefix}/photoAlbums/{album.StringId}/relationships/photos"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.ShouldNotBeNull(); - responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.Related.Should().Be($"{HostPrefix}{PathPrefix}/photoAlbums/{album.StringId}/photos"); - responseDocument.Links.First.Should().Be(responseDocument.Links.Self); - responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); - responseDocument.Links.Prev.Should().BeNull(); - responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); + responseDocument.Links.Related.Should().Be($"{HostPrefix}{PathPrefix}/photoAlbums/{album.StringId}/photos"); + responseDocument.Links.First.Should().Be(responseDocument.Links.Self); + responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); - responseDocument.Data.ManyValue.ShouldHaveCount(1); - responseDocument.Data.ManyValue[0].Links.Should().BeNull(); - responseDocument.Data.ManyValue[0].Relationships.Should().BeNull(); - } + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Links.Should().BeNull(); + responseDocument.Data.ManyValue[0].Relationships.Should().BeNull(); + } - [Fact] - public async Task Create_resource_with_side_effects_and_include_returns_absolute_links() - { - // Arrange - Photo existingPhoto = _fakers.Photo.Generate(); + [Fact] + public async Task Create_resource_with_side_effects_and_include_returns_absolute_links() + { + // Arrange + Photo existingPhoto = _fakers.Photo.Generate(); - string newAlbumName = _fakers.PhotoAlbum.Generate().Name; + string newAlbumName = _fakers.PhotoAlbum.Generate().Name; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Photos.Add(existingPhoto); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Photos.Add(existingPhoto); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new { - data = new + type = "photoAlbums", + attributes = new { - type = "photoAlbums", - attributes = new - { - name = newAlbumName - }, - relationships = new + name = newAlbumName + }, + relationships = new + { + photos = new { - photos = new + data = new[] { - data = new[] + new { - new - { - type = "photos", - id = existingPhoto.StringId - } + type = "photos", + id = existingPhoto.StringId } } } } - }; + } + }; - string route = $"{PathPrefix}/photoAlbums?include=photos"; + const string route = $"{PathPrefix}/photoAlbums?include=photos"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Links.ShouldNotBeNull(); - responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.Related.Should().BeNull(); - responseDocument.Links.First.Should().BeNull(); - responseDocument.Links.Last.Should().BeNull(); - responseDocument.Links.Prev.Should().BeNull(); - responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); - string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{responseDocument.Data.SingleValue.Id}"; + string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{responseDocument.Data.SingleValue.Id}"; - responseDocument.Data.SingleValue.Links.Self.Should().Be(albumLink); + responseDocument.Data.SingleValue.Links.Self.Should().Be(albumLink); - responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photos").With(value => - { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); - value.Links.Related.Should().Be($"{albumLink}/photos"); - }); + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photos").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); + value.Links.Related.Should().Be($"{albumLink}/photos"); + }); - responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included.ShouldHaveCount(1); - responseDocument.Included[0].With(resource => - { - string photoLink = $"{HostPrefix}{PathPrefix}/photos/{existingPhoto.StringId}"; + responseDocument.Included[0].With(resource => + { + string photoLink = $"{HostPrefix}{PathPrefix}/photos/{existingPhoto.StringId}"; - resource.Links.ShouldNotBeNull(); - resource.Links.Self.Should().Be(photoLink); + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(photoLink); - resource.Relationships.ShouldContainKey("album").With(value => - { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - value.Links.Self.Should().Be($"{photoLink}/relationships/album"); - value.Links.Related.Should().Be($"{photoLink}/album"); - }); + resource.Relationships.ShouldContainKey("album").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{photoLink}/relationships/album"); + value.Links.Related.Should().Be($"{photoLink}/album"); }); - } + }); + } - [Fact] - public async Task Update_resource_with_side_effects_and_include_returns_absolute_links() - { - // Arrange - Photo existingPhoto = _fakers.Photo.Generate(); - PhotoAlbum existingAlbum = _fakers.PhotoAlbum.Generate(); + [Fact] + public async Task Update_resource_with_side_effects_and_include_returns_absolute_links() + { + // Arrange + Photo existingPhoto = _fakers.Photo.Generate(); + PhotoAlbum existingAlbum = _fakers.PhotoAlbum.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddInRange(existingPhoto, existingAlbum); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingPhoto, existingAlbum); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new { - data = new + type = "photos", + id = existingPhoto.StringId, + relationships = new { - type = "photos", - id = existingPhoto.StringId, - relationships = new + album = new { - album = new + data = new { - data = new - { - type = "photoAlbums", - id = existingAlbum.StringId - } + type = "photoAlbums", + id = existingAlbum.StringId } } } - }; + } + }; - string route = $"{PathPrefix}/photos/{existingPhoto.StringId}?include=album"; + string route = $"{PathPrefix}/photos/{existingPhoto.StringId}?include=album"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.ShouldNotBeNull(); - responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.Related.Should().BeNull(); - responseDocument.Links.First.Should().BeNull(); - responseDocument.Links.Last.Should().BeNull(); - responseDocument.Links.Prev.Should().BeNull(); - responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); - string photoLink = $"{HostPrefix}{PathPrefix}/photos/{existingPhoto.StringId}"; + string photoLink = $"{HostPrefix}{PathPrefix}/photos/{existingPhoto.StringId}"; - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Links.Self.Should().Be(photoLink); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.Self.Should().Be(photoLink); - responseDocument.Data.SingleValue.Relationships.ShouldContainKey("album").With(value => - { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - value.Links.Self.Should().Be($"{photoLink}/relationships/album"); - value.Links.Related.Should().Be($"{photoLink}/album"); - }); + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("album").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{photoLink}/relationships/album"); + value.Links.Related.Should().Be($"{photoLink}/album"); + }); - responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included.ShouldHaveCount(1); - responseDocument.Included[0].With(resource => - { - string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{existingAlbum.StringId}"; + responseDocument.Included[0].With(resource => + { + string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{existingAlbum.StringId}"; - resource.Links.ShouldNotBeNull(); - resource.Links.Self.Should().Be(albumLink); + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(albumLink); - resource.Relationships.ShouldContainKey("photos").With(value => - { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); - value.Links.Related.Should().Be($"{albumLink}/photos"); - }); + resource.Relationships.ShouldContainKey("photos").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); + value.Links.Related.Should().Be($"{albumLink}/photos"); }); - } + }); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/LinkInclusionTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/LinkInclusionTests.cs index 77eaaf376f..128a19efb9 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/LinkInclusionTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/LinkInclusionTests.cs @@ -1,133 +1,130 @@ using System.Net; -using System.Net.Http; -using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.Links +namespace JsonApiDotNetCoreTests.IntegrationTests.Links; + +public sealed class LinkInclusionTests : IClassFixture, LinksDbContext>> { - public sealed class LinkInclusionTests : IClassFixture, LinksDbContext>> + private readonly IntegrationTestContext, LinksDbContext> _testContext; + private readonly LinksFakers _fakers = new(); + + public LinkInclusionTests(IntegrationTestContext, LinksDbContext> testContext) { - private readonly IntegrationTestContext, LinksDbContext> _testContext; - private readonly LinksFakers _fakers = new(); + _testContext = testContext; - public LinkInclusionTests(IntegrationTestContext, LinksDbContext> testContext) - { - _testContext = testContext; + testContext.UseController(); + testContext.UseController(); + } - testContext.UseController(); - testContext.UseController(); - } + [Fact] + public async Task Get_primary_resource_with_include_applies_links_visibility_from_ResourceLinksAttribute() + { + // Arrange + PhotoLocation location = _fakers.PhotoLocation.Generate(); + location.Photo = _fakers.Photo.Generate(); + location.Album = _fakers.PhotoAlbum.Generate(); - [Fact] - public async Task Get_primary_resource_with_include_applies_links_visibility_from_ResourceLinksAttribute() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - PhotoLocation location = _fakers.PhotoLocation.Generate(); - location.Photo = _fakers.Photo.Generate(); - location.Album = _fakers.PhotoAlbum.Generate(); + dbContext.PhotoLocations.Add(location); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.PhotoLocations.Add(location); - await dbContext.SaveChangesAsync(); - }); + string route = $"/photoLocations/{location.StringId}?include=photo,album"; - string route = $"/photoLocations/{location.StringId}?include=photo,album"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Links.Should().BeNull(); - responseDocument.Links.Should().BeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.Should().BeNull(); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Links.Should().BeNull(); + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photo").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().BeNull(); + value.Links.Related.ShouldNotBeNull(); + }); - responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photo").With(value => + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("album").With(value => + { + value.ShouldNotBeNull(); + value.Links.Should().BeNull(); + }); + + responseDocument.Included.ShouldHaveCount(2); + + responseDocument.Included[0].With(resource => + { + resource.Links.ShouldNotBeNull(); + resource.Links.Self.ShouldNotBeNull(); + + resource.Relationships.ShouldContainKey("location").With(value => { value.ShouldNotBeNull(); value.Links.ShouldNotBeNull(); - value.Links.Self.Should().BeNull(); + value.Links.Self.ShouldNotBeNull(); value.Links.Related.ShouldNotBeNull(); }); + }); - responseDocument.Data.SingleValue.Relationships.ShouldContainKey("album").With(value => - { - value.ShouldNotBeNull(); - value.Links.Should().BeNull(); - }); - - responseDocument.Included.ShouldHaveCount(2); + responseDocument.Included[1].With(resource => + { + resource.Links.ShouldNotBeNull(); + resource.Links.Self.ShouldNotBeNull(); - responseDocument.Included[0].With(resource => + resource.Relationships.ShouldContainKey("photos").With(value => { - resource.Links.ShouldNotBeNull(); - resource.Links.Self.ShouldNotBeNull(); - - resource.Relationships.ShouldContainKey("location").With(value => - { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - value.Links.Self.ShouldNotBeNull(); - value.Links.Related.ShouldNotBeNull(); - }); + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.ShouldNotBeNull(); + value.Links.Related.ShouldNotBeNull(); }); + }); + } - responseDocument.Included[1].With(resource => - { - resource.Links.ShouldNotBeNull(); - resource.Links.Self.ShouldNotBeNull(); - - resource.Relationships.ShouldContainKey("photos").With(value => - { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - value.Links.Self.ShouldNotBeNull(); - value.Links.Related.ShouldNotBeNull(); - }); - }); - } + [Fact] + public async Task Get_secondary_resource_applies_links_visibility_from_ResourceLinksAttribute() + { + // Arrange + Photo photo = _fakers.Photo.Generate(); + photo.Location = _fakers.PhotoLocation.Generate(); - [Fact] - public async Task Get_secondary_resource_applies_links_visibility_from_ResourceLinksAttribute() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - Photo photo = _fakers.Photo.Generate(); - photo.Location = _fakers.PhotoLocation.Generate(); + dbContext.Photos.Add(photo); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Photos.Add(photo); - await dbContext.SaveChangesAsync(); - }); - - string route = $"/photos/{photo.StringId}/location"; + string route = $"/photos/{photo.StringId}/location"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.Should().BeNull(); + responseDocument.Links.Should().BeNull(); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Links.Should().BeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.Should().BeNull(); - responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photo").With(value => - { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - value.Links.Self.Should().BeNull(); - value.Links.Related.ShouldNotBeNull(); - }); + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photo").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().BeNull(); + value.Links.Related.ShouldNotBeNull(); + }); - responseDocument.Data.SingleValue.Relationships.Should().NotContainKey("album"); - } + responseDocument.Data.SingleValue.Relationships.Should().NotContainKey("album"); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/LinksDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/LinksDbContext.cs index 935c4b6718..390f8ec5e2 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/LinksDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/LinksDbContext.cs @@ -3,26 +3,25 @@ // @formatter:wrap_chained_method_calls chop_always -namespace JsonApiDotNetCoreTests.IntegrationTests.Links +namespace JsonApiDotNetCoreTests.IntegrationTests.Links; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class LinksDbContext : DbContext { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class LinksDbContext : DbContext - { - public DbSet PhotoAlbums => Set(); - public DbSet Photos => Set(); - public DbSet PhotoLocations => Set(); + public DbSet PhotoAlbums => Set(); + public DbSet Photos => Set(); + public DbSet PhotoLocations => Set(); - public LinksDbContext(DbContextOptions options) - : base(options) - { - } + public LinksDbContext(DbContextOptions options) + : base(options) + { + } - protected override void OnModelCreating(ModelBuilder builder) - { - builder.Entity() - .HasOne(photo => photo.Location) - .WithOne(location => location!.Photo) - .HasForeignKey("LocationId"); - } + protected override void OnModelCreating(ModelBuilder builder) + { + builder.Entity() + .HasOne(photo => photo.Location) + .WithOne(location => location.Photo) + .HasForeignKey("LocationId"); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/LinksFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/LinksFakers.cs index dfe263c43b..bd34d85cf5 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/LinksFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/LinksFakers.cs @@ -1,33 +1,31 @@ -using System; using Bogus; using TestBuildingBlocks; // @formatter:wrap_chained_method_calls chop_always // @formatter:keep_existing_linebreaks true -namespace JsonApiDotNetCoreTests.IntegrationTests.Links +namespace JsonApiDotNetCoreTests.IntegrationTests.Links; + +internal sealed class LinksFakers : FakerContainer { - internal sealed class LinksFakers : FakerContainer - { - private readonly Lazy> _lazyPhotoAlbumFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(photoAlbum => photoAlbum.Name, faker => faker.Lorem.Sentence())); + private readonly Lazy> _lazyPhotoAlbumFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(photoAlbum => photoAlbum.Name, faker => faker.Lorem.Sentence())); - private readonly Lazy> _lazyPhotoFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(photo => photo.Url, faker => faker.Image.PlaceImgUrl())); + private readonly Lazy> _lazyPhotoFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(photo => photo.Url, faker => faker.Image.PlaceImgUrl())); - private readonly Lazy> _lazyPhotoLocationFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(photoLocation => photoLocation.PlaceName, faker => faker.Address.FullAddress()) - .RuleFor(photoLocation => photoLocation.Latitude, faker => faker.Address.Latitude()) - .RuleFor(photoLocation => photoLocation.Longitude, faker => faker.Address.Longitude())); + private readonly Lazy> _lazyPhotoLocationFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(photoLocation => photoLocation.PlaceName, faker => faker.Address.FullAddress()) + .RuleFor(photoLocation => photoLocation.Latitude, faker => faker.Address.Latitude()) + .RuleFor(photoLocation => photoLocation.Longitude, faker => faker.Address.Longitude())); - public Faker PhotoAlbum => _lazyPhotoAlbumFaker.Value; - public Faker Photo => _lazyPhotoFaker.Value; - public Faker PhotoLocation => _lazyPhotoLocationFaker.Value; - } + public Faker PhotoAlbum => _lazyPhotoAlbumFaker.Value; + public Faker Photo => _lazyPhotoFaker.Value; + public Faker PhotoLocation => _lazyPhotoLocationFaker.Value; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/Photo.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/Photo.cs index a3b3e3b3ec..a4766b38ab 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/Photo.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/Photo.cs @@ -1,21 +1,19 @@ -using System; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.Links +namespace JsonApiDotNetCoreTests.IntegrationTests.Links; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Links")] +public sealed class Photo : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - [Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Links")] - public sealed class Photo : Identifiable - { - [Attr] - public string? Url { get; set; } + [Attr] + public string? Url { get; set; } - [HasOne] - public PhotoLocation? Location { get; set; } + [HasOne] + public PhotoLocation? Location { get; set; } - [HasOne] - public PhotoAlbum? Album { get; set; } - } + [HasOne] + public PhotoAlbum? Album { get; set; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/PhotoAlbum.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/PhotoAlbum.cs index 5e9ac09105..32bdf79a40 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/PhotoAlbum.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/PhotoAlbum.cs @@ -1,19 +1,16 @@ -using System; -using System.Collections.Generic; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.Links +namespace JsonApiDotNetCoreTests.IntegrationTests.Links; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Links")] +public sealed class PhotoAlbum : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - [Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Links")] - public sealed class PhotoAlbum : Identifiable - { - [Attr] - public string Name { get; set; } = null!; + [Attr] + public string Name { get; set; } = null!; - [HasMany] - public ISet Photos { get; set; } = new HashSet(); - } + [HasMany] + public ISet Photos { get; set; } = new HashSet(); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/PhotoLocation.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/PhotoLocation.cs index 42d671fc13..ddd1489cbe 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/PhotoLocation.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/PhotoLocation.cs @@ -2,26 +2,25 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.Links +namespace JsonApiDotNetCoreTests.IntegrationTests.Links; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[ResourceLinks(TopLevelLinks = LinkTypes.None, ResourceLinks = LinkTypes.None, RelationshipLinks = LinkTypes.Related)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Links")] +public sealed class PhotoLocation : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - [ResourceLinks(TopLevelLinks = LinkTypes.None, ResourceLinks = LinkTypes.None, RelationshipLinks = LinkTypes.Related)] - [Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Links")] - public sealed class PhotoLocation : Identifiable - { - [Attr] - public string? PlaceName { get; set; } + [Attr] + public string? PlaceName { get; set; } - [Attr] - public double Latitude { get; set; } + [Attr] + public double Latitude { get; set; } - [Attr] - public double Longitude { get; set; } + [Attr] + public double Longitude { get; set; } - [HasOne] - public Photo Photo { get; set; } = null!; + [HasOne] + public Photo Photo { get; set; } = null!; - [HasOne(Links = LinkTypes.None)] - public PhotoAlbum? Album { get; set; } - } + [HasOne(Links = LinkTypes.None)] + public PhotoAlbum? Album { get; set; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs index 5213db54dd..be55eec1d1 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs @@ -1,7 +1,4 @@ -using System.Linq; using System.Net; -using System.Net.Http; -using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; @@ -11,471 +8,469 @@ using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.Links +namespace JsonApiDotNetCoreTests.IntegrationTests.Links; + +public sealed class RelativeLinksWithNamespaceTests : IClassFixture, LinksDbContext>> { - public sealed class RelativeLinksWithNamespaceTests - : IClassFixture, LinksDbContext>> + private const string HostPrefix = ""; + private const string PathPrefix = "/api"; + + private readonly IntegrationTestContext, LinksDbContext> _testContext; + private readonly LinksFakers _fakers = new(); + + public RelativeLinksWithNamespaceTests(IntegrationTestContext, LinksDbContext> testContext) { - private const string HostPrefix = ""; - private const string PathPrefix = "/api"; + _testContext = testContext; - private readonly IntegrationTestContext, LinksDbContext> _testContext; - private readonly LinksFakers _fakers = new(); + testContext.UseController(); + testContext.UseController(); - public RelativeLinksWithNamespaceTests(IntegrationTestContext, LinksDbContext> testContext) + testContext.ConfigureServicesAfterStartup(services => { - _testContext = testContext; - - testContext.UseController(); - testContext.UseController(); + services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); + }); - testContext.ConfigureServicesAfterStartup(services => - { - services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); - }); + var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); + options.IncludeTotalResourceCount = true; + } - var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); - options.IncludeTotalResourceCount = true; - } + [Fact] + public async Task Get_primary_resource_by_ID_returns_relative_links() + { + // Arrange + PhotoAlbum album = _fakers.PhotoAlbum.Generate(); - [Fact] - public async Task Get_primary_resource_by_ID_returns_relative_links() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - PhotoAlbum album = _fakers.PhotoAlbum.Generate(); + dbContext.PhotoAlbums.Add(album); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.PhotoAlbums.Add(album); - await dbContext.SaveChangesAsync(); - }); + string route = $"{PathPrefix}/photoAlbums/{album.StringId}"; - string route = $"{PathPrefix}/photoAlbums/{album.StringId}"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); - responseDocument.Links.ShouldNotBeNull(); - responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.Related.Should().BeNull(); - responseDocument.Links.First.Should().BeNull(); - responseDocument.Links.Last.Should().BeNull(); - responseDocument.Links.Prev.Should().BeNull(); - responseDocument.Links.Next.Should().BeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Links.Self.Should().Be($"{HostPrefix}{route}"); + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photos").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{HostPrefix}{route}/relationships/photos"); + value.Links.Related.Should().Be($"{HostPrefix}{route}/photos"); + }); + } - responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photos").With(value => - { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - value.Links.Self.Should().Be($"{HostPrefix}{route}/relationships/photos"); - value.Links.Related.Should().Be($"{HostPrefix}{route}/photos"); - }); - } + [Fact] + public async Task Get_primary_resources_with_include_returns_relative_links() + { + // Arrange + PhotoAlbum album = _fakers.PhotoAlbum.Generate(); + album.Photos = _fakers.Photo.Generate(1).ToHashSet(); - [Fact] - public async Task Get_primary_resources_with_include_returns_relative_links() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - PhotoAlbum album = _fakers.PhotoAlbum.Generate(); - album.Photos = _fakers.Photo.Generate(1).ToHashSet(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.PhotoAlbums.Add(album); - await dbContext.SaveChangesAsync(); - }); + await dbContext.ClearTableAsync(); + dbContext.PhotoAlbums.Add(album); + await dbContext.SaveChangesAsync(); + }); - string route = $"{PathPrefix}/photoAlbums?include=photos"; + const string route = $"{PathPrefix}/photoAlbums?include=photos"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.ShouldNotBeNull(); - responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.Related.Should().BeNull(); - responseDocument.Links.First.Should().Be(responseDocument.Links.Self); - responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); - responseDocument.Links.Prev.Should().BeNull(); - responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().Be(responseDocument.Links.Self); + responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.ShouldHaveCount(1); - responseDocument.Data.ManyValue[0].With(resource => - { - string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{album.StringId}"; + responseDocument.Data.ManyValue[0].With(resource => + { + string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{album.StringId}"; - resource.Links.ShouldNotBeNull(); - resource.Links.Self.Should().Be(albumLink); + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(albumLink); - resource.Relationships.ShouldContainKey("photos").With(value => - { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); - value.Links.Related.Should().Be($"{albumLink}/photos"); - }); + resource.Relationships.ShouldContainKey("photos").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); + value.Links.Related.Should().Be($"{albumLink}/photos"); }); + }); - responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included.ShouldHaveCount(1); - responseDocument.Included[0].With(resource => - { - string photoLink = $"{HostPrefix}{PathPrefix}/photos/{album.Photos.ElementAt(0).StringId}"; + responseDocument.Included[0].With(resource => + { + string photoLink = $"{HostPrefix}{PathPrefix}/photos/{album.Photos.ElementAt(0).StringId}"; - resource.Links.ShouldNotBeNull(); - resource.Links.Self.Should().Be(photoLink); + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(photoLink); - resource.Relationships.ShouldContainKey("album").With(value => - { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - value.Links.Self.Should().Be($"{photoLink}/relationships/album"); - value.Links.Related.Should().Be($"{photoLink}/album"); - }); + resource.Relationships.ShouldContainKey("album").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{photoLink}/relationships/album"); + value.Links.Related.Should().Be($"{photoLink}/album"); }); - } + }); + } + + [Fact] + public async Task Get_secondary_resource_returns_relative_links() + { + // Arrange + Photo photo = _fakers.Photo.Generate(); + photo.Album = _fakers.PhotoAlbum.Generate(); - [Fact] - public async Task Get_secondary_resource_returns_relative_links() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - Photo photo = _fakers.Photo.Generate(); - photo.Album = _fakers.PhotoAlbum.Generate(); + dbContext.Photos.Add(photo); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Photos.Add(photo); - await dbContext.SaveChangesAsync(); - }); + string route = $"{PathPrefix}/photos/{photo.StringId}/album"; - string route = $"{PathPrefix}/photos/{photo.StringId}/album"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); - responseDocument.Links.ShouldNotBeNull(); - responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.Related.Should().BeNull(); - responseDocument.Links.First.Should().BeNull(); - responseDocument.Links.Last.Should().BeNull(); - responseDocument.Links.Prev.Should().BeNull(); - responseDocument.Links.Next.Should().BeNull(); + string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{photo.Album.StringId}"; - string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{photo.Album.StringId}"; + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.Self.Should().Be(albumLink); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Links.Self.Should().Be(albumLink); + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photos").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); + value.Links.Related.Should().Be($"{albumLink}/photos"); + }); + } - responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photos").With(value => - { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); - value.Links.Related.Should().Be($"{albumLink}/photos"); - }); - } + [Fact] + public async Task Get_secondary_resources_returns_relative_links() + { + // Arrange + PhotoAlbum album = _fakers.PhotoAlbum.Generate(); + album.Photos = _fakers.Photo.Generate(1).ToHashSet(); - [Fact] - public async Task Get_secondary_resources_returns_relative_links() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - PhotoAlbum album = _fakers.PhotoAlbum.Generate(); - album.Photos = _fakers.Photo.Generate(1).ToHashSet(); + dbContext.PhotoAlbums.Add(album); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.PhotoAlbums.Add(album); - await dbContext.SaveChangesAsync(); - }); + string route = $"{PathPrefix}/photoAlbums/{album.StringId}/photos"; - string route = $"{PathPrefix}/photoAlbums/{album.StringId}/photos"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().Be(responseDocument.Links.Self); + responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); - responseDocument.Links.ShouldNotBeNull(); - responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.Related.Should().BeNull(); - responseDocument.Links.First.Should().Be(responseDocument.Links.Self); - responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); - responseDocument.Links.Prev.Should().BeNull(); - responseDocument.Links.Next.Should().BeNull(); + responseDocument.Data.ManyValue.ShouldHaveCount(1); - responseDocument.Data.ManyValue.ShouldHaveCount(1); - - responseDocument.Data.ManyValue[0].With(resource => - { - string photoLink = $"{HostPrefix}{PathPrefix}/photos/{album.Photos.ElementAt(0).StringId}"; + responseDocument.Data.ManyValue[0].With(resource => + { + string photoLink = $"{HostPrefix}{PathPrefix}/photos/{album.Photos.ElementAt(0).StringId}"; - resource.Links.ShouldNotBeNull(); - resource.Links.Self.Should().Be(photoLink); + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(photoLink); - resource.Relationships.ShouldContainKey("album").With(value => - { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - value.Links.Self.Should().Be($"{photoLink}/relationships/album"); - value.Links.Related.Should().Be($"{photoLink}/album"); - }); + resource.Relationships.ShouldContainKey("album").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{photoLink}/relationships/album"); + value.Links.Related.Should().Be($"{photoLink}/album"); }); - } + }); + } + + [Fact] + public async Task Get_ToOne_relationship_returns_relative_links() + { + // Arrange + Photo photo = _fakers.Photo.Generate(); + photo.Album = _fakers.PhotoAlbum.Generate(); - [Fact] - public async Task Get_ToOne_relationship_returns_relative_links() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - Photo photo = _fakers.Photo.Generate(); - photo.Album = _fakers.PhotoAlbum.Generate(); + dbContext.Photos.Add(photo); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Photos.Add(photo); - await dbContext.SaveChangesAsync(); - }); + string route = $"{PathPrefix}/photos/{photo.StringId}/relationships/album"; - string route = $"{PathPrefix}/photos/{photo.StringId}/relationships/album"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); + responseDocument.Links.Related.Should().Be($"{HostPrefix}{PathPrefix}/photos/{photo.StringId}/album"); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); - responseDocument.Links.ShouldNotBeNull(); - responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.Related.Should().Be($"{HostPrefix}{PathPrefix}/photos/{photo.StringId}/album"); - responseDocument.Links.First.Should().BeNull(); - responseDocument.Links.Last.Should().BeNull(); - responseDocument.Links.Prev.Should().BeNull(); - responseDocument.Links.Next.Should().BeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.Should().BeNull(); + responseDocument.Data.SingleValue.Relationships.Should().BeNull(); + } - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Links.Should().BeNull(); - responseDocument.Data.SingleValue.Relationships.Should().BeNull(); - } + [Fact] + public async Task Get_ToMany_relationship_returns_relative_links() + { + // Arrange + PhotoAlbum album = _fakers.PhotoAlbum.Generate(); + album.Photos = _fakers.Photo.Generate(1).ToHashSet(); - [Fact] - public async Task Get_ToMany_relationship_returns_relative_links() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - PhotoAlbum album = _fakers.PhotoAlbum.Generate(); - album.Photos = _fakers.Photo.Generate(1).ToHashSet(); + dbContext.PhotoAlbums.Add(album); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.PhotoAlbums.Add(album); - await dbContext.SaveChangesAsync(); - }); - - string route = $"{PathPrefix}/photoAlbums/{album.StringId}/relationships/photos"; + string route = $"{PathPrefix}/photoAlbums/{album.StringId}/relationships/photos"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.ShouldNotBeNull(); - responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.Related.Should().Be($"{HostPrefix}{PathPrefix}/photoAlbums/{album.StringId}/photos"); - responseDocument.Links.First.Should().Be(responseDocument.Links.Self); - responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); - responseDocument.Links.Prev.Should().BeNull(); - responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); + responseDocument.Links.Related.Should().Be($"{HostPrefix}{PathPrefix}/photoAlbums/{album.StringId}/photos"); + responseDocument.Links.First.Should().Be(responseDocument.Links.Self); + responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); - responseDocument.Data.ManyValue.ShouldHaveCount(1); - responseDocument.Data.ManyValue[0].Links.Should().BeNull(); - responseDocument.Data.ManyValue[0].Relationships.Should().BeNull(); - } + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Links.Should().BeNull(); + responseDocument.Data.ManyValue[0].Relationships.Should().BeNull(); + } - [Fact] - public async Task Create_resource_with_side_effects_and_include_returns_relative_links() - { - // Arrange - Photo existingPhoto = _fakers.Photo.Generate(); + [Fact] + public async Task Create_resource_with_side_effects_and_include_returns_relative_links() + { + // Arrange + Photo existingPhoto = _fakers.Photo.Generate(); - string newAlbumName = _fakers.PhotoAlbum.Generate().Name; + string newAlbumName = _fakers.PhotoAlbum.Generate().Name; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Photos.Add(existingPhoto); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Photos.Add(existingPhoto); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new { - data = new + type = "photoAlbums", + attributes = new { - type = "photoAlbums", - attributes = new - { - name = newAlbumName - }, - relationships = new + name = newAlbumName + }, + relationships = new + { + photos = new { - photos = new + data = new[] { - data = new[] + new { - new - { - type = "photos", - id = existingPhoto.StringId - } + type = "photos", + id = existingPhoto.StringId } } } } - }; + } + }; - string route = $"{PathPrefix}/photoAlbums?include=photos"; + const string route = $"{PathPrefix}/photoAlbums?include=photos"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Links.ShouldNotBeNull(); - responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.Related.Should().BeNull(); - responseDocument.Links.First.Should().BeNull(); - responseDocument.Links.Last.Should().BeNull(); - responseDocument.Links.Prev.Should().BeNull(); - responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); - string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{responseDocument.Data.SingleValue.Id}"; + string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{responseDocument.Data.SingleValue.Id}"; - responseDocument.Data.SingleValue.Links.Self.Should().Be(albumLink); + responseDocument.Data.SingleValue.Links.Self.Should().Be(albumLink); - responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photos").With(value => - { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); - value.Links.Related.Should().Be($"{albumLink}/photos"); - }); + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photos").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); + value.Links.Related.Should().Be($"{albumLink}/photos"); + }); - responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included.ShouldHaveCount(1); - responseDocument.Included[0].With(resource => - { - string photoLink = $"{HostPrefix}{PathPrefix}/photos/{existingPhoto.StringId}"; + responseDocument.Included[0].With(resource => + { + string photoLink = $"{HostPrefix}{PathPrefix}/photos/{existingPhoto.StringId}"; - resource.Links.ShouldNotBeNull(); - resource.Links.Self.Should().Be(photoLink); + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(photoLink); - resource.Relationships.ShouldContainKey("album").With(value => - { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - value.Links.Self.Should().Be($"{photoLink}/relationships/album"); - value.Links.Related.Should().Be($"{photoLink}/album"); - }); + resource.Relationships.ShouldContainKey("album").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{photoLink}/relationships/album"); + value.Links.Related.Should().Be($"{photoLink}/album"); }); - } + }); + } - [Fact] - public async Task Update_resource_with_side_effects_and_include_returns_relative_links() - { - // Arrange - Photo existingPhoto = _fakers.Photo.Generate(); - PhotoAlbum existingAlbum = _fakers.PhotoAlbum.Generate(); + [Fact] + public async Task Update_resource_with_side_effects_and_include_returns_relative_links() + { + // Arrange + Photo existingPhoto = _fakers.Photo.Generate(); + PhotoAlbum existingAlbum = _fakers.PhotoAlbum.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddInRange(existingPhoto, existingAlbum); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingPhoto, existingAlbum); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new { - data = new + type = "photos", + id = existingPhoto.StringId, + relationships = new { - type = "photos", - id = existingPhoto.StringId, - relationships = new + album = new { - album = new + data = new { - data = new - { - type = "photoAlbums", - id = existingAlbum.StringId - } + type = "photoAlbums", + id = existingAlbum.StringId } } } - }; + } + }; - string route = $"{PathPrefix}/photos/{existingPhoto.StringId}?include=album"; + string route = $"{PathPrefix}/photos/{existingPhoto.StringId}?include=album"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.ShouldNotBeNull(); - responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.Related.Should().BeNull(); - responseDocument.Links.First.Should().BeNull(); - responseDocument.Links.Last.Should().BeNull(); - responseDocument.Links.Prev.Should().BeNull(); - responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); - string photoLink = $"{HostPrefix}{PathPrefix}/photos/{existingPhoto.StringId}"; + string photoLink = $"{HostPrefix}{PathPrefix}/photos/{existingPhoto.StringId}"; - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Links.Self.Should().Be(photoLink); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.Self.Should().Be(photoLink); - responseDocument.Data.SingleValue.Relationships.ShouldContainKey("album").With(value => - { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - value.Links.Self.Should().Be($"{photoLink}/relationships/album"); - value.Links.Related.Should().Be($"{photoLink}/album"); - }); + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("album").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{photoLink}/relationships/album"); + value.Links.Related.Should().Be($"{photoLink}/album"); + }); - responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included.ShouldHaveCount(1); - responseDocument.Included[0].With(resource => - { - string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{existingAlbum.StringId}"; + responseDocument.Included[0].With(resource => + { + string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{existingAlbum.StringId}"; - resource.Links.ShouldNotBeNull(); - resource.Links.Self.Should().Be(albumLink); + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(albumLink); - resource.Relationships.ShouldContainKey("photos").With(value => - { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); - value.Links.Related.Should().Be($"{albumLink}/photos"); - }); + resource.Relationships.ShouldContainKey("photos").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); + value.Links.Related.Should().Be($"{albumLink}/photos"); }); - } + }); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithoutNamespaceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithoutNamespaceTests.cs index 7fb86278c8..93e85cd0e1 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithoutNamespaceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithoutNamespaceTests.cs @@ -1,7 +1,4 @@ -using System.Linq; using System.Net; -using System.Net.Http; -using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; @@ -11,471 +8,469 @@ using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.Links +namespace JsonApiDotNetCoreTests.IntegrationTests.Links; + +public sealed class RelativeLinksWithoutNamespaceTests : IClassFixture, LinksDbContext>> { - public sealed class RelativeLinksWithoutNamespaceTests - : IClassFixture, LinksDbContext>> + private const string HostPrefix = ""; + private const string PathPrefix = ""; + + private readonly IntegrationTestContext, LinksDbContext> _testContext; + private readonly LinksFakers _fakers = new(); + + public RelativeLinksWithoutNamespaceTests(IntegrationTestContext, LinksDbContext> testContext) { - private const string HostPrefix = ""; - private const string PathPrefix = ""; + _testContext = testContext; - private readonly IntegrationTestContext, LinksDbContext> _testContext; - private readonly LinksFakers _fakers = new(); + testContext.UseController(); + testContext.UseController(); - public RelativeLinksWithoutNamespaceTests(IntegrationTestContext, LinksDbContext> testContext) + testContext.ConfigureServicesAfterStartup(services => { - _testContext = testContext; - - testContext.UseController(); - testContext.UseController(); + services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); + }); - testContext.ConfigureServicesAfterStartup(services => - { - services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); - }); + var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); + options.IncludeTotalResourceCount = true; + } - var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); - options.IncludeTotalResourceCount = true; - } + [Fact] + public async Task Get_primary_resource_by_ID_returns_relative_links() + { + // Arrange + PhotoAlbum album = _fakers.PhotoAlbum.Generate(); - [Fact] - public async Task Get_primary_resource_by_ID_returns_relative_links() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - PhotoAlbum album = _fakers.PhotoAlbum.Generate(); + dbContext.PhotoAlbums.Add(album); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.PhotoAlbums.Add(album); - await dbContext.SaveChangesAsync(); - }); + string route = $"{PathPrefix}/photoAlbums/{album.StringId}"; - string route = $"{PathPrefix}/photoAlbums/{album.StringId}"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); - responseDocument.Links.ShouldNotBeNull(); - responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.Related.Should().BeNull(); - responseDocument.Links.First.Should().BeNull(); - responseDocument.Links.Last.Should().BeNull(); - responseDocument.Links.Prev.Should().BeNull(); - responseDocument.Links.Next.Should().BeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Links.Self.Should().Be($"{HostPrefix}{route}"); + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photos").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{HostPrefix}{route}/relationships/photos"); + value.Links.Related.Should().Be($"{HostPrefix}{route}/photos"); + }); + } - responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photos").With(value => - { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - value.Links.Self.Should().Be($"{HostPrefix}{route}/relationships/photos"); - value.Links.Related.Should().Be($"{HostPrefix}{route}/photos"); - }); - } + [Fact] + public async Task Get_primary_resources_with_include_returns_relative_links() + { + // Arrange + PhotoAlbum album = _fakers.PhotoAlbum.Generate(); + album.Photos = _fakers.Photo.Generate(1).ToHashSet(); - [Fact] - public async Task Get_primary_resources_with_include_returns_relative_links() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - PhotoAlbum album = _fakers.PhotoAlbum.Generate(); - album.Photos = _fakers.Photo.Generate(1).ToHashSet(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.PhotoAlbums.Add(album); - await dbContext.SaveChangesAsync(); - }); + await dbContext.ClearTableAsync(); + dbContext.PhotoAlbums.Add(album); + await dbContext.SaveChangesAsync(); + }); - string route = $"{PathPrefix}/photoAlbums?include=photos"; + const string route = $"{PathPrefix}/photoAlbums?include=photos"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.ShouldNotBeNull(); - responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.Related.Should().BeNull(); - responseDocument.Links.First.Should().Be(responseDocument.Links.Self); - responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); - responseDocument.Links.Prev.Should().BeNull(); - responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().Be(responseDocument.Links.Self); + responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.ShouldHaveCount(1); - responseDocument.Data.ManyValue[0].With(resource => - { - string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{album.StringId}"; + responseDocument.Data.ManyValue[0].With(resource => + { + string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{album.StringId}"; - resource.Links.ShouldNotBeNull(); - resource.Links.Self.Should().Be(albumLink); + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(albumLink); - resource.Relationships.ShouldContainKey("photos").With(value => - { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); - value.Links.Related.Should().Be($"{albumLink}/photos"); - }); + resource.Relationships.ShouldContainKey("photos").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); + value.Links.Related.Should().Be($"{albumLink}/photos"); }); + }); - responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included.ShouldHaveCount(1); - responseDocument.Included[0].With(resource => - { - string photoLink = $"{HostPrefix}{PathPrefix}/photos/{album.Photos.ElementAt(0).StringId}"; + responseDocument.Included[0].With(resource => + { + string photoLink = $"{HostPrefix}{PathPrefix}/photos/{album.Photos.ElementAt(0).StringId}"; - resource.Links.ShouldNotBeNull(); - resource.Links.Self.Should().Be(photoLink); + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(photoLink); - resource.Relationships.ShouldContainKey("album").With(value => - { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - value.Links.Self.Should().Be($"{photoLink}/relationships/album"); - value.Links.Related.Should().Be($"{photoLink}/album"); - }); + resource.Relationships.ShouldContainKey("album").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{photoLink}/relationships/album"); + value.Links.Related.Should().Be($"{photoLink}/album"); }); - } + }); + } + + [Fact] + public async Task Get_secondary_resource_returns_relative_links() + { + // Arrange + Photo photo = _fakers.Photo.Generate(); + photo.Album = _fakers.PhotoAlbum.Generate(); - [Fact] - public async Task Get_secondary_resource_returns_relative_links() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - Photo photo = _fakers.Photo.Generate(); - photo.Album = _fakers.PhotoAlbum.Generate(); + dbContext.Photos.Add(photo); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Photos.Add(photo); - await dbContext.SaveChangesAsync(); - }); + string route = $"{PathPrefix}/photos/{photo.StringId}/album"; - string route = $"{PathPrefix}/photos/{photo.StringId}/album"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); - responseDocument.Links.ShouldNotBeNull(); - responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.Related.Should().BeNull(); - responseDocument.Links.First.Should().BeNull(); - responseDocument.Links.Last.Should().BeNull(); - responseDocument.Links.Prev.Should().BeNull(); - responseDocument.Links.Next.Should().BeNull(); + string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{photo.Album.StringId}"; - string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{photo.Album.StringId}"; + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.Self.Should().Be(albumLink); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Links.Self.Should().Be(albumLink); + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photos").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); + value.Links.Related.Should().Be($"{albumLink}/photos"); + }); + } - responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photos").With(value => - { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); - value.Links.Related.Should().Be($"{albumLink}/photos"); - }); - } + [Fact] + public async Task Get_secondary_resources_returns_relative_links() + { + // Arrange + PhotoAlbum album = _fakers.PhotoAlbum.Generate(); + album.Photos = _fakers.Photo.Generate(1).ToHashSet(); - [Fact] - public async Task Get_secondary_resources_returns_relative_links() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - PhotoAlbum album = _fakers.PhotoAlbum.Generate(); - album.Photos = _fakers.Photo.Generate(1).ToHashSet(); + dbContext.PhotoAlbums.Add(album); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.PhotoAlbums.Add(album); - await dbContext.SaveChangesAsync(); - }); + string route = $"{PathPrefix}/photoAlbums/{album.StringId}/photos"; - string route = $"{PathPrefix}/photoAlbums/{album.StringId}/photos"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().Be(responseDocument.Links.Self); + responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); - responseDocument.Links.ShouldNotBeNull(); - responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.Related.Should().BeNull(); - responseDocument.Links.First.Should().Be(responseDocument.Links.Self); - responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); - responseDocument.Links.Prev.Should().BeNull(); - responseDocument.Links.Next.Should().BeNull(); + responseDocument.Data.ManyValue.ShouldHaveCount(1); - responseDocument.Data.ManyValue.ShouldHaveCount(1); - - responseDocument.Data.ManyValue[0].With(resource => - { - string photoLink = $"{HostPrefix}{PathPrefix}/photos/{album.Photos.ElementAt(0).StringId}"; + responseDocument.Data.ManyValue[0].With(resource => + { + string photoLink = $"{HostPrefix}{PathPrefix}/photos/{album.Photos.ElementAt(0).StringId}"; - resource.Links.ShouldNotBeNull(); - resource.Links.Self.Should().Be(photoLink); + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(photoLink); - resource.Relationships.ShouldContainKey("album").With(value => - { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - value.Links.Self.Should().Be($"{photoLink}/relationships/album"); - value.Links.Related.Should().Be($"{photoLink}/album"); - }); + resource.Relationships.ShouldContainKey("album").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{photoLink}/relationships/album"); + value.Links.Related.Should().Be($"{photoLink}/album"); }); - } + }); + } + + [Fact] + public async Task Get_ToOne_relationship_returns_relative_links() + { + // Arrange + Photo photo = _fakers.Photo.Generate(); + photo.Album = _fakers.PhotoAlbum.Generate(); - [Fact] - public async Task Get_ToOne_relationship_returns_relative_links() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - Photo photo = _fakers.Photo.Generate(); - photo.Album = _fakers.PhotoAlbum.Generate(); + dbContext.Photos.Add(photo); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Photos.Add(photo); - await dbContext.SaveChangesAsync(); - }); + string route = $"{PathPrefix}/photos/{photo.StringId}/relationships/album"; - string route = $"{PathPrefix}/photos/{photo.StringId}/relationships/album"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); + responseDocument.Links.Related.Should().Be($"{HostPrefix}{PathPrefix}/photos/{photo.StringId}/album"); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); - responseDocument.Links.ShouldNotBeNull(); - responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.Related.Should().Be($"{HostPrefix}{PathPrefix}/photos/{photo.StringId}/album"); - responseDocument.Links.First.Should().BeNull(); - responseDocument.Links.Last.Should().BeNull(); - responseDocument.Links.Prev.Should().BeNull(); - responseDocument.Links.Next.Should().BeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.Should().BeNull(); + responseDocument.Data.SingleValue.Relationships.Should().BeNull(); + } - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Links.Should().BeNull(); - responseDocument.Data.SingleValue.Relationships.Should().BeNull(); - } + [Fact] + public async Task Get_ToMany_relationship_returns_relative_links() + { + // Arrange + PhotoAlbum album = _fakers.PhotoAlbum.Generate(); + album.Photos = _fakers.Photo.Generate(1).ToHashSet(); - [Fact] - public async Task Get_ToMany_relationship_returns_relative_links() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - PhotoAlbum album = _fakers.PhotoAlbum.Generate(); - album.Photos = _fakers.Photo.Generate(1).ToHashSet(); + dbContext.PhotoAlbums.Add(album); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.PhotoAlbums.Add(album); - await dbContext.SaveChangesAsync(); - }); - - string route = $"{PathPrefix}/photoAlbums/{album.StringId}/relationships/photos"; + string route = $"{PathPrefix}/photoAlbums/{album.StringId}/relationships/photos"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.ShouldNotBeNull(); - responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.Related.Should().Be($"{HostPrefix}{PathPrefix}/photoAlbums/{album.StringId}/photos"); - responseDocument.Links.First.Should().Be(responseDocument.Links.Self); - responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); - responseDocument.Links.Prev.Should().BeNull(); - responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); + responseDocument.Links.Related.Should().Be($"{HostPrefix}{PathPrefix}/photoAlbums/{album.StringId}/photos"); + responseDocument.Links.First.Should().Be(responseDocument.Links.Self); + responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); - responseDocument.Data.ManyValue.ShouldHaveCount(1); - responseDocument.Data.ManyValue[0].Links.Should().BeNull(); - responseDocument.Data.ManyValue[0].Relationships.Should().BeNull(); - } + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Links.Should().BeNull(); + responseDocument.Data.ManyValue[0].Relationships.Should().BeNull(); + } - [Fact] - public async Task Create_resource_with_side_effects_and_include_returns_relative_links() - { - // Arrange - Photo existingPhoto = _fakers.Photo.Generate(); + [Fact] + public async Task Create_resource_with_side_effects_and_include_returns_relative_links() + { + // Arrange + Photo existingPhoto = _fakers.Photo.Generate(); - string newAlbumName = _fakers.PhotoAlbum.Generate().Name; + string newAlbumName = _fakers.PhotoAlbum.Generate().Name; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Photos.Add(existingPhoto); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Photos.Add(existingPhoto); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new { - data = new + type = "photoAlbums", + attributes = new { - type = "photoAlbums", - attributes = new - { - name = newAlbumName - }, - relationships = new + name = newAlbumName + }, + relationships = new + { + photos = new { - photos = new + data = new[] { - data = new[] + new { - new - { - type = "photos", - id = existingPhoto.StringId - } + type = "photos", + id = existingPhoto.StringId } } } } - }; + } + }; - string route = $"{PathPrefix}/photoAlbums?include=photos"; + const string route = $"{PathPrefix}/photoAlbums?include=photos"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Links.ShouldNotBeNull(); - responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.Related.Should().BeNull(); - responseDocument.Links.First.Should().BeNull(); - responseDocument.Links.Last.Should().BeNull(); - responseDocument.Links.Prev.Should().BeNull(); - responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); - string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{responseDocument.Data.SingleValue.Id}"; + string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{responseDocument.Data.SingleValue.Id}"; - responseDocument.Data.SingleValue.Links.Self.Should().Be(albumLink); + responseDocument.Data.SingleValue.Links.Self.Should().Be(albumLink); - responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photos").With(value => - { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); - value.Links.Related.Should().Be($"{albumLink}/photos"); - }); + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photos").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); + value.Links.Related.Should().Be($"{albumLink}/photos"); + }); - responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included.ShouldHaveCount(1); - responseDocument.Included[0].With(resource => - { - string photoLink = $"{HostPrefix}{PathPrefix}/photos/{existingPhoto.StringId}"; + responseDocument.Included[0].With(resource => + { + string photoLink = $"{HostPrefix}{PathPrefix}/photos/{existingPhoto.StringId}"; - resource.Links.ShouldNotBeNull(); - resource.Links.Self.Should().Be(photoLink); + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(photoLink); - resource.Relationships.ShouldContainKey("album").With(value => - { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - value.Links.Self.Should().Be($"{photoLink}/relationships/album"); - value.Links.Related.Should().Be($"{photoLink}/album"); - }); + resource.Relationships.ShouldContainKey("album").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{photoLink}/relationships/album"); + value.Links.Related.Should().Be($"{photoLink}/album"); }); - } + }); + } - [Fact] - public async Task Update_resource_with_side_effects_and_include_returns_relative_links() - { - // Arrange - Photo existingPhoto = _fakers.Photo.Generate(); - PhotoAlbum existingAlbum = _fakers.PhotoAlbum.Generate(); + [Fact] + public async Task Update_resource_with_side_effects_and_include_returns_relative_links() + { + // Arrange + Photo existingPhoto = _fakers.Photo.Generate(); + PhotoAlbum existingAlbum = _fakers.PhotoAlbum.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddInRange(existingPhoto, existingAlbum); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingPhoto, existingAlbum); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new { - data = new + type = "photos", + id = existingPhoto.StringId, + relationships = new { - type = "photos", - id = existingPhoto.StringId, - relationships = new + album = new { - album = new + data = new { - data = new - { - type = "photoAlbums", - id = existingAlbum.StringId - } + type = "photoAlbums", + id = existingAlbum.StringId } } } - }; + } + }; - string route = $"{PathPrefix}/photos/{existingPhoto.StringId}?include=album"; + string route = $"{PathPrefix}/photos/{existingPhoto.StringId}?include=album"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.ShouldNotBeNull(); - responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.Related.Should().BeNull(); - responseDocument.Links.First.Should().BeNull(); - responseDocument.Links.Last.Should().BeNull(); - responseDocument.Links.Prev.Should().BeNull(); - responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); - string photoLink = $"{HostPrefix}{PathPrefix}/photos/{existingPhoto.StringId}"; + string photoLink = $"{HostPrefix}{PathPrefix}/photos/{existingPhoto.StringId}"; - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Links.Self.Should().Be(photoLink); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.Self.Should().Be(photoLink); - responseDocument.Data.SingleValue.Relationships.ShouldContainKey("album").With(value => - { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - value.Links.Self.Should().Be($"{photoLink}/relationships/album"); - value.Links.Related.Should().Be($"{photoLink}/album"); - }); + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("album").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{photoLink}/relationships/album"); + value.Links.Related.Should().Be($"{photoLink}/album"); + }); - responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included.ShouldHaveCount(1); - responseDocument.Included[0].With(resource => - { - string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{existingAlbum.StringId}"; + responseDocument.Included[0].With(resource => + { + string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{existingAlbum.StringId}"; - resource.Links.ShouldNotBeNull(); - resource.Links.Self.Should().Be(albumLink); + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(albumLink); - resource.Relationships.ShouldContainKey("photos").With(value => - { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); - value.Links.Related.Should().Be($"{albumLink}/photos"); - }); + resource.Relationships.ShouldContainKey("photos").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); + value.Links.Related.Should().Be($"{albumLink}/photos"); }); - } + }); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/AuditEntry.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/AuditEntry.cs index 287478b9da..cbd1dd0989 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/AuditEntry.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/AuditEntry.cs @@ -1,18 +1,16 @@ -using System; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.Logging +namespace JsonApiDotNetCoreTests.IntegrationTests.Logging; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Logging")] +public sealed class AuditEntry : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - [Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Logging")] - public sealed class AuditEntry : Identifiable - { - [Attr] - public string UserName { get; set; } = null!; + [Attr] + public string UserName { get; set; } = null!; - [Attr] - public DateTimeOffset CreatedAt { get; set; } - } + [Attr] + public DateTimeOffset CreatedAt { get; set; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingDbContext.cs index d99bf62f8a..39c497616c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingDbContext.cs @@ -1,16 +1,15 @@ using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; -namespace JsonApiDotNetCoreTests.IntegrationTests.Logging +namespace JsonApiDotNetCoreTests.IntegrationTests.Logging; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class LoggingDbContext : DbContext { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class LoggingDbContext : DbContext - { - public DbSet AuditEntries => Set(); + public DbSet AuditEntries => Set(); - public LoggingDbContext(DbContextOptions options) - : base(options) - { - } + public LoggingDbContext(DbContextOptions options) + : base(options) + { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingFakers.cs index f2e17e5494..1c467dc782 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingFakers.cs @@ -1,21 +1,19 @@ -using System; using Bogus; using TestBuildingBlocks; // @formatter:wrap_chained_method_calls chop_always // @formatter:keep_existing_linebreaks true -namespace JsonApiDotNetCoreTests.IntegrationTests.Logging +namespace JsonApiDotNetCoreTests.IntegrationTests.Logging; + +internal sealed class LoggingFakers : FakerContainer { - internal sealed class LoggingFakers : FakerContainer - { - private readonly Lazy> _lazyAuditEntryFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(auditEntry => auditEntry.UserName, faker => faker.Internet.UserName()) - .RuleFor(auditEntry => auditEntry.CreatedAt, faker => faker.Date.PastOffset() - .TruncateToWholeMilliseconds())); + private readonly Lazy> _lazyAuditEntryFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(auditEntry => auditEntry.UserName, faker => faker.Internet.UserName()) + .RuleFor(auditEntry => auditEntry.CreatedAt, faker => faker.Date.PastOffset() + .TruncateToWholeMilliseconds())); - public Faker AuditEntry => _lazyAuditEntryFaker.Value; - } + public Faker AuditEntry => _lazyAuditEntryFaker.Value; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingTests.cs index 0ac74170db..f7d800a2af 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingTests.cs @@ -1,122 +1,118 @@ -using System; using System.Net; -using System.Net.Http; -using System.Threading.Tasks; using FluentAssertions; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.Logging +namespace JsonApiDotNetCoreTests.IntegrationTests.Logging; + +public sealed class LoggingTests : IClassFixture, LoggingDbContext>> { - public sealed class LoggingTests : IClassFixture, LoggingDbContext>> - { - private readonly IntegrationTestContext, LoggingDbContext> _testContext; - private readonly LoggingFakers _fakers = new(); + private readonly IntegrationTestContext, LoggingDbContext> _testContext; + private readonly LoggingFakers _fakers = new(); - public LoggingTests(IntegrationTestContext, LoggingDbContext> testContext) - { - _testContext = testContext; + public LoggingTests(IntegrationTestContext, LoggingDbContext> testContext) + { + _testContext = testContext; - testContext.UseController(); + testContext.UseController(); - var loggerFactory = new FakeLoggerFactory(LogLevel.Trace); + var loggerFactory = new FakeLoggerFactory(LogLevel.Trace); - testContext.ConfigureLogging(options => - { - options.ClearProviders(); - options.AddProvider(loggerFactory); - options.SetMinimumLevel(LogLevel.Trace); - }); - - testContext.ConfigureServicesBeforeStartup(services => - { - services.AddSingleton(loggerFactory); - }); - } + testContext.ConfigureLogging(options => + { + options.ClearProviders(); + options.AddProvider(loggerFactory); + options.SetMinimumLevel(LogLevel.Trace); + }); - [Fact] - public async Task Logs_request_body_at_Trace_level() + testContext.ConfigureServicesBeforeStartup(services => { - // Arrange - var loggerFactory = _testContext.Factory.Services.GetRequiredService(); - loggerFactory.Logger.Clear(); + services.AddSingleton(loggerFactory); + }); + } - AuditEntry newEntry = _fakers.AuditEntry.Generate(); + [Fact] + public async Task Logs_request_body_at_Trace_level() + { + // Arrange + var loggerFactory = _testContext.Factory.Services.GetRequiredService(); + loggerFactory.Logger.Clear(); + + AuditEntry newEntry = _fakers.AuditEntry.Generate(); - var requestBody = new + var requestBody = new + { + data = new { - data = new + type = "auditEntries", + attributes = new { - type = "auditEntries", - attributes = new - { - userName = newEntry.UserName, - createdAt = newEntry.CreatedAt - } + userName = newEntry.UserName, + createdAt = newEntry.CreatedAt } - }; + } + }; - // Arrange - const string route = "/auditEntries"; + // Arrange + const string route = "/auditEntries"; - // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - loggerFactory.Logger.Messages.ShouldNotBeEmpty(); + loggerFactory.Logger.Messages.ShouldNotBeEmpty(); - loggerFactory.Logger.Messages.Should().ContainSingle(message => message.LogLevel == LogLevel.Trace && - message.Text.StartsWith("Received POST request at 'http://localhost/auditEntries' with body: <<", StringComparison.Ordinal)); - } + loggerFactory.Logger.Messages.Should().ContainSingle(message => message.LogLevel == LogLevel.Trace && + message.Text.StartsWith("Received POST request at 'http://localhost/auditEntries' with body: <<", StringComparison.Ordinal)); + } - [Fact] - public async Task Logs_response_body_at_Trace_level() - { - // Arrange - var loggerFactory = _testContext.Factory.Services.GetRequiredService(); - loggerFactory.Logger.Clear(); + [Fact] + public async Task Logs_response_body_at_Trace_level() + { + // Arrange + var loggerFactory = _testContext.Factory.Services.GetRequiredService(); + loggerFactory.Logger.Clear(); - // Arrange - const string route = "/auditEntries"; + // Arrange + const string route = "/auditEntries"; - // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteGetAsync(route); + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteGetAsync(route); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - loggerFactory.Logger.Messages.ShouldNotBeEmpty(); + loggerFactory.Logger.Messages.ShouldNotBeEmpty(); - loggerFactory.Logger.Messages.Should().ContainSingle(message => message.LogLevel == LogLevel.Trace && - message.Text.StartsWith("Sending 200 response for GET request at 'http://localhost/auditEntries' with body: <<", StringComparison.Ordinal)); - } + loggerFactory.Logger.Messages.Should().ContainSingle(message => message.LogLevel == LogLevel.Trace && + message.Text.StartsWith("Sending 200 response for GET request at 'http://localhost/auditEntries' with body: <<", StringComparison.Ordinal)); + } - [Fact] - public async Task Logs_invalid_request_body_error_at_Information_level() - { - // Arrange - var loggerFactory = _testContext.Factory.Services.GetRequiredService(); - loggerFactory.Logger.Clear(); + [Fact] + public async Task Logs_invalid_request_body_error_at_Information_level() + { + // Arrange + var loggerFactory = _testContext.Factory.Services.GetRequiredService(); + loggerFactory.Logger.Clear(); - // Arrange - const string requestBody = "{ \"data\" {"; + // Arrange + const string requestBody = "{ \"data\" {"; - const string route = "/auditEntries"; + const string route = "/auditEntries"; - // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - loggerFactory.Logger.Messages.ShouldNotBeEmpty(); + loggerFactory.Logger.Messages.ShouldNotBeEmpty(); - loggerFactory.Logger.Messages.Should().ContainSingle(message => message.LogLevel == LogLevel.Information && - message.Text.Contains("Failed to deserialize request body.")); - } + loggerFactory.Logger.Messages.Should().ContainSingle(message => message.LogLevel == LogLevel.Information && + message.Text.Contains("Failed to deserialize request body.")); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/MetaDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/MetaDbContext.cs index 9c46c51709..25f6f10810 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/MetaDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/MetaDbContext.cs @@ -1,17 +1,16 @@ using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; -namespace JsonApiDotNetCoreTests.IntegrationTests.Meta +namespace JsonApiDotNetCoreTests.IntegrationTests.Meta; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class MetaDbContext : DbContext { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class MetaDbContext : DbContext - { - public DbSet ProductFamilies => Set(); - public DbSet SupportTickets => Set(); + public DbSet ProductFamilies => Set(); + public DbSet SupportTickets => Set(); - public MetaDbContext(DbContextOptions options) - : base(options) - { - } + public MetaDbContext(DbContextOptions options) + : base(options) + { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/MetaFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/MetaFakers.cs index 3deae16930..726dd82923 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/MetaFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/MetaFakers.cs @@ -1,25 +1,23 @@ -using System; using Bogus; using TestBuildingBlocks; // @formatter:wrap_chained_method_calls chop_always // @formatter:keep_existing_linebreaks true -namespace JsonApiDotNetCoreTests.IntegrationTests.Meta +namespace JsonApiDotNetCoreTests.IntegrationTests.Meta; + +internal sealed class MetaFakers : FakerContainer { - internal sealed class MetaFakers : FakerContainer - { - private readonly Lazy> _lazyProductFamilyFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(productFamily => productFamily.Name, faker => faker.Commerce.ProductName())); + private readonly Lazy> _lazyProductFamilyFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(productFamily => productFamily.Name, faker => faker.Commerce.ProductName())); - private readonly Lazy> _lazySupportTicketFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(supportTicket => supportTicket.Description, faker => faker.Lorem.Paragraph())); + private readonly Lazy> _lazySupportTicketFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(supportTicket => supportTicket.Description, faker => faker.Lorem.Paragraph())); - public Faker ProductFamily => _lazyProductFamilyFaker.Value; - public Faker SupportTicket => _lazySupportTicketFaker.Value; - } + public Faker ProductFamily => _lazyProductFamilyFaker.Value; + public Faker SupportTicket => _lazySupportTicketFaker.Value; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ProductFamily.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ProductFamily.cs index 36b18a47df..f860972abe 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ProductFamily.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ProductFamily.cs @@ -1,18 +1,16 @@ -using System.Collections.Generic; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.Meta +namespace JsonApiDotNetCoreTests.IntegrationTests.Meta; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Meta")] +public sealed class ProductFamily : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - [Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Meta")] - public sealed class ProductFamily : Identifiable - { - [Attr] - public string Name { get; set; } = null!; + [Attr] + public string Name { get; set; } = null!; - [HasMany] - public IList Tickets { get; set; } = new List(); - } + [HasMany] + public IList Tickets { get; set; } = new List(); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ResourceMetaTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ResourceMetaTests.cs index dacfe150df..1ee473bffd 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ResourceMetaTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ResourceMetaTests.cs @@ -1,7 +1,4 @@ -using System.Collections.Generic; using System.Net; -using System.Net.Http; -using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; @@ -9,101 +6,100 @@ using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.Meta +namespace JsonApiDotNetCoreTests.IntegrationTests.Meta; + +public sealed class ResourceMetaTests : IClassFixture, MetaDbContext>> { - public sealed class ResourceMetaTests : IClassFixture, MetaDbContext>> + private readonly IntegrationTestContext, MetaDbContext> _testContext; + private readonly MetaFakers _fakers = new(); + + public ResourceMetaTests(IntegrationTestContext, MetaDbContext> testContext) { - private readonly IntegrationTestContext, MetaDbContext> _testContext; - private readonly MetaFakers _fakers = new(); + _testContext = testContext; - public ResourceMetaTests(IntegrationTestContext, MetaDbContext> testContext) + testContext.UseController(); + testContext.UseController(); + + testContext.ConfigureServicesAfterStartup(services => { - _testContext = testContext; + services.AddResourceDefinition(); + services.AddSingleton(); + }); - testContext.UseController(); - testContext.UseController(); + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + hitCounter.Reset(); + } - testContext.ConfigureServicesAfterStartup(services => - { - services.AddResourceDefinition(); - services.AddSingleton(); - }); + [Fact] + public async Task Returns_resource_meta_from_ResourceDefinition() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - hitCounter.Reset(); - } + List tickets = _fakers.SupportTicket.Generate(3); + tickets[0].Description = $"Critical: {tickets[0].Description}"; + tickets[2].Description = $"Critical: {tickets[2].Description}"; - [Fact] - public async Task Returns_resource_meta_from_ResourceDefinition() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - - List tickets = _fakers.SupportTicket.Generate(3); - tickets[0].Description = $"Critical: {tickets[0].Description}"; - tickets[2].Description = $"Critical: {tickets[2].Description}"; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.SupportTickets.AddRange(tickets); - await dbContext.SaveChangesAsync(); - }); - - const string route = "/supportTickets"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Data.ManyValue.ShouldHaveCount(3); - responseDocument.Data.ManyValue[0].Meta.ShouldContainKey("hasHighPriority"); - responseDocument.Data.ManyValue[1].Meta.Should().BeNull(); - responseDocument.Data.ManyValue[2].Meta.ShouldContainKey("hasHighPriority"); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(SupportTicket), ResourceDefinitionExtensibilityPoints.GetMeta), - (typeof(SupportTicket), ResourceDefinitionExtensibilityPoints.GetMeta), - (typeof(SupportTicket), ResourceDefinitionExtensibilityPoints.GetMeta) - }, options => options.WithStrictOrdering()); - } - - [Fact] - public async Task Returns_resource_meta_from_ResourceDefinition_in_included_resources() + await dbContext.ClearTableAsync(); + dbContext.SupportTickets.AddRange(tickets); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/supportTickets"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(3); + responseDocument.Data.ManyValue[0].Meta.ShouldContainKey("hasHighPriority"); + responseDocument.Data.ManyValue[1].Meta.Should().BeNull(); + responseDocument.Data.ManyValue[2].Meta.ShouldContainKey("hasHighPriority"); + + hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); + (typeof(SupportTicket), ResourceDefinitionExtensibilityPoints.GetMeta), + (typeof(SupportTicket), ResourceDefinitionExtensibilityPoints.GetMeta), + (typeof(SupportTicket), ResourceDefinitionExtensibilityPoints.GetMeta) + }, options => options.WithStrictOrdering()); + } - ProductFamily family = _fakers.ProductFamily.Generate(); - family.Tickets = _fakers.SupportTicket.Generate(1); - family.Tickets[0].Description = $"Critical: {family.Tickets[0].Description}"; + [Fact] + public async Task Returns_resource_meta_from_ResourceDefinition_in_included_resources() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.ProductFamilies.Add(family); - await dbContext.SaveChangesAsync(); - }); + ProductFamily family = _fakers.ProductFamily.Generate(); + family.Tickets = _fakers.SupportTicket.Generate(1); + family.Tickets[0].Description = $"Critical: {family.Tickets[0].Description}"; - string route = $"/productFamilies/{family.StringId}?include=tickets"; + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.ProductFamilies.Add(family); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/productFamilies/{family.StringId}?include=tickets"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Included.ShouldHaveCount(1); - responseDocument.Included[0].Meta.ShouldContainKey("hasHighPriority"); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included[0].Meta.ShouldContainKey("hasHighPriority"); - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(SupportTicket), ResourceDefinitionExtensibilityPoints.GetMeta) - }, options => options.WithStrictOrdering()); - } + hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] + { + (typeof(SupportTicket), ResourceDefinitionExtensibilityPoints.GetMeta) + }, options => options.WithStrictOrdering()); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ResponseMetaTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ResponseMetaTests.cs index d416eac792..14818db0d5 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ResponseMetaTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ResponseMetaTests.cs @@ -1,6 +1,4 @@ using System.Net; -using System.Net.Http; -using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Response; @@ -8,46 +6,46 @@ using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.Meta +namespace JsonApiDotNetCoreTests.IntegrationTests.Meta; + +public sealed class ResponseMetaTests : IClassFixture, MetaDbContext>> { - public sealed class ResponseMetaTests : IClassFixture, MetaDbContext>> - { - private readonly IntegrationTestContext, MetaDbContext> _testContext; + private readonly IntegrationTestContext, MetaDbContext> _testContext; - public ResponseMetaTests(IntegrationTestContext, MetaDbContext> testContext) - { - _testContext = testContext; + public ResponseMetaTests(IntegrationTestContext, MetaDbContext> testContext) + { + _testContext = testContext; - testContext.UseController(); - testContext.UseController(); + testContext.UseController(); + testContext.UseController(); - testContext.ConfigureServicesAfterStartup(services => - { - services.AddSingleton(); - }); + testContext.ConfigureServicesAfterStartup(services => + { + services.AddSingleton(); + }); - var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); - options.IncludeTotalResourceCount = false; - } + var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); + options.IncludeTotalResourceCount = false; + } - [Fact] - public async Task Returns_top_level_meta() + [Fact] + public async Task Returns_top_level_meta() + { + // Arrange + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - }); + await dbContext.ClearTableAsync(); + }); - const string route = "/supportTickets"; + const string route = "/supportTickets"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteGetAsync(route); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteGetAsync(route); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Should().BeJson(@"{ + responseDocument.Should().BeJson(@"{ ""links"": { ""self"": ""http://localhost/supportTickets"", ""first"": ""http://localhost/supportTickets"" @@ -64,6 +62,5 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ] } }"); - } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportResponseMeta.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportResponseMeta.cs index 7bb94a52c8..8e3b27b286 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportResponseMeta.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportResponseMeta.cs @@ -1,24 +1,22 @@ -using System.Collections.Generic; using JsonApiDotNetCore.Serialization.Response; -namespace JsonApiDotNetCoreTests.IntegrationTests.Meta +namespace JsonApiDotNetCoreTests.IntegrationTests.Meta; + +public sealed class SupportResponseMeta : IResponseMeta { - public sealed class SupportResponseMeta : IResponseMeta + public IReadOnlyDictionary GetMeta() { - public IReadOnlyDictionary GetMeta() + return new Dictionary { - return new Dictionary + ["license"] = "MIT", + ["projectUrl"] = "https://github.com/json-api-dotnet/JsonApiDotNetCore/", + ["versions"] = new[] { - ["license"] = "MIT", - ["projectUrl"] = "https://github.com/json-api-dotnet/JsonApiDotNetCore/", - ["versions"] = new[] - { - "v4.0.0", - "v3.1.0", - "v2.5.2", - "v1.3.1" - } - }; - } + "v4.0.0", + "v3.1.0", + "v2.5.2", + "v1.3.1" + } + }; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportTicket.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportTicket.cs index 7d944b407b..619542ad6d 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportTicket.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportTicket.cs @@ -2,13 +2,12 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.Meta +namespace JsonApiDotNetCoreTests.IntegrationTests.Meta; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Meta")] +public sealed class SupportTicket : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - [Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Meta")] - public sealed class SupportTicket : Identifiable - { - [Attr] - public string Description { get; set; } = null!; - } + [Attr] + public string Description { get; set; } = null!; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportTicketDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportTicketDefinition.cs index 0c355abce6..6ca207d0aa 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportTicketDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportTicketDefinition.cs @@ -1,33 +1,30 @@ -using System; -using System.Collections.Generic; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; -namespace JsonApiDotNetCoreTests.IntegrationTests.Meta +namespace JsonApiDotNetCoreTests.IntegrationTests.Meta; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class SupportTicketDefinition : HitCountingResourceDefinition { - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class SupportTicketDefinition : HitCountingResourceDefinition + protected override ResourceDefinitionExtensibilityPoints ExtensibilityPointsToTrack => ResourceDefinitionExtensibilityPoints.GetMeta; + + public SupportTicketDefinition(IResourceGraph resourceGraph, ResourceDefinitionHitCounter hitCounter) + : base(resourceGraph, hitCounter) { - protected override ResourceDefinitionExtensibilityPoints ExtensibilityPointsToTrack => ResourceDefinitionExtensibilityPoints.GetMeta; + } - public SupportTicketDefinition(IResourceGraph resourceGraph, ResourceDefinitionHitCounter hitCounter) - : base(resourceGraph, hitCounter) - { - } + public override IDictionary? GetMeta(SupportTicket resource) + { + base.GetMeta(resource); - public override IDictionary? GetMeta(SupportTicket resource) + if (!string.IsNullOrEmpty(resource.Description) && resource.Description.StartsWith("Critical:", StringComparison.Ordinal)) { - base.GetMeta(resource); - - if (!string.IsNullOrEmpty(resource.Description) && resource.Description.StartsWith("Critical:", StringComparison.Ordinal)) + return new Dictionary { - return new Dictionary - { - ["hasHighPriority"] = true - }; - } - - return null; + ["hasHighPriority"] = true + }; } + + return null; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs index 60604b91f0..20de051454 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs @@ -1,7 +1,5 @@ using System.Net; -using System.Net.Http; using System.Text.Json; -using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; @@ -10,150 +8,149 @@ using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.Meta +namespace JsonApiDotNetCoreTests.IntegrationTests.Meta; + +public sealed class TopLevelCountTests : IClassFixture, MetaDbContext>> { - public sealed class TopLevelCountTests : IClassFixture, MetaDbContext>> + private readonly IntegrationTestContext, MetaDbContext> _testContext; + private readonly MetaFakers _fakers = new(); + + public TopLevelCountTests(IntegrationTestContext, MetaDbContext> testContext) { - private readonly IntegrationTestContext, MetaDbContext> _testContext; - private readonly MetaFakers _fakers = new(); + _testContext = testContext; - public TopLevelCountTests(IntegrationTestContext, MetaDbContext> testContext) - { - _testContext = testContext; + testContext.UseController(); + testContext.UseController(); - testContext.UseController(); - testContext.UseController(); + testContext.ConfigureServicesAfterStartup(services => + { + services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); + }); - testContext.ConfigureServicesAfterStartup(services => - { - services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); - }); + var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); + options.IncludeTotalResourceCount = true; + } - var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); - options.IncludeTotalResourceCount = true; - } + [Fact] + public async Task Renders_resource_count_for_collection() + { + // Arrange + SupportTicket ticket = _fakers.SupportTicket.Generate(); - [Fact] - public async Task Renders_resource_count_for_collection() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - SupportTicket ticket = _fakers.SupportTicket.Generate(); + await dbContext.ClearTableAsync(); + dbContext.SupportTickets.Add(ticket); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.SupportTickets.Add(ticket); - await dbContext.SaveChangesAsync(); - }); - - const string route = "/supportTickets"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + const string route = "/supportTickets"; - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - responseDocument.Meta.ShouldNotBeNull(); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Meta.ShouldContainKey("total").With(value => - { - JsonElement element = value.Should().BeOfType().Subject; - element.GetInt32().Should().Be(1); - }); - } + responseDocument.Meta.ShouldNotBeNull(); - [Fact] - public async Task Renders_resource_count_for_empty_collection() + responseDocument.Meta.ShouldContainKey("total").With(value => { - // Arrange - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - }); + JsonElement element = value.Should().BeOfType().Subject; + element.GetInt32().Should().Be(1); + }); + } - const string route = "/supportTickets"; + [Fact] + public async Task Renders_resource_count_for_empty_collection() + { + // Arrange + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + }); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + const string route = "/supportTickets"; - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - responseDocument.Meta.ShouldNotBeNull(); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Meta.ShouldContainKey("total").With(value => - { - JsonElement element = value.Should().BeOfType().Subject; - element.GetInt32().Should().Be(0); - }); - } + responseDocument.Meta.ShouldNotBeNull(); - [Fact] - public async Task Hides_resource_count_in_create_resource_response() + responseDocument.Meta.ShouldContainKey("total").With(value => { - // Arrange - string newDescription = _fakers.SupportTicket.Generate().Description; + JsonElement element = value.Should().BeOfType().Subject; + element.GetInt32().Should().Be(0); + }); + } - var requestBody = new + [Fact] + public async Task Hides_resource_count_in_create_resource_response() + { + // Arrange + string newDescription = _fakers.SupportTicket.Generate().Description; + + var requestBody = new + { + data = new { - data = new + type = "supportTickets", + attributes = new { - type = "supportTickets", - attributes = new - { - description = newDescription - } + description = newDescription } - }; + } + }; - const string route = "/supportTickets"; + const string route = "/supportTickets"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Meta.Should().BeNull(); - } + responseDocument.Meta.Should().BeNull(); + } - [Fact] - public async Task Hides_resource_count_in_update_resource_response() - { - // Arrange - SupportTicket existingTicket = _fakers.SupportTicket.Generate(); + [Fact] + public async Task Hides_resource_count_in_update_resource_response() + { + // Arrange + SupportTicket existingTicket = _fakers.SupportTicket.Generate(); - string newDescription = _fakers.SupportTicket.Generate().Description; + string newDescription = _fakers.SupportTicket.Generate().Description; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.SupportTickets.Add(existingTicket); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.SupportTickets.Add(existingTicket); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new { - data = new + type = "supportTickets", + id = existingTicket.StringId, + attributes = new { - type = "supportTickets", - id = existingTicket.StringId, - attributes = new - { - description = newDescription - } + description = newDescription } - }; + } + }; - string route = $"/supportTickets/{existingTicket.StringId}"; + string route = $"/supportTickets/{existingTicket.StringId}"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Meta.Should().BeNull(); - } + responseDocument.Meta.Should().BeNull(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/DomainFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/DomainFakers.cs index 8f8a9168db..8109dadf31 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/DomainFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/DomainFakers.cs @@ -1,26 +1,24 @@ -using System; using Bogus; using TestBuildingBlocks; // @formatter:wrap_chained_method_calls chop_always // @formatter:keep_existing_linebreaks true -namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices +namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices; + +internal sealed class DomainFakers : FakerContainer { - internal sealed class DomainFakers : FakerContainer - { - private readonly Lazy> _lazyDomainUserFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(domainUser => domainUser.LoginName, faker => faker.Person.UserName) - .RuleFor(domainUser => domainUser.DisplayName, faker => faker.Person.FullName)); + private readonly Lazy> _lazyDomainUserFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(domainUser => domainUser.LoginName, faker => faker.Person.UserName) + .RuleFor(domainUser => domainUser.DisplayName, faker => faker.Person.FullName)); - private readonly Lazy> _lazyDomainGroupFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(domainGroup => domainGroup.Name, faker => faker.Commerce.Department())); + private readonly Lazy> _lazyDomainGroupFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(domainGroup => domainGroup.Name, faker => faker.Commerce.Department())); - public Faker DomainUser => _lazyDomainUserFaker.Value; - public Faker DomainGroup => _lazyDomainGroupFaker.Value; - } + public Faker DomainUser => _lazyDomainUserFaker.Value; + public Faker DomainGroup => _lazyDomainGroupFaker.Value; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/DomainGroup.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/DomainGroup.cs index 33387c5cd4..dd87456892 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/DomainGroup.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/DomainGroup.cs @@ -1,19 +1,16 @@ -using System; -using System.Collections.Generic; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices +namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Microservices")] +public sealed class DomainGroup : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - [Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Microservices")] - public sealed class DomainGroup : Identifiable - { - [Attr] - public string Name { get; set; } = null!; + [Attr] + public string Name { get; set; } = null!; - [HasMany] - public ISet Users { get; set; } = new HashSet(); - } + [HasMany] + public ISet Users { get; set; } = new HashSet(); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/DomainUser.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/DomainUser.cs index 0af57e8a49..0efb562355 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/DomainUser.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/DomainUser.cs @@ -1,21 +1,19 @@ -using System; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices +namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Microservices")] +public sealed class DomainUser : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - [Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Microservices")] - public sealed class DomainUser : Identifiable - { - [Attr] - public string LoginName { get; set; } = null!; + [Attr] + public string LoginName { get; set; } = null!; - [Attr] - public string? DisplayName { get; set; } + [Attr] + public string? DisplayName { get; set; } - [HasOne] - public DomainGroup? Group { get; set; } - } + [HasOne] + public DomainGroup? Group { get; set; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetDbContext.cs index c778390d39..2dd337421a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetDbContext.cs @@ -1,17 +1,16 @@ using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; -namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.FireAndForgetDelivery +namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.FireAndForgetDelivery; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class FireForgetDbContext : DbContext { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class FireForgetDbContext : DbContext - { - public DbSet Users => Set(); - public DbSet Groups => Set(); + public DbSet Users => Set(); + public DbSet Groups => Set(); - public FireForgetDbContext(DbContextOptions options) - : base(options) - { - } + public FireForgetDbContext(DbContextOptions options) + : base(options) + { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetGroupDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetGroupDefinition.cs index 23c17aa47f..a148a47b20 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetGroupDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetGroupDefinition.cs @@ -1,51 +1,47 @@ -using System; -using System.Threading; -using System.Threading.Tasks; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCoreTests.IntegrationTests.Microservices.Messages; -namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.FireAndForgetDelivery +namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.FireAndForgetDelivery; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class FireForgetGroupDefinition : MessagingGroupDefinition { - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class FireForgetGroupDefinition : MessagingGroupDefinition + private readonly MessageBroker _messageBroker; + private DomainGroup? _groupToDelete; + + public FireForgetGroupDefinition(IResourceGraph resourceGraph, FireForgetDbContext dbContext, MessageBroker messageBroker, + ResourceDefinitionHitCounter hitCounter) + : base(resourceGraph, dbContext.Users, dbContext.Groups, hitCounter) { - private readonly MessageBroker _messageBroker; - private DomainGroup? _groupToDelete; + _messageBroker = messageBroker; + } - public FireForgetGroupDefinition(IResourceGraph resourceGraph, FireForgetDbContext dbContext, MessageBroker messageBroker, - ResourceDefinitionHitCounter hitCounter) - : base(resourceGraph, dbContext.Users, dbContext.Groups, hitCounter) - { - _messageBroker = messageBroker; - } + public override async Task OnWritingAsync(DomainGroup group, WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + await base.OnWritingAsync(group, writeOperation, cancellationToken); - public override async Task OnWritingAsync(DomainGroup group, WriteOperationKind writeOperation, CancellationToken cancellationToken) + if (writeOperation == WriteOperationKind.DeleteResource) { - await base.OnWritingAsync(group, writeOperation, cancellationToken); - - if (writeOperation == WriteOperationKind.DeleteResource) - { - _groupToDelete = await base.GetGroupToDeleteAsync(group.Id, cancellationToken); - } + _groupToDelete = await base.GetGroupToDeleteAsync(group.Id, cancellationToken); } + } - public override async Task OnWriteSucceededAsync(DomainGroup group, WriteOperationKind writeOperation, CancellationToken cancellationToken) - { - await base.OnWriteSucceededAsync(group, writeOperation, cancellationToken); + public override async Task OnWriteSucceededAsync(DomainGroup group, WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + await base.OnWriteSucceededAsync(group, writeOperation, cancellationToken); - await FinishWriteAsync(group, writeOperation, cancellationToken); - } + await FinishWriteAsync(group, writeOperation, cancellationToken); + } - protected override Task FlushMessageAsync(OutgoingMessage message, CancellationToken cancellationToken) - { - return _messageBroker.PostMessageAsync(message, cancellationToken); - } + protected override Task FlushMessageAsync(OutgoingMessage message, CancellationToken cancellationToken) + { + return _messageBroker.PostMessageAsync(message, cancellationToken); + } - protected override Task GetGroupToDeleteAsync(Guid groupId, CancellationToken cancellationToken) - { - return Task.FromResult(_groupToDelete); - } + protected override Task GetGroupToDeleteAsync(Guid groupId, CancellationToken cancellationToken) + { + return Task.FromResult(_groupToDelete); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.Group.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.Group.cs index 31a01feb99..e35efaeb52 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.Group.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.Group.cs @@ -1,8 +1,4 @@ -using System; -using System.Linq; using System.Net; -using System.Net.Http; -using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreTests.IntegrationTests.Microservices.Messages; @@ -10,579 +6,578 @@ using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.FireAndForgetDelivery +namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.FireAndForgetDelivery; + +public sealed partial class FireForgetTests { - public sealed partial class FireForgetTests + [Fact] + public async Task Create_group_sends_messages() { - [Fact] - public async Task Create_group_sends_messages() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - var messageBroker = _testContext.Factory.Services.GetRequiredService(); + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + var messageBroker = _testContext.Factory.Services.GetRequiredService(); - string newGroupName = _fakers.DomainGroup.Generate().Name; + string newGroupName = _fakers.DomainGroup.Generate().Name; - var requestBody = new + var requestBody = new + { + data = new { - data = new + type = "domainGroups", + attributes = new { - type = "domainGroups", - attributes = new - { - name = newGroupName - } + name = newGroupName } - }; + } + }; - const string route = "/domainGroups"; + const string route = "/domainGroups"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newGroupName)); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newGroupName)); - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), - (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync), - (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); + hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] + { + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) + }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.ShouldHaveCount(1); + messageBroker.SentMessages.ShouldHaveCount(1); - Guid newGroupId = Guid.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); + Guid newGroupId = Guid.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); - var content = messageBroker.SentMessages[0].GetContentAs(); - content.GroupId.Should().Be(newGroupId); - content.GroupName.Should().Be(newGroupName); - } + var content = messageBroker.SentMessages[0].GetContentAs(); + content.GroupId.Should().Be(newGroupId); + content.GroupName.Should().Be(newGroupName); + } - [Fact] - public async Task Create_group_with_users_sends_messages() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - var messageBroker = _testContext.Factory.Services.GetRequiredService(); + [Fact] + public async Task Create_group_with_users_sends_messages() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + var messageBroker = _testContext.Factory.Services.GetRequiredService(); - DomainUser existingUserWithoutGroup = _fakers.DomainUser.Generate(); + DomainUser existingUserWithoutGroup = _fakers.DomainUser.Generate(); - DomainUser existingUserWithOtherGroup = _fakers.DomainUser.Generate(); - existingUserWithOtherGroup.Group = _fakers.DomainGroup.Generate(); + DomainUser existingUserWithOtherGroup = _fakers.DomainUser.Generate(); + existingUserWithOtherGroup.Group = _fakers.DomainGroup.Generate(); - string newGroupName = _fakers.DomainGroup.Generate().Name; + string newGroupName = _fakers.DomainGroup.Generate().Name; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Users.AddRange(existingUserWithoutGroup, existingUserWithOtherGroup); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Users.AddRange(existingUserWithoutGroup, existingUserWithOtherGroup); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new { - data = new + type = "domainGroups", + attributes = new { - type = "domainGroups", - attributes = new - { - name = newGroupName - }, - relationships = new + name = newGroupName + }, + relationships = new + { + users = new { - users = new + data = new[] { - data = new[] + new + { + type = "domainUsers", + id = existingUserWithoutGroup.StringId + }, + new { - new - { - type = "domainUsers", - id = existingUserWithoutGroup.StringId - }, - new - { - type = "domainUsers", - id = existingUserWithOtherGroup.StringId - } + type = "domainUsers", + id = existingUserWithOtherGroup.StringId } } } } - }; + } + }; - const string route = "/domainGroups"; + const string route = "/domainGroups"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newGroupName)); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newGroupName)); - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), - (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnSetToManyRelationshipAsync), - (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync), - (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); + hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] + { + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnSetToManyRelationshipAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) + }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.ShouldHaveCount(3); + messageBroker.SentMessages.ShouldHaveCount(3); - Guid newGroupId = Guid.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); + Guid newGroupId = Guid.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); - var content1 = messageBroker.SentMessages[0].GetContentAs(); - content1.GroupId.Should().Be(newGroupId); - content1.GroupName.Should().Be(newGroupName); + var content1 = messageBroker.SentMessages[0].GetContentAs(); + content1.GroupId.Should().Be(newGroupId); + content1.GroupName.Should().Be(newGroupName); - var content2 = messageBroker.SentMessages[1].GetContentAs(); - content2.UserId.Should().Be(existingUserWithoutGroup.Id); - content2.GroupId.Should().Be(newGroupId); + var content2 = messageBroker.SentMessages[1].GetContentAs(); + content2.UserId.Should().Be(existingUserWithoutGroup.Id); + content2.GroupId.Should().Be(newGroupId); - var content3 = messageBroker.SentMessages[2].GetContentAs(); - content3.UserId.Should().Be(existingUserWithOtherGroup.Id); - content3.BeforeGroupId.Should().Be(existingUserWithOtherGroup.Group.Id); - content3.AfterGroupId.Should().Be(newGroupId); - } + var content3 = messageBroker.SentMessages[2].GetContentAs(); + content3.UserId.Should().Be(existingUserWithOtherGroup.Id); + content3.BeforeGroupId.Should().Be(existingUserWithOtherGroup.Group.Id); + content3.AfterGroupId.Should().Be(newGroupId); + } - [Fact] - public async Task Update_group_sends_messages() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - var messageBroker = _testContext.Factory.Services.GetRequiredService(); + [Fact] + public async Task Update_group_sends_messages() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + var messageBroker = _testContext.Factory.Services.GetRequiredService(); - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); - string newGroupName = _fakers.DomainGroup.Generate().Name; + string newGroupName = _fakers.DomainGroup.Generate().Name; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Groups.Add(existingGroup); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Groups.Add(existingGroup); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new { - data = new + type = "domainGroups", + id = existingGroup.StringId, + attributes = new { - type = "domainGroups", - id = existingGroup.StringId, - attributes = new - { - name = newGroupName - } + name = newGroupName } - }; + } + }; - string route = $"/domainGroups/{existingGroup.StringId}"; + string route = $"/domainGroups/{existingGroup.StringId}"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), - (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync), - (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); + hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] + { + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) + }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.ShouldHaveCount(1); + messageBroker.SentMessages.ShouldHaveCount(1); - var content = messageBroker.SentMessages[0].GetContentAs(); - content.GroupId.Should().Be(existingGroup.StringId); - content.BeforeGroupName.Should().Be(existingGroup.Name); - content.AfterGroupName.Should().Be(newGroupName); - } + var content = messageBroker.SentMessages[0].GetContentAs(); + content.GroupId.Should().Be(existingGroup.StringId); + content.BeforeGroupName.Should().Be(existingGroup.Name); + content.AfterGroupName.Should().Be(newGroupName); + } - [Fact] - public async Task Update_group_with_users_sends_messages() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - var messageBroker = _testContext.Factory.Services.GetRequiredService(); + [Fact] + public async Task Update_group_with_users_sends_messages() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + var messageBroker = _testContext.Factory.Services.GetRequiredService(); - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); - DomainUser existingUserWithoutGroup = _fakers.DomainUser.Generate(); + DomainUser existingUserWithoutGroup = _fakers.DomainUser.Generate(); - DomainUser existingUserWithSameGroup1 = _fakers.DomainUser.Generate(); - existingUserWithSameGroup1.Group = existingGroup; + DomainUser existingUserWithSameGroup1 = _fakers.DomainUser.Generate(); + existingUserWithSameGroup1.Group = existingGroup; - DomainUser existingUserWithSameGroup2 = _fakers.DomainUser.Generate(); - existingUserWithSameGroup2.Group = existingGroup; + DomainUser existingUserWithSameGroup2 = _fakers.DomainUser.Generate(); + existingUserWithSameGroup2.Group = existingGroup; - DomainUser existingUserWithOtherGroup = _fakers.DomainUser.Generate(); - existingUserWithOtherGroup.Group = _fakers.DomainGroup.Generate(); + DomainUser existingUserWithOtherGroup = _fakers.DomainUser.Generate(); + existingUserWithOtherGroup.Group = _fakers.DomainGroup.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Users.AddRange(existingUserWithoutGroup, existingUserWithSameGroup1, existingUserWithSameGroup2, existingUserWithOtherGroup); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Users.AddRange(existingUserWithoutGroup, existingUserWithSameGroup1, existingUserWithSameGroup2, existingUserWithOtherGroup); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new { - data = new + type = "domainGroups", + id = existingGroup.StringId, + relationships = new { - type = "domainGroups", - id = existingGroup.StringId, - relationships = new + users = new { - users = new + data = new[] { - data = new[] + new + { + type = "domainUsers", + id = existingUserWithoutGroup.StringId + }, + new + { + type = "domainUsers", + id = existingUserWithSameGroup1.StringId + }, + new { - new - { - type = "domainUsers", - id = existingUserWithoutGroup.StringId - }, - new - { - type = "domainUsers", - id = existingUserWithSameGroup1.StringId - }, - new - { - type = "domainUsers", - id = existingUserWithOtherGroup.StringId - } + type = "domainUsers", + id = existingUserWithOtherGroup.StringId } } } } - }; + } + }; - string route = $"/domainGroups/{existingGroup.StringId}"; + string route = $"/domainGroups/{existingGroup.StringId}"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), - (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnSetToManyRelationshipAsync), - (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync), - (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); - - messageBroker.SentMessages.ShouldHaveCount(3); - - var content1 = messageBroker.SentMessages[0].GetContentAs(); - content1.UserId.Should().Be(existingUserWithoutGroup.Id); - content1.GroupId.Should().Be(existingGroup.Id); - - var content2 = messageBroker.SentMessages[1].GetContentAs(); - content2.UserId.Should().Be(existingUserWithOtherGroup.Id); - content2.BeforeGroupId.Should().Be(existingUserWithOtherGroup.Group.Id); - content2.AfterGroupId.Should().Be(existingGroup.Id); - - var content3 = messageBroker.SentMessages[2].GetContentAs(); - content3.UserId.Should().Be(existingUserWithSameGroup2.Id); - content3.GroupId.Should().Be(existingGroup.Id); - } - - [Fact] - public async Task Delete_group_sends_messages() + hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - var messageBroker = _testContext.Factory.Services.GetRequiredService(); + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnSetToManyRelationshipAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) + }, options => options.WithStrictOrdering()); + + messageBroker.SentMessages.ShouldHaveCount(3); + + var content1 = messageBroker.SentMessages[0].GetContentAs(); + content1.UserId.Should().Be(existingUserWithoutGroup.Id); + content1.GroupId.Should().Be(existingGroup.Id); + + var content2 = messageBroker.SentMessages[1].GetContentAs(); + content2.UserId.Should().Be(existingUserWithOtherGroup.Id); + content2.BeforeGroupId.Should().Be(existingUserWithOtherGroup.Group.Id); + content2.AfterGroupId.Should().Be(existingGroup.Id); + + var content3 = messageBroker.SentMessages[2].GetContentAs(); + content3.UserId.Should().Be(existingUserWithSameGroup2.Id); + content3.GroupId.Should().Be(existingGroup.Id); + } - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + [Fact] + public async Task Delete_group_sends_messages() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + var messageBroker = _testContext.Factory.Services.GetRequiredService(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Groups.Add(existingGroup); - await dbContext.SaveChangesAsync(); - }); + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); - string route = $"/domainGroups/{existingGroup.StringId}"; + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Groups.Add(existingGroup); + await dbContext.SaveChangesAsync(); + }); - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); + string route = $"/domainGroups/{existingGroup.StringId}"; - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); - responseDocument.Should().BeEmpty(); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync), - (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); + responseDocument.Should().BeEmpty(); + + hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] + { + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) + }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.ShouldHaveCount(1); + messageBroker.SentMessages.ShouldHaveCount(1); - var content = messageBroker.SentMessages[0].GetContentAs(); - content.GroupId.Should().Be(existingGroup.StringId); - } + var content = messageBroker.SentMessages[0].GetContentAs(); + content.GroupId.Should().Be(existingGroup.StringId); + } - [Fact] - public async Task Delete_group_with_users_sends_messages() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - var messageBroker = _testContext.Factory.Services.GetRequiredService(); + [Fact] + public async Task Delete_group_with_users_sends_messages() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + var messageBroker = _testContext.Factory.Services.GetRequiredService(); - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); - existingGroup.Users = _fakers.DomainUser.Generate(1).ToHashSet(); + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + existingGroup.Users = _fakers.DomainUser.Generate(1).ToHashSet(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Groups.Add(existingGroup); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Groups.Add(existingGroup); + await dbContext.SaveChangesAsync(); + }); - string route = $"/domainGroups/{existingGroup.StringId}"; + string route = $"/domainGroups/{existingGroup.StringId}"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync), - (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); + hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] + { + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) + }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.ShouldHaveCount(2); + messageBroker.SentMessages.ShouldHaveCount(2); - var content1 = messageBroker.SentMessages[0].GetContentAs(); - content1.UserId.Should().Be(existingGroup.Users.ElementAt(0).Id); - content1.GroupId.Should().Be(existingGroup.StringId); + var content1 = messageBroker.SentMessages[0].GetContentAs(); + content1.UserId.Should().Be(existingGroup.Users.ElementAt(0).Id); + content1.GroupId.Should().Be(existingGroup.StringId); - var content2 = messageBroker.SentMessages[1].GetContentAs(); - content2.GroupId.Should().Be(existingGroup.StringId); - } + var content2 = messageBroker.SentMessages[1].GetContentAs(); + content2.GroupId.Should().Be(existingGroup.StringId); + } - [Fact] - public async Task Replace_users_in_group_sends_messages() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - var messageBroker = _testContext.Factory.Services.GetRequiredService(); + [Fact] + public async Task Replace_users_in_group_sends_messages() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + var messageBroker = _testContext.Factory.Services.GetRequiredService(); - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); - DomainUser existingUserWithoutGroup = _fakers.DomainUser.Generate(); + DomainUser existingUserWithoutGroup = _fakers.DomainUser.Generate(); - DomainUser existingUserWithSameGroup1 = _fakers.DomainUser.Generate(); - existingUserWithSameGroup1.Group = existingGroup; + DomainUser existingUserWithSameGroup1 = _fakers.DomainUser.Generate(); + existingUserWithSameGroup1.Group = existingGroup; - DomainUser existingUserWithSameGroup2 = _fakers.DomainUser.Generate(); - existingUserWithSameGroup2.Group = existingGroup; + DomainUser existingUserWithSameGroup2 = _fakers.DomainUser.Generate(); + existingUserWithSameGroup2.Group = existingGroup; - DomainUser existingUserWithOtherGroup = _fakers.DomainUser.Generate(); - existingUserWithOtherGroup.Group = _fakers.DomainGroup.Generate(); + DomainUser existingUserWithOtherGroup = _fakers.DomainUser.Generate(); + existingUserWithOtherGroup.Group = _fakers.DomainGroup.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Users.AddRange(existingUserWithoutGroup, existingUserWithSameGroup1, existingUserWithSameGroup2, existingUserWithOtherGroup); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Users.AddRange(existingUserWithoutGroup, existingUserWithSameGroup1, existingUserWithSameGroup2, existingUserWithOtherGroup); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new[] { - data = new[] + new { - new - { - type = "domainUsers", - id = existingUserWithoutGroup.StringId - }, - new - { - type = "domainUsers", - id = existingUserWithSameGroup1.StringId - }, - new - { - type = "domainUsers", - id = existingUserWithOtherGroup.StringId - } + type = "domainUsers", + id = existingUserWithoutGroup.StringId + }, + new + { + type = "domainUsers", + id = existingUserWithSameGroup1.StringId + }, + new + { + type = "domainUsers", + id = existingUserWithOtherGroup.StringId } - }; + } + }; - string route = $"/domainGroups/{existingGroup.StringId}/relationships/users"; + string route = $"/domainGroups/{existingGroup.StringId}/relationships/users"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), - (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnSetToManyRelationshipAsync), - (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync), - (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); - - messageBroker.SentMessages.ShouldHaveCount(3); - - var content1 = messageBroker.SentMessages[0].GetContentAs(); - content1.UserId.Should().Be(existingUserWithoutGroup.Id); - content1.GroupId.Should().Be(existingGroup.Id); - - var content2 = messageBroker.SentMessages[1].GetContentAs(); - content2.UserId.Should().Be(existingUserWithOtherGroup.Id); - content2.BeforeGroupId.Should().Be(existingUserWithOtherGroup.Group.Id); - content2.AfterGroupId.Should().Be(existingGroup.Id); - - var content3 = messageBroker.SentMessages[2].GetContentAs(); - content3.UserId.Should().Be(existingUserWithSameGroup2.Id); - content3.GroupId.Should().Be(existingGroup.Id); - } - - [Fact] - public async Task Add_users_to_group_sends_messages() + hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - var messageBroker = _testContext.Factory.Services.GetRequiredService(); + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnSetToManyRelationshipAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) + }, options => options.WithStrictOrdering()); + + messageBroker.SentMessages.ShouldHaveCount(3); + + var content1 = messageBroker.SentMessages[0].GetContentAs(); + content1.UserId.Should().Be(existingUserWithoutGroup.Id); + content1.GroupId.Should().Be(existingGroup.Id); + + var content2 = messageBroker.SentMessages[1].GetContentAs(); + content2.UserId.Should().Be(existingUserWithOtherGroup.Id); + content2.BeforeGroupId.Should().Be(existingUserWithOtherGroup.Group.Id); + content2.AfterGroupId.Should().Be(existingGroup.Id); + + var content3 = messageBroker.SentMessages[2].GetContentAs(); + content3.UserId.Should().Be(existingUserWithSameGroup2.Id); + content3.GroupId.Should().Be(existingGroup.Id); + } - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + [Fact] + public async Task Add_users_to_group_sends_messages() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + var messageBroker = _testContext.Factory.Services.GetRequiredService(); - DomainUser existingUserWithoutGroup = _fakers.DomainUser.Generate(); + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); - DomainUser existingUserWithSameGroup = _fakers.DomainUser.Generate(); - existingUserWithSameGroup.Group = existingGroup; + DomainUser existingUserWithoutGroup = _fakers.DomainUser.Generate(); - DomainUser existingUserWithOtherGroup = _fakers.DomainUser.Generate(); - existingUserWithOtherGroup.Group = _fakers.DomainGroup.Generate(); + DomainUser existingUserWithSameGroup = _fakers.DomainUser.Generate(); + existingUserWithSameGroup.Group = existingGroup; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Users.AddRange(existingUserWithoutGroup, existingUserWithSameGroup, existingUserWithOtherGroup); - await dbContext.SaveChangesAsync(); - }); + DomainUser existingUserWithOtherGroup = _fakers.DomainUser.Generate(); + existingUserWithOtherGroup.Group = _fakers.DomainGroup.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Users.AddRange(existingUserWithoutGroup, existingUserWithSameGroup, existingUserWithOtherGroup); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new[] { - data = new[] + new { - new - { - type = "domainUsers", - id = existingUserWithoutGroup.StringId - }, - new - { - type = "domainUsers", - id = existingUserWithOtherGroup.StringId - } + type = "domainUsers", + id = existingUserWithoutGroup.StringId + }, + new + { + type = "domainUsers", + id = existingUserWithOtherGroup.StringId } - }; + } + }; - string route = $"/domainGroups/{existingGroup.StringId}/relationships/users"; + string route = $"/domainGroups/{existingGroup.StringId}/relationships/users"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnAddToRelationshipAsync), - (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync), - (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); + hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] + { + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnAddToRelationshipAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) + }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.ShouldHaveCount(2); + messageBroker.SentMessages.ShouldHaveCount(2); - var content1 = messageBroker.SentMessages[0].GetContentAs(); - content1.UserId.Should().Be(existingUserWithoutGroup.Id); - content1.GroupId.Should().Be(existingGroup.Id); + var content1 = messageBroker.SentMessages[0].GetContentAs(); + content1.UserId.Should().Be(existingUserWithoutGroup.Id); + content1.GroupId.Should().Be(existingGroup.Id); - var content2 = messageBroker.SentMessages[1].GetContentAs(); - content2.UserId.Should().Be(existingUserWithOtherGroup.Id); - content2.BeforeGroupId.Should().Be(existingUserWithOtherGroup.Group.Id); - content2.AfterGroupId.Should().Be(existingGroup.Id); - } + var content2 = messageBroker.SentMessages[1].GetContentAs(); + content2.UserId.Should().Be(existingUserWithOtherGroup.Id); + content2.BeforeGroupId.Should().Be(existingUserWithOtherGroup.Group.Id); + content2.AfterGroupId.Should().Be(existingGroup.Id); + } - [Fact] - public async Task Remove_users_from_group_sends_messages() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - var messageBroker = _testContext.Factory.Services.GetRequiredService(); + [Fact] + public async Task Remove_users_from_group_sends_messages() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + var messageBroker = _testContext.Factory.Services.GetRequiredService(); - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); - DomainUser existingUserWithSameGroup1 = _fakers.DomainUser.Generate(); - existingUserWithSameGroup1.Group = existingGroup; + DomainUser existingUserWithSameGroup1 = _fakers.DomainUser.Generate(); + existingUserWithSameGroup1.Group = existingGroup; - DomainUser existingUserWithSameGroup2 = _fakers.DomainUser.Generate(); - existingUserWithSameGroup2.Group = existingGroup; + DomainUser existingUserWithSameGroup2 = _fakers.DomainUser.Generate(); + existingUserWithSameGroup2.Group = existingGroup; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Users.AddRange(existingUserWithSameGroup1, existingUserWithSameGroup2); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Users.AddRange(existingUserWithSameGroup1, existingUserWithSameGroup2); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new[] { - data = new[] + new { - new - { - type = "domainUsers", - id = existingUserWithSameGroup2.StringId - } + type = "domainUsers", + id = existingUserWithSameGroup2.StringId } - }; + } + }; - string route = $"/domainGroups/{existingGroup.StringId}/relationships/users"; + string route = $"/domainGroups/{existingGroup.StringId}/relationships/users"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnRemoveFromRelationshipAsync), - (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync), - (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); + hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] + { + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnRemoveFromRelationshipAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) + }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.ShouldHaveCount(1); + messageBroker.SentMessages.ShouldHaveCount(1); - var content = messageBroker.SentMessages[0].GetContentAs(); - content.UserId.Should().Be(existingUserWithSameGroup2.Id); - content.GroupId.Should().Be(existingGroup.Id); - } + var content = messageBroker.SentMessages[0].GetContentAs(); + content.UserId.Should().Be(existingUserWithSameGroup2.Id); + content.GroupId.Should().Be(existingGroup.Id); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.User.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.User.cs index b6014d9fb0..35b2666c02 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.User.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.User.cs @@ -1,7 +1,4 @@ -using System; using System.Net; -using System.Net.Http; -using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreTests.IntegrationTests.Microservices.Messages; @@ -9,639 +6,638 @@ using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.FireAndForgetDelivery +namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.FireAndForgetDelivery; + +public sealed partial class FireForgetTests { - public sealed partial class FireForgetTests + [Fact] + public async Task Create_user_sends_messages() { - [Fact] - public async Task Create_user_sends_messages() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - var messageBroker = _testContext.Factory.Services.GetRequiredService(); + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + var messageBroker = _testContext.Factory.Services.GetRequiredService(); - string newLoginName = _fakers.DomainUser.Generate().LoginName; - string newDisplayName = _fakers.DomainUser.Generate().DisplayName!; + string newLoginName = _fakers.DomainUser.Generate().LoginName; + string newDisplayName = _fakers.DomainUser.Generate().DisplayName!; - var requestBody = new + var requestBody = new + { + data = new { - data = new + type = "domainUsers", + attributes = new { - type = "domainUsers", - attributes = new - { - loginName = newLoginName, - displayName = newDisplayName - } + loginName = newLoginName, + displayName = newDisplayName } - }; + } + }; - const string route = "/domainUsers"; + const string route = "/domainUsers"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("loginName").With(value => value.Should().Be(newLoginName)); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("displayName").With(value => value.Should().Be(newDisplayName)); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("loginName").With(value => value.Should().Be(newLoginName)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("displayName").With(value => value.Should().Be(newDisplayName)); - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); + hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] + { + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) + }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.ShouldHaveCount(1); + messageBroker.SentMessages.ShouldHaveCount(1); - Guid newUserId = Guid.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); + Guid newUserId = Guid.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); - var content = messageBroker.SentMessages[0].GetContentAs(); - content.UserId.Should().Be(newUserId); - content.UserLoginName.Should().Be(newLoginName); - content.UserDisplayName.Should().Be(newDisplayName); - } + var content = messageBroker.SentMessages[0].GetContentAs(); + content.UserId.Should().Be(newUserId); + content.UserLoginName.Should().Be(newLoginName); + content.UserDisplayName.Should().Be(newDisplayName); + } - [Fact] - public async Task Create_user_in_group_sends_messages() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - var messageBroker = _testContext.Factory.Services.GetRequiredService(); + [Fact] + public async Task Create_user_in_group_sends_messages() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + var messageBroker = _testContext.Factory.Services.GetRequiredService(); - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); - string newLoginName = _fakers.DomainUser.Generate().LoginName; + string newLoginName = _fakers.DomainUser.Generate().LoginName; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Groups.Add(existingGroup); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Groups.Add(existingGroup); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new { - data = new + type = "domainUsers", + attributes = new { - type = "domainUsers", - attributes = new - { - loginName = newLoginName - }, - relationships = new + loginName = newLoginName + }, + relationships = new + { + group = new { - group = new + data = new { - data = new - { - type = "domainGroups", - id = existingGroup.StringId - } + type = "domainGroups", + id = existingGroup.StringId } } } - }; + } + }; - const string route = "/domainUsers"; + const string route = "/domainUsers"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("loginName").With(value => value.Should().Be(newLoginName)); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("displayName").With(value => value.Should().BeNull()); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("loginName").With(value => value.Should().Be(newLoginName)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("displayName").With(value => value.Should().BeNull()); - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnSetToOneRelationshipAsync), - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); + hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] + { + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnSetToOneRelationshipAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) + }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.ShouldHaveCount(2); + messageBroker.SentMessages.ShouldHaveCount(2); - Guid newUserId = Guid.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); + Guid newUserId = Guid.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); - var content1 = messageBroker.SentMessages[0].GetContentAs(); - content1.UserId.Should().Be(newUserId); - content1.UserLoginName.Should().Be(newLoginName); - content1.UserDisplayName.Should().BeNull(); + var content1 = messageBroker.SentMessages[0].GetContentAs(); + content1.UserId.Should().Be(newUserId); + content1.UserLoginName.Should().Be(newLoginName); + content1.UserDisplayName.Should().BeNull(); - var content2 = messageBroker.SentMessages[1].GetContentAs(); - content2.UserId.Should().Be(newUserId); - content2.GroupId.Should().Be(existingGroup.Id); - } + var content2 = messageBroker.SentMessages[1].GetContentAs(); + content2.UserId.Should().Be(newUserId); + content2.GroupId.Should().Be(existingGroup.Id); + } - [Fact] - public async Task Update_user_sends_messages() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - var messageBroker = _testContext.Factory.Services.GetRequiredService(); + [Fact] + public async Task Update_user_sends_messages() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + var messageBroker = _testContext.Factory.Services.GetRequiredService(); - DomainUser existingUser = _fakers.DomainUser.Generate(); + DomainUser existingUser = _fakers.DomainUser.Generate(); - string newLoginName = _fakers.DomainUser.Generate().LoginName; - string newDisplayName = _fakers.DomainUser.Generate().DisplayName!; + string newLoginName = _fakers.DomainUser.Generate().LoginName; + string newDisplayName = _fakers.DomainUser.Generate().DisplayName!; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Users.Add(existingUser); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Users.Add(existingUser); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new { - data = new + type = "domainUsers", + id = existingUser.StringId, + attributes = new { - type = "domainUsers", - id = existingUser.StringId, - attributes = new - { - loginName = newLoginName, - displayName = newDisplayName - } + loginName = newLoginName, + displayName = newDisplayName } - }; + } + }; - string route = $"/domainUsers/{existingUser.StringId}"; + string route = $"/domainUsers/{existingUser.StringId}"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); - - messageBroker.SentMessages.ShouldHaveCount(2); - - var content1 = messageBroker.SentMessages[0].GetContentAs(); - content1.UserId.Should().Be(existingUser.Id); - content1.BeforeUserLoginName.Should().Be(existingUser.LoginName); - content1.AfterUserLoginName.Should().Be(newLoginName); - - var content2 = messageBroker.SentMessages[1].GetContentAs(); - content2.UserId.Should().Be(existingUser.Id); - content2.BeforeUserDisplayName.Should().Be(existingUser.DisplayName); - content2.AfterUserDisplayName.Should().Be(newDisplayName); - } - - [Fact] - public async Task Update_user_clear_group_sends_messages() + hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - var messageBroker = _testContext.Factory.Services.GetRequiredService(); + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) + }, options => options.WithStrictOrdering()); + + messageBroker.SentMessages.ShouldHaveCount(2); + + var content1 = messageBroker.SentMessages[0].GetContentAs(); + content1.UserId.Should().Be(existingUser.Id); + content1.BeforeUserLoginName.Should().Be(existingUser.LoginName); + content1.AfterUserLoginName.Should().Be(newLoginName); + + var content2 = messageBroker.SentMessages[1].GetContentAs(); + content2.UserId.Should().Be(existingUser.Id); + content2.BeforeUserDisplayName.Should().Be(existingUser.DisplayName); + content2.AfterUserDisplayName.Should().Be(newDisplayName); + } - DomainUser existingUser = _fakers.DomainUser.Generate(); - existingUser.Group = _fakers.DomainGroup.Generate(); + [Fact] + public async Task Update_user_clear_group_sends_messages() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + var messageBroker = _testContext.Factory.Services.GetRequiredService(); - string newDisplayName = _fakers.DomainUser.Generate().DisplayName!; + DomainUser existingUser = _fakers.DomainUser.Generate(); + existingUser.Group = _fakers.DomainGroup.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Users.Add(existingUser); - await dbContext.SaveChangesAsync(); - }); + string newDisplayName = _fakers.DomainUser.Generate().DisplayName!; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Users.Add(existingUser); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new { - data = new + type = "domainUsers", + id = existingUser.StringId, + attributes = new { - type = "domainUsers", - id = existingUser.StringId, - attributes = new - { - displayName = newDisplayName - }, - relationships = new + displayName = newDisplayName + }, + relationships = new + { + group = new { - group = new - { - data = (object?)null - } + data = (object?)null } } - }; + } + }; - string route = $"/domainUsers/{existingUser.StringId}"; + string route = $"/domainUsers/{existingUser.StringId}"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnSetToOneRelationshipAsync), - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); - - messageBroker.SentMessages.ShouldHaveCount(2); - - var content1 = messageBroker.SentMessages[0].GetContentAs(); - content1.UserId.Should().Be(existingUser.Id); - content1.BeforeUserDisplayName.Should().Be(existingUser.DisplayName); - content1.AfterUserDisplayName.Should().Be(newDisplayName); - - var content2 = messageBroker.SentMessages[1].GetContentAs(); - content2.UserId.Should().Be(existingUser.Id); - content2.GroupId.Should().Be(existingUser.Group.Id); - } - - [Fact] - public async Task Update_user_add_to_group_sends_messages() + hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - var messageBroker = _testContext.Factory.Services.GetRequiredService(); + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnSetToOneRelationshipAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) + }, options => options.WithStrictOrdering()); + + messageBroker.SentMessages.ShouldHaveCount(2); + + var content1 = messageBroker.SentMessages[0].GetContentAs(); + content1.UserId.Should().Be(existingUser.Id); + content1.BeforeUserDisplayName.Should().Be(existingUser.DisplayName); + content1.AfterUserDisplayName.Should().Be(newDisplayName); + + var content2 = messageBroker.SentMessages[1].GetContentAs(); + content2.UserId.Should().Be(existingUser.Id); + content2.GroupId.Should().Be(existingUser.Group.Id); + } + + [Fact] + public async Task Update_user_add_to_group_sends_messages() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + var messageBroker = _testContext.Factory.Services.GetRequiredService(); - DomainUser existingUser = _fakers.DomainUser.Generate(); - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + DomainUser existingUser = _fakers.DomainUser.Generate(); + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); - string newDisplayName = _fakers.DomainUser.Generate().DisplayName!; + string newDisplayName = _fakers.DomainUser.Generate().DisplayName!; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddInRange(existingUser, existingGroup); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingUser, existingGroup); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new { - data = new + type = "domainUsers", + id = existingUser.StringId, + attributes = new { - type = "domainUsers", - id = existingUser.StringId, - attributes = new - { - displayName = newDisplayName - }, - relationships = new + displayName = newDisplayName + }, + relationships = new + { + group = new { - group = new + data = new { - data = new - { - type = "domainGroups", - id = existingGroup.StringId - } + type = "domainGroups", + id = existingGroup.StringId } } } - }; + } + }; - string route = $"/domainUsers/{existingUser.StringId}"; + string route = $"/domainUsers/{existingUser.StringId}"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnSetToOneRelationshipAsync), - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); - - messageBroker.SentMessages.ShouldHaveCount(2); - - var content1 = messageBroker.SentMessages[0].GetContentAs(); - content1.UserId.Should().Be(existingUser.Id); - content1.BeforeUserDisplayName.Should().Be(existingUser.DisplayName); - content1.AfterUserDisplayName.Should().Be(newDisplayName); - - var content2 = messageBroker.SentMessages[1].GetContentAs(); - content2.UserId.Should().Be(existingUser.Id); - content2.GroupId.Should().Be(existingGroup.Id); - } - - [Fact] - public async Task Update_user_move_to_group_sends_messages() + hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - var messageBroker = _testContext.Factory.Services.GetRequiredService(); + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnSetToOneRelationshipAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) + }, options => options.WithStrictOrdering()); + + messageBroker.SentMessages.ShouldHaveCount(2); + + var content1 = messageBroker.SentMessages[0].GetContentAs(); + content1.UserId.Should().Be(existingUser.Id); + content1.BeforeUserDisplayName.Should().Be(existingUser.DisplayName); + content1.AfterUserDisplayName.Should().Be(newDisplayName); + + var content2 = messageBroker.SentMessages[1].GetContentAs(); + content2.UserId.Should().Be(existingUser.Id); + content2.GroupId.Should().Be(existingGroup.Id); + } + + [Fact] + public async Task Update_user_move_to_group_sends_messages() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + var messageBroker = _testContext.Factory.Services.GetRequiredService(); - DomainUser existingUser = _fakers.DomainUser.Generate(); - existingUser.Group = _fakers.DomainGroup.Generate(); + DomainUser existingUser = _fakers.DomainUser.Generate(); + existingUser.Group = _fakers.DomainGroup.Generate(); - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); - string newDisplayName = _fakers.DomainUser.Generate().DisplayName!; + string newDisplayName = _fakers.DomainUser.Generate().DisplayName!; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddInRange(existingUser, existingGroup); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingUser, existingGroup); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new { - data = new + type = "domainUsers", + id = existingUser.StringId, + attributes = new { - type = "domainUsers", - id = existingUser.StringId, - attributes = new - { - displayName = newDisplayName - }, - relationships = new + displayName = newDisplayName + }, + relationships = new + { + group = new { - group = new + data = new { - data = new - { - type = "domainGroups", - id = existingGroup.StringId - } + type = "domainGroups", + id = existingGroup.StringId } } } - }; + } + }; - string route = $"/domainUsers/{existingUser.StringId}"; + string route = $"/domainUsers/{existingUser.StringId}"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnSetToOneRelationshipAsync), - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); - - messageBroker.SentMessages.ShouldHaveCount(2); - - var content1 = messageBroker.SentMessages[0].GetContentAs(); - content1.UserId.Should().Be(existingUser.Id); - content1.BeforeUserDisplayName.Should().Be(existingUser.DisplayName); - content1.AfterUserDisplayName.Should().Be(newDisplayName); - - var content2 = messageBroker.SentMessages[1].GetContentAs(); - content2.UserId.Should().Be(existingUser.Id); - content2.BeforeGroupId.Should().Be(existingUser.Group.Id); - content2.AfterGroupId.Should().Be(existingGroup.Id); - } - - [Fact] - public async Task Delete_user_sends_messages() + hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - var messageBroker = _testContext.Factory.Services.GetRequiredService(); + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnSetToOneRelationshipAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) + }, options => options.WithStrictOrdering()); + + messageBroker.SentMessages.ShouldHaveCount(2); + + var content1 = messageBroker.SentMessages[0].GetContentAs(); + content1.UserId.Should().Be(existingUser.Id); + content1.BeforeUserDisplayName.Should().Be(existingUser.DisplayName); + content1.AfterUserDisplayName.Should().Be(newDisplayName); + + var content2 = messageBroker.SentMessages[1].GetContentAs(); + content2.UserId.Should().Be(existingUser.Id); + content2.BeforeGroupId.Should().Be(existingUser.Group.Id); + content2.AfterGroupId.Should().Be(existingGroup.Id); + } - DomainUser existingUser = _fakers.DomainUser.Generate(); + [Fact] + public async Task Delete_user_sends_messages() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + var messageBroker = _testContext.Factory.Services.GetRequiredService(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Users.Add(existingUser); - await dbContext.SaveChangesAsync(); - }); + DomainUser existingUser = _fakers.DomainUser.Generate(); - string route = $"/domainUsers/{existingUser.StringId}"; + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Users.Add(existingUser); + await dbContext.SaveChangesAsync(); + }); - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); + string route = $"/domainUsers/{existingUser.StringId}"; - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); - responseDocument.Should().BeEmpty(); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); + responseDocument.Should().BeEmpty(); - messageBroker.SentMessages.ShouldHaveCount(1); + hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] + { + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) + }, options => options.WithStrictOrdering()); + + messageBroker.SentMessages.ShouldHaveCount(1); + + var content = messageBroker.SentMessages[0].GetContentAs(); + content.UserId.Should().Be(existingUser.Id); + } + + [Fact] + public async Task Delete_user_in_group_sends_messages() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + var messageBroker = _testContext.Factory.Services.GetRequiredService(); - var content = messageBroker.SentMessages[0].GetContentAs(); - content.UserId.Should().Be(existingUser.Id); - } + DomainUser existingUser = _fakers.DomainUser.Generate(); + existingUser.Group = _fakers.DomainGroup.Generate(); - [Fact] - public async Task Delete_user_in_group_sends_messages() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - var messageBroker = _testContext.Factory.Services.GetRequiredService(); + dbContext.Users.Add(existingUser); + await dbContext.SaveChangesAsync(); + }); - DomainUser existingUser = _fakers.DomainUser.Generate(); - existingUser.Group = _fakers.DomainGroup.Generate(); + string route = $"/domainUsers/{existingUser.StringId}"; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Users.Add(existingUser); - await dbContext.SaveChangesAsync(); - }); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); - string route = $"/domainUsers/{existingUser.StringId}"; + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); + responseDocument.Should().BeEmpty(); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] + { + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) + }, options => options.WithStrictOrdering()); - responseDocument.Should().BeEmpty(); + messageBroker.SentMessages.ShouldHaveCount(2); - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); + var content1 = messageBroker.SentMessages[0].GetContentAs(); + content1.UserId.Should().Be(existingUser.Id); + content1.GroupId.Should().Be(existingUser.Group.Id); - messageBroker.SentMessages.ShouldHaveCount(2); + var content2 = messageBroker.SentMessages[1].GetContentAs(); + content2.UserId.Should().Be(existingUser.Id); + } - var content1 = messageBroker.SentMessages[0].GetContentAs(); - content1.UserId.Should().Be(existingUser.Id); - content1.GroupId.Should().Be(existingUser.Group.Id); + [Fact] + public async Task Clear_group_from_user_sends_messages() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + var messageBroker = _testContext.Factory.Services.GetRequiredService(); - var content2 = messageBroker.SentMessages[1].GetContentAs(); - content2.UserId.Should().Be(existingUser.Id); - } + DomainUser existingUser = _fakers.DomainUser.Generate(); + existingUser.Group = _fakers.DomainGroup.Generate(); - [Fact] - public async Task Clear_group_from_user_sends_messages() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - var messageBroker = _testContext.Factory.Services.GetRequiredService(); + dbContext.Users.Add(existingUser); + await dbContext.SaveChangesAsync(); + }); - DomainUser existingUser = _fakers.DomainUser.Generate(); - existingUser.Group = _fakers.DomainGroup.Generate(); + var requestBody = new + { + data = (object?)null + }; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Users.Add(existingUser); - await dbContext.SaveChangesAsync(); - }); + string route = $"/domainUsers/{existingUser.StringId}/relationships/group"; - var requestBody = new - { - data = (object?)null - }; + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - string route = $"/domainUsers/{existingUser.StringId}/relationships/group"; + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + responseDocument.Should().BeEmpty(); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] + { + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnSetToOneRelationshipAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) + }, options => options.WithStrictOrdering()); - responseDocument.Should().BeEmpty(); + messageBroker.SentMessages.ShouldHaveCount(1); - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnSetToOneRelationshipAsync), - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); + var content = messageBroker.SentMessages[0].GetContentAs(); + content.UserId.Should().Be(existingUser.Id); + content.GroupId.Should().Be(existingUser.Group.Id); + } - messageBroker.SentMessages.ShouldHaveCount(1); + [Fact] + public async Task Assign_group_to_user_sends_messages() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + var messageBroker = _testContext.Factory.Services.GetRequiredService(); - var content = messageBroker.SentMessages[0].GetContentAs(); - content.UserId.Should().Be(existingUser.Id); - content.GroupId.Should().Be(existingUser.Group.Id); - } + DomainUser existingUser = _fakers.DomainUser.Generate(); + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); - [Fact] - public async Task Assign_group_to_user_sends_messages() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - var messageBroker = _testContext.Factory.Services.GetRequiredService(); + dbContext.AddInRange(existingUser, existingGroup); + await dbContext.SaveChangesAsync(); + }); - DomainUser existingUser = _fakers.DomainUser.Generate(); - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddInRange(existingUser, existingGroup); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new + var requestBody = new + { + data = new { - data = new - { - type = "domainGroups", - id = existingGroup.StringId - } - }; + type = "domainGroups", + id = existingGroup.StringId + } + }; - string route = $"/domainUsers/{existingUser.StringId}/relationships/group"; + string route = $"/domainUsers/{existingUser.StringId}/relationships/group"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnSetToOneRelationshipAsync), - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); + hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] + { + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnSetToOneRelationshipAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) + }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.ShouldHaveCount(1); + messageBroker.SentMessages.ShouldHaveCount(1); - var content = messageBroker.SentMessages[0].GetContentAs(); - content.UserId.Should().Be(existingUser.Id); - content.GroupId.Should().Be(existingGroup.Id); - } + var content = messageBroker.SentMessages[0].GetContentAs(); + content.UserId.Should().Be(existingUser.Id); + content.GroupId.Should().Be(existingGroup.Id); + } - [Fact] - public async Task Replace_group_for_user_sends_messages() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - var messageBroker = _testContext.Factory.Services.GetRequiredService(); + [Fact] + public async Task Replace_group_for_user_sends_messages() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + var messageBroker = _testContext.Factory.Services.GetRequiredService(); - DomainUser existingUser = _fakers.DomainUser.Generate(); - existingUser.Group = _fakers.DomainGroup.Generate(); + DomainUser existingUser = _fakers.DomainUser.Generate(); + existingUser.Group = _fakers.DomainGroup.Generate(); - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddInRange(existingUser, existingGroup); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingUser, existingGroup); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new { - data = new - { - type = "domainGroups", - id = existingGroup.StringId - } - }; + type = "domainGroups", + id = existingGroup.StringId + } + }; - string route = $"/domainUsers/{existingUser.StringId}/relationships/group"; + string route = $"/domainUsers/{existingUser.StringId}/relationships/group"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnSetToOneRelationshipAsync), - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); - - messageBroker.SentMessages.ShouldHaveCount(1); - - var content = messageBroker.SentMessages[0].GetContentAs(); - content.UserId.Should().Be(existingUser.Id); - content.BeforeGroupId.Should().Be(existingUser.Group.Id); - content.AfterGroupId.Should().Be(existingGroup.Id); - } + hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] + { + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnSetToOneRelationshipAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) + }, options => options.WithStrictOrdering()); + + messageBroker.SentMessages.ShouldHaveCount(1); + + var content = messageBroker.SentMessages[0].GetContentAs(); + content.UserId.Should().Be(existingUser.Id); + content.BeforeGroupId.Should().Be(existingUser.Group.Id); + content.AfterGroupId.Should().Be(existingGroup.Id); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.cs index 93932d1acb..89afb428f1 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.cs @@ -1,7 +1,4 @@ -using System; using System.Net; -using System.Net.Http; -using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; @@ -9,114 +6,113 @@ using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.FireAndForgetDelivery +namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.FireAndForgetDelivery; + +public sealed partial class FireForgetTests : IClassFixture, FireForgetDbContext>> { - public sealed partial class FireForgetTests : IClassFixture, FireForgetDbContext>> - { - private readonly IntegrationTestContext, FireForgetDbContext> _testContext; - private readonly DomainFakers _fakers = new(); + private readonly IntegrationTestContext, FireForgetDbContext> _testContext; + private readonly DomainFakers _fakers = new(); - public FireForgetTests(IntegrationTestContext, FireForgetDbContext> testContext) - { - _testContext = testContext; + public FireForgetTests(IntegrationTestContext, FireForgetDbContext> testContext) + { + _testContext = testContext; - testContext.UseController(); - testContext.UseController(); + testContext.UseController(); + testContext.UseController(); - testContext.ConfigureServicesAfterStartup(services => - { - services.AddResourceDefinition(); - services.AddResourceDefinition(); + testContext.ConfigureServicesAfterStartup(services => + { + services.AddResourceDefinition(); + services.AddResourceDefinition(); - services.AddSingleton(); - services.AddSingleton(); - }); + services.AddSingleton(); + services.AddSingleton(); + }); - var messageBroker = _testContext.Factory.Services.GetRequiredService(); - messageBroker.Reset(); + var messageBroker = _testContext.Factory.Services.GetRequiredService(); + messageBroker.Reset(); - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - hitCounter.Reset(); - } + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + hitCounter.Reset(); + } - [Fact] - public async Task Does_not_send_message_on_write_error() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - var messageBroker = _testContext.Factory.Services.GetRequiredService(); + [Fact] + public async Task Does_not_send_message_on_write_error() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + var messageBroker = _testContext.Factory.Services.GetRequiredService(); - string unknownUserId = Unknown.StringId.For(); + string unknownUserId = Unknown.StringId.For(); - string route = $"/domainUsers/{unknownUserId}"; + string route = $"/domainUsers/{unknownUserId}"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteDeleteAsync(route); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteDeleteAsync(route); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.NotFound); - error.Title.Should().Be("The requested resource does not exist."); - error.Detail.Should().Be($"Resource of type 'domainUsers' with ID '{unknownUserId}' does not exist."); + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("The requested resource does not exist."); + error.Detail.Should().Be($"Resource of type 'domainUsers' with ID '{unknownUserId}' does not exist."); - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync) - }, options => options.WithStrictOrdering()); + hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] + { + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync) + }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.Should().BeEmpty(); - } + messageBroker.SentMessages.Should().BeEmpty(); + } - [Fact] - public async Task Does_not_rollback_on_message_delivery_error() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); + [Fact] + public async Task Does_not_rollback_on_message_delivery_error() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); - var messageBroker = _testContext.Factory.Services.GetRequiredService(); - messageBroker.SimulateFailure = true; + var messageBroker = _testContext.Factory.Services.GetRequiredService(); + messageBroker.SimulateFailure = true; - DomainUser existingUser = _fakers.DomainUser.Generate(); + DomainUser existingUser = _fakers.DomainUser.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Users.Add(existingUser); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Users.Add(existingUser); + await dbContext.SaveChangesAsync(); + }); - string route = $"/domainUsers/{existingUser.StringId}"; + string route = $"/domainUsers/{existingUser.StringId}"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteDeleteAsync(route); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteDeleteAsync(route); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.ServiceUnavailable); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.ServiceUnavailable); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.ServiceUnavailable); - error.Title.Should().Be("Message delivery failed."); - error.Detail.Should().BeNull(); + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.ServiceUnavailable); + error.Title.Should().Be("Message delivery failed."); + error.Detail.Should().BeNull(); - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); + hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] + { + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) + }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.ShouldHaveCount(1); + messageBroker.SentMessages.ShouldHaveCount(1); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - DomainUser? user = await dbContext.Users.FirstWithIdOrDefaultAsync(existingUser.Id); - user.Should().BeNull(); - }); - } + await _testContext.RunOnDatabaseAsync(async dbContext => + { + DomainUser? user = await dbContext.Users.FirstWithIdOrDefaultAsync(existingUser.Id); + user.Should().BeNull(); + }); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetUserDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetUserDefinition.cs index 512c340222..c72cd667f0 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetUserDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetUserDefinition.cs @@ -1,51 +1,47 @@ -using System; -using System.Threading; -using System.Threading.Tasks; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCoreTests.IntegrationTests.Microservices.Messages; -namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.FireAndForgetDelivery +namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.FireAndForgetDelivery; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class FireForgetUserDefinition : MessagingUserDefinition { - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class FireForgetUserDefinition : MessagingUserDefinition + private readonly MessageBroker _messageBroker; + private DomainUser? _userToDelete; + + public FireForgetUserDefinition(IResourceGraph resourceGraph, FireForgetDbContext dbContext, MessageBroker messageBroker, + ResourceDefinitionHitCounter hitCounter) + : base(resourceGraph, dbContext.Users, hitCounter) { - private readonly MessageBroker _messageBroker; - private DomainUser? _userToDelete; + _messageBroker = messageBroker; + } - public FireForgetUserDefinition(IResourceGraph resourceGraph, FireForgetDbContext dbContext, MessageBroker messageBroker, - ResourceDefinitionHitCounter hitCounter) - : base(resourceGraph, dbContext.Users, hitCounter) - { - _messageBroker = messageBroker; - } + public override async Task OnWritingAsync(DomainUser user, WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + await base.OnWritingAsync(user, writeOperation, cancellationToken); - public override async Task OnWritingAsync(DomainUser user, WriteOperationKind writeOperation, CancellationToken cancellationToken) + if (writeOperation == WriteOperationKind.DeleteResource) { - await base.OnWritingAsync(user, writeOperation, cancellationToken); - - if (writeOperation == WriteOperationKind.DeleteResource) - { - _userToDelete = await base.GetUserToDeleteAsync(user.Id, cancellationToken); - } + _userToDelete = await base.GetUserToDeleteAsync(user.Id, cancellationToken); } + } - public override async Task OnWriteSucceededAsync(DomainUser user, WriteOperationKind writeOperation, CancellationToken cancellationToken) - { - await base.OnWriteSucceededAsync(user, writeOperation, cancellationToken); + public override async Task OnWriteSucceededAsync(DomainUser user, WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + await base.OnWriteSucceededAsync(user, writeOperation, cancellationToken); - await FinishWriteAsync(user, writeOperation, cancellationToken); - } + await FinishWriteAsync(user, writeOperation, cancellationToken); + } - protected override Task FlushMessageAsync(OutgoingMessage message, CancellationToken cancellationToken) - { - return _messageBroker.PostMessageAsync(message, cancellationToken); - } + protected override Task FlushMessageAsync(OutgoingMessage message, CancellationToken cancellationToken) + { + return _messageBroker.PostMessageAsync(message, cancellationToken); + } - protected override Task GetUserToDeleteAsync(Guid userId, CancellationToken cancellationToken) - { - return Task.FromResult(_userToDelete); - } + protected override Task GetUserToDeleteAsync(Guid userId, CancellationToken cancellationToken) + { + return Task.FromResult(_userToDelete); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/MessageBroker.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/MessageBroker.cs index 1fbf64df46..a0872e382b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/MessageBroker.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/MessageBroker.cs @@ -1,39 +1,35 @@ -using System.Collections.Generic; using System.Net; -using System.Threading; -using System.Threading.Tasks; using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreTests.IntegrationTests.Microservices.Messages; -namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.FireAndForgetDelivery +namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.FireAndForgetDelivery; + +public sealed class MessageBroker { - public sealed class MessageBroker - { - internal IList SentMessages { get; } = new List(); + internal IList SentMessages { get; } = new List(); - internal bool SimulateFailure { get; set; } + internal bool SimulateFailure { get; set; } - internal void Reset() - { - SimulateFailure = false; - SentMessages.Clear(); - } + internal void Reset() + { + SimulateFailure = false; + SentMessages.Clear(); + } - internal Task PostMessageAsync(OutgoingMessage message, CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - SentMessages.Add(message); + internal Task PostMessageAsync(OutgoingMessage message, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + SentMessages.Add(message); - if (SimulateFailure) + if (SimulateFailure) + { + throw new JsonApiException(new ErrorObject(HttpStatusCode.ServiceUnavailable) { - throw new JsonApiException(new ErrorObject(HttpStatusCode.ServiceUnavailable) - { - Title = "Message delivery failed." - }); - } - - return Task.CompletedTask; + Title = "Message delivery failed." + }); } + + return Task.CompletedTask; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/GroupCreatedContent.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/GroupCreatedContent.cs index 8b13860f7a..c7c400ae4f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/GroupCreatedContent.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/GroupCreatedContent.cs @@ -1,20 +1,18 @@ -using System; using JetBrains.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.Messages +namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.Messages; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class GroupCreatedContent : IMessageContent { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class GroupCreatedContent : IMessageContent - { - public int FormatVersion => 1; + public int FormatVersion => 1; - public Guid GroupId { get; } - public string GroupName { get; } + public Guid GroupId { get; } + public string GroupName { get; } - public GroupCreatedContent(Guid groupId, string groupName) - { - GroupId = groupId; - GroupName = groupName; - } + public GroupCreatedContent(Guid groupId, string groupName) + { + GroupId = groupId; + GroupName = groupName; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/GroupDeletedContent.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/GroupDeletedContent.cs index 7dc9d4e93f..338b88a676 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/GroupDeletedContent.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/GroupDeletedContent.cs @@ -1,18 +1,16 @@ -using System; using JetBrains.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.Messages +namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.Messages; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class GroupDeletedContent : IMessageContent { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class GroupDeletedContent : IMessageContent - { - public int FormatVersion => 1; + public int FormatVersion => 1; - public Guid GroupId { get; } + public Guid GroupId { get; } - public GroupDeletedContent(Guid groupId) - { - GroupId = groupId; - } + public GroupDeletedContent(Guid groupId) + { + GroupId = groupId; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/GroupRenamedContent.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/GroupRenamedContent.cs index 068c1dabdd..2412e9c8c1 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/GroupRenamedContent.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/GroupRenamedContent.cs @@ -1,22 +1,20 @@ -using System; using JetBrains.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.Messages +namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.Messages; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class GroupRenamedContent : IMessageContent { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class GroupRenamedContent : IMessageContent - { - public int FormatVersion => 1; + public int FormatVersion => 1; - public Guid GroupId { get; } - public string BeforeGroupName { get; } - public string AfterGroupName { get; } + public Guid GroupId { get; } + public string BeforeGroupName { get; } + public string AfterGroupName { get; } - public GroupRenamedContent(Guid groupId, string beforeGroupName, string afterGroupName) - { - GroupId = groupId; - BeforeGroupName = beforeGroupName; - AfterGroupName = afterGroupName; - } + public GroupRenamedContent(Guid groupId, string beforeGroupName, string afterGroupName) + { + GroupId = groupId; + BeforeGroupName = beforeGroupName; + AfterGroupName = afterGroupName; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/IMessageContent.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/IMessageContent.cs index 74fc43ab80..07ddcf9b10 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/IMessageContent.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/IMessageContent.cs @@ -1,8 +1,7 @@ -namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.Messages +namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.Messages; + +public interface IMessageContent { - public interface IMessageContent - { - // Increment when content structure changes. - int FormatVersion { get; } - } + // Increment when content structure changes. + int FormatVersion { get; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/OutgoingMessage.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/OutgoingMessage.cs index eb797263a8..bedbf79889 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/OutgoingMessage.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/OutgoingMessage.cs @@ -1,36 +1,37 @@ using System.Text.Json; using JetBrains.Annotations; +using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.Messages +namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.Messages; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[NoResource] +public sealed class OutgoingMessage { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class OutgoingMessage - { - public long Id { get; set; } - public string Type { get; set; } - public int FormatVersion { get; set; } - public string Content { get; set; } + public long Id { get; set; } + public string Type { get; set; } + public int FormatVersion { get; set; } + public string Content { get; set; } - private OutgoingMessage(string type, int formatVersion, string content) - { - Type = type; - FormatVersion = formatVersion; - Content = content; - } + private OutgoingMessage(string type, int formatVersion, string content) + { + Type = type; + FormatVersion = formatVersion; + Content = content; + } - public T GetContentAs() - where T : IMessageContent - { - string namespacePrefix = typeof(IMessageContent).Namespace!; - var contentType = System.Type.GetType($"{namespacePrefix}.{Type}", true)!; + public T GetContentAs() + where T : IMessageContent + { + string namespacePrefix = typeof(IMessageContent).Namespace!; + var contentType = System.Type.GetType($"{namespacePrefix}.{Type}", true)!; - return (T)JsonSerializer.Deserialize(Content, contentType)!; - } + return (T)JsonSerializer.Deserialize(Content, contentType)!; + } - public static OutgoingMessage CreateFromContent(IMessageContent content) - { - string value = JsonSerializer.Serialize(content, content.GetType()); - return new OutgoingMessage(content.GetType().Name, content.FormatVersion, value); - } + public static OutgoingMessage CreateFromContent(IMessageContent content) + { + string value = JsonSerializer.Serialize(content, content.GetType()); + return new OutgoingMessage(content.GetType().Name, content.FormatVersion, value); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserAddedToGroupContent.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserAddedToGroupContent.cs index e4cf0d0864..fd8400b9a4 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserAddedToGroupContent.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserAddedToGroupContent.cs @@ -1,20 +1,18 @@ -using System; using JetBrains.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.Messages +namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.Messages; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class UserAddedToGroupContent : IMessageContent { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class UserAddedToGroupContent : IMessageContent - { - public int FormatVersion => 1; + public int FormatVersion => 1; - public Guid UserId { get; } - public Guid GroupId { get; } + public Guid UserId { get; } + public Guid GroupId { get; } - public UserAddedToGroupContent(Guid userId, Guid groupId) - { - UserId = userId; - GroupId = groupId; - } + public UserAddedToGroupContent(Guid userId, Guid groupId) + { + UserId = userId; + GroupId = groupId; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserCreatedContent.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserCreatedContent.cs index c6de505362..8c9f2c08f7 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserCreatedContent.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserCreatedContent.cs @@ -1,22 +1,20 @@ -using System; using JetBrains.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.Messages +namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.Messages; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class UserCreatedContent : IMessageContent { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class UserCreatedContent : IMessageContent - { - public int FormatVersion => 1; + public int FormatVersion => 1; - public Guid UserId { get; } - public string UserLoginName { get; } - public string? UserDisplayName { get; } + public Guid UserId { get; } + public string UserLoginName { get; } + public string? UserDisplayName { get; } - public UserCreatedContent(Guid userId, string userLoginName, string? userDisplayName) - { - UserId = userId; - UserLoginName = userLoginName; - UserDisplayName = userDisplayName; - } + public UserCreatedContent(Guid userId, string userLoginName, string? userDisplayName) + { + UserId = userId; + UserLoginName = userLoginName; + UserDisplayName = userDisplayName; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserDeletedContent.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserDeletedContent.cs index 21d5789b25..f0a5a30102 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserDeletedContent.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserDeletedContent.cs @@ -1,18 +1,16 @@ -using System; using JetBrains.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.Messages +namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.Messages; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class UserDeletedContent : IMessageContent { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class UserDeletedContent : IMessageContent - { - public int FormatVersion => 1; + public int FormatVersion => 1; - public Guid UserId { get; } + public Guid UserId { get; } - public UserDeletedContent(Guid userId) - { - UserId = userId; - } + public UserDeletedContent(Guid userId) + { + UserId = userId; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserDisplayNameChangedContent.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserDisplayNameChangedContent.cs index 64be5883ab..aff93ee7db 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserDisplayNameChangedContent.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserDisplayNameChangedContent.cs @@ -1,22 +1,20 @@ -using System; using JetBrains.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.Messages +namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.Messages; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class UserDisplayNameChangedContent : IMessageContent { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class UserDisplayNameChangedContent : IMessageContent - { - public int FormatVersion => 1; + public int FormatVersion => 1; - public Guid UserId { get; } - public string? BeforeUserDisplayName { get; } - public string? AfterUserDisplayName { get; } + public Guid UserId { get; } + public string? BeforeUserDisplayName { get; } + public string? AfterUserDisplayName { get; } - public UserDisplayNameChangedContent(Guid userId, string? beforeUserDisplayName, string? afterUserDisplayName) - { - UserId = userId; - BeforeUserDisplayName = beforeUserDisplayName; - AfterUserDisplayName = afterUserDisplayName; - } + public UserDisplayNameChangedContent(Guid userId, string? beforeUserDisplayName, string? afterUserDisplayName) + { + UserId = userId; + BeforeUserDisplayName = beforeUserDisplayName; + AfterUserDisplayName = afterUserDisplayName; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserLoginNameChangedContent.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserLoginNameChangedContent.cs index 8adc213fa2..d835220aa0 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserLoginNameChangedContent.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserLoginNameChangedContent.cs @@ -1,22 +1,20 @@ -using System; using JetBrains.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.Messages +namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.Messages; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class UserLoginNameChangedContent : IMessageContent { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class UserLoginNameChangedContent : IMessageContent - { - public int FormatVersion => 1; + public int FormatVersion => 1; - public Guid UserId { get; } - public string BeforeUserLoginName { get; } - public string AfterUserLoginName { get; } + public Guid UserId { get; } + public string BeforeUserLoginName { get; } + public string AfterUserLoginName { get; } - public UserLoginNameChangedContent(Guid userId, string beforeUserLoginName, string afterUserLoginName) - { - UserId = userId; - BeforeUserLoginName = beforeUserLoginName; - AfterUserLoginName = afterUserLoginName; - } + public UserLoginNameChangedContent(Guid userId, string beforeUserLoginName, string afterUserLoginName) + { + UserId = userId; + BeforeUserLoginName = beforeUserLoginName; + AfterUserLoginName = afterUserLoginName; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserMovedToGroupContent.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserMovedToGroupContent.cs index 2f1234734e..766422e595 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserMovedToGroupContent.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserMovedToGroupContent.cs @@ -1,22 +1,20 @@ -using System; using JetBrains.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.Messages +namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.Messages; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class UserMovedToGroupContent : IMessageContent { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class UserMovedToGroupContent : IMessageContent - { - public int FormatVersion => 1; + public int FormatVersion => 1; - public Guid UserId { get; } - public Guid BeforeGroupId { get; } - public Guid AfterGroupId { get; } + public Guid UserId { get; } + public Guid BeforeGroupId { get; } + public Guid AfterGroupId { get; } - public UserMovedToGroupContent(Guid userId, Guid beforeGroupId, Guid afterGroupId) - { - UserId = userId; - BeforeGroupId = beforeGroupId; - AfterGroupId = afterGroupId; - } + public UserMovedToGroupContent(Guid userId, Guid beforeGroupId, Guid afterGroupId) + { + UserId = userId; + BeforeGroupId = beforeGroupId; + AfterGroupId = afterGroupId; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserRemovedFromGroupContent.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserRemovedFromGroupContent.cs index 8bc1805942..8623d101b9 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserRemovedFromGroupContent.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserRemovedFromGroupContent.cs @@ -1,20 +1,18 @@ -using System; using JetBrains.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.Messages +namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.Messages; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class UserRemovedFromGroupContent : IMessageContent { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class UserRemovedFromGroupContent : IMessageContent - { - public int FormatVersion => 1; + public int FormatVersion => 1; - public Guid UserId { get; } - public Guid GroupId { get; } + public Guid UserId { get; } + public Guid GroupId { get; } - public UserRemovedFromGroupContent(Guid userId, Guid groupId) - { - UserId = userId; - GroupId = groupId; - } + public UserRemovedFromGroupContent(Guid userId, Guid groupId) + { + UserId = userId; + GroupId = groupId; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/MessagingGroupDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/MessagingGroupDefinition.cs index e08f8398e8..ce8e10c62e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/MessagingGroupDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/MessagingGroupDefinition.cs @@ -1,8 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources; @@ -10,176 +5,175 @@ using JsonApiDotNetCoreTests.IntegrationTests.Microservices.Messages; using Microsoft.EntityFrameworkCore; -namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices +namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices; + +public abstract class MessagingGroupDefinition : HitCountingResourceDefinition { - public abstract class MessagingGroupDefinition : HitCountingResourceDefinition - { - private readonly DbSet _userSet; - private readonly DbSet _groupSet; - private readonly List _pendingMessages = new(); + private readonly DbSet _userSet; + private readonly DbSet _groupSet; + private readonly List _pendingMessages = new(); + + private string? _beforeGroupName; + + protected override ResourceDefinitionExtensibilityPoints ExtensibilityPointsToTrack => ResourceDefinitionExtensibilityPoints.Writing; - private string? _beforeGroupName; + protected MessagingGroupDefinition(IResourceGraph resourceGraph, DbSet userSet, DbSet groupSet, + ResourceDefinitionHitCounter hitCounter) + : base(resourceGraph, hitCounter) + { + _userSet = userSet; + _groupSet = groupSet; + } - protected override ResourceDefinitionExtensibilityPoints ExtensibilityPointsToTrack => ResourceDefinitionExtensibilityPoints.Writing; + public override async Task OnPrepareWriteAsync(DomainGroup group, WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + await base.OnPrepareWriteAsync(group, writeOperation, cancellationToken); - protected MessagingGroupDefinition(IResourceGraph resourceGraph, DbSet userSet, DbSet groupSet, - ResourceDefinitionHitCounter hitCounter) - : base(resourceGraph, hitCounter) + if (writeOperation == WriteOperationKind.CreateResource) { - _userSet = userSet; - _groupSet = groupSet; + group.Id = Guid.NewGuid(); } - - public override async Task OnPrepareWriteAsync(DomainGroup group, WriteOperationKind writeOperation, CancellationToken cancellationToken) + else if (writeOperation == WriteOperationKind.UpdateResource) { - await base.OnPrepareWriteAsync(group, writeOperation, cancellationToken); - - if (writeOperation == WriteOperationKind.CreateResource) - { - group.Id = Guid.NewGuid(); - } - else if (writeOperation == WriteOperationKind.UpdateResource) - { - _beforeGroupName = group.Name; - } + _beforeGroupName = group.Name; } + } + + public override async Task OnSetToManyRelationshipAsync(DomainGroup group, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + await base.OnSetToManyRelationshipAsync(group, hasManyRelationship, rightResourceIds, writeOperation, cancellationToken); - public override async Task OnSetToManyRelationshipAsync(DomainGroup group, HasManyAttribute hasManyRelationship, ISet rightResourceIds, - WriteOperationKind writeOperation, CancellationToken cancellationToken) + if (hasManyRelationship.Property.Name == nameof(DomainGroup.Users)) { - await base.OnSetToManyRelationshipAsync(group, hasManyRelationship, rightResourceIds, writeOperation, cancellationToken); + HashSet rightUserIds = rightResourceIds.Select(resource => (Guid)resource.GetTypedId()).ToHashSet(); - if (hasManyRelationship.Property.Name == nameof(DomainGroup.Users)) - { - HashSet rightUserIds = rightResourceIds.Select(resource => (Guid)resource.GetTypedId()).ToHashSet(); + List beforeUsers = await _userSet.Include(user => user.Group).Where(user => rightUserIds.Contains(user.Id)) + .ToListAsync(cancellationToken); - List beforeUsers = await _userSet.Include(user => user.Group).Where(user => rightUserIds.Contains(user.Id)) - .ToListAsync(cancellationToken); + foreach (DomainUser beforeUser in beforeUsers) + { + IMessageContent? content = null; - foreach (DomainUser beforeUser in beforeUsers) + if (beforeUser.Group == null) + { + content = new UserAddedToGroupContent(beforeUser.Id, group.Id); + } + else if (beforeUser.Group != null && beforeUser.Group.Id != group.Id) { - IMessageContent? content = null; - - if (beforeUser.Group == null) - { - content = new UserAddedToGroupContent(beforeUser.Id, group.Id); - } - else if (beforeUser.Group != null && beforeUser.Group.Id != group.Id) - { - content = new UserMovedToGroupContent(beforeUser.Id, beforeUser.Group.Id, group.Id); - } - - if (content != null) - { - var message = OutgoingMessage.CreateFromContent(content); - _pendingMessages.Add(message); - } + content = new UserMovedToGroupContent(beforeUser.Id, beforeUser.Group.Id, group.Id); } - foreach (DomainUser userToRemoveFromGroup in group.Users.Where(user => !rightUserIds.Contains(user.Id))) + if (content != null) { - var content = new UserRemovedFromGroupContent(userToRemoveFromGroup.Id, group.Id); var message = OutgoingMessage.CreateFromContent(content); _pendingMessages.Add(message); } } + + foreach (DomainUser userToRemoveFromGroup in group.Users.Where(user => !rightUserIds.Contains(user.Id))) + { + var content = new UserRemovedFromGroupContent(userToRemoveFromGroup.Id, group.Id); + var message = OutgoingMessage.CreateFromContent(content); + _pendingMessages.Add(message); + } } + } - public override async Task OnAddToRelationshipAsync(Guid groupId, HasManyAttribute hasManyRelationship, ISet rightResourceIds, - CancellationToken cancellationToken) + public override async Task OnAddToRelationshipAsync(Guid groupId, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + CancellationToken cancellationToken) + { + await base.OnAddToRelationshipAsync(groupId, hasManyRelationship, rightResourceIds, cancellationToken); + + if (hasManyRelationship.Property.Name == nameof(DomainGroup.Users)) { - await base.OnAddToRelationshipAsync(groupId, hasManyRelationship, rightResourceIds, cancellationToken); + HashSet rightUserIds = rightResourceIds.Select(resource => (Guid)resource.GetTypedId()).ToHashSet(); - if (hasManyRelationship.Property.Name == nameof(DomainGroup.Users)) + List beforeUsers = await _userSet.Include(user => user.Group).Where(user => rightUserIds.Contains(user.Id)) + .ToListAsync(cancellationToken); + + foreach (DomainUser beforeUser in beforeUsers) { - HashSet rightUserIds = rightResourceIds.Select(resource => (Guid)resource.GetTypedId()).ToHashSet(); + IMessageContent? content = null; - List beforeUsers = await _userSet.Include(user => user.Group).Where(user => rightUserIds.Contains(user.Id)) - .ToListAsync(cancellationToken); + if (beforeUser.Group == null) + { + content = new UserAddedToGroupContent(beforeUser.Id, groupId); + } + else if (beforeUser.Group != null && beforeUser.Group.Id != groupId) + { + content = new UserMovedToGroupContent(beforeUser.Id, beforeUser.Group.Id, groupId); + } - foreach (DomainUser beforeUser in beforeUsers) + if (content != null) { - IMessageContent? content = null; - - if (beforeUser.Group == null) - { - content = new UserAddedToGroupContent(beforeUser.Id, groupId); - } - else if (beforeUser.Group != null && beforeUser.Group.Id != groupId) - { - content = new UserMovedToGroupContent(beforeUser.Id, beforeUser.Group.Id, groupId); - } - - if (content != null) - { - var message = OutgoingMessage.CreateFromContent(content); - _pendingMessages.Add(message); - } + var message = OutgoingMessage.CreateFromContent(content); + _pendingMessages.Add(message); } } } + } + + public override async Task OnRemoveFromRelationshipAsync(DomainGroup group, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + CancellationToken cancellationToken) + { + await base.OnRemoveFromRelationshipAsync(group, hasManyRelationship, rightResourceIds, cancellationToken); - public override async Task OnRemoveFromRelationshipAsync(DomainGroup group, HasManyAttribute hasManyRelationship, ISet rightResourceIds, - CancellationToken cancellationToken) + if (hasManyRelationship.Property.Name == nameof(DomainGroup.Users)) { - await base.OnRemoveFromRelationshipAsync(group, hasManyRelationship, rightResourceIds, cancellationToken); + HashSet rightUserIds = rightResourceIds.Select(resource => (Guid)resource.GetTypedId()).ToHashSet(); - if (hasManyRelationship.Property.Name == nameof(DomainGroup.Users)) + foreach (DomainUser userToRemoveFromGroup in group.Users.Where(user => rightUserIds.Contains(user.Id))) { - HashSet rightUserIds = rightResourceIds.Select(resource => (Guid)resource.GetTypedId()).ToHashSet(); - - foreach (DomainUser userToRemoveFromGroup in group.Users.Where(user => rightUserIds.Contains(user.Id))) - { - var content = new UserRemovedFromGroupContent(userToRemoveFromGroup.Id, group.Id); - var message = OutgoingMessage.CreateFromContent(content); - _pendingMessages.Add(message); - } + var content = new UserRemovedFromGroupContent(userToRemoveFromGroup.Id, group.Id); + var message = OutgoingMessage.CreateFromContent(content); + _pendingMessages.Add(message); } } + } - protected async Task FinishWriteAsync(DomainGroup group, WriteOperationKind writeOperation, CancellationToken cancellationToken) + protected async Task FinishWriteAsync(DomainGroup group, WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + if (writeOperation == WriteOperationKind.CreateResource) { - if (writeOperation == WriteOperationKind.CreateResource) + var message = OutgoingMessage.CreateFromContent(new GroupCreatedContent(group.Id, group.Name)); + await FlushMessageAsync(message, cancellationToken); + } + else if (writeOperation == WriteOperationKind.UpdateResource) + { + if (_beforeGroupName != group.Name) { - var message = OutgoingMessage.CreateFromContent(new GroupCreatedContent(group.Id, group.Name)); + var message = OutgoingMessage.CreateFromContent(new GroupRenamedContent(group.Id, _beforeGroupName!, group.Name)); await FlushMessageAsync(message, cancellationToken); } - else if (writeOperation == WriteOperationKind.UpdateResource) - { - if (_beforeGroupName != group.Name) - { - var message = OutgoingMessage.CreateFromContent(new GroupRenamedContent(group.Id, _beforeGroupName!, group.Name)); - await FlushMessageAsync(message, cancellationToken); - } - } - else if (writeOperation == WriteOperationKind.DeleteResource) - { - DomainGroup? groupToDelete = await GetGroupToDeleteAsync(group.Id, cancellationToken); + } + else if (writeOperation == WriteOperationKind.DeleteResource) + { + DomainGroup? groupToDelete = await GetGroupToDeleteAsync(group.Id, cancellationToken); - if (groupToDelete != null) + if (groupToDelete != null) + { + foreach (DomainUser user in groupToDelete.Users) { - foreach (DomainUser user in groupToDelete.Users) - { - var removeMessage = OutgoingMessage.CreateFromContent(new UserRemovedFromGroupContent(user.Id, group.Id)); - await FlushMessageAsync(removeMessage, cancellationToken); - } + var removeMessage = OutgoingMessage.CreateFromContent(new UserRemovedFromGroupContent(user.Id, group.Id)); + await FlushMessageAsync(removeMessage, cancellationToken); } - - var deleteMessage = OutgoingMessage.CreateFromContent(new GroupDeletedContent(group.Id)); - await FlushMessageAsync(deleteMessage, cancellationToken); } - foreach (OutgoingMessage nextMessage in _pendingMessages) - { - await FlushMessageAsync(nextMessage, cancellationToken); - } + var deleteMessage = OutgoingMessage.CreateFromContent(new GroupDeletedContent(group.Id)); + await FlushMessageAsync(deleteMessage, cancellationToken); } - protected abstract Task FlushMessageAsync(OutgoingMessage message, CancellationToken cancellationToken); - - protected virtual async Task GetGroupToDeleteAsync(Guid groupId, CancellationToken cancellationToken) + foreach (OutgoingMessage nextMessage in _pendingMessages) { - return await _groupSet.Include(group => group.Users).FirstOrDefaultAsync(group => group.Id == groupId, cancellationToken); + await FlushMessageAsync(nextMessage, cancellationToken); } } + + protected abstract Task FlushMessageAsync(OutgoingMessage message, CancellationToken cancellationToken); + + protected virtual async Task GetGroupToDeleteAsync(Guid groupId, CancellationToken cancellationToken) + { + return await _groupSet.Include(group => group.Users).FirstOrDefaultAsync(group => group.Id == groupId, cancellationToken); + } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/MessagingUserDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/MessagingUserDefinition.cs index 4af5076cca..499c572b88 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/MessagingUserDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/MessagingUserDefinition.cs @@ -1,7 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources; @@ -9,122 +5,121 @@ using JsonApiDotNetCoreTests.IntegrationTests.Microservices.Messages; using Microsoft.EntityFrameworkCore; -namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices +namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices; + +public abstract class MessagingUserDefinition : HitCountingResourceDefinition { - public abstract class MessagingUserDefinition : HitCountingResourceDefinition - { - private readonly DbSet _userSet; - private readonly List _pendingMessages = new(); + private readonly DbSet _userSet; + private readonly List _pendingMessages = new(); + + private string? _beforeLoginName; + private string? _beforeDisplayName; - private string? _beforeLoginName; - private string? _beforeDisplayName; + protected override ResourceDefinitionExtensibilityPoints ExtensibilityPointsToTrack => ResourceDefinitionExtensibilityPoints.Writing; - protected override ResourceDefinitionExtensibilityPoints ExtensibilityPointsToTrack => ResourceDefinitionExtensibilityPoints.Writing; + protected MessagingUserDefinition(IResourceGraph resourceGraph, DbSet userSet, ResourceDefinitionHitCounter hitCounter) + : base(resourceGraph, hitCounter) + { + _userSet = userSet; + } - protected MessagingUserDefinition(IResourceGraph resourceGraph, DbSet userSet, ResourceDefinitionHitCounter hitCounter) - : base(resourceGraph, hitCounter) + public override async Task OnPrepareWriteAsync(DomainUser user, WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + await base.OnPrepareWriteAsync(user, writeOperation, cancellationToken); + + if (writeOperation == WriteOperationKind.CreateResource) + { + user.Id = Guid.NewGuid(); + } + else if (writeOperation == WriteOperationKind.UpdateResource) { - _userSet = userSet; + _beforeLoginName = user.LoginName; + _beforeDisplayName = user.DisplayName; } + } - public override async Task OnPrepareWriteAsync(DomainUser user, WriteOperationKind writeOperation, CancellationToken cancellationToken) + public override async Task OnSetToOneRelationshipAsync(DomainUser user, HasOneAttribute hasOneRelationship, IIdentifiable? rightResourceId, + WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + await base.OnSetToOneRelationshipAsync(user, hasOneRelationship, rightResourceId, writeOperation, cancellationToken); + + if (hasOneRelationship.Property.Name == nameof(DomainUser.Group)) { - await base.OnPrepareWriteAsync(user, writeOperation, cancellationToken); + var afterGroupId = (Guid?)rightResourceId?.GetTypedId(); + IMessageContent? content = null; - if (writeOperation == WriteOperationKind.CreateResource) + if (user.Group != null && afterGroupId == null) { - user.Id = Guid.NewGuid(); + content = new UserRemovedFromGroupContent(user.Id, user.Group.Id); } - else if (writeOperation == WriteOperationKind.UpdateResource) + else if (user.Group == null && afterGroupId != null) { - _beforeLoginName = user.LoginName; - _beforeDisplayName = user.DisplayName; + content = new UserAddedToGroupContent(user.Id, afterGroupId.Value); } - } - - public override async Task OnSetToOneRelationshipAsync(DomainUser user, HasOneAttribute hasOneRelationship, - IIdentifiable? rightResourceId, WriteOperationKind writeOperation, CancellationToken cancellationToken) - { - await base.OnSetToOneRelationshipAsync(user, hasOneRelationship, rightResourceId, writeOperation, cancellationToken); - - if (hasOneRelationship.Property.Name == nameof(DomainUser.Group)) + else if (user.Group != null && afterGroupId != null && user.Group.Id != afterGroupId) { - var afterGroupId = (Guid?)rightResourceId?.GetTypedId(); - IMessageContent? content = null; - - if (user.Group != null && afterGroupId == null) - { - content = new UserRemovedFromGroupContent(user.Id, user.Group.Id); - } - else if (user.Group == null && afterGroupId != null) - { - content = new UserAddedToGroupContent(user.Id, afterGroupId.Value); - } - else if (user.Group != null && afterGroupId != null && user.Group.Id != afterGroupId) - { - content = new UserMovedToGroupContent(user.Id, user.Group.Id, afterGroupId.Value); - } - - if (content != null) - { - var message = OutgoingMessage.CreateFromContent(content); - _pendingMessages.Add(message); - } + content = new UserMovedToGroupContent(user.Id, user.Group.Id, afterGroupId.Value); } - return rightResourceId; + if (content != null) + { + var message = OutgoingMessage.CreateFromContent(content); + _pendingMessages.Add(message); + } } - protected async Task FinishWriteAsync(DomainUser user, WriteOperationKind writeOperation, CancellationToken cancellationToken) + return rightResourceId; + } + + protected async Task FinishWriteAsync(DomainUser user, WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + if (writeOperation == WriteOperationKind.CreateResource) + { + var content = new UserCreatedContent(user.Id, user.LoginName, user.DisplayName); + var message = OutgoingMessage.CreateFromContent(content); + await FlushMessageAsync(message, cancellationToken); + } + else if (writeOperation == WriteOperationKind.UpdateResource) { - if (writeOperation == WriteOperationKind.CreateResource) + if (_beforeLoginName != user.LoginName) { - var content = new UserCreatedContent(user.Id, user.LoginName, user.DisplayName); + var content = new UserLoginNameChangedContent(user.Id, _beforeLoginName!, user.LoginName); var message = OutgoingMessage.CreateFromContent(content); await FlushMessageAsync(message, cancellationToken); } - else if (writeOperation == WriteOperationKind.UpdateResource) - { - if (_beforeLoginName != user.LoginName) - { - var content = new UserLoginNameChangedContent(user.Id, _beforeLoginName!, user.LoginName); - var message = OutgoingMessage.CreateFromContent(content); - await FlushMessageAsync(message, cancellationToken); - } - - if (_beforeDisplayName != user.DisplayName) - { - var content = new UserDisplayNameChangedContent(user.Id, _beforeDisplayName!, user.DisplayName); - var message = OutgoingMessage.CreateFromContent(content); - await FlushMessageAsync(message, cancellationToken); - } - } - else if (writeOperation == WriteOperationKind.DeleteResource) - { - DomainUser? userToDelete = await GetUserToDeleteAsync(user.Id, cancellationToken); - - if (userToDelete?.Group != null) - { - var content = new UserRemovedFromGroupContent(user.Id, userToDelete.Group.Id); - var message = OutgoingMessage.CreateFromContent(content); - await FlushMessageAsync(message, cancellationToken); - } - var deleteMessage = OutgoingMessage.CreateFromContent(new UserDeletedContent(user.Id)); - await FlushMessageAsync(deleteMessage, cancellationToken); + if (_beforeDisplayName != user.DisplayName) + { + var content = new UserDisplayNameChangedContent(user.Id, _beforeDisplayName!, user.DisplayName); + var message = OutgoingMessage.CreateFromContent(content); + await FlushMessageAsync(message, cancellationToken); } + } + else if (writeOperation == WriteOperationKind.DeleteResource) + { + DomainUser? userToDelete = await GetUserToDeleteAsync(user.Id, cancellationToken); - foreach (OutgoingMessage nextMessage in _pendingMessages) + if (userToDelete?.Group != null) { - await FlushMessageAsync(nextMessage, cancellationToken); + var content = new UserRemovedFromGroupContent(user.Id, userToDelete.Group.Id); + var message = OutgoingMessage.CreateFromContent(content); + await FlushMessageAsync(message, cancellationToken); } - } - protected abstract Task FlushMessageAsync(OutgoingMessage message, CancellationToken cancellationToken); + var deleteMessage = OutgoingMessage.CreateFromContent(new UserDeletedContent(user.Id)); + await FlushMessageAsync(deleteMessage, cancellationToken); + } - protected virtual async Task GetUserToDeleteAsync(Guid userId, CancellationToken cancellationToken) + foreach (OutgoingMessage nextMessage in _pendingMessages) { - return await _userSet.Include(domainUser => domainUser.Group).FirstOrDefaultAsync(domainUser => domainUser.Id == userId, cancellationToken); + await FlushMessageAsync(nextMessage, cancellationToken); } } + + protected abstract Task FlushMessageAsync(OutgoingMessage message, CancellationToken cancellationToken); + + protected virtual async Task GetUserToDeleteAsync(Guid userId, CancellationToken cancellationToken) + { + return await _userSet.Include(domainUser => domainUser.Group).FirstOrDefaultAsync(domainUser => domainUser.Id == userId, cancellationToken); + } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxDbContext.cs index 87e8d63707..f2a88e1c8d 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxDbContext.cs @@ -2,18 +2,17 @@ using JsonApiDotNetCoreTests.IntegrationTests.Microservices.Messages; using Microsoft.EntityFrameworkCore; -namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.TransactionalOutboxPattern +namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.TransactionalOutboxPattern; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class OutboxDbContext : DbContext { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class OutboxDbContext : DbContext - { - public DbSet Users => Set(); - public DbSet Groups => Set(); - public DbSet OutboxMessages => Set(); + public DbSet Users => Set(); + public DbSet Groups => Set(); + public DbSet OutboxMessages => Set(); - public OutboxDbContext(DbContextOptions options) - : base(options) - { - } + public OutboxDbContext(DbContextOptions options) + : base(options) + { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxGroupDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxGroupDefinition.cs index f79bce5e00..8aaef59e35 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxGroupDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxGroupDefinition.cs @@ -1,34 +1,31 @@ -using System.Threading; -using System.Threading.Tasks; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCoreTests.IntegrationTests.Microservices.Messages; using Microsoft.EntityFrameworkCore; -namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.TransactionalOutboxPattern +namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.TransactionalOutboxPattern; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class OutboxGroupDefinition : MessagingGroupDefinition { - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class OutboxGroupDefinition : MessagingGroupDefinition - { - private readonly DbSet _outboxMessageSet; + private readonly DbSet _outboxMessageSet; - public OutboxGroupDefinition(IResourceGraph resourceGraph, OutboxDbContext dbContext, ResourceDefinitionHitCounter hitCounter) - : base(resourceGraph, dbContext.Users, dbContext.Groups, hitCounter) - { - _outboxMessageSet = dbContext.OutboxMessages; - } + public OutboxGroupDefinition(IResourceGraph resourceGraph, OutboxDbContext dbContext, ResourceDefinitionHitCounter hitCounter) + : base(resourceGraph, dbContext.Users, dbContext.Groups, hitCounter) + { + _outboxMessageSet = dbContext.OutboxMessages; + } - public override async Task OnWritingAsync(DomainGroup group, WriteOperationKind writeOperation, CancellationToken cancellationToken) - { - await base.OnWritingAsync(group, writeOperation, cancellationToken); + public override async Task OnWritingAsync(DomainGroup group, WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + await base.OnWritingAsync(group, writeOperation, cancellationToken); - await FinishWriteAsync(group, writeOperation, cancellationToken); - } + await FinishWriteAsync(group, writeOperation, cancellationToken); + } - protected override async Task FlushMessageAsync(OutgoingMessage message, CancellationToken cancellationToken) - { - await _outboxMessageSet.AddAsync(message, cancellationToken); - } + protected override async Task FlushMessageAsync(OutgoingMessage message, CancellationToken cancellationToken) + { + await _outboxMessageSet.AddAsync(message, cancellationToken); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.Group.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.Group.cs index 4bbe4e4aa7..a5c61d1f70 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.Group.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.Group.cs @@ -1,9 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; using System.Net; -using System.Net.Http; -using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreTests.IntegrationTests.Microservices.Messages; @@ -12,619 +7,618 @@ using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.TransactionalOutboxPattern +namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.TransactionalOutboxPattern; + +public sealed partial class OutboxTests { - public sealed partial class OutboxTests + [Fact] + public async Task Create_group_writes_to_outbox() { - [Fact] - public async Task Create_group_writes_to_outbox() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); - string newGroupName = _fakers.DomainGroup.Generate().Name; + string newGroupName = _fakers.DomainGroup.Generate().Name; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new { - data = new + type = "domainGroups", + attributes = new { - type = "domainGroups", - attributes = new - { - name = newGroupName - } + name = newGroupName } - }; + } + }; - const string route = "/domainGroups"; + const string route = "/domainGroups"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newGroupName)); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newGroupName)); - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), - (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync), - (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); + hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] + { + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) + }, options => options.WithStrictOrdering()); - Guid newGroupId = Guid.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); + Guid newGroupId = Guid.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.ShouldHaveCount(1); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.ShouldHaveCount(1); - var content = messages[0].GetContentAs(); - content.GroupId.Should().Be(newGroupId); - content.GroupName.Should().Be(newGroupName); - }); - } + var content = messages[0].GetContentAs(); + content.GroupId.Should().Be(newGroupId); + content.GroupName.Should().Be(newGroupName); + }); + } - [Fact] - public async Task Create_group_with_users_writes_to_outbox() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); + [Fact] + public async Task Create_group_with_users_writes_to_outbox() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); - DomainUser existingUserWithoutGroup = _fakers.DomainUser.Generate(); + DomainUser existingUserWithoutGroup = _fakers.DomainUser.Generate(); - DomainUser existingUserWithOtherGroup = _fakers.DomainUser.Generate(); - existingUserWithOtherGroup.Group = _fakers.DomainGroup.Generate(); + DomainUser existingUserWithOtherGroup = _fakers.DomainUser.Generate(); + existingUserWithOtherGroup.Group = _fakers.DomainGroup.Generate(); - string newGroupName = _fakers.DomainGroup.Generate().Name; + string newGroupName = _fakers.DomainGroup.Generate().Name; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Users.AddRange(existingUserWithoutGroup, existingUserWithOtherGroup); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Users.AddRange(existingUserWithoutGroup, existingUserWithOtherGroup); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new { - data = new + type = "domainGroups", + attributes = new { - type = "domainGroups", - attributes = new - { - name = newGroupName - }, - relationships = new + name = newGroupName + }, + relationships = new + { + users = new { - users = new + data = new[] { - data = new[] + new { - new - { - type = "domainUsers", - id = existingUserWithoutGroup.StringId - }, - new - { - type = "domainUsers", - id = existingUserWithOtherGroup.StringId - } + type = "domainUsers", + id = existingUserWithoutGroup.StringId + }, + new + { + type = "domainUsers", + id = existingUserWithOtherGroup.StringId } } } } - }; + } + }; - const string route = "/domainGroups"; + const string route = "/domainGroups"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newGroupName)); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newGroupName)); - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), - (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnSetToManyRelationshipAsync), - (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync), - (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); + hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] + { + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnSetToManyRelationshipAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) + }, options => options.WithStrictOrdering()); - Guid newGroupId = Guid.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); + Guid newGroupId = Guid.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.ShouldHaveCount(3); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.ShouldHaveCount(3); + + var content1 = messages[0].GetContentAs(); + content1.GroupId.Should().Be(newGroupId); + content1.GroupName.Should().Be(newGroupName); + + var content2 = messages[1].GetContentAs(); + content2.UserId.Should().Be(existingUserWithoutGroup.Id); + content2.GroupId.Should().Be(newGroupId); + + var content3 = messages[2].GetContentAs(); + content3.UserId.Should().Be(existingUserWithOtherGroup.Id); + content3.BeforeGroupId.Should().Be(existingUserWithOtherGroup.Group.Id); + content3.AfterGroupId.Should().Be(newGroupId); + }); + } - var content1 = messages[0].GetContentAs(); - content1.GroupId.Should().Be(newGroupId); - content1.GroupName.Should().Be(newGroupName); + [Fact] + public async Task Update_group_writes_to_outbox() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); - var content2 = messages[1].GetContentAs(); - content2.UserId.Should().Be(existingUserWithoutGroup.Id); - content2.GroupId.Should().Be(newGroupId); + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); - var content3 = messages[2].GetContentAs(); - content3.UserId.Should().Be(existingUserWithOtherGroup.Id); - content3.BeforeGroupId.Should().Be(existingUserWithOtherGroup.Group.Id); - content3.AfterGroupId.Should().Be(newGroupId); - }); - } + string newGroupName = _fakers.DomainGroup.Generate().Name; - [Fact] - public async Task Update_group_writes_to_outbox() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); - - string newGroupName = _fakers.DomainGroup.Generate().Name; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Groups.Add(existingGroup); - await dbContext.SaveChangesAsync(); - }); + await dbContext.ClearTableAsync(); + dbContext.Groups.Add(existingGroup); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new { - data = new + type = "domainGroups", + id = existingGroup.StringId, + attributes = new { - type = "domainGroups", - id = existingGroup.StringId, - attributes = new - { - name = newGroupName - } + name = newGroupName } - }; + } + }; - string route = $"/domainGroups/{existingGroup.StringId}"; + string route = $"/domainGroups/{existingGroup.StringId}"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), - (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync), - (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.ShouldHaveCount(1); + responseDocument.Should().BeEmpty(); - var content = messages[0].GetContentAs(); - content.GroupId.Should().Be(existingGroup.StringId); - content.BeforeGroupName.Should().Be(existingGroup.Name); - content.AfterGroupName.Should().Be(newGroupName); - }); - } + hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] + { + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) + }, options => options.WithStrictOrdering()); - [Fact] - public async Task Update_group_with_users_writes_to_outbox() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.ShouldHaveCount(1); + + var content = messages[0].GetContentAs(); + content.GroupId.Should().Be(existingGroup.StringId); + content.BeforeGroupName.Should().Be(existingGroup.Name); + content.AfterGroupName.Should().Be(newGroupName); + }); + } - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + [Fact] + public async Task Update_group_with_users_writes_to_outbox() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); - DomainUser existingUserWithoutGroup = _fakers.DomainUser.Generate(); + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); - DomainUser existingUserWithSameGroup1 = _fakers.DomainUser.Generate(); - existingUserWithSameGroup1.Group = existingGroup; + DomainUser existingUserWithoutGroup = _fakers.DomainUser.Generate(); - DomainUser existingUserWithSameGroup2 = _fakers.DomainUser.Generate(); - existingUserWithSameGroup2.Group = existingGroup; + DomainUser existingUserWithSameGroup1 = _fakers.DomainUser.Generate(); + existingUserWithSameGroup1.Group = existingGroup; - DomainUser existingUserWithOtherGroup = _fakers.DomainUser.Generate(); - existingUserWithOtherGroup.Group = _fakers.DomainGroup.Generate(); + DomainUser existingUserWithSameGroup2 = _fakers.DomainUser.Generate(); + existingUserWithSameGroup2.Group = existingGroup; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Users.AddRange(existingUserWithoutGroup, existingUserWithSameGroup1, existingUserWithSameGroup2, existingUserWithOtherGroup); - await dbContext.SaveChangesAsync(); - }); + DomainUser existingUserWithOtherGroup = _fakers.DomainUser.Generate(); + existingUserWithOtherGroup.Group = _fakers.DomainGroup.Generate(); - var requestBody = new + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Users.AddRange(existingUserWithoutGroup, existingUserWithSameGroup1, existingUserWithSameGroup2, existingUserWithOtherGroup); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new { - data = new + type = "domainGroups", + id = existingGroup.StringId, + relationships = new { - type = "domainGroups", - id = existingGroup.StringId, - relationships = new + users = new { - users = new + data = new[] { - data = new[] + new { - new - { - type = "domainUsers", - id = existingUserWithoutGroup.StringId - }, - new - { - type = "domainUsers", - id = existingUserWithSameGroup1.StringId - }, - new - { - type = "domainUsers", - id = existingUserWithOtherGroup.StringId - } + type = "domainUsers", + id = existingUserWithoutGroup.StringId + }, + new + { + type = "domainUsers", + id = existingUserWithSameGroup1.StringId + }, + new + { + type = "domainUsers", + id = existingUserWithOtherGroup.StringId } } } } - }; + } + }; - string route = $"/domainGroups/{existingGroup.StringId}"; + string route = $"/domainGroups/{existingGroup.StringId}"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), - (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnSetToManyRelationshipAsync), - (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync), - (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.ShouldHaveCount(3); + hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] + { + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnSetToManyRelationshipAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) + }, options => options.WithStrictOrdering()); - var content1 = messages[0].GetContentAs(); - content1.UserId.Should().Be(existingUserWithoutGroup.Id); - content1.GroupId.Should().Be(existingGroup.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.ShouldHaveCount(3); + + var content1 = messages[0].GetContentAs(); + content1.UserId.Should().Be(existingUserWithoutGroup.Id); + content1.GroupId.Should().Be(existingGroup.Id); + + var content2 = messages[1].GetContentAs(); + content2.UserId.Should().Be(existingUserWithOtherGroup.Id); + content2.BeforeGroupId.Should().Be(existingUserWithOtherGroup.Group.Id); + content2.AfterGroupId.Should().Be(existingGroup.Id); + + var content3 = messages[2].GetContentAs(); + content3.UserId.Should().Be(existingUserWithSameGroup2.Id); + content3.GroupId.Should().Be(existingGroup.Id); + }); + } - var content2 = messages[1].GetContentAs(); - content2.UserId.Should().Be(existingUserWithOtherGroup.Id); - content2.BeforeGroupId.Should().Be(existingUserWithOtherGroup.Group.Id); - content2.AfterGroupId.Should().Be(existingGroup.Id); + [Fact] + public async Task Delete_group_writes_to_outbox() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); - var content3 = messages[2].GetContentAs(); - content3.UserId.Should().Be(existingUserWithSameGroup2.Id); - content3.GroupId.Should().Be(existingGroup.Id); - }); - } + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); - [Fact] - public async Task Delete_group_writes_to_outbox() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); + await dbContext.ClearTableAsync(); + dbContext.Groups.Add(existingGroup); + await dbContext.SaveChangesAsync(); + }); - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + string route = $"/domainGroups/{existingGroup.StringId}"; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Groups.Add(existingGroup); - await dbContext.SaveChangesAsync(); - }); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); - string route = $"/domainGroups/{existingGroup.StringId}"; + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); + responseDocument.Should().BeEmpty(); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] + { + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) + }, options => options.WithStrictOrdering()); - responseDocument.Should().BeEmpty(); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.ShouldHaveCount(1); - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync), - (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); + var content = messages[0].GetContentAs(); + content.GroupId.Should().Be(existingGroup.StringId); + }); + } - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.ShouldHaveCount(1); + [Fact] + public async Task Delete_group_with_users_writes_to_outbox() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); - var content = messages[0].GetContentAs(); - content.GroupId.Should().Be(existingGroup.StringId); - }); - } + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + existingGroup.Users = _fakers.DomainUser.Generate(1).ToHashSet(); - [Fact] - public async Task Delete_group_with_users_writes_to_outbox() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); - existingGroup.Users = _fakers.DomainUser.Generate(1).ToHashSet(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Groups.Add(existingGroup); - await dbContext.SaveChangesAsync(); - }); + await dbContext.ClearTableAsync(); + dbContext.Groups.Add(existingGroup); + await dbContext.SaveChangesAsync(); + }); - string route = $"/domainGroups/{existingGroup.StringId}"; + string route = $"/domainGroups/{existingGroup.StringId}"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync), - (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); + hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] + { + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) + }, options => options.WithStrictOrdering()); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.ShouldHaveCount(2); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.ShouldHaveCount(2); - var content1 = messages[0].GetContentAs(); - content1.UserId.Should().Be(existingGroup.Users.ElementAt(0).Id); - content1.GroupId.Should().Be(existingGroup.StringId); + var content1 = messages[0].GetContentAs(); + content1.UserId.Should().Be(existingGroup.Users.ElementAt(0).Id); + content1.GroupId.Should().Be(existingGroup.StringId); - var content2 = messages[1].GetContentAs(); - content2.GroupId.Should().Be(existingGroup.StringId); - }); - } + var content2 = messages[1].GetContentAs(); + content2.GroupId.Should().Be(existingGroup.StringId); + }); + } - [Fact] - public async Task Replace_users_in_group_writes_to_outbox() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); + [Fact] + public async Task Replace_users_in_group_writes_to_outbox() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); - DomainUser existingUserWithoutGroup = _fakers.DomainUser.Generate(); + DomainUser existingUserWithoutGroup = _fakers.DomainUser.Generate(); - DomainUser existingUserWithSameGroup1 = _fakers.DomainUser.Generate(); - existingUserWithSameGroup1.Group = existingGroup; + DomainUser existingUserWithSameGroup1 = _fakers.DomainUser.Generate(); + existingUserWithSameGroup1.Group = existingGroup; - DomainUser existingUserWithSameGroup2 = _fakers.DomainUser.Generate(); - existingUserWithSameGroup2.Group = existingGroup; + DomainUser existingUserWithSameGroup2 = _fakers.DomainUser.Generate(); + existingUserWithSameGroup2.Group = existingGroup; - DomainUser existingUserWithOtherGroup = _fakers.DomainUser.Generate(); - existingUserWithOtherGroup.Group = _fakers.DomainGroup.Generate(); + DomainUser existingUserWithOtherGroup = _fakers.DomainUser.Generate(); + existingUserWithOtherGroup.Group = _fakers.DomainGroup.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Users.AddRange(existingUserWithoutGroup, existingUserWithSameGroup1, existingUserWithSameGroup2, existingUserWithOtherGroup); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Users.AddRange(existingUserWithoutGroup, existingUserWithSameGroup1, existingUserWithSameGroup2, existingUserWithOtherGroup); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new[] { - data = new[] + new { - new - { - type = "domainUsers", - id = existingUserWithoutGroup.StringId - }, - new - { - type = "domainUsers", - id = existingUserWithSameGroup1.StringId - }, - new - { - type = "domainUsers", - id = existingUserWithOtherGroup.StringId - } + type = "domainUsers", + id = existingUserWithoutGroup.StringId + }, + new + { + type = "domainUsers", + id = existingUserWithSameGroup1.StringId + }, + new + { + type = "domainUsers", + id = existingUserWithOtherGroup.StringId } - }; - - string route = $"/domainGroups/{existingGroup.StringId}/relationships/users"; + } + }; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + string route = $"/domainGroups/{existingGroup.StringId}/relationships/users"; - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - responseDocument.Should().BeEmpty(); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), - (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnSetToManyRelationshipAsync), - (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync), - (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.ShouldHaveCount(3); - - var content1 = messages[0].GetContentAs(); - content1.UserId.Should().Be(existingUserWithoutGroup.Id); - content1.GroupId.Should().Be(existingGroup.Id); + responseDocument.Should().BeEmpty(); - var content2 = messages[1].GetContentAs(); - content2.UserId.Should().Be(existingUserWithOtherGroup.Id); - content2.BeforeGroupId.Should().Be(existingUserWithOtherGroup.Group.Id); - content2.AfterGroupId.Should().Be(existingGroup.Id); - - var content3 = messages[2].GetContentAs(); - content3.UserId.Should().Be(existingUserWithSameGroup2.Id); - content3.GroupId.Should().Be(existingGroup.Id); - }); - } + hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] + { + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnSetToManyRelationshipAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) + }, options => options.WithStrictOrdering()); - [Fact] - public async Task Add_users_to_group_writes_to_outbox() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.ShouldHaveCount(3); + + var content1 = messages[0].GetContentAs(); + content1.UserId.Should().Be(existingUserWithoutGroup.Id); + content1.GroupId.Should().Be(existingGroup.Id); + + var content2 = messages[1].GetContentAs(); + content2.UserId.Should().Be(existingUserWithOtherGroup.Id); + content2.BeforeGroupId.Should().Be(existingUserWithOtherGroup.Group.Id); + content2.AfterGroupId.Should().Be(existingGroup.Id); + + var content3 = messages[2].GetContentAs(); + content3.UserId.Should().Be(existingUserWithSameGroup2.Id); + content3.GroupId.Should().Be(existingGroup.Id); + }); + } - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + [Fact] + public async Task Add_users_to_group_writes_to_outbox() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); - DomainUser existingUserWithoutGroup = _fakers.DomainUser.Generate(); + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); - DomainUser existingUserWithSameGroup = _fakers.DomainUser.Generate(); - existingUserWithSameGroup.Group = existingGroup; + DomainUser existingUserWithoutGroup = _fakers.DomainUser.Generate(); - DomainUser existingUserWithOtherGroup = _fakers.DomainUser.Generate(); - existingUserWithOtherGroup.Group = _fakers.DomainGroup.Generate(); + DomainUser existingUserWithSameGroup = _fakers.DomainUser.Generate(); + existingUserWithSameGroup.Group = existingGroup; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Users.AddRange(existingUserWithoutGroup, existingUserWithSameGroup, existingUserWithOtherGroup); - await dbContext.SaveChangesAsync(); - }); + DomainUser existingUserWithOtherGroup = _fakers.DomainUser.Generate(); + existingUserWithOtherGroup.Group = _fakers.DomainGroup.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Users.AddRange(existingUserWithoutGroup, existingUserWithSameGroup, existingUserWithOtherGroup); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new[] { - data = new[] + new { - new - { - type = "domainUsers", - id = existingUserWithoutGroup.StringId - }, - new - { - type = "domainUsers", - id = existingUserWithOtherGroup.StringId - } + type = "domainUsers", + id = existingUserWithoutGroup.StringId + }, + new + { + type = "domainUsers", + id = existingUserWithOtherGroup.StringId } - }; - - string route = $"/domainGroups/{existingGroup.StringId}/relationships/users"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + } + }; - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + string route = $"/domainGroups/{existingGroup.StringId}/relationships/users"; - responseDocument.Should().BeEmpty(); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnAddToRelationshipAsync), - (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync), - (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.ShouldHaveCount(2); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - var content1 = messages[0].GetContentAs(); - content1.UserId.Should().Be(existingUserWithoutGroup.Id); - content1.GroupId.Should().Be(existingGroup.Id); + responseDocument.Should().BeEmpty(); - var content2 = messages[1].GetContentAs(); - content2.UserId.Should().Be(existingUserWithOtherGroup.Id); - content2.BeforeGroupId.Should().Be(existingUserWithOtherGroup.Group.Id); - content2.AfterGroupId.Should().Be(existingGroup.Id); - }); - } + hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] + { + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnAddToRelationshipAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) + }, options => options.WithStrictOrdering()); - [Fact] - public async Task Remove_users_from_group_writes_to_outbox() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.ShouldHaveCount(2); + + var content1 = messages[0].GetContentAs(); + content1.UserId.Should().Be(existingUserWithoutGroup.Id); + content1.GroupId.Should().Be(existingGroup.Id); + + var content2 = messages[1].GetContentAs(); + content2.UserId.Should().Be(existingUserWithOtherGroup.Id); + content2.BeforeGroupId.Should().Be(existingUserWithOtherGroup.Group.Id); + content2.AfterGroupId.Should().Be(existingGroup.Id); + }); + } - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + [Fact] + public async Task Remove_users_from_group_writes_to_outbox() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); - DomainUser existingUserWithSameGroup1 = _fakers.DomainUser.Generate(); - existingUserWithSameGroup1.Group = existingGroup; + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); - DomainUser existingUserWithSameGroup2 = _fakers.DomainUser.Generate(); - existingUserWithSameGroup2.Group = existingGroup; + DomainUser existingUserWithSameGroup1 = _fakers.DomainUser.Generate(); + existingUserWithSameGroup1.Group = existingGroup; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Users.AddRange(existingUserWithSameGroup1, existingUserWithSameGroup2); - await dbContext.SaveChangesAsync(); - }); + DomainUser existingUserWithSameGroup2 = _fakers.DomainUser.Generate(); + existingUserWithSameGroup2.Group = existingGroup; - var requestBody = new + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Users.AddRange(existingUserWithSameGroup1, existingUserWithSameGroup2); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] { - data = new[] + new { - new - { - type = "domainUsers", - id = existingUserWithSameGroup2.StringId - } + type = "domainUsers", + id = existingUserWithSameGroup2.StringId } - }; + } + }; - string route = $"/domainGroups/{existingGroup.StringId}/relationships/users"; + string route = $"/domainGroups/{existingGroup.StringId}/relationships/users"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnRemoveFromRelationshipAsync), - (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync), - (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); + hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] + { + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnRemoveFromRelationshipAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) + }, options => options.WithStrictOrdering()); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.ShouldHaveCount(1); - - var content = messages[0].GetContentAs(); - content.UserId.Should().Be(existingUserWithSameGroup2.Id); - content.GroupId.Should().Be(existingGroup.Id); - }); - } + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.ShouldHaveCount(1); + + var content = messages[0].GetContentAs(); + content.UserId.Should().Be(existingUserWithSameGroup2.Id); + content.GroupId.Should().Be(existingGroup.Id); + }); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.User.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.User.cs index 8b233b272c..ee8c6aaa58 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.User.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.User.cs @@ -1,9 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; using System.Net; -using System.Net.Http; -using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreTests.IntegrationTests.Microservices.Messages; @@ -12,687 +7,686 @@ using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.TransactionalOutboxPattern +namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.TransactionalOutboxPattern; + +public sealed partial class OutboxTests { - public sealed partial class OutboxTests + [Fact] + public async Task Create_user_writes_to_outbox() { - [Fact] - public async Task Create_user_writes_to_outbox() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); - string newLoginName = _fakers.DomainUser.Generate().LoginName; - string newDisplayName = _fakers.DomainUser.Generate().DisplayName!; + string newLoginName = _fakers.DomainUser.Generate().LoginName; + string newDisplayName = _fakers.DomainUser.Generate().DisplayName!; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new { - data = new + type = "domainUsers", + attributes = new { - type = "domainUsers", - attributes = new - { - loginName = newLoginName, - displayName = newDisplayName - } + loginName = newLoginName, + displayName = newDisplayName } - }; + } + }; - const string route = "/domainUsers"; + const string route = "/domainUsers"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("loginName").With(value => value.Should().Be(newLoginName)); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("displayName").With(value => value.Should().Be(newDisplayName)); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("loginName").With(value => value.Should().Be(newLoginName)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("displayName").With(value => value.Should().Be(newDisplayName)); - Guid newUserId = Guid.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.ShouldHaveCount(1); + hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] + { + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) + }, options => options.WithStrictOrdering()); - var content = messages[0].GetContentAs(); - content.UserId.Should().Be(newUserId); - content.UserLoginName.Should().Be(newLoginName); - content.UserDisplayName.Should().Be(newDisplayName); - }); - } + Guid newUserId = Guid.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); - [Fact] - public async Task Create_user_in_group_writes_to_outbox() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.ShouldHaveCount(1); + + var content = messages[0].GetContentAs(); + content.UserId.Should().Be(newUserId); + content.UserLoginName.Should().Be(newLoginName); + content.UserDisplayName.Should().Be(newDisplayName); + }); + } - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + [Fact] + public async Task Create_user_in_group_writes_to_outbox() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); - string newLoginName = _fakers.DomainUser.Generate().LoginName; + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Groups.Add(existingGroup); - await dbContext.SaveChangesAsync(); - }); + string newLoginName = _fakers.DomainUser.Generate().LoginName; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Groups.Add(existingGroup); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new { - data = new + type = "domainUsers", + attributes = new { - type = "domainUsers", - attributes = new - { - loginName = newLoginName - }, - relationships = new + loginName = newLoginName + }, + relationships = new + { + group = new { - group = new + data = new { - data = new - { - type = "domainGroups", - id = existingGroup.StringId - } + type = "domainGroups", + id = existingGroup.StringId } } } - }; - - const string route = "/domainUsers"; + } + }; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + const string route = "/domainUsers"; - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("loginName").With(value => value.Should().Be(newLoginName)); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("displayName").With(value => value.Should().BeNull()); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnSetToOneRelationshipAsync), - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); - - Guid newUserId = Guid.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.ShouldHaveCount(2); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("loginName").With(value => value.Should().Be(newLoginName)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("displayName").With(value => value.Should().BeNull()); - var content1 = messages[0].GetContentAs(); - content1.UserId.Should().Be(newUserId); - content1.UserLoginName.Should().Be(newLoginName); - content1.UserDisplayName.Should().BeNull(); + hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] + { + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnSetToOneRelationshipAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) + }, options => options.WithStrictOrdering()); - var content2 = messages[1].GetContentAs(); - content2.UserId.Should().Be(newUserId); - content2.GroupId.Should().Be(existingGroup.Id); - }); - } + Guid newUserId = Guid.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); - [Fact] - public async Task Update_user_writes_to_outbox() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.ShouldHaveCount(2); + + var content1 = messages[0].GetContentAs(); + content1.UserId.Should().Be(newUserId); + content1.UserLoginName.Should().Be(newLoginName); + content1.UserDisplayName.Should().BeNull(); + + var content2 = messages[1].GetContentAs(); + content2.UserId.Should().Be(newUserId); + content2.GroupId.Should().Be(existingGroup.Id); + }); + } - DomainUser existingUser = _fakers.DomainUser.Generate(); + [Fact] + public async Task Update_user_writes_to_outbox() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); - string newLoginName = _fakers.DomainUser.Generate().LoginName; - string newDisplayName = _fakers.DomainUser.Generate().DisplayName!; + DomainUser existingUser = _fakers.DomainUser.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Users.Add(existingUser); - await dbContext.SaveChangesAsync(); - }); + string newLoginName = _fakers.DomainUser.Generate().LoginName; + string newDisplayName = _fakers.DomainUser.Generate().DisplayName!; - var requestBody = new + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Users.Add(existingUser); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new { - data = new + type = "domainUsers", + id = existingUser.StringId, + attributes = new { - type = "domainUsers", - id = existingUser.StringId, - attributes = new - { - loginName = newLoginName, - displayName = newDisplayName - } + loginName = newLoginName, + displayName = newDisplayName } - }; - - string route = $"/domainUsers/{existingUser.StringId}"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + } + }; - responseDocument.Should().BeEmpty(); + string route = $"/domainUsers/{existingUser.StringId}"; - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.ShouldHaveCount(2); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - var content1 = messages[0].GetContentAs(); - content1.UserId.Should().Be(existingUser.Id); - content1.BeforeUserLoginName.Should().Be(existingUser.LoginName); - content1.AfterUserLoginName.Should().Be(newLoginName); + responseDocument.Should().BeEmpty(); - var content2 = messages[1].GetContentAs(); - content2.UserId.Should().Be(existingUser.Id); - content2.BeforeUserDisplayName.Should().Be(existingUser.DisplayName); - content2.AfterUserDisplayName.Should().Be(newDisplayName); - }); - } + hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] + { + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) + }, options => options.WithStrictOrdering()); - [Fact] - public async Task Update_user_clear_group_writes_to_outbox() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.ShouldHaveCount(2); + + var content1 = messages[0].GetContentAs(); + content1.UserId.Should().Be(existingUser.Id); + content1.BeforeUserLoginName.Should().Be(existingUser.LoginName); + content1.AfterUserLoginName.Should().Be(newLoginName); + + var content2 = messages[1].GetContentAs(); + content2.UserId.Should().Be(existingUser.Id); + content2.BeforeUserDisplayName.Should().Be(existingUser.DisplayName); + content2.AfterUserDisplayName.Should().Be(newDisplayName); + }); + } - DomainUser existingUser = _fakers.DomainUser.Generate(); - existingUser.Group = _fakers.DomainGroup.Generate(); + [Fact] + public async Task Update_user_clear_group_writes_to_outbox() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); - string newDisplayName = _fakers.DomainUser.Generate().DisplayName!; + DomainUser existingUser = _fakers.DomainUser.Generate(); + existingUser.Group = _fakers.DomainGroup.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Users.Add(existingUser); - await dbContext.SaveChangesAsync(); - }); + string newDisplayName = _fakers.DomainUser.Generate().DisplayName!; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Users.Add(existingUser); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new { - data = new + type = "domainUsers", + id = existingUser.StringId, + attributes = new { - type = "domainUsers", - id = existingUser.StringId, - attributes = new - { - displayName = newDisplayName - }, - relationships = new + displayName = newDisplayName + }, + relationships = new + { + group = new { - group = new - { - data = (object?)null - } + data = (object?)null } } - }; - - string route = $"/domainUsers/{existingUser.StringId}"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + } + }; - responseDocument.Should().BeEmpty(); + string route = $"/domainUsers/{existingUser.StringId}"; - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnSetToOneRelationshipAsync), - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.ShouldHaveCount(2); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - var content1 = messages[0].GetContentAs(); - content1.UserId.Should().Be(existingUser.Id); - content1.BeforeUserDisplayName.Should().Be(existingUser.DisplayName); - content1.AfterUserDisplayName.Should().Be(newDisplayName); + responseDocument.Should().BeEmpty(); - var content2 = messages[1].GetContentAs(); - content2.UserId.Should().Be(existingUser.Id); - content2.GroupId.Should().Be(existingUser.Group.Id); - }); - } + hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] + { + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnSetToOneRelationshipAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) + }, options => options.WithStrictOrdering()); - [Fact] - public async Task Update_user_add_to_group_writes_to_outbox() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.ShouldHaveCount(2); + + var content1 = messages[0].GetContentAs(); + content1.UserId.Should().Be(existingUser.Id); + content1.BeforeUserDisplayName.Should().Be(existingUser.DisplayName); + content1.AfterUserDisplayName.Should().Be(newDisplayName); + + var content2 = messages[1].GetContentAs(); + content2.UserId.Should().Be(existingUser.Id); + content2.GroupId.Should().Be(existingUser.Group.Id); + }); + } - DomainUser existingUser = _fakers.DomainUser.Generate(); - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + [Fact] + public async Task Update_user_add_to_group_writes_to_outbox() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); - string newDisplayName = _fakers.DomainUser.Generate().DisplayName!; + DomainUser existingUser = _fakers.DomainUser.Generate(); + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.AddInRange(existingUser, existingGroup); - await dbContext.SaveChangesAsync(); - }); + string newDisplayName = _fakers.DomainUser.Generate().DisplayName!; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddInRange(existingUser, existingGroup); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new { - data = new + type = "domainUsers", + id = existingUser.StringId, + attributes = new { - type = "domainUsers", - id = existingUser.StringId, - attributes = new - { - displayName = newDisplayName - }, - relationships = new + displayName = newDisplayName + }, + relationships = new + { + group = new { - group = new + data = new { - data = new - { - type = "domainGroups", - id = existingGroup.StringId - } + type = "domainGroups", + id = existingGroup.StringId } } } - }; + } + }; - string route = $"/domainUsers/{existingUser.StringId}"; + string route = $"/domainUsers/{existingUser.StringId}"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnSetToOneRelationshipAsync), - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.ShouldHaveCount(2); - - var content1 = messages[0].GetContentAs(); - content1.UserId.Should().Be(existingUser.Id); - content1.BeforeUserDisplayName.Should().Be(existingUser.DisplayName); - content1.AfterUserDisplayName.Should().Be(newDisplayName); - - var content2 = messages[1].GetContentAs(); - content2.UserId.Should().Be(existingUser.Id); - content2.GroupId.Should().Be(existingGroup.Id); - }); - } + hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] + { + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnSetToOneRelationshipAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) + }, options => options.WithStrictOrdering()); - [Fact] - public async Task Update_user_move_to_group_writes_to_outbox() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.ShouldHaveCount(2); + + var content1 = messages[0].GetContentAs(); + content1.UserId.Should().Be(existingUser.Id); + content1.BeforeUserDisplayName.Should().Be(existingUser.DisplayName); + content1.AfterUserDisplayName.Should().Be(newDisplayName); + + var content2 = messages[1].GetContentAs(); + content2.UserId.Should().Be(existingUser.Id); + content2.GroupId.Should().Be(existingGroup.Id); + }); + } - DomainUser existingUser = _fakers.DomainUser.Generate(); - existingUser.Group = _fakers.DomainGroup.Generate(); + [Fact] + public async Task Update_user_move_to_group_writes_to_outbox() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + DomainUser existingUser = _fakers.DomainUser.Generate(); + existingUser.Group = _fakers.DomainGroup.Generate(); - string newDisplayName = _fakers.DomainUser.Generate().DisplayName!; + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.AddInRange(existingUser, existingGroup); - await dbContext.SaveChangesAsync(); - }); + string newDisplayName = _fakers.DomainUser.Generate().DisplayName!; - var requestBody = new + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddInRange(existingUser, existingGroup); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new { - data = new + type = "domainUsers", + id = existingUser.StringId, + attributes = new { - type = "domainUsers", - id = existingUser.StringId, - attributes = new - { - displayName = newDisplayName - }, - relationships = new + displayName = newDisplayName + }, + relationships = new + { + group = new { - group = new + data = new { - data = new - { - type = "domainGroups", - id = existingGroup.StringId - } + type = "domainGroups", + id = existingGroup.StringId } } } - }; + } + }; - string route = $"/domainUsers/{existingUser.StringId}"; + string route = $"/domainUsers/{existingUser.StringId}"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnSetToOneRelationshipAsync), - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); + hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] + { + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnSetToOneRelationshipAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) + }, options => options.WithStrictOrdering()); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.ShouldHaveCount(2); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.ShouldHaveCount(2); + + var content1 = messages[0].GetContentAs(); + content1.UserId.Should().Be(existingUser.Id); + content1.BeforeUserDisplayName.Should().Be(existingUser.DisplayName); + content1.AfterUserDisplayName.Should().Be(newDisplayName); + + var content2 = messages[1].GetContentAs(); + content2.UserId.Should().Be(existingUser.Id); + content2.BeforeGroupId.Should().Be(existingUser.Group.Id); + content2.AfterGroupId.Should().Be(existingGroup.Id); + }); + } - var content1 = messages[0].GetContentAs(); - content1.UserId.Should().Be(existingUser.Id); - content1.BeforeUserDisplayName.Should().Be(existingUser.DisplayName); - content1.AfterUserDisplayName.Should().Be(newDisplayName); + [Fact] + public async Task Delete_user_writes_to_outbox() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); - var content2 = messages[1].GetContentAs(); - content2.UserId.Should().Be(existingUser.Id); - content2.BeforeGroupId.Should().Be(existingUser.Group.Id); - content2.AfterGroupId.Should().Be(existingGroup.Id); - }); - } + DomainUser existingUser = _fakers.DomainUser.Generate(); - [Fact] - public async Task Delete_user_writes_to_outbox() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); + await dbContext.ClearTableAsync(); + dbContext.Users.Add(existingUser); + await dbContext.SaveChangesAsync(); + }); - DomainUser existingUser = _fakers.DomainUser.Generate(); + string route = $"/domainUsers/{existingUser.StringId}"; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Users.Add(existingUser); - await dbContext.SaveChangesAsync(); - }); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); - string route = $"/domainUsers/{existingUser.StringId}"; + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); + responseDocument.Should().BeEmpty(); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] + { + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) + }, options => options.WithStrictOrdering()); - responseDocument.Should().BeEmpty(); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.ShouldHaveCount(1); - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); + var content = messages[0].GetContentAs(); + content.UserId.Should().Be(existingUser.Id); + }); + } - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.ShouldHaveCount(1); + [Fact] + public async Task Delete_user_in_group_writes_to_outbox() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); - var content = messages[0].GetContentAs(); - content.UserId.Should().Be(existingUser.Id); - }); - } + DomainUser existingUser = _fakers.DomainUser.Generate(); + existingUser.Group = _fakers.DomainGroup.Generate(); - [Fact] - public async Task Delete_user_in_group_writes_to_outbox() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); + await dbContext.ClearTableAsync(); + dbContext.Users.Add(existingUser); + await dbContext.SaveChangesAsync(); + }); - DomainUser existingUser = _fakers.DomainUser.Generate(); - existingUser.Group = _fakers.DomainGroup.Generate(); + string route = $"/domainUsers/{existingUser.StringId}"; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Users.Add(existingUser); - await dbContext.SaveChangesAsync(); - }); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); - string route = $"/domainUsers/{existingUser.StringId}"; + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); + responseDocument.Should().BeEmpty(); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] + { + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) + }, options => options.WithStrictOrdering()); - responseDocument.Should().BeEmpty(); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.ShouldHaveCount(2); - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); + var content1 = messages[0].GetContentAs(); + content1.UserId.Should().Be(existingUser.Id); + content1.GroupId.Should().Be(existingUser.Group.Id); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.ShouldHaveCount(2); + var content2 = messages[1].GetContentAs(); + content2.UserId.Should().Be(existingUser.Id); + }); + } - var content1 = messages[0].GetContentAs(); - content1.UserId.Should().Be(existingUser.Id); - content1.GroupId.Should().Be(existingUser.Group.Id); + [Fact] + public async Task Clear_group_from_user_writes_to_outbox() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); - var content2 = messages[1].GetContentAs(); - content2.UserId.Should().Be(existingUser.Id); - }); - } + DomainUser existingUser = _fakers.DomainUser.Generate(); + existingUser.Group = _fakers.DomainGroup.Generate(); - [Fact] - public async Task Clear_group_from_user_writes_to_outbox() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); + await dbContext.ClearTableAsync(); + dbContext.Users.Add(existingUser); + await dbContext.SaveChangesAsync(); + }); - DomainUser existingUser = _fakers.DomainUser.Generate(); - existingUser.Group = _fakers.DomainGroup.Generate(); + var requestBody = new + { + data = (object?)null + }; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Users.Add(existingUser); - await dbContext.SaveChangesAsync(); - }); + string route = $"/domainUsers/{existingUser.StringId}/relationships/group"; - var requestBody = new - { - data = (object?)null - }; + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - string route = $"/domainUsers/{existingUser.StringId}/relationships/group"; + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + responseDocument.Should().BeEmpty(); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] + { + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnSetToOneRelationshipAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) + }, options => options.WithStrictOrdering()); - responseDocument.Should().BeEmpty(); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.ShouldHaveCount(1); - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnSetToOneRelationshipAsync), - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); + var content = messages[0].GetContentAs(); + content.UserId.Should().Be(existingUser.Id); + content.GroupId.Should().Be(existingUser.Group.Id); + }); + } - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.ShouldHaveCount(1); + [Fact] + public async Task Assign_group_to_user_writes_to_outbox() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); - var content = messages[0].GetContentAs(); - content.UserId.Should().Be(existingUser.Id); - content.GroupId.Should().Be(existingUser.Group.Id); - }); - } + DomainUser existingUser = _fakers.DomainUser.Generate(); + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); - [Fact] - public async Task Assign_group_to_user_writes_to_outbox() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - - DomainUser existingUser = _fakers.DomainUser.Generate(); - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.AddInRange(existingUser, existingGroup); - await dbContext.SaveChangesAsync(); - }); + await dbContext.ClearTableAsync(); + dbContext.AddInRange(existingUser, existingGroup); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new { - data = new - { - type = "domainGroups", - id = existingGroup.StringId - } - }; + type = "domainGroups", + id = existingGroup.StringId + } + }; - string route = $"/domainUsers/{existingUser.StringId}/relationships/group"; + string route = $"/domainUsers/{existingUser.StringId}/relationships/group"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnSetToOneRelationshipAsync), - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); + hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] + { + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnSetToOneRelationshipAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) + }, options => options.WithStrictOrdering()); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.ShouldHaveCount(1); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.ShouldHaveCount(1); - var content = messages[0].GetContentAs(); - content.UserId.Should().Be(existingUser.Id); - content.GroupId.Should().Be(existingGroup.Id); - }); - } + var content = messages[0].GetContentAs(); + content.UserId.Should().Be(existingUser.Id); + content.GroupId.Should().Be(existingGroup.Id); + }); + } - [Fact] - public async Task Replace_group_for_user_writes_to_outbox() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); + [Fact] + public async Task Replace_group_for_user_writes_to_outbox() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); - DomainUser existingUser = _fakers.DomainUser.Generate(); - existingUser.Group = _fakers.DomainGroup.Generate(); + DomainUser existingUser = _fakers.DomainUser.Generate(); + existingUser.Group = _fakers.DomainGroup.Generate(); - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.AddInRange(existingUser, existingGroup); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddInRange(existingUser, existingGroup); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new { - data = new - { - type = "domainGroups", - id = existingGroup.StringId - } - }; + type = "domainGroups", + id = existingGroup.StringId + } + }; - string route = $"/domainUsers/{existingUser.StringId}/relationships/group"; + string route = $"/domainUsers/{existingUser.StringId}/relationships/group"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnSetToOneRelationshipAsync), - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), - (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); + hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] + { + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnSetToOneRelationshipAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) + }, options => options.WithStrictOrdering()); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.ShouldHaveCount(1); - - var content = messages[0].GetContentAs(); - content.UserId.Should().Be(existingUser.Id); - content.BeforeGroupId.Should().Be(existingUser.Group.Id); - content.AfterGroupId.Should().Be(existingGroup.Id); - }); - } + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.ShouldHaveCount(1); + + var content = messages[0].GetContentAs(); + content.UserId.Should().Be(existingUser.Id); + content.BeforeGroupId.Should().Be(existingUser.Group.Id); + content.AfterGroupId.Should().Be(existingGroup.Id); + }); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.cs index 5c4a353a1b..a67c539c85 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.cs @@ -1,9 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; using System.Net; -using System.Net.Http; -using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; @@ -13,96 +8,94 @@ using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.TransactionalOutboxPattern +namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.TransactionalOutboxPattern; +// Implements the Transactional Outbox Microservices pattern, described at: https://microservices.io/patterns/data/transactional-outbox.html + +public sealed partial class OutboxTests : IClassFixture, OutboxDbContext>> { - // Implements the Transactional Outbox Microservices pattern, described at: https://microservices.io/patterns/data/transactional-outbox.html + private readonly IntegrationTestContext, OutboxDbContext> _testContext; + private readonly DomainFakers _fakers = new(); - public sealed partial class OutboxTests : IClassFixture, OutboxDbContext>> + public OutboxTests(IntegrationTestContext, OutboxDbContext> testContext) { - private readonly IntegrationTestContext, OutboxDbContext> _testContext; - private readonly DomainFakers _fakers = new(); - - public OutboxTests(IntegrationTestContext, OutboxDbContext> testContext) - { - _testContext = testContext; + _testContext = testContext; - testContext.UseController(); - testContext.UseController(); + testContext.UseController(); + testContext.UseController(); - testContext.ConfigureServicesAfterStartup(services => - { - services.AddResourceDefinition(); - services.AddResourceDefinition(); + testContext.ConfigureServicesAfterStartup(services => + { + services.AddResourceDefinition(); + services.AddResourceDefinition(); - services.AddSingleton(); - }); + services.AddSingleton(); + }); - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - hitCounter.Reset(); - } + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + hitCounter.Reset(); + } - [Fact] - public async Task Does_not_add_to_outbox_on_write_error() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); + [Fact] + public async Task Does_not_add_to_outbox_on_write_error() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); - DomainUser existingUser = _fakers.DomainUser.Generate(); + DomainUser existingUser = _fakers.DomainUser.Generate(); - string unknownUserId = Unknown.StringId.For(); + string unknownUserId = Unknown.StringId.For(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.AddInRange(existingGroup, existingUser); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddInRange(existingGroup, existingUser); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new[] { - data = new[] + new + { + type = "domainUsers", + id = existingUser.StringId! + }, + new { - new - { - type = "domainUsers", - id = existingUser.StringId! - }, - new - { - type = "domainUsers", - id = unknownUserId - } + type = "domainUsers", + id = unknownUserId } - }; + } + }; - string route = $"/domainGroups/{existingGroup.StringId}/relationships/users"; + string route = $"/domainGroups/{existingGroup.StringId}/relationships/users"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.NotFound); - error.Title.Should().Be("A related resource does not exist."); - error.Detail.Should().Be($"Related resource of type 'domainUsers' with ID '{unknownUserId}' in relationship 'users' does not exist."); + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("A related resource does not exist."); + error.Detail.Should().Be($"Related resource of type 'domainUsers' with ID '{unknownUserId}' in relationship 'users' does not exist."); - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnAddToRelationshipAsync), - (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync) - }, options => options.WithStrictOrdering()); + hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] + { + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnAddToRelationshipAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync) + }, options => options.WithStrictOrdering()); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().BeEmpty(); - }); - } + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.Should().BeEmpty(); + }); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxUserDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxUserDefinition.cs index c071842d09..8076c944ec 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxUserDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxUserDefinition.cs @@ -1,34 +1,31 @@ -using System.Threading; -using System.Threading.Tasks; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCoreTests.IntegrationTests.Microservices.Messages; using Microsoft.EntityFrameworkCore; -namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.TransactionalOutboxPattern +namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.TransactionalOutboxPattern; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class OutboxUserDefinition : MessagingUserDefinition { - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class OutboxUserDefinition : MessagingUserDefinition - { - private readonly DbSet _outboxMessageSet; + private readonly DbSet _outboxMessageSet; - public OutboxUserDefinition(IResourceGraph resourceGraph, OutboxDbContext dbContext, ResourceDefinitionHitCounter hitCounter) - : base(resourceGraph, dbContext.Users, hitCounter) - { - _outboxMessageSet = dbContext.OutboxMessages; - } + public OutboxUserDefinition(IResourceGraph resourceGraph, OutboxDbContext dbContext, ResourceDefinitionHitCounter hitCounter) + : base(resourceGraph, dbContext.Users, hitCounter) + { + _outboxMessageSet = dbContext.OutboxMessages; + } - public override async Task OnWritingAsync(DomainUser user, WriteOperationKind writeOperation, CancellationToken cancellationToken) - { - await base.OnWritingAsync(user, writeOperation, cancellationToken); + public override async Task OnWritingAsync(DomainUser user, WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + await base.OnWritingAsync(user, writeOperation, cancellationToken); - await FinishWriteAsync(user, writeOperation, cancellationToken); - } + await FinishWriteAsync(user, writeOperation, cancellationToken); + } - protected override async Task FlushMessageAsync(OutgoingMessage message, CancellationToken cancellationToken) - { - await _outboxMessageSet.AddAsync(message, cancellationToken); - } + protected override async Task FlushMessageAsync(OutgoingMessage message, CancellationToken cancellationToken) + { + await _outboxMessageSet.AddAsync(message, cancellationToken); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/IHasTenant.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/IHasTenant.cs index a936cf9de1..b293ba7e47 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/IHasTenant.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/IHasTenant.cs @@ -1,11 +1,9 @@ -using System; using JetBrains.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.MultiTenancy +namespace JsonApiDotNetCoreTests.IntegrationTests.MultiTenancy; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public interface IHasTenant { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public interface IHasTenant - { - Guid TenantId { get; set; } - } + Guid TenantId { get; set; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/ITenantProvider.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/ITenantProvider.cs index d3bef58692..244a4a6b83 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/ITenantProvider.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/ITenantProvider.cs @@ -1,11 +1,8 @@ -using System; +namespace JsonApiDotNetCoreTests.IntegrationTests.MultiTenancy; -namespace JsonApiDotNetCoreTests.IntegrationTests.MultiTenancy +public interface ITenantProvider { - public interface ITenantProvider - { - // An implementation would obtain the tenant ID from the request, for example from the incoming - // authentication token, a custom HTTP header, the route or a query string parameter. - Guid TenantId { get; } - } + // An implementation would obtain the tenant ID from the request, for example from the incoming + // authentication token, a custom HTTP header, the route or a query string parameter. + Guid TenantId { get; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenancyDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenancyDbContext.cs index 59bc69faff..ce54a154d8 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenancyDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenancyDbContext.cs @@ -3,33 +3,32 @@ // @formatter:wrap_chained_method_calls chop_always -namespace JsonApiDotNetCoreTests.IntegrationTests.MultiTenancy +namespace JsonApiDotNetCoreTests.IntegrationTests.MultiTenancy; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class MultiTenancyDbContext : DbContext { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class MultiTenancyDbContext : DbContext - { - private readonly ITenantProvider _tenantProvider; + private readonly ITenantProvider _tenantProvider; - public DbSet WebShops => Set(); - public DbSet WebProducts => Set(); + public DbSet WebShops => Set(); + public DbSet WebProducts => Set(); - public MultiTenancyDbContext(DbContextOptions options, ITenantProvider tenantProvider) - : base(options) - { - _tenantProvider = tenantProvider; - } + public MultiTenancyDbContext(DbContextOptions options, ITenantProvider tenantProvider) + : base(options) + { + _tenantProvider = tenantProvider; + } - protected override void OnModelCreating(ModelBuilder builder) - { - builder.Entity() - .HasMany(webShop => webShop.Products) - .WithOne(webProduct => webProduct.Shop); + protected override void OnModelCreating(ModelBuilder builder) + { + builder.Entity() + .HasMany(webShop => webShop.Products) + .WithOne(webProduct => webProduct.Shop); - builder.Entity() - .HasQueryFilter(webShop => webShop.TenantId == _tenantProvider.TenantId); + builder.Entity() + .HasQueryFilter(webShop => webShop.TenantId == _tenantProvider.TenantId); - builder.Entity() - .HasQueryFilter(webProduct => webProduct.Shop.TenantId == _tenantProvider.TenantId); - } + builder.Entity() + .HasQueryFilter(webProduct => webProduct.Shop.TenantId == _tenantProvider.TenantId); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenancyFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenancyFakers.cs index 669ad84200..564985f107 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenancyFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenancyFakers.cs @@ -1,26 +1,24 @@ -using System; using Bogus; using TestBuildingBlocks; // @formatter:wrap_chained_method_calls chop_always // @formatter:keep_existing_linebreaks true -namespace JsonApiDotNetCoreTests.IntegrationTests.MultiTenancy +namespace JsonApiDotNetCoreTests.IntegrationTests.MultiTenancy; + +internal sealed class MultiTenancyFakers : FakerContainer { - internal sealed class MultiTenancyFakers : FakerContainer - { - private readonly Lazy> _lazyWebShopFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(webShop => webShop.Url, faker => faker.Internet.Url())); + private readonly Lazy> _lazyWebShopFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(webShop => webShop.Url, faker => faker.Internet.Url())); - private readonly Lazy> _lazyWebProductFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(webProduct => webProduct.Name, faker => faker.Commerce.ProductName()) - .RuleFor(webProduct => webProduct.Price, faker => faker.Finance.Amount())); + private readonly Lazy> _lazyWebProductFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(webProduct => webProduct.Name, faker => faker.Commerce.ProductName()) + .RuleFor(webProduct => webProduct.Price, faker => faker.Finance.Amount())); - public Faker WebShop => _lazyWebShopFaker.Value; - public Faker WebProduct => _lazyWebProductFaker.Value; - } + public Faker WebShop => _lazyWebShopFaker.Value; + public Faker WebProduct => _lazyWebProductFaker.Value; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenancyTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenancyTests.cs index 992b19c40e..0ddbe8efd0 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenancyTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenancyTests.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Generic; using System.Net; -using System.Net.Http; -using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; @@ -12,1052 +8,1051 @@ using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.MultiTenancy +namespace JsonApiDotNetCoreTests.IntegrationTests.MultiTenancy; + +public sealed class MultiTenancyTests : IClassFixture, MultiTenancyDbContext>> { - public sealed class MultiTenancyTests : IClassFixture, MultiTenancyDbContext>> + private static readonly Guid ThisTenantId = RouteTenantProvider.TenantRegistry["nld"]; + private static readonly Guid OtherTenantId = RouteTenantProvider.TenantRegistry["ita"]; + + private readonly IntegrationTestContext, MultiTenancyDbContext> _testContext; + private readonly MultiTenancyFakers _fakers = new(); + + public MultiTenancyTests(IntegrationTestContext, MultiTenancyDbContext> testContext) { - private static readonly Guid ThisTenantId = RouteTenantProvider.TenantRegistry["nld"]; - private static readonly Guid OtherTenantId = RouteTenantProvider.TenantRegistry["ita"]; + _testContext = testContext; - private readonly IntegrationTestContext, MultiTenancyDbContext> _testContext; - private readonly MultiTenancyFakers _fakers = new(); + testContext.UseController(); + testContext.UseController(); - public MultiTenancyTests(IntegrationTestContext, MultiTenancyDbContext> testContext) + testContext.ConfigureServicesBeforeStartup(services => { - _testContext = testContext; - - testContext.UseController(); - testContext.UseController(); + services.AddSingleton(); + services.AddScoped(); + }); - testContext.ConfigureServicesBeforeStartup(services => - { - services.AddSingleton(); - services.AddScoped(); - }); + testContext.ConfigureServicesAfterStartup(services => + { + services.AddResourceService>(); + services.AddResourceService>(); + }); - testContext.ConfigureServicesAfterStartup(services => - { - services.AddResourceService>(); - services.AddResourceService>(); - }); + var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); + options.UseRelativeLinks = true; + options.IncludeTotalResourceCount = true; + } - var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); - options.UseRelativeLinks = true; - options.IncludeTotalResourceCount = true; - } + [Fact] + public async Task Get_primary_resources_hides_other_tenants() + { + // Arrange + List shops = _fakers.WebShop.Generate(2); + shops[0].TenantId = OtherTenantId; + shops[1].TenantId = ThisTenantId; - [Fact] - public async Task Get_primary_resources_hides_other_tenants() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - List shops = _fakers.WebShop.Generate(2); - shops[0].TenantId = OtherTenantId; - shops[1].TenantId = ThisTenantId; + await dbContext.ClearTableAsync(); + dbContext.WebShops.AddRange(shops); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.WebShops.AddRange(shops); - await dbContext.SaveChangesAsync(); - }); + const string route = "/nld/shops"; - const string route = "/nld/shops"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be(shops[1].StringId); + } + + [Fact] + public async Task Filter_on_primary_resources_hides_other_tenants() + { + // Arrange + List shops = _fakers.WebShop.Generate(2); + shops[0].TenantId = OtherTenantId; + shops[0].Products = _fakers.WebProduct.Generate(1); - responseDocument.Data.ManyValue.ShouldHaveCount(1); - responseDocument.Data.ManyValue[0].Id.Should().Be(shops[1].StringId); - } + shops[1].TenantId = ThisTenantId; + shops[1].Products = _fakers.WebProduct.Generate(1); - [Fact] - public async Task Filter_on_primary_resources_hides_other_tenants() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - List shops = _fakers.WebShop.Generate(2); - shops[0].TenantId = OtherTenantId; - shops[0].Products = _fakers.WebProduct.Generate(1); + await dbContext.ClearTableAsync(); + dbContext.WebShops.AddRange(shops); + await dbContext.SaveChangesAsync(); + }); - shops[1].TenantId = ThisTenantId; - shops[1].Products = _fakers.WebProduct.Generate(1); + const string route = "/nld/shops?filter=has(products)"; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.WebShops.AddRange(shops); - await dbContext.SaveChangesAsync(); - }); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - const string route = "/nld/shops?filter=has(products)"; + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be(shops[1].StringId); + } - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + [Fact] + public async Task Get_primary_resources_with_include_hides_other_tenants() + { + // Arrange + List shops = _fakers.WebShop.Generate(2); + shops[0].TenantId = OtherTenantId; + shops[0].Products = _fakers.WebProduct.Generate(1); - responseDocument.Data.ManyValue.ShouldHaveCount(1); - responseDocument.Data.ManyValue[0].Id.Should().Be(shops[1].StringId); - } + shops[1].TenantId = ThisTenantId; + shops[1].Products = _fakers.WebProduct.Generate(1); - [Fact] - public async Task Get_primary_resources_with_include_hides_other_tenants() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - List shops = _fakers.WebShop.Generate(2); - shops[0].TenantId = OtherTenantId; - shops[0].Products = _fakers.WebProduct.Generate(1); + await dbContext.ClearTableAsync(); + dbContext.WebShops.AddRange(shops); + await dbContext.SaveChangesAsync(); + }); - shops[1].TenantId = ThisTenantId; - shops[1].Products = _fakers.WebProduct.Generate(1); + const string route = "/nld/shops?include=products"; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.WebShops.AddRange(shops); - await dbContext.SaveChangesAsync(); - }); - - const string route = "/nld/shops?include=products"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("webShops"); + responseDocument.Data.ManyValue[0].Id.Should().Be(shops[1].StringId); - responseDocument.Data.ManyValue.ShouldHaveCount(1); - responseDocument.Data.ManyValue[0].Type.Should().Be("webShops"); - responseDocument.Data.ManyValue[0].Id.Should().Be(shops[1].StringId); + responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included[0].Type.Should().Be("webProducts"); + responseDocument.Included[0].Id.Should().Be(shops[1].Products[0].StringId); + } - responseDocument.Included.ShouldHaveCount(1); - responseDocument.Included[0].Type.Should().Be("webProducts"); - responseDocument.Included[0].Id.Should().Be(shops[1].Products[0].StringId); - } + [Fact] + public async Task Cannot_get_primary_resource_by_ID_from_other_tenant() + { + // Arrange + WebShop shop = _fakers.WebShop.Generate(); + shop.TenantId = OtherTenantId; - [Fact] - public async Task Cannot_get_primary_resource_by_ID_from_other_tenant() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - WebShop shop = _fakers.WebShop.Generate(); - shop.TenantId = OtherTenantId; + dbContext.WebShops.Add(shop); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.WebShops.Add(shop); - await dbContext.SaveChangesAsync(); - }); + string route = $"/nld/shops/{shop.StringId}"; - string route = $"/nld/shops/{shop.StringId}"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + responseDocument.Errors.ShouldHaveCount(1); - responseDocument.Errors.ShouldHaveCount(1); + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("The requested resource does not exist."); + error.Detail.Should().Be($"Resource of type 'webShops' with ID '{shop.StringId}' does not exist."); + } - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.NotFound); - error.Title.Should().Be("The requested resource does not exist."); - error.Detail.Should().Be($"Resource of type 'webShops' with ID '{shop.StringId}' does not exist."); - } + [Fact] + public async Task Cannot_get_secondary_resources_from_other_parent_tenant() + { + // Arrange + WebShop shop = _fakers.WebShop.Generate(); + shop.TenantId = OtherTenantId; + shop.Products = _fakers.WebProduct.Generate(1); - [Fact] - public async Task Cannot_get_secondary_resources_from_other_parent_tenant() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - WebShop shop = _fakers.WebShop.Generate(); - shop.TenantId = OtherTenantId; - shop.Products = _fakers.WebProduct.Generate(1); + dbContext.WebShops.Add(shop); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.WebShops.Add(shop); - await dbContext.SaveChangesAsync(); - }); + string route = $"/nld/shops/{shop.StringId}/products"; - string route = $"/nld/shops/{shop.StringId}/products"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + responseDocument.Errors.ShouldHaveCount(1); - responseDocument.Errors.ShouldHaveCount(1); + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("The requested resource does not exist."); + error.Detail.Should().Be($"Resource of type 'webShops' with ID '{shop.StringId}' does not exist."); + } - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.NotFound); - error.Title.Should().Be("The requested resource does not exist."); - error.Detail.Should().Be($"Resource of type 'webShops' with ID '{shop.StringId}' does not exist."); - } + [Fact] + public async Task Cannot_get_secondary_resource_from_other_parent_tenant() + { + // Arrange + WebProduct product = _fakers.WebProduct.Generate(); + product.Shop = _fakers.WebShop.Generate(); + product.Shop.TenantId = OtherTenantId; - [Fact] - public async Task Cannot_get_secondary_resource_from_other_parent_tenant() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - WebProduct product = _fakers.WebProduct.Generate(); - product.Shop = _fakers.WebShop.Generate(); - product.Shop.TenantId = OtherTenantId; + dbContext.WebProducts.Add(product); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.WebProducts.Add(product); - await dbContext.SaveChangesAsync(); - }); + string route = $"/nld/products/{product.StringId}/shop"; - string route = $"/nld/products/{product.StringId}/shop"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + responseDocument.Errors.ShouldHaveCount(1); - responseDocument.Errors.ShouldHaveCount(1); + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("The requested resource does not exist."); + error.Detail.Should().Be($"Resource of type 'webProducts' with ID '{product.StringId}' does not exist."); + } - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.NotFound); - error.Title.Should().Be("The requested resource does not exist."); - error.Detail.Should().Be($"Resource of type 'webProducts' with ID '{product.StringId}' does not exist."); - } + [Fact] + public async Task Cannot_get_ToMany_relationship_for_other_parent_tenant() + { + // Arrange + WebShop shop = _fakers.WebShop.Generate(); + shop.TenantId = OtherTenantId; + shop.Products = _fakers.WebProduct.Generate(1); - [Fact] - public async Task Cannot_get_ToMany_relationship_for_other_parent_tenant() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - WebShop shop = _fakers.WebShop.Generate(); - shop.TenantId = OtherTenantId; - shop.Products = _fakers.WebProduct.Generate(1); + dbContext.WebShops.Add(shop); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.WebShops.Add(shop); - await dbContext.SaveChangesAsync(); - }); + string route = $"/nld/shops/{shop.StringId}/relationships/products"; - string route = $"/nld/shops/{shop.StringId}/relationships/products"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + responseDocument.Errors.ShouldHaveCount(1); - responseDocument.Errors.ShouldHaveCount(1); + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("The requested resource does not exist."); + error.Detail.Should().Be($"Resource of type 'webShops' with ID '{shop.StringId}' does not exist."); + } - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.NotFound); - error.Title.Should().Be("The requested resource does not exist."); - error.Detail.Should().Be($"Resource of type 'webShops' with ID '{shop.StringId}' does not exist."); - } + [Fact] + public async Task Cannot_get_ToOne_relationship_for_other_parent_tenant() + { + // Arrange + WebProduct product = _fakers.WebProduct.Generate(); + product.Shop = _fakers.WebShop.Generate(); + product.Shop.TenantId = OtherTenantId; - [Fact] - public async Task Cannot_get_ToOne_relationship_for_other_parent_tenant() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - WebProduct product = _fakers.WebProduct.Generate(); - product.Shop = _fakers.WebShop.Generate(); - product.Shop.TenantId = OtherTenantId; + dbContext.WebProducts.Add(product); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.WebProducts.Add(product); - await dbContext.SaveChangesAsync(); - }); + string route = $"/nld/products/{product.StringId}/relationships/shop"; - string route = $"/nld/products/{product.StringId}/relationships/shop"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + responseDocument.Errors.ShouldHaveCount(1); - responseDocument.Errors.ShouldHaveCount(1); + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("The requested resource does not exist."); + error.Detail.Should().Be($"Resource of type 'webProducts' with ID '{product.StringId}' does not exist."); + } - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.NotFound); - error.Title.Should().Be("The requested resource does not exist."); - error.Detail.Should().Be($"Resource of type 'webProducts' with ID '{product.StringId}' does not exist."); - } + [Fact] + public async Task Can_create_resource() + { + // Arrange + string newShopUrl = _fakers.WebShop.Generate().Url; - [Fact] - public async Task Can_create_resource() + var requestBody = new { - // Arrange - string newShopUrl = _fakers.WebShop.Generate().Url; - - var requestBody = new + data = new { - data = new + type = "webShops", + attributes = new { - type = "webShops", - attributes = new - { - url = newShopUrl - } + url = newShopUrl } - }; + } + }; - const string route = "/nld/shops"; + const string route = "/nld/shops"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("url").With(value => value.Should().Be(newShopUrl)); - responseDocument.Data.SingleValue.Relationships.ShouldNotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("url").With(value => value.Should().Be(newShopUrl)); + responseDocument.Data.SingleValue.Relationships.ShouldNotBeNull(); - int newShopId = int.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); + int newShopId = int.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - WebShop shopInDatabase = await dbContext.WebShops.IgnoreQueryFilters().FirstWithIdAsync(newShopId); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + WebShop shopInDatabase = await dbContext.WebShops.IgnoreQueryFilters().FirstWithIdAsync(newShopId); - shopInDatabase.Url.Should().Be(newShopUrl); - shopInDatabase.TenantId.Should().Be(ThisTenantId); - }); - } + shopInDatabase.Url.Should().Be(newShopUrl); + shopInDatabase.TenantId.Should().Be(ThisTenantId); + }); + } - [Fact] - public async Task Cannot_create_resource_with_ToMany_relationship_to_other_tenant() - { - // Arrange - WebProduct existingProduct = _fakers.WebProduct.Generate(); - existingProduct.Shop = _fakers.WebShop.Generate(); - existingProduct.Shop.TenantId = OtherTenantId; + [Fact] + public async Task Cannot_create_resource_with_ToMany_relationship_to_other_tenant() + { + // Arrange + WebProduct existingProduct = _fakers.WebProduct.Generate(); + existingProduct.Shop = _fakers.WebShop.Generate(); + existingProduct.Shop.TenantId = OtherTenantId; - string newShopUrl = _fakers.WebShop.Generate().Url; + string newShopUrl = _fakers.WebShop.Generate().Url; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.WebProducts.Add(existingProduct); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WebProducts.Add(existingProduct); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new { - data = new + type = "webShops", + attributes = new { - type = "webShops", - attributes = new - { - url = newShopUrl - }, - relationships = new + url = newShopUrl + }, + relationships = new + { + products = new { - products = new + data = new[] { - data = new[] + new { - new - { - type = "webProducts", - id = existingProduct.StringId - } + type = "webProducts", + id = existingProduct.StringId } } } } - }; + } + }; - const string route = "/nld/shops"; + const string route = "/nld/shops"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.NotFound); - error.Title.Should().Be("A related resource does not exist."); - error.Detail.Should().Be($"Related resource of type 'webProducts' with ID '{existingProduct.StringId}' in relationship 'products' does not exist."); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("A related resource does not exist."); + error.Detail.Should().Be($"Related resource of type 'webProducts' with ID '{existingProduct.StringId}' in relationship 'products' does not exist."); + } - [Fact] - public async Task Cannot_create_resource_with_ToOne_relationship_to_other_tenant() - { - // Arrange - WebShop existingShop = _fakers.WebShop.Generate(); - existingShop.TenantId = OtherTenantId; + [Fact] + public async Task Cannot_create_resource_with_ToOne_relationship_to_other_tenant() + { + // Arrange + WebShop existingShop = _fakers.WebShop.Generate(); + existingShop.TenantId = OtherTenantId; - string newProductName = _fakers.WebProduct.Generate().Name; + string newProductName = _fakers.WebProduct.Generate().Name; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.WebShops.Add(existingShop); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WebShops.Add(existingShop); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new { - data = new + type = "webProducts", + attributes = new { - type = "webProducts", - attributes = new - { - name = newProductName - }, - relationships = new + name = newProductName + }, + relationships = new + { + shop = new { - shop = new + data = new { - data = new - { - type = "webShops", - id = existingShop.StringId - } + type = "webShops", + id = existingShop.StringId } } } - }; + } + }; - const string route = "/nld/products"; + const string route = "/nld/products"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.NotFound); - error.Title.Should().Be("A related resource does not exist."); - error.Detail.Should().Be($"Related resource of type 'webShops' with ID '{existingShop.StringId}' in relationship 'shop' does not exist."); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("A related resource does not exist."); + error.Detail.Should().Be($"Related resource of type 'webShops' with ID '{existingShop.StringId}' in relationship 'shop' does not exist."); + } - [Fact] - public async Task Can_update_resource() - { - // Arrange - WebProduct existingProduct = _fakers.WebProduct.Generate(); - existingProduct.Shop = _fakers.WebShop.Generate(); - existingProduct.Shop.TenantId = ThisTenantId; + [Fact] + public async Task Can_update_resource() + { + // Arrange + WebProduct existingProduct = _fakers.WebProduct.Generate(); + existingProduct.Shop = _fakers.WebShop.Generate(); + existingProduct.Shop.TenantId = ThisTenantId; - string newProductName = _fakers.WebProduct.Generate().Name; + string newProductName = _fakers.WebProduct.Generate().Name; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.WebProducts.Add(existingProduct); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WebProducts.Add(existingProduct); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new { - data = new + type = "webProducts", + id = existingProduct.StringId, + attributes = new { - type = "webProducts", - id = existingProduct.StringId, - attributes = new - { - name = newProductName - } + name = newProductName } - }; + } + }; - string route = $"/nld/products/{existingProduct.StringId}"; + string route = $"/nld/products/{existingProduct.StringId}"; - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Should().BeEmpty(); + responseDocument.Should().BeEmpty(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - WebProduct productInDatabase = await dbContext.WebProducts.IgnoreQueryFilters().FirstWithIdAsync(existingProduct.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + WebProduct productInDatabase = await dbContext.WebProducts.IgnoreQueryFilters().FirstWithIdAsync(existingProduct.Id); - productInDatabase.Name.Should().Be(newProductName); - productInDatabase.Price.Should().Be(existingProduct.Price); - }); - } + productInDatabase.Name.Should().Be(newProductName); + productInDatabase.Price.Should().Be(existingProduct.Price); + }); + } - [Fact] - public async Task Cannot_update_resource_from_other_tenant() - { - // Arrange - WebProduct existingProduct = _fakers.WebProduct.Generate(); - existingProduct.Shop = _fakers.WebShop.Generate(); - existingProduct.Shop.TenantId = OtherTenantId; + [Fact] + public async Task Cannot_update_resource_from_other_tenant() + { + // Arrange + WebProduct existingProduct = _fakers.WebProduct.Generate(); + existingProduct.Shop = _fakers.WebShop.Generate(); + existingProduct.Shop.TenantId = OtherTenantId; - string newProductName = _fakers.WebProduct.Generate().Name; + string newProductName = _fakers.WebProduct.Generate().Name; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.WebProducts.Add(existingProduct); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WebProducts.Add(existingProduct); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new { - data = new + type = "webProducts", + id = existingProduct.StringId, + attributes = new { - type = "webProducts", - id = existingProduct.StringId, - attributes = new - { - name = newProductName - } + name = newProductName } - }; + } + }; - string route = $"/nld/products/{existingProduct.StringId}"; + string route = $"/nld/products/{existingProduct.StringId}"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.NotFound); - error.Title.Should().Be("The requested resource does not exist."); - error.Detail.Should().Be($"Resource of type 'webProducts' with ID '{existingProduct.StringId}' does not exist."); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("The requested resource does not exist."); + error.Detail.Should().Be($"Resource of type 'webProducts' with ID '{existingProduct.StringId}' does not exist."); + } - [Fact] - public async Task Cannot_update_resource_with_ToMany_relationship_to_other_tenant() - { - // Arrange - WebShop existingShop = _fakers.WebShop.Generate(); - existingShop.TenantId = ThisTenantId; + [Fact] + public async Task Cannot_update_resource_with_ToMany_relationship_to_other_tenant() + { + // Arrange + WebShop existingShop = _fakers.WebShop.Generate(); + existingShop.TenantId = ThisTenantId; - WebProduct existingProduct = _fakers.WebProduct.Generate(); - existingProduct.Shop = _fakers.WebShop.Generate(); - existingProduct.Shop.TenantId = OtherTenantId; + WebProduct existingProduct = _fakers.WebProduct.Generate(); + existingProduct.Shop = _fakers.WebShop.Generate(); + existingProduct.Shop.TenantId = OtherTenantId; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddInRange(existingShop, existingProduct); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingShop, existingProduct); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new { - data = new + type = "webShops", + id = existingShop.StringId, + relationships = new { - type = "webShops", - id = existingShop.StringId, - relationships = new + products = new { - products = new + data = new[] { - data = new[] + new { - new - { - type = "webProducts", - id = existingProduct.StringId - } + type = "webProducts", + id = existingProduct.StringId } } } } - }; + } + }; - string route = $"/nld/shops/{existingShop.StringId}"; + string route = $"/nld/shops/{existingShop.StringId}"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.NotFound); - error.Title.Should().Be("A related resource does not exist."); - error.Detail.Should().Be($"Related resource of type 'webProducts' with ID '{existingProduct.StringId}' in relationship 'products' does not exist."); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("A related resource does not exist."); + error.Detail.Should().Be($"Related resource of type 'webProducts' with ID '{existingProduct.StringId}' in relationship 'products' does not exist."); + } - [Fact] - public async Task Cannot_update_resource_with_ToOne_relationship_to_other_tenant() - { - // Arrange - WebProduct existingProduct = _fakers.WebProduct.Generate(); - existingProduct.Shop = _fakers.WebShop.Generate(); - existingProduct.Shop.TenantId = ThisTenantId; + [Fact] + public async Task Cannot_update_resource_with_ToOne_relationship_to_other_tenant() + { + // Arrange + WebProduct existingProduct = _fakers.WebProduct.Generate(); + existingProduct.Shop = _fakers.WebShop.Generate(); + existingProduct.Shop.TenantId = ThisTenantId; - WebShop existingShop = _fakers.WebShop.Generate(); - existingShop.TenantId = OtherTenantId; + WebShop existingShop = _fakers.WebShop.Generate(); + existingShop.TenantId = OtherTenantId; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddInRange(existingProduct, existingShop); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingProduct, existingShop); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new { - data = new + type = "webProducts", + id = existingProduct.StringId, + relationships = new { - type = "webProducts", - id = existingProduct.StringId, - relationships = new + shop = new { - shop = new + data = new { - data = new - { - type = "webShops", - id = existingShop.StringId - } + type = "webShops", + id = existingShop.StringId } } } - }; + } + }; - string route = $"/nld/products/{existingProduct.StringId}"; + string route = $"/nld/products/{existingProduct.StringId}"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.NotFound); - error.Title.Should().Be("A related resource does not exist."); - error.Detail.Should().Be($"Related resource of type 'webShops' with ID '{existingShop.StringId}' in relationship 'shop' does not exist."); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("A related resource does not exist."); + error.Detail.Should().Be($"Related resource of type 'webShops' with ID '{existingShop.StringId}' in relationship 'shop' does not exist."); + } - [Fact] - public async Task Cannot_update_ToMany_relationship_for_other_parent_tenant() - { - // Arrange - WebShop existingShop = _fakers.WebShop.Generate(); - existingShop.TenantId = OtherTenantId; - existingShop.Products = _fakers.WebProduct.Generate(1); + [Fact] + public async Task Cannot_update_ToMany_relationship_for_other_parent_tenant() + { + // Arrange + WebShop existingShop = _fakers.WebShop.Generate(); + existingShop.TenantId = OtherTenantId; + existingShop.Products = _fakers.WebProduct.Generate(1); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.WebShops.Add(existingShop); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WebShops.Add(existingShop); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new - { - data = Array.Empty() - }; + var requestBody = new + { + data = Array.Empty() + }; - string route = $"/nld/shops/{existingShop.StringId}/relationships/products"; + string route = $"/nld/shops/{existingShop.StringId}/relationships/products"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.NotFound); - error.Title.Should().Be("The requested resource does not exist."); - error.Detail.Should().Be($"Resource of type 'webShops' with ID '{existingShop.StringId}' does not exist."); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("The requested resource does not exist."); + error.Detail.Should().Be($"Resource of type 'webShops' with ID '{existingShop.StringId}' does not exist."); + } - [Fact] - public async Task Cannot_update_ToMany_relationship_to_other_tenant() - { - // Arrange - WebShop existingShop = _fakers.WebShop.Generate(); - existingShop.TenantId = ThisTenantId; + [Fact] + public async Task Cannot_update_ToMany_relationship_to_other_tenant() + { + // Arrange + WebShop existingShop = _fakers.WebShop.Generate(); + existingShop.TenantId = ThisTenantId; - WebProduct existingProduct = _fakers.WebProduct.Generate(); - existingProduct.Shop = _fakers.WebShop.Generate(); - existingProduct.Shop.TenantId = OtherTenantId; + WebProduct existingProduct = _fakers.WebProduct.Generate(); + existingProduct.Shop = _fakers.WebShop.Generate(); + existingProduct.Shop.TenantId = OtherTenantId; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddInRange(existingShop, existingProduct); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingShop, existingProduct); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new[] { - data = new[] + new { - new - { - type = "webProducts", - id = existingProduct.StringId - } + type = "webProducts", + id = existingProduct.StringId } - }; + } + }; - string route = $"/nld/shops/{existingShop.StringId}/relationships/products"; + string route = $"/nld/shops/{existingShop.StringId}/relationships/products"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.NotFound); - error.Title.Should().Be("A related resource does not exist."); - error.Detail.Should().Be($"Related resource of type 'webProducts' with ID '{existingProduct.StringId}' in relationship 'products' does not exist."); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("A related resource does not exist."); + error.Detail.Should().Be($"Related resource of type 'webProducts' with ID '{existingProduct.StringId}' in relationship 'products' does not exist."); + } - [Fact] - public async Task Cannot_update_ToOne_relationship_for_other_parent_tenant() - { - // Arrange - WebProduct existingProduct = _fakers.WebProduct.Generate(); - existingProduct.Shop = _fakers.WebShop.Generate(); - existingProduct.Shop.TenantId = OtherTenantId; + [Fact] + public async Task Cannot_update_ToOne_relationship_for_other_parent_tenant() + { + // Arrange + WebProduct existingProduct = _fakers.WebProduct.Generate(); + existingProduct.Shop = _fakers.WebShop.Generate(); + existingProduct.Shop.TenantId = OtherTenantId; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.WebProducts.Add(existingProduct); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WebProducts.Add(existingProduct); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new - { - data = (object?)null - }; + var requestBody = new + { + data = (object?)null + }; - string route = $"/nld/products/{existingProduct.StringId}/relationships/shop"; + string route = $"/nld/products/{existingProduct.StringId}/relationships/shop"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.NotFound); - error.Title.Should().Be("The requested resource does not exist."); - error.Detail.Should().Be($"Resource of type 'webProducts' with ID '{existingProduct.StringId}' does not exist."); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("The requested resource does not exist."); + error.Detail.Should().Be($"Resource of type 'webProducts' with ID '{existingProduct.StringId}' does not exist."); + } - [Fact] - public async Task Cannot_update_ToOne_relationship_to_other_tenant() - { - // Arrange - WebProduct existingProduct = _fakers.WebProduct.Generate(); - existingProduct.Shop = _fakers.WebShop.Generate(); - existingProduct.Shop.TenantId = ThisTenantId; + [Fact] + public async Task Cannot_update_ToOne_relationship_to_other_tenant() + { + // Arrange + WebProduct existingProduct = _fakers.WebProduct.Generate(); + existingProduct.Shop = _fakers.WebShop.Generate(); + existingProduct.Shop.TenantId = ThisTenantId; - WebShop existingShop = _fakers.WebShop.Generate(); - existingShop.TenantId = OtherTenantId; + WebShop existingShop = _fakers.WebShop.Generate(); + existingShop.TenantId = OtherTenantId; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddInRange(existingProduct, existingShop); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingProduct, existingShop); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new { - data = new - { - type = "webShops", - id = existingShop.StringId - } - }; + type = "webShops", + id = existingShop.StringId + } + }; - string route = $"/nld/products/{existingProduct.StringId}/relationships/shop"; + string route = $"/nld/products/{existingProduct.StringId}/relationships/shop"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.NotFound); - error.Title.Should().Be("A related resource does not exist."); - error.Detail.Should().Be($"Related resource of type 'webShops' with ID '{existingShop.StringId}' in relationship 'shop' does not exist."); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("A related resource does not exist."); + error.Detail.Should().Be($"Related resource of type 'webShops' with ID '{existingShop.StringId}' in relationship 'shop' does not exist."); + } - [Fact] - public async Task Cannot_add_to_ToMany_relationship_for_other_parent_tenant() - { - // Arrange - WebShop existingShop = _fakers.WebShop.Generate(); - existingShop.TenantId = OtherTenantId; + [Fact] + public async Task Cannot_add_to_ToMany_relationship_for_other_parent_tenant() + { + // Arrange + WebShop existingShop = _fakers.WebShop.Generate(); + existingShop.TenantId = OtherTenantId; - WebProduct existingProduct = _fakers.WebProduct.Generate(); - existingProduct.Shop = _fakers.WebShop.Generate(); - existingProduct.Shop.TenantId = ThisTenantId; + WebProduct existingProduct = _fakers.WebProduct.Generate(); + existingProduct.Shop = _fakers.WebShop.Generate(); + existingProduct.Shop.TenantId = ThisTenantId; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddInRange(existingShop, existingProduct); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingShop, existingProduct); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new[] { - data = new[] + new { - new - { - type = "webProducts", - id = existingProduct.StringId - } + type = "webProducts", + id = existingProduct.StringId } - }; + } + }; - string route = $"/nld/shops/{existingShop.StringId}/relationships/products"; + string route = $"/nld/shops/{existingShop.StringId}/relationships/products"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.NotFound); - error.Title.Should().Be("The requested resource does not exist."); - error.Detail.Should().Be($"Resource of type 'webShops' with ID '{existingShop.StringId}' does not exist."); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("The requested resource does not exist."); + error.Detail.Should().Be($"Resource of type 'webShops' with ID '{existingShop.StringId}' does not exist."); + } - [Fact] - public async Task Cannot_add_to_ToMany_relationship_with_other_tenant() - { - WebShop existingShop = _fakers.WebShop.Generate(); - existingShop.TenantId = ThisTenantId; + [Fact] + public async Task Cannot_add_to_ToMany_relationship_with_other_tenant() + { + WebShop existingShop = _fakers.WebShop.Generate(); + existingShop.TenantId = ThisTenantId; - WebProduct existingProduct = _fakers.WebProduct.Generate(); - existingProduct.Shop = _fakers.WebShop.Generate(); - existingProduct.Shop.TenantId = OtherTenantId; + WebProduct existingProduct = _fakers.WebProduct.Generate(); + existingProduct.Shop = _fakers.WebShop.Generate(); + existingProduct.Shop.TenantId = OtherTenantId; - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddInRange(existingShop, existingProduct); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingShop, existingProduct); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new[] { - data = new[] + new { - new - { - type = "webProducts", - id = existingProduct.StringId - } + type = "webProducts", + id = existingProduct.StringId } - }; + } + }; - string route = $"/nld/shops/{existingShop.StringId}/relationships/products"; + string route = $"/nld/shops/{existingShop.StringId}/relationships/products"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.NotFound); - error.Title.Should().Be("A related resource does not exist."); - error.Detail.Should().Be($"Related resource of type 'webProducts' with ID '{existingProduct.StringId}' in relationship 'products' does not exist."); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("A related resource does not exist."); + error.Detail.Should().Be($"Related resource of type 'webProducts' with ID '{existingProduct.StringId}' in relationship 'products' does not exist."); + } - [Fact] - public async Task Cannot_remove_from_ToMany_relationship_for_other_parent_tenant() - { - // Arrange - WebShop existingShop = _fakers.WebShop.Generate(); - existingShop.TenantId = OtherTenantId; - existingShop.Products = _fakers.WebProduct.Generate(1); + [Fact] + public async Task Cannot_remove_from_ToMany_relationship_for_other_parent_tenant() + { + // Arrange + WebShop existingShop = _fakers.WebShop.Generate(); + existingShop.TenantId = OtherTenantId; + existingShop.Products = _fakers.WebProduct.Generate(1); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.WebShops.Add(existingShop); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WebShops.Add(existingShop); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new[] { - data = new[] + new { - new - { - type = "webProducts", - id = existingShop.Products[0].StringId - } + type = "webProducts", + id = existingShop.Products[0].StringId } - }; + } + }; - string route = $"/nld/shops/{existingShop.StringId}/relationships/products"; + string route = $"/nld/shops/{existingShop.StringId}/relationships/products"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.NotFound); - error.Title.Should().Be("The requested resource does not exist."); - error.Detail.Should().Be($"Resource of type 'webShops' with ID '{existingShop.StringId}' does not exist."); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("The requested resource does not exist."); + error.Detail.Should().Be($"Resource of type 'webShops' with ID '{existingShop.StringId}' does not exist."); + } + + [Fact] + public async Task Can_delete_resource() + { + // Arrange + WebProduct existingProduct = _fakers.WebProduct.Generate(); + existingProduct.Shop = _fakers.WebShop.Generate(); + existingProduct.Shop.TenantId = ThisTenantId; - [Fact] - public async Task Can_delete_resource() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - WebProduct existingProduct = _fakers.WebProduct.Generate(); - existingProduct.Shop = _fakers.WebShop.Generate(); - existingProduct.Shop.TenantId = ThisTenantId; + dbContext.WebProducts.Add(existingProduct); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.WebProducts.Add(existingProduct); - await dbContext.SaveChangesAsync(); - }); + string route = $"/nld/products/{existingProduct.StringId}"; - string route = $"/nld/products/{existingProduct.StringId}"; + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + responseDocument.Should().BeEmpty(); - responseDocument.Should().BeEmpty(); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + WebProduct? productInDatabase = await dbContext.WebProducts.IgnoreQueryFilters().FirstWithIdOrDefaultAsync(existingProduct.Id); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - WebProduct? productInDatabase = await dbContext.WebProducts.IgnoreQueryFilters().FirstWithIdOrDefaultAsync(existingProduct.Id); + productInDatabase.Should().BeNull(); + }); + } - productInDatabase.Should().BeNull(); - }); - } + [Fact] + public async Task Cannot_delete_resource_from_other_tenant() + { + // Arrange + WebProduct existingProduct = _fakers.WebProduct.Generate(); + existingProduct.Shop = _fakers.WebShop.Generate(); + existingProduct.Shop.TenantId = OtherTenantId; - [Fact] - public async Task Cannot_delete_resource_from_other_tenant() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - WebProduct existingProduct = _fakers.WebProduct.Generate(); - existingProduct.Shop = _fakers.WebShop.Generate(); - existingProduct.Shop.TenantId = OtherTenantId; + dbContext.WebProducts.Add(existingProduct); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.WebProducts.Add(existingProduct); - await dbContext.SaveChangesAsync(); - }); + string route = $"/nld/products/{existingProduct.StringId}"; - string route = $"/nld/products/{existingProduct.StringId}"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteDeleteAsync(route); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteDeleteAsync(route); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + responseDocument.Errors.ShouldHaveCount(1); - responseDocument.Errors.ShouldHaveCount(1); + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("The requested resource does not exist."); + error.Detail.Should().Be($"Resource of type 'webProducts' with ID '{existingProduct.StringId}' does not exist."); + } - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.NotFound); - error.Title.Should().Be("The requested resource does not exist."); - error.Detail.Should().Be($"Resource of type 'webProducts' with ID '{existingProduct.StringId}' does not exist."); - } + [Fact] + public async Task Renders_links_with_tenant_route_parameter() + { + // Arrange + WebShop shop = _fakers.WebShop.Generate(); + shop.TenantId = ThisTenantId; + shop.Products = _fakers.WebProduct.Generate(1); - [Fact] - public async Task Renders_links_with_tenant_route_parameter() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - WebShop shop = _fakers.WebShop.Generate(); - shop.TenantId = ThisTenantId; - shop.Products = _fakers.WebProduct.Generate(1); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.WebShops.Add(shop); - await dbContext.SaveChangesAsync(); - }); + await dbContext.ClearTableAsync(); + dbContext.WebShops.Add(shop); + await dbContext.SaveChangesAsync(); + }); - const string route = "/nld/shops?include=products"; + const string route = "/nld/shops?include=products"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.ShouldNotBeNull(); - responseDocument.Links.Self.Should().Be(route); - responseDocument.Links.Related.Should().BeNull(); - responseDocument.Links.First.Should().Be(responseDocument.Links.Self); - responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); - responseDocument.Links.Prev.Should().BeNull(); - responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be(route); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().Be(responseDocument.Links.Self); + responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.ShouldHaveCount(1); - responseDocument.Data.ManyValue[0].With(resource => - { - string shopLink = $"/nld/shops/{shop.StringId}"; + responseDocument.Data.ManyValue[0].With(resource => + { + string shopLink = $"/nld/shops/{shop.StringId}"; - resource.Links.ShouldNotBeNull(); - resource.Links.Self.Should().Be(shopLink); + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(shopLink); - resource.Relationships.ShouldContainKey("products").With(value => - { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - value.Links.Self.Should().Be($"{shopLink}/relationships/products"); - value.Links.Related.Should().Be($"{shopLink}/products"); - }); + resource.Relationships.ShouldContainKey("products").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{shopLink}/relationships/products"); + value.Links.Related.Should().Be($"{shopLink}/products"); }); + }); - responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included.ShouldHaveCount(1); - responseDocument.Included[0].With(resource => - { - string productLink = $"/nld/products/{shop.Products[0].StringId}"; + responseDocument.Included[0].With(resource => + { + string productLink = $"/nld/products/{shop.Products[0].StringId}"; - resource.Links.ShouldNotBeNull(); - resource.Links.Self.Should().Be(productLink); + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(productLink); - resource.Relationships.ShouldContainKey("shop").With(value => - { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - value.Links.Self.Should().Be($"{productLink}/relationships/shop"); - value.Links.Related.Should().Be($"{productLink}/shop"); - }); + resource.Relationships.ShouldContainKey("shop").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{productLink}/relationships/shop"); + value.Links.Related.Should().Be($"{productLink}/shop"); }); - } + }); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenantResourceService.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenantResourceService.cs index 2d3086a236..b46a8133d0 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenantResourceService.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenantResourceService.cs @@ -1,7 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; @@ -11,76 +7,74 @@ using JsonApiDotNetCore.Services; using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCoreTests.IntegrationTests.MultiTenancy +namespace JsonApiDotNetCoreTests.IntegrationTests.MultiTenancy; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public class MultiTenantResourceService : JsonApiResourceService + where TResource : class, IIdentifiable { - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public class MultiTenantResourceService : JsonApiResourceService - where TResource : class, IIdentifiable - { - private readonly ITenantProvider _tenantProvider; + private readonly ITenantProvider _tenantProvider; - private static bool ResourceHasTenant => typeof(IHasTenant).IsAssignableFrom(typeof(TResource)); + private static bool ResourceHasTenant => typeof(IHasTenant).IsAssignableFrom(typeof(TResource)); - public MultiTenantResourceService(ITenantProvider tenantProvider, IResourceRepositoryAccessor repositoryAccessor, - IQueryLayerComposer queryLayerComposer, IPaginationContext paginationContext, IJsonApiOptions options, ILoggerFactory loggerFactory, - IJsonApiRequest request, IResourceChangeTracker resourceChangeTracker, IResourceDefinitionAccessor resourceDefinitionAccessor) - : base(repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, resourceChangeTracker, - resourceDefinitionAccessor) - { - _tenantProvider = tenantProvider; - } + public MultiTenantResourceService(ITenantProvider tenantProvider, IResourceRepositoryAccessor repositoryAccessor, IQueryLayerComposer queryLayerComposer, + IPaginationContext paginationContext, IJsonApiOptions options, ILoggerFactory loggerFactory, IJsonApiRequest request, + IResourceChangeTracker resourceChangeTracker, IResourceDefinitionAccessor resourceDefinitionAccessor) + : base(repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, resourceChangeTracker, resourceDefinitionAccessor) + { + _tenantProvider = tenantProvider; + } - protected override async Task InitializeResourceAsync(TResource resourceForDatabase, CancellationToken cancellationToken) - { - await base.InitializeResourceAsync(resourceForDatabase, cancellationToken); + protected override async Task InitializeResourceAsync(TResource resourceForDatabase, CancellationToken cancellationToken) + { + await base.InitializeResourceAsync(resourceForDatabase, cancellationToken); - if (ResourceHasTenant) - { - Guid tenantId = _tenantProvider.TenantId; + if (ResourceHasTenant) + { + Guid tenantId = _tenantProvider.TenantId; - var resourceWithTenant = (IHasTenant)resourceForDatabase; - resourceWithTenant.TenantId = tenantId; - } + var resourceWithTenant = (IHasTenant)resourceForDatabase; + resourceWithTenant.TenantId = tenantId; } + } - // To optimize performance, the default resource service does not always fetch all resources on write operations. - // We do that here, to assure everything belongs to the active tenant. On mismatch, a 404 error is thrown. + // To optimize performance, the default resource service does not always fetch all resources on write operations. + // We do that here, to assure everything belongs to the active tenant. On mismatch, a 404 error is thrown. - public override async Task CreateAsync(TResource resource, CancellationToken cancellationToken) - { - await AssertResourcesToAssignInRelationshipsExistAsync(resource, cancellationToken); + public override async Task CreateAsync(TResource resource, CancellationToken cancellationToken) + { + await AssertResourcesToAssignInRelationshipsExistAsync(resource, cancellationToken); - return await base.CreateAsync(resource, cancellationToken); - } + return await base.CreateAsync(resource, cancellationToken); + } - public override async Task UpdateAsync(TId id, TResource resource, CancellationToken cancellationToken) - { - await AssertResourcesToAssignInRelationshipsExistAsync(resource, cancellationToken); + public override async Task UpdateAsync(TId id, TResource resource, CancellationToken cancellationToken) + { + await AssertResourcesToAssignInRelationshipsExistAsync(resource, cancellationToken); - return await base.UpdateAsync(id, resource, cancellationToken); - } + return await base.UpdateAsync(id, resource, cancellationToken); + } - public override async Task SetRelationshipAsync(TId leftId, string relationshipName, object? rightValue, CancellationToken cancellationToken) - { - await AssertRightResourcesExistAsync(rightValue, cancellationToken); + public override async Task SetRelationshipAsync(TId leftId, string relationshipName, object? rightValue, CancellationToken cancellationToken) + { + await AssertRightResourcesExistAsync(rightValue, cancellationToken); - await base.SetRelationshipAsync(leftId, relationshipName, rightValue, cancellationToken); - } + await base.SetRelationshipAsync(leftId, relationshipName, rightValue, cancellationToken); + } - public override async Task AddToToManyRelationshipAsync(TId leftId, string relationshipName, ISet rightResourceIds, - CancellationToken cancellationToken) - { - _ = await GetPrimaryResourceByIdAsync(leftId, TopFieldSelection.OnlyIdAttribute, cancellationToken); - await AssertRightResourcesExistAsync(rightResourceIds, cancellationToken); + public override async Task AddToToManyRelationshipAsync(TId leftId, string relationshipName, ISet rightResourceIds, + CancellationToken cancellationToken) + { + _ = await GetPrimaryResourceByIdAsync(leftId, TopFieldSelection.OnlyIdAttribute, cancellationToken); + await AssertRightResourcesExistAsync(rightResourceIds, cancellationToken); - await base.AddToToManyRelationshipAsync(leftId, relationshipName, rightResourceIds, cancellationToken); - } + await base.AddToToManyRelationshipAsync(leftId, relationshipName, rightResourceIds, cancellationToken); + } - public override async Task DeleteAsync(TId id, CancellationToken cancellationToken) - { - _ = await GetPrimaryResourceByIdAsync(id, TopFieldSelection.OnlyIdAttribute, cancellationToken); + public override async Task DeleteAsync(TId id, CancellationToken cancellationToken) + { + _ = await GetPrimaryResourceByIdAsync(id, TopFieldSelection.OnlyIdAttribute, cancellationToken); - await base.DeleteAsync(id, cancellationToken); - } + await base.DeleteAsync(id, cancellationToken); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/RouteTenantProvider.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/RouteTenantProvider.cs index 560944e8cb..f51c214f0e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/RouteTenantProvider.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/RouteTenantProvider.cs @@ -1,37 +1,34 @@ -using System; -using System.Collections.Generic; using Microsoft.AspNetCore.Http; -namespace JsonApiDotNetCoreTests.IntegrationTests.MultiTenancy +namespace JsonApiDotNetCoreTests.IntegrationTests.MultiTenancy; + +internal sealed class RouteTenantProvider : ITenantProvider { - internal sealed class RouteTenantProvider : ITenantProvider + // In reality, this would be looked up in a database. We'll keep it hardcoded for simplicity. + public static readonly IDictionary TenantRegistry = new Dictionary { - // In reality, this would be looked up in a database. We'll keep it hardcoded for simplicity. - public static readonly IDictionary TenantRegistry = new Dictionary - { - ["nld"] = Guid.NewGuid(), - ["ita"] = Guid.NewGuid() - }; + ["nld"] = Guid.NewGuid(), + ["ita"] = Guid.NewGuid() + }; - private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IHttpContextAccessor _httpContextAccessor; - public Guid TenantId + public Guid TenantId + { + get { - get + if (_httpContextAccessor.HttpContext == null) { - if (_httpContextAccessor.HttpContext == null) - { - throw new InvalidOperationException(); - } - - string? countryCode = (string?)_httpContextAccessor.HttpContext.Request.RouteValues["countryCode"]; - return countryCode != null && TenantRegistry.TryGetValue(countryCode, out Guid tenantId) ? tenantId : Guid.Empty; + throw new InvalidOperationException(); } - } - public RouteTenantProvider(IHttpContextAccessor httpContextAccessor) - { - _httpContextAccessor = httpContextAccessor; + string? countryCode = (string?)_httpContextAccessor.HttpContext.Request.RouteValues["countryCode"]; + return countryCode != null && TenantRegistry.TryGetValue(countryCode, out Guid tenantId) ? tenantId : Guid.Empty; } } + + public RouteTenantProvider(IHttpContextAccessor httpContextAccessor) + { + _httpContextAccessor = httpContextAccessor; + } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebProduct.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebProduct.cs index a2cf280b0b..8bdf4bfb18 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebProduct.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebProduct.cs @@ -2,19 +2,18 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.MultiTenancy +namespace JsonApiDotNetCoreTests.IntegrationTests.MultiTenancy; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.MultiTenancy")] +public sealed class WebProduct : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - [Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.MultiTenancy")] - public sealed class WebProduct : Identifiable - { - [Attr] - public string Name { get; set; } = null!; + [Attr] + public string Name { get; set; } = null!; - [Attr] - public decimal Price { get; set; } + [Attr] + public decimal Price { get; set; } - [HasOne] - public WebShop Shop { get; set; } = null!; - } + [HasOne] + public WebShop Shop { get; set; } = null!; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebProductsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebProductsController.cs index a21e576a59..c11db3422e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebProductsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebProductsController.cs @@ -1,16 +1,15 @@ using JsonApiDotNetCore.Controllers.Annotations; using Microsoft.AspNetCore.Mvc; -namespace JsonApiDotNetCoreTests.IntegrationTests.MultiTenancy +namespace JsonApiDotNetCoreTests.IntegrationTests.MultiTenancy; + +// Workaround for https://youtrack.jetbrains.com/issue/RSRP-487028 +public partial class WebProductsController { - // Workaround for https://youtrack.jetbrains.com/issue/RSRP-487028 - public partial class WebProductsController - { - } +} - [DisableRoutingConvention] - [Route("{countryCode}/products")] - partial class WebProductsController - { - } +[DisableRoutingConvention] +[Route("{countryCode}/products")] +partial class WebProductsController +{ } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebShop.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebShop.cs index 47441dfd8a..5c06148ee5 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebShop.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebShop.cs @@ -1,21 +1,18 @@ -using System; -using System.Collections.Generic; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.MultiTenancy +namespace JsonApiDotNetCoreTests.IntegrationTests.MultiTenancy; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.MultiTenancy")] +public sealed class WebShop : Identifiable, IHasTenant { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - [Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.MultiTenancy")] - public sealed class WebShop : Identifiable, IHasTenant - { - [Attr] - public string Url { get; set; } = null!; + [Attr] + public string Url { get; set; } = null!; - public Guid TenantId { get; set; } + public Guid TenantId { get; set; } - [HasMany] - public IList Products { get; set; } = new List(); - } + [HasMany] + public IList Products { get; set; } = new List(); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebShopsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebShopsController.cs index b532be8f88..b300e0095d 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebShopsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebShopsController.cs @@ -1,16 +1,15 @@ using JsonApiDotNetCore.Controllers.Annotations; using Microsoft.AspNetCore.Mvc; -namespace JsonApiDotNetCoreTests.IntegrationTests.MultiTenancy +namespace JsonApiDotNetCoreTests.IntegrationTests.MultiTenancy; + +// Workaround for https://youtrack.jetbrains.com/issue/RSRP-487028 +public partial class WebShopsController { - // Workaround for https://youtrack.jetbrains.com/issue/RSRP-487028 - public partial class WebShopsController - { - } +} - [DisableRoutingConvention] - [Route("{countryCode}/shops")] - partial class WebShopsController - { - } +[DisableRoutingConvention] +[Route("{countryCode}/shops")] +partial class WebShopsController +{ } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/DivingBoard.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/DivingBoard.cs index fd84f2231b..4cb4581291 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/DivingBoard.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/DivingBoard.cs @@ -3,13 +3,12 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.NamingConventions +namespace JsonApiDotNetCoreTests.IntegrationTests.NamingConventions; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class DivingBoard : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class DivingBoard : Identifiable - { - [Attr] - [Range(1, 20)] - public decimal HeightInMeters { get; set; } - } + [Attr] + [Range(1, 20)] + public decimal HeightInMeters { get; set; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/DivingBoardsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/DivingBoardsController.cs index 673ddca0c8..55a3cd97da 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/DivingBoardsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/DivingBoardsController.cs @@ -3,14 +3,13 @@ using JsonApiDotNetCore.Services; using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCoreTests.IntegrationTests.NamingConventions +namespace JsonApiDotNetCoreTests.IntegrationTests.NamingConventions; + +public sealed class DivingBoardsController : JsonApiController { - public sealed class DivingBoardsController : JsonApiController + public DivingBoardsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { - public DivingBoardsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, - IResourceService resourceService) - : base(options, resourceGraph, loggerFactory, resourceService) - { - } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/JsonKebabCaseNamingPolicy.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/JsonKebabCaseNamingPolicy.cs index 1c50a67fb8..d47de4cf66 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/JsonKebabCaseNamingPolicy.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/JsonKebabCaseNamingPolicy.cs @@ -1,83 +1,81 @@ -using System; using System.Text; using System.Text.Json; -namespace JsonApiDotNetCoreTests.IntegrationTests.NamingConventions +namespace JsonApiDotNetCoreTests.IntegrationTests.NamingConventions; + +// Based on https://github.com/J0rgeSerran0/JsonNamingPolicy +internal sealed class JsonKebabCaseNamingPolicy : JsonNamingPolicy { - // Based on https://github.com/J0rgeSerran0/JsonNamingPolicy - internal sealed class JsonKebabCaseNamingPolicy : JsonNamingPolicy - { - private const char Separator = '-'; + private const char Separator = '-'; - public static readonly JsonKebabCaseNamingPolicy Instance = new(); + public static readonly JsonKebabCaseNamingPolicy Instance = new(); - public override string ConvertName(string name) + public override string ConvertName(string name) + { + if (string.IsNullOrWhiteSpace(name)) { - if (string.IsNullOrWhiteSpace(name)) - { - return string.Empty; - } + return string.Empty; + } - ReadOnlySpan spanName = name.Trim(); + ReadOnlySpan spanName = name.Trim(); - var stringBuilder = new StringBuilder(); - bool addCharacter = true; + var stringBuilder = new StringBuilder(); + bool addCharacter = true; - bool isNextLower = false; - bool isNextUpper = false; - bool isNextSpace = false; + bool isNextLower = false; + bool isNextUpper = false; + bool isNextSpace = false; - for (int position = 0; position < spanName.Length; position++) + for (int position = 0; position < spanName.Length; position++) + { + if (position != 0) { - if (position != 0) + bool isCurrentSpace = spanName[position] == 32; + bool isPreviousSpace = spanName[position - 1] == 32; + bool isPreviousSeparator = spanName[position - 1] == 95; + + if (position + 1 != spanName.Length) { - bool isCurrentSpace = spanName[position] == 32; - bool isPreviousSpace = spanName[position - 1] == 32; - bool isPreviousSeparator = spanName[position - 1] == 95; + isNextLower = spanName[position + 1] > 96 && spanName[position + 1] < 123; + isNextUpper = spanName[position + 1] > 64 && spanName[position + 1] < 91; + isNextSpace = spanName[position + 1] == 32; + } - if (position + 1 != spanName.Length) - { - isNextLower = spanName[position + 1] > 96 && spanName[position + 1] < 123; - isNextUpper = spanName[position + 1] > 64 && spanName[position + 1] < 91; - isNextSpace = spanName[position + 1] == 32; - } + if (isCurrentSpace && (isPreviousSpace || isPreviousSeparator || isNextUpper || isNextSpace)) + { + addCharacter = false; + } + else + { + bool isCurrentUpper = spanName[position] > 64 && spanName[position] < 91; + bool isPreviousLower = spanName[position - 1] > 96 && spanName[position - 1] < 123; + bool isPreviousNumber = spanName[position - 1] > 47 && spanName[position - 1] < 58; - if (isCurrentSpace && (isPreviousSpace || isPreviousSeparator || isNextUpper || isNextSpace)) + if (isCurrentUpper && (isPreviousLower || isPreviousNumber || isNextLower || isNextSpace)) { - addCharacter = false; + stringBuilder.Append(Separator); } else { - bool isCurrentUpper = spanName[position] > 64 && spanName[position] < 91; - bool isPreviousLower = spanName[position - 1] > 96 && spanName[position - 1] < 123; - bool isPreviousNumber = spanName[position - 1] > 47 && spanName[position - 1] < 58; - - if (isCurrentUpper && (isPreviousLower || isPreviousNumber || isNextLower || isNextSpace)) + if (isCurrentSpace) { stringBuilder.Append(Separator); - } - else - { - if (isCurrentSpace) - { - stringBuilder.Append(Separator); - addCharacter = false; - } + addCharacter = false; } } } - - if (addCharacter) - { - stringBuilder.Append(spanName[position]); - } - else - { - addCharacter = true; - } } - return stringBuilder.ToString().ToLower(); + if (addCharacter) + { + stringBuilder.Append(spanName[position]); + } + else + { + addCharacter = true; + } } + + return stringBuilder.ToString().ToLower(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/KebabCasingConventionStartup.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/KebabCasingConventionStartup.cs index 6114b514cb..cc0dfcd11d 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/KebabCasingConventionStartup.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/KebabCasingConventionStartup.cs @@ -3,22 +3,21 @@ using Microsoft.EntityFrameworkCore; using TestBuildingBlocks; -namespace JsonApiDotNetCoreTests.IntegrationTests.NamingConventions +namespace JsonApiDotNetCoreTests.IntegrationTests.NamingConventions; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class KebabCasingConventionStartup : TestableStartup + where TDbContext : DbContext { - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class KebabCasingConventionStartup : TestableStartup - where TDbContext : DbContext + protected override void SetJsonApiOptions(JsonApiOptions options) { - protected override void SetJsonApiOptions(JsonApiOptions options) - { - base.SetJsonApiOptions(options); + base.SetJsonApiOptions(options); - options.Namespace = "public-api"; - options.UseRelativeLinks = true; - options.IncludeTotalResourceCount = true; + options.Namespace = "public-api"; + options.UseRelativeLinks = true; + options.IncludeTotalResourceCount = true; - options.SerializerOptions.PropertyNamingPolicy = JsonKebabCaseNamingPolicy.Instance; - options.SerializerOptions.DictionaryKeyPolicy = JsonKebabCaseNamingPolicy.Instance; - } + options.SerializerOptions.PropertyNamingPolicy = JsonKebabCaseNamingPolicy.Instance; + options.SerializerOptions.DictionaryKeyPolicy = JsonKebabCaseNamingPolicy.Instance; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/KebabCasingTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/KebabCasingTests.cs index d2be3f1c74..1f9b9a7b97 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/KebabCasingTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/KebabCasingTests.cs @@ -1,222 +1,218 @@ -using System.Collections.Generic; using System.Net; -using System.Net.Http; using System.Text.Json; -using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.NamingConventions +namespace JsonApiDotNetCoreTests.IntegrationTests.NamingConventions; + +public sealed class KebabCasingTests : IClassFixture, NamingDbContext>> { - public sealed class KebabCasingTests : IClassFixture, NamingDbContext>> + private readonly IntegrationTestContext, NamingDbContext> _testContext; + private readonly NamingFakers _fakers = new(); + + public KebabCasingTests(IntegrationTestContext, NamingDbContext> testContext) { - private readonly IntegrationTestContext, NamingDbContext> _testContext; - private readonly NamingFakers _fakers = new(); + _testContext = testContext; - public KebabCasingTests(IntegrationTestContext, NamingDbContext> testContext) - { - _testContext = testContext; + testContext.UseController(); + testContext.UseController(); + } - testContext.UseController(); - testContext.UseController(); - } + [Fact] + public async Task Can_get_resources_with_include() + { + // Arrange + List pools = _fakers.SwimmingPool.Generate(2); + pools[1].DivingBoards = _fakers.DivingBoard.Generate(1); - [Fact] - public async Task Can_get_resources_with_include() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - List pools = _fakers.SwimmingPool.Generate(2); - pools[1].DivingBoards = _fakers.DivingBoard.Generate(1); + await dbContext.ClearTableAsync(); + dbContext.SwimmingPools.AddRange(pools); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.SwimmingPools.AddRange(pools); - await dbContext.SaveChangesAsync(); - }); + const string route = "/public-api/swimming-pools?include=diving-boards"; - const string route = "/public-api/swimming-pools?include=diving-boards"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.ManyValue.ShouldHaveCount(2); + responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Type == "swimming-pools"); + responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Attributes.ShouldContainKey("is-indoor") != null); + responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Relationships.ShouldContainKey("water-slides") != null); + responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Relationships.ShouldContainKey("diving-boards") != null); - responseDocument.Data.ManyValue.ShouldHaveCount(2); - responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Type == "swimming-pools"); - responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Attributes.ShouldContainKey("is-indoor") != null); - responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Relationships.ShouldContainKey("water-slides") != null); - responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Relationships.ShouldContainKey("diving-boards") != null); + decimal height = pools[1].DivingBoards[0].HeightInMeters; - decimal height = pools[1].DivingBoards[0].HeightInMeters; + responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included[0].Type.Should().Be("diving-boards"); + responseDocument.Included[0].Id.Should().Be(pools[1].DivingBoards[0].StringId); + responseDocument.Included[0].Attributes.ShouldContainKey("height-in-meters").With(value => value.As().Should().BeApproximately(height)); + responseDocument.Included[0].Relationships.Should().BeNull(); + responseDocument.Included[0].Links.ShouldNotBeNull().Self.Should().Be($"/public-api/diving-boards/{pools[1].DivingBoards[0].StringId}"); - responseDocument.Included.ShouldHaveCount(1); - responseDocument.Included[0].Type.Should().Be("diving-boards"); - responseDocument.Included[0].Id.Should().Be(pools[1].DivingBoards[0].StringId); - responseDocument.Included[0].Attributes.ShouldContainKey("height-in-meters").With(value => value.As().Should().BeApproximately(height)); - responseDocument.Included[0].Relationships.Should().BeNull(); - responseDocument.Included[0].Links.ShouldNotBeNull().Self.Should().Be($"/public-api/diving-boards/{pools[1].DivingBoards[0].StringId}"); + responseDocument.Meta.ShouldContainKey("total").With(value => + { + JsonElement element = value.Should().BeOfType().Subject; + element.GetInt32().Should().Be(2); + }); + } - responseDocument.Meta.ShouldContainKey("total").With(value => - { - JsonElement element = value.Should().BeOfType().Subject; - element.GetInt32().Should().Be(2); - }); - } + [Fact] + public async Task Can_filter_secondary_resources_with_sparse_fieldset() + { + // Arrange + SwimmingPool pool = _fakers.SwimmingPool.Generate(); + pool.WaterSlides = _fakers.WaterSlide.Generate(2); + pool.WaterSlides[0].LengthInMeters = 1; + pool.WaterSlides[1].LengthInMeters = 5; - [Fact] - public async Task Can_filter_secondary_resources_with_sparse_fieldset() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - SwimmingPool pool = _fakers.SwimmingPool.Generate(); - pool.WaterSlides = _fakers.WaterSlide.Generate(2); - pool.WaterSlides[0].LengthInMeters = 1; - pool.WaterSlides[1].LengthInMeters = 5; + dbContext.SwimmingPools.Add(pool); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.SwimmingPools.Add(pool); - await dbContext.SaveChangesAsync(); - }); + string route = $"/public-api/swimming-pools/{pool.StringId}/water-slides" + + "?filter=greaterThan(length-in-meters,'1')&fields[water-slides]=length-in-meters"; - string route = $"/public-api/swimming-pools/{pool.StringId}/water-slides" + - "?filter=greaterThan(length-in-meters,'1')&fields[water-slides]=length-in-meters"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("water-slides"); + responseDocument.Data.ManyValue[0].Id.Should().Be(pool.WaterSlides[1].StringId); + responseDocument.Data.ManyValue[0].Attributes.ShouldHaveCount(1); + } - responseDocument.Data.ManyValue.ShouldHaveCount(1); - responseDocument.Data.ManyValue[0].Type.Should().Be("water-slides"); - responseDocument.Data.ManyValue[0].Id.Should().Be(pool.WaterSlides[1].StringId); - responseDocument.Data.ManyValue[0].Attributes.ShouldHaveCount(1); - } + [Fact] + public async Task Can_create_resource() + { + // Arrange + SwimmingPool newPool = _fakers.SwimmingPool.Generate(); - [Fact] - public async Task Can_create_resource() + var requestBody = new { - // Arrange - SwimmingPool newPool = _fakers.SwimmingPool.Generate(); - - var requestBody = new + data = new { - data = new + type = "swimming-pools", + attributes = new Dictionary { - type = "swimming-pools", - attributes = new Dictionary - { - ["is-indoor"] = newPool.IsIndoor - } + ["is-indoor"] = newPool.IsIndoor } - }; + } + }; - const string route = "/public-api/swimming-pools"; + const string route = "/public-api/swimming-pools"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Type.Should().Be("swimming-pools"); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("is-indoor").With(value => value.Should().Be(newPool.IsIndoor)); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("swimming-pools"); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("is-indoor").With(value => value.Should().Be(newPool.IsIndoor)); - int newPoolId = int.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); - string poolLink = $"{route}/{newPoolId}"; + int newPoolId = int.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); + string poolLink = $"{route}/{newPoolId}"; - responseDocument.Data.SingleValue.Relationships.ShouldContainKey("water-slides").With(value => - { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - value.Links.Self.Should().Be($"{poolLink}/relationships/water-slides"); - value.Links.Related.Should().Be($"{poolLink}/water-slides"); - }); + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("water-slides").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{poolLink}/relationships/water-slides"); + value.Links.Related.Should().Be($"{poolLink}/water-slides"); + }); - responseDocument.Data.SingleValue.Relationships.ShouldContainKey("diving-boards").With(value => - { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - value.Links.Self.Should().Be($"{poolLink}/relationships/diving-boards"); - value.Links.Related.Should().Be($"{poolLink}/diving-boards"); - }); + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("diving-boards").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{poolLink}/relationships/diving-boards"); + value.Links.Related.Should().Be($"{poolLink}/diving-boards"); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - SwimmingPool poolInDatabase = await dbContext.SwimmingPools.FirstWithIdAsync(newPoolId); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + SwimmingPool poolInDatabase = await dbContext.SwimmingPools.FirstWithIdAsync(newPoolId); - poolInDatabase.IsIndoor.Should().Be(newPool.IsIndoor); - }); - } + poolInDatabase.IsIndoor.Should().Be(newPool.IsIndoor); + }); + } - [Fact] - public async Task Applies_casing_convention_on_error_stack_trace() - { - // Arrange - const string requestBody = "{ \"data\": {"; + [Fact] + public async Task Applies_casing_convention_on_error_stack_trace() + { + // Arrange + const string requestBody = "{ \"data\": {"; - const string route = "/public-api/swimming-pools"; + const string route = "/public-api/swimming-pools"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body."); - error.Meta.ShouldContainKey("stack-trace"); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body."); + error.Meta.ShouldContainKey("stack-trace"); + } - [Fact] - public async Task Applies_casing_convention_on_source_pointer_from_ModelState() - { - // Arrange - DivingBoard existingBoard = _fakers.DivingBoard.Generate(); + [Fact] + public async Task Applies_casing_convention_on_source_pointer_from_ModelState() + { + // Arrange + DivingBoard existingBoard = _fakers.DivingBoard.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.DivingBoards.Add(existingBoard); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.DivingBoards.Add(existingBoard); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new { - data = new + type = "diving-boards", + id = existingBoard.StringId, + attributes = new Dictionary { - type = "diving-boards", - id = existingBoard.StringId, - attributes = new Dictionary - { - ["height-in-meters"] = -1 - } + ["height-in-meters"] = -1 } - }; + } + }; - string route = $"/public-api/diving-boards/{existingBoard.StringId}"; + string route = $"/public-api/diving-boards/{existingBoard.StringId}"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Input validation failed."); - error.Detail.Should().Be("The field HeightInMeters must be between 1 and 20."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/data/attributes/height-in-meters"); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Input validation failed."); + error.Detail.Should().Be("The field HeightInMeters must be between 1 and 20."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data/attributes/height-in-meters"); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/NamingDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/NamingDbContext.cs index 120c28ff72..d231a5f822 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/NamingDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/NamingDbContext.cs @@ -1,18 +1,17 @@ using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; -namespace JsonApiDotNetCoreTests.IntegrationTests.NamingConventions +namespace JsonApiDotNetCoreTests.IntegrationTests.NamingConventions; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class NamingDbContext : DbContext { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class NamingDbContext : DbContext - { - public DbSet SwimmingPools => Set(); - public DbSet WaterSlides => Set(); - public DbSet DivingBoards => Set(); + public DbSet SwimmingPools => Set(); + public DbSet WaterSlides => Set(); + public DbSet DivingBoards => Set(); - public NamingDbContext(DbContextOptions options) - : base(options) - { - } + public NamingDbContext(DbContextOptions options) + : base(options) + { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/NamingFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/NamingFakers.cs index fa387cf3bf..e60dbdf525 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/NamingFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/NamingFakers.cs @@ -1,31 +1,29 @@ -using System; using Bogus; using TestBuildingBlocks; // @formatter:wrap_chained_method_calls chop_always // @formatter:keep_existing_linebreaks true -namespace JsonApiDotNetCoreTests.IntegrationTests.NamingConventions +namespace JsonApiDotNetCoreTests.IntegrationTests.NamingConventions; + +internal sealed class NamingFakers : FakerContainer { - internal sealed class NamingFakers : FakerContainer - { - private readonly Lazy> _lazySwimmingPoolFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(swimmingPool => swimmingPool.IsIndoor, faker => faker.Random.Bool())); + private readonly Lazy> _lazySwimmingPoolFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(swimmingPool => swimmingPool.IsIndoor, faker => faker.Random.Bool())); - private readonly Lazy> _lazyWaterSlideFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(waterSlide => waterSlide.LengthInMeters, faker => faker.Random.Decimal(3, 100))); + private readonly Lazy> _lazyWaterSlideFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(waterSlide => waterSlide.LengthInMeters, faker => faker.Random.Decimal(3, 100))); - private readonly Lazy> _lazyDivingBoardFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(divingBoard => divingBoard.HeightInMeters, faker => faker.Random.Decimal(1, 15))); + private readonly Lazy> _lazyDivingBoardFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(divingBoard => divingBoard.HeightInMeters, faker => faker.Random.Decimal(1, 15))); - public Faker SwimmingPool => _lazySwimmingPoolFaker.Value; - public Faker WaterSlide => _lazyWaterSlideFaker.Value; - public Faker DivingBoard => _lazyDivingBoardFaker.Value; - } + public Faker SwimmingPool => _lazySwimmingPoolFaker.Value; + public Faker WaterSlide => _lazyWaterSlideFaker.Value; + public Faker DivingBoard => _lazyDivingBoardFaker.Value; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/PascalCasingConventionStartup.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/PascalCasingConventionStartup.cs index 0f863df251..c1c8cb8fa1 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/PascalCasingConventionStartup.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/PascalCasingConventionStartup.cs @@ -3,22 +3,21 @@ using Microsoft.EntityFrameworkCore; using TestBuildingBlocks; -namespace JsonApiDotNetCoreTests.IntegrationTests.NamingConventions +namespace JsonApiDotNetCoreTests.IntegrationTests.NamingConventions; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class PascalCasingConventionStartup : TestableStartup + where TDbContext : DbContext { - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class PascalCasingConventionStartup : TestableStartup - where TDbContext : DbContext + protected override void SetJsonApiOptions(JsonApiOptions options) { - protected override void SetJsonApiOptions(JsonApiOptions options) - { - base.SetJsonApiOptions(options); + base.SetJsonApiOptions(options); - options.Namespace = "PublicApi"; - options.UseRelativeLinks = true; - options.IncludeTotalResourceCount = true; + options.Namespace = "PublicApi"; + options.UseRelativeLinks = true; + options.IncludeTotalResourceCount = true; - options.SerializerOptions.PropertyNamingPolicy = null; - options.SerializerOptions.DictionaryKeyPolicy = null; - } + options.SerializerOptions.PropertyNamingPolicy = null; + options.SerializerOptions.DictionaryKeyPolicy = null; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/PascalCasingTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/PascalCasingTests.cs index 6692bf337e..98334a432f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/PascalCasingTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/PascalCasingTests.cs @@ -1,222 +1,217 @@ -using System.Collections.Generic; using System.Net; -using System.Net.Http; using System.Text.Json; -using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.NamingConventions +namespace JsonApiDotNetCoreTests.IntegrationTests.NamingConventions; + +public sealed class PascalCasingTests : IClassFixture, NamingDbContext>> { - public sealed class PascalCasingTests : IClassFixture, NamingDbContext>> + private readonly IntegrationTestContext, NamingDbContext> _testContext; + private readonly NamingFakers _fakers = new(); + + public PascalCasingTests(IntegrationTestContext, NamingDbContext> testContext) { - private readonly IntegrationTestContext, NamingDbContext> _testContext; - private readonly NamingFakers _fakers = new(); + _testContext = testContext; - public PascalCasingTests(IntegrationTestContext, NamingDbContext> testContext) - { - _testContext = testContext; + testContext.UseController(); + testContext.UseController(); + } - testContext.UseController(); - testContext.UseController(); - } + [Fact] + public async Task Can_get_resources_with_include() + { + // Arrange + List pools = _fakers.SwimmingPool.Generate(2); + pools[1].DivingBoards = _fakers.DivingBoard.Generate(1); - [Fact] - public async Task Can_get_resources_with_include() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - List pools = _fakers.SwimmingPool.Generate(2); - pools[1].DivingBoards = _fakers.DivingBoard.Generate(1); + await dbContext.ClearTableAsync(); + dbContext.SwimmingPools.AddRange(pools); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.SwimmingPools.AddRange(pools); - await dbContext.SaveChangesAsync(); - }); + const string route = "/PublicApi/SwimmingPools?include=DivingBoards"; - const string route = "/PublicApi/SwimmingPools?include=DivingBoards"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.ManyValue.ShouldHaveCount(2); + responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Type == "SwimmingPools"); + responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Attributes.ShouldContainKey("IsIndoor") != null); + responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Relationships.ShouldContainKey("WaterSlides") != null); + responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Relationships.ShouldContainKey("DivingBoards") != null); - responseDocument.Data.ManyValue.ShouldHaveCount(2); - responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Type == "SwimmingPools"); - responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Attributes.ShouldContainKey("IsIndoor") != null); - responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Relationships.ShouldContainKey("WaterSlides") != null); - responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Relationships.ShouldContainKey("DivingBoards") != null); + decimal height = pools[1].DivingBoards[0].HeightInMeters; - decimal height = pools[1].DivingBoards[0].HeightInMeters; + responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included[0].Type.Should().Be("DivingBoards"); + responseDocument.Included[0].Id.Should().Be(pools[1].DivingBoards[0].StringId); + responseDocument.Included[0].Attributes.ShouldContainKey("HeightInMeters").With(value => value.As().Should().BeApproximately(height)); + responseDocument.Included[0].Relationships.Should().BeNull(); + responseDocument.Included[0].Links.ShouldNotBeNull().Self.Should().Be($"/PublicApi/DivingBoards/{pools[1].DivingBoards[0].StringId}"); - responseDocument.Included.ShouldHaveCount(1); - responseDocument.Included[0].Type.Should().Be("DivingBoards"); - responseDocument.Included[0].Id.Should().Be(pools[1].DivingBoards[0].StringId); - responseDocument.Included[0].Attributes.ShouldContainKey("HeightInMeters").With(value => value.As().Should().BeApproximately(height)); - responseDocument.Included[0].Relationships.Should().BeNull(); - responseDocument.Included[0].Links.ShouldNotBeNull().Self.Should().Be($"/PublicApi/DivingBoards/{pools[1].DivingBoards[0].StringId}"); + responseDocument.Meta.ShouldContainKey("Total").With(value => + { + JsonElement element = value.Should().BeOfType().Subject; + element.GetInt32().Should().Be(2); + }); + } - responseDocument.Meta.ShouldContainKey("Total").With(value => - { - JsonElement element = value.Should().BeOfType().Subject; - element.GetInt32().Should().Be(2); - }); - } + [Fact] + public async Task Can_filter_secondary_resources_with_sparse_fieldset() + { + // Arrange + SwimmingPool pool = _fakers.SwimmingPool.Generate(); + pool.WaterSlides = _fakers.WaterSlide.Generate(2); + pool.WaterSlides[0].LengthInMeters = 1; + pool.WaterSlides[1].LengthInMeters = 5; - [Fact] - public async Task Can_filter_secondary_resources_with_sparse_fieldset() + await _testContext.RunOnDatabaseAsync(async dbContext => { - // Arrange - SwimmingPool pool = _fakers.SwimmingPool.Generate(); - pool.WaterSlides = _fakers.WaterSlide.Generate(2); - pool.WaterSlides[0].LengthInMeters = 1; - pool.WaterSlides[1].LengthInMeters = 5; + dbContext.SwimmingPools.Add(pool); + await dbContext.SaveChangesAsync(); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.SwimmingPools.Add(pool); - await dbContext.SaveChangesAsync(); - }); + string route = $"/PublicApi/SwimmingPools/{pool.StringId}/WaterSlides" + "?filter=greaterThan(LengthInMeters,'1')&fields[WaterSlides]=LengthInMeters"; - string route = $"/PublicApi/SwimmingPools/{pool.StringId}/WaterSlides" + - "?filter=greaterThan(LengthInMeters,'1')&fields[WaterSlides]=LengthInMeters"; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("WaterSlides"); + responseDocument.Data.ManyValue[0].Id.Should().Be(pool.WaterSlides[1].StringId); + responseDocument.Data.ManyValue[0].Attributes.ShouldHaveCount(1); + } - responseDocument.Data.ManyValue.ShouldHaveCount(1); - responseDocument.Data.ManyValue[0].Type.Should().Be("WaterSlides"); - responseDocument.Data.ManyValue[0].Id.Should().Be(pool.WaterSlides[1].StringId); - responseDocument.Data.ManyValue[0].Attributes.ShouldHaveCount(1); - } + [Fact] + public async Task Can_create_resource() + { + // Arrange + SwimmingPool newPool = _fakers.SwimmingPool.Generate(); - [Fact] - public async Task Can_create_resource() + var requestBody = new { - // Arrange - SwimmingPool newPool = _fakers.SwimmingPool.Generate(); - - var requestBody = new + data = new { - data = new + type = "SwimmingPools", + attributes = new Dictionary { - type = "SwimmingPools", - attributes = new Dictionary - { - ["IsIndoor"] = newPool.IsIndoor - } + ["IsIndoor"] = newPool.IsIndoor } - }; + } + }; - const string route = "/PublicApi/SwimmingPools"; + const string route = "/PublicApi/SwimmingPools"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Type.Should().Be("SwimmingPools"); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("IsIndoor").With(value => value.Should().Be(newPool.IsIndoor)); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("SwimmingPools"); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("IsIndoor").With(value => value.Should().Be(newPool.IsIndoor)); - int newPoolId = int.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); - string poolLink = $"{route}/{newPoolId}"; + int newPoolId = int.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); + string poolLink = $"{route}/{newPoolId}"; - responseDocument.Data.SingleValue.Relationships.ShouldContainKey("WaterSlides").With(value => - { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - value.Links.Self.Should().Be($"{poolLink}/relationships/WaterSlides"); - value.Links.Related.Should().Be($"{poolLink}/WaterSlides"); - }); + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("WaterSlides").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{poolLink}/relationships/WaterSlides"); + value.Links.Related.Should().Be($"{poolLink}/WaterSlides"); + }); - responseDocument.Data.SingleValue.Relationships.ShouldContainKey("DivingBoards").With(value => - { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - value.Links.Self.Should().Be($"{poolLink}/relationships/DivingBoards"); - value.Links.Related.Should().Be($"{poolLink}/DivingBoards"); - }); + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("DivingBoards").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{poolLink}/relationships/DivingBoards"); + value.Links.Related.Should().Be($"{poolLink}/DivingBoards"); + }); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - SwimmingPool poolInDatabase = await dbContext.SwimmingPools.FirstWithIdAsync(newPoolId); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + SwimmingPool poolInDatabase = await dbContext.SwimmingPools.FirstWithIdAsync(newPoolId); - poolInDatabase.IsIndoor.Should().Be(newPool.IsIndoor); - }); - } + poolInDatabase.IsIndoor.Should().Be(newPool.IsIndoor); + }); + } - [Fact] - public async Task Applies_casing_convention_on_error_stack_trace() - { - // Arrange - const string requestBody = "{ \"data\": {"; + [Fact] + public async Task Applies_casing_convention_on_error_stack_trace() + { + // Arrange + const string requestBody = "{ \"data\": {"; - const string route = "/PublicApi/SwimmingPools"; + const string route = "/PublicApi/SwimmingPools"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body."); - error.Meta.ShouldContainKey("StackTrace"); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body."); + error.Meta.ShouldContainKey("StackTrace"); + } - [Fact] - public async Task Applies_casing_convention_on_source_pointer_from_ModelState() - { - // Arrange - DivingBoard existingBoard = _fakers.DivingBoard.Generate(); + [Fact] + public async Task Applies_casing_convention_on_source_pointer_from_ModelState() + { + // Arrange + DivingBoard existingBoard = _fakers.DivingBoard.Generate(); - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.DivingBoards.Add(existingBoard); - await dbContext.SaveChangesAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.DivingBoards.Add(existingBoard); + await dbContext.SaveChangesAsync(); + }); - var requestBody = new + var requestBody = new + { + data = new { - data = new + type = "DivingBoards", + id = existingBoard.StringId, + attributes = new Dictionary { - type = "DivingBoards", - id = existingBoard.StringId, - attributes = new Dictionary - { - ["HeightInMeters"] = -1 - } + ["HeightInMeters"] = -1 } - }; + } + }; - string route = $"/PublicApi/DivingBoards/{existingBoard.StringId}"; + string route = $"/PublicApi/DivingBoards/{existingBoard.StringId}"; - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Input validation failed."); - error.Detail.Should().Be("The field HeightInMeters must be between 1 and 20."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/data/attributes/HeightInMeters"); - } + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Input validation failed."); + error.Detail.Should().Be("The field HeightInMeters must be between 1 and 20."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data/attributes/HeightInMeters"); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/SwimmingPool.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/SwimmingPool.cs index fa05fec5ed..2b715edc7d 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/SwimmingPool.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/SwimmingPool.cs @@ -1,20 +1,18 @@ -using System.Collections.Generic; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.NamingConventions +namespace JsonApiDotNetCoreTests.IntegrationTests.NamingConventions; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class SwimmingPool : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class SwimmingPool : Identifiable - { - [Attr] - public bool IsIndoor { get; set; } + [Attr] + public bool IsIndoor { get; set; } - [HasMany] - public IList WaterSlides { get; set; } = new List(); + [HasMany] + public IList WaterSlides { get; set; } = new List(); - [HasMany] - public IList DivingBoards { get; set; } = new List(); - } + [HasMany] + public IList DivingBoards { get; set; } = new List(); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/SwimmingPoolsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/SwimmingPoolsController.cs index 7147413c51..b87206097b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/SwimmingPoolsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/SwimmingPoolsController.cs @@ -3,14 +3,13 @@ using JsonApiDotNetCore.Services; using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCoreTests.IntegrationTests.NamingConventions +namespace JsonApiDotNetCoreTests.IntegrationTests.NamingConventions; + +public sealed class SwimmingPoolsController : JsonApiController { - public sealed class SwimmingPoolsController : JsonApiController + public SwimmingPoolsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { - public SwimmingPoolsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, - IResourceService resourceService) - : base(options, resourceGraph, loggerFactory, resourceService) - { - } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/WaterSlide.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/WaterSlide.cs index 7c436bb5e4..95bda04456 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/WaterSlide.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/WaterSlide.cs @@ -2,12 +2,11 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.NamingConventions +namespace JsonApiDotNetCoreTests.IntegrationTests.NamingConventions; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class WaterSlide : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class WaterSlide : Identifiable - { - [Attr] - public decimal LengthInMeters { get; set; } - } + [Attr] + public decimal LengthInMeters { get; set; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/DuplicateKnownResourcesController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/DuplicateKnownResourcesController.cs index 80974043d1..b127a8cc81 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/DuplicateKnownResourcesController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/DuplicateKnownResourcesController.cs @@ -3,14 +3,13 @@ using JsonApiDotNetCore.Services; using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCoreTests.IntegrationTests.NonJsonApiControllers +namespace JsonApiDotNetCoreTests.IntegrationTests.NonJsonApiControllers; + +public sealed class DuplicateKnownResourcesController : JsonApiController { - public sealed class DuplicateKnownResourcesController : JsonApiController + public DuplicateKnownResourcesController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { - public DuplicateKnownResourcesController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, - IResourceService resourceService) - : base(options, resourceGraph, loggerFactory, resourceService) - { - } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/DuplicateResourceControllerTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/DuplicateResourceControllerTests.cs index 483fa7f21a..7fec2a877e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/DuplicateResourceControllerTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/DuplicateResourceControllerTests.cs @@ -1,32 +1,30 @@ -using System; using FluentAssertions; using JsonApiDotNetCore.Errors; using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.NonJsonApiControllers +namespace JsonApiDotNetCoreTests.IntegrationTests.NonJsonApiControllers; + +public sealed class DuplicateResourceControllerTests : IntegrationTestContext, KnownDbContext> { - public sealed class DuplicateResourceControllerTests : IntegrationTestContext, KnownDbContext> + public DuplicateResourceControllerTests() { - public DuplicateResourceControllerTests() - { - UseController(); - UseController(); - } + UseController(); + UseController(); + } - [Fact] - public void Fails_at_startup_when_multiple_controllers_exist_for_same_resource_type() - { - // Act - Action action = () => _ = Factory; + [Fact] + public void Fails_at_startup_when_multiple_controllers_exist_for_same_resource_type() + { + // Act + Action action = () => _ = Factory; - // Assert - action.Should().ThrowExactly().WithMessage("Multiple controllers found for resource type 'knownResources'."); - } + // Assert + action.Should().ThrowExactly().WithMessage("Multiple controllers found for resource type 'knownResources'."); + } - public override void Dispose() - { - // Prevents crash when test cleanup tries to access lazily constructed Factory. - } + public override void Dispose() + { + // Prevents crash when test cleanup tries to access lazily constructed Factory. } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/EmptyDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/EmptyDbContext.cs index 9f23ae9da2..f4a24e1d00 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/EmptyDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/EmptyDbContext.cs @@ -1,14 +1,13 @@ using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; -namespace JsonApiDotNetCoreTests.IntegrationTests.NonJsonApiControllers +namespace JsonApiDotNetCoreTests.IntegrationTests.NonJsonApiControllers; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class EmptyDbContext : DbContext { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class EmptyDbContext : DbContext + public EmptyDbContext(DbContextOptions options) + : base(options) { - public EmptyDbContext(DbContextOptions options) - : base(options) - { - } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/KnownDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/KnownDbContext.cs index 86508c4655..39014e85b3 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/KnownDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/KnownDbContext.cs @@ -1,16 +1,15 @@ using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; -namespace JsonApiDotNetCoreTests.IntegrationTests.NonJsonApiControllers +namespace JsonApiDotNetCoreTests.IntegrationTests.NonJsonApiControllers; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class KnownDbContext : DbContext { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class KnownDbContext : DbContext - { - public DbSet KnownResources => Set(); + public DbSet KnownResources => Set(); - public KnownDbContext(DbContextOptions options) - : base(options) - { - } + public KnownDbContext(DbContextOptions options) + : base(options) + { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/KnownResource.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/KnownResource.cs index 739b824c9b..a5a25efbb0 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/KnownResource.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/KnownResource.cs @@ -2,12 +2,12 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.NonJsonApiControllers +namespace JsonApiDotNetCoreTests.IntegrationTests.NonJsonApiControllers; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.NonJsonApiControllers")] +public sealed class KnownResource : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - [Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.NonJsonApiControllers")] - public sealed class KnownResource : Identifiable - { - public string? Value { get; set; } - } + [Attr] + public string? Value { get; set; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/NonJsonApiController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/NonJsonApiController.cs index 3f108548ac..170a8eb194 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/NonJsonApiController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/NonJsonApiController.cs @@ -1,55 +1,52 @@ -using System.IO; -using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; -namespace JsonApiDotNetCoreTests.IntegrationTests.NonJsonApiControllers +namespace JsonApiDotNetCoreTests.IntegrationTests.NonJsonApiControllers; + +[Route("[controller]")] +public sealed class NonJsonApiController : ControllerBase { - [Route("[controller]")] - public sealed class NonJsonApiController : ControllerBase + [HttpGet] + public IActionResult Get() { - [HttpGet] - public IActionResult Get() - { - string[] result = - { - "Welcome!" - }; - - return Ok(result); - } - - [HttpPost] - public async Task PostAsync() + string[] result = { - string name = await new StreamReader(Request.Body).ReadToEndAsync(); + "Welcome!" + }; - if (string.IsNullOrEmpty(name)) - { - return BadRequest("Please send your name."); - } + return Ok(result); + } - string result = $"Hello, {name}"; - return Ok(result); - } + [HttpPost] + public async Task PostAsync() + { + string name = await new StreamReader(Request.Body).ReadToEndAsync(); - [HttpPut] - public IActionResult Put([FromBody] string name) + if (string.IsNullOrEmpty(name)) { - string result = $"Hi, {name}"; - return Ok(result); + return BadRequest("Please send your name."); } - [HttpPatch] - public IActionResult Patch(string name) - { - string result = $"Good day, {name}"; - return Ok(result); - } + string result = $"Hello, {name}"; + return Ok(result); + } - [HttpDelete] - public IActionResult Delete() - { - return Ok("Bye."); - } + [HttpPut] + public IActionResult Put([FromBody] string name) + { + string result = $"Hi, {name}"; + return Ok(result); + } + + [HttpPatch] + public IActionResult Patch(string name) + { + string result = $"Good day, {name}"; + return Ok(result); + } + + [HttpDelete] + public IActionResult Delete() + { + return Ok("Bye."); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/NonJsonApiControllerTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/NonJsonApiControllerTests.cs index 1fd1e8be42..9c3364f4c5 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/NonJsonApiControllerTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/NonJsonApiControllerTests.cs @@ -1,160 +1,157 @@ using System.Net; -using System.Net.Http; using System.Net.Http.Headers; -using System.Threading.Tasks; using FluentAssertions; using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.NonJsonApiControllers +namespace JsonApiDotNetCoreTests.IntegrationTests.NonJsonApiControllers; + +public sealed class NonJsonApiControllerTests : IClassFixture, EmptyDbContext>> { - public sealed class NonJsonApiControllerTests : IClassFixture, EmptyDbContext>> - { - private readonly IntegrationTestContext, EmptyDbContext> _testContext; + private readonly IntegrationTestContext, EmptyDbContext> _testContext; - public NonJsonApiControllerTests(IntegrationTestContext, EmptyDbContext> testContext) - { - _testContext = testContext; + public NonJsonApiControllerTests(IntegrationTestContext, EmptyDbContext> testContext) + { + _testContext = testContext; - testContext.UseController(); - } + testContext.UseController(); + } - [Fact] - public async Task Get_skips_middleware_and_formatters() - { - // Arrange - using var request = new HttpRequestMessage(HttpMethod.Get, "/NonJsonApi"); + [Fact] + public async Task Get_skips_middleware_and_formatters() + { + // Arrange + using var request = new HttpRequestMessage(HttpMethod.Get, "/NonJsonApi"); - HttpClient client = _testContext.Factory.CreateClient(); + HttpClient client = _testContext.Factory.CreateClient(); - // Act - HttpResponseMessage httpResponse = await client.SendAsync(request); + // Act + HttpResponseMessage httpResponse = await client.SendAsync(request); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); - httpResponse.Content.Headers.ContentType.ToString().Should().Be("application/json; charset=utf-8"); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be("application/json; charset=utf-8"); - string responseText = await httpResponse.Content.ReadAsStringAsync(); - responseText.Should().Be("[\"Welcome!\"]"); - } + string responseText = await httpResponse.Content.ReadAsStringAsync(); + responseText.Should().Be("[\"Welcome!\"]"); + } - [Fact] - public async Task Post_skips_middleware_and_formatters() + [Fact] + public async Task Post_skips_middleware_and_formatters() + { + // Arrange + using var request = new HttpRequestMessage(HttpMethod.Post, "/NonJsonApi") { - // Arrange - using var request = new HttpRequestMessage(HttpMethod.Post, "/NonJsonApi") + Content = new StringContent("Jack") { - Content = new StringContent("Jack") + Headers = { - Headers = - { - ContentType = new MediaTypeHeaderValue("text/plain") - } + ContentType = new MediaTypeHeaderValue("text/plain") } - }; + } + }; - HttpClient client = _testContext.Factory.CreateClient(); + HttpClient client = _testContext.Factory.CreateClient(); - // Act - HttpResponseMessage httpResponse = await client.SendAsync(request); + // Act + HttpResponseMessage httpResponse = await client.SendAsync(request); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); - httpResponse.Content.Headers.ContentType.ToString().Should().Be("text/plain; charset=utf-8"); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be("text/plain; charset=utf-8"); - string responseText = await httpResponse.Content.ReadAsStringAsync(); - responseText.Should().Be("Hello, Jack"); - } + string responseText = await httpResponse.Content.ReadAsStringAsync(); + responseText.Should().Be("Hello, Jack"); + } - [Fact] - public async Task Post_skips_error_handler() - { - // Arrange - using var request = new HttpRequestMessage(HttpMethod.Post, "/NonJsonApi"); + [Fact] + public async Task Post_skips_error_handler() + { + // Arrange + using var request = new HttpRequestMessage(HttpMethod.Post, "/NonJsonApi"); - HttpClient client = _testContext.Factory.CreateClient(); + HttpClient client = _testContext.Factory.CreateClient(); - // Act - HttpResponseMessage httpResponse = await client.SendAsync(request); + // Act + HttpResponseMessage httpResponse = await client.SendAsync(request); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); - httpResponse.Content.Headers.ContentType.ToString().Should().Be("text/plain; charset=utf-8"); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be("text/plain; charset=utf-8"); - string responseText = await httpResponse.Content.ReadAsStringAsync(); - responseText.Should().Be("Please send your name."); - } + string responseText = await httpResponse.Content.ReadAsStringAsync(); + responseText.Should().Be("Please send your name."); + } - [Fact] - public async Task Put_skips_middleware_and_formatters() + [Fact] + public async Task Put_skips_middleware_and_formatters() + { + // Arrange + using var request = new HttpRequestMessage(HttpMethod.Put, "/NonJsonApi") { - // Arrange - using var request = new HttpRequestMessage(HttpMethod.Put, "/NonJsonApi") + Content = new StringContent("\"Jane\"") { - Content = new StringContent("\"Jane\"") + Headers = { - Headers = - { - ContentType = new MediaTypeHeaderValue("application/json") - } + ContentType = new MediaTypeHeaderValue("application/json") } - }; + } + }; - HttpClient client = _testContext.Factory.CreateClient(); + HttpClient client = _testContext.Factory.CreateClient(); - // Act - HttpResponseMessage httpResponse = await client.SendAsync(request); + // Act + HttpResponseMessage httpResponse = await client.SendAsync(request); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); - httpResponse.Content.Headers.ContentType.ToString().Should().Be("text/plain; charset=utf-8"); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be("text/plain; charset=utf-8"); - string responseText = await httpResponse.Content.ReadAsStringAsync(); - responseText.Should().Be("Hi, Jane"); - } + string responseText = await httpResponse.Content.ReadAsStringAsync(); + responseText.Should().Be("Hi, Jane"); + } - [Fact] - public async Task Patch_skips_middleware_and_formatters() - { - // Arrange - using var request = new HttpRequestMessage(HttpMethod.Patch, "/NonJsonApi?name=Janice"); + [Fact] + public async Task Patch_skips_middleware_and_formatters() + { + // Arrange + using var request = new HttpRequestMessage(HttpMethod.Patch, "/NonJsonApi?name=Janice"); - HttpClient client = _testContext.Factory.CreateClient(); + HttpClient client = _testContext.Factory.CreateClient(); - // Act - HttpResponseMessage httpResponse = await client.SendAsync(request); + // Act + HttpResponseMessage httpResponse = await client.SendAsync(request); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); - httpResponse.Content.Headers.ContentType.ToString().Should().Be("text/plain; charset=utf-8"); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be("text/plain; charset=utf-8"); - string responseText = await httpResponse.Content.ReadAsStringAsync(); - responseText.Should().Be("Good day, Janice"); - } + string responseText = await httpResponse.Content.ReadAsStringAsync(); + responseText.Should().Be("Good day, Janice"); + } - [Fact] - public async Task Delete_skips_middleware_and_formatters() - { - // Arrange - using var request = new HttpRequestMessage(HttpMethod.Delete, "/NonJsonApi"); + [Fact] + public async Task Delete_skips_middleware_and_formatters() + { + // Arrange + using var request = new HttpRequestMessage(HttpMethod.Delete, "/NonJsonApi"); - HttpClient client = _testContext.Factory.CreateClient(); + HttpClient client = _testContext.Factory.CreateClient(); - // Act - HttpResponseMessage httpResponse = await client.SendAsync(request); + // Act + HttpResponseMessage httpResponse = await client.SendAsync(request); - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); - httpResponse.Content.Headers.ContentType.ToString().Should().Be("text/plain; charset=utf-8"); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be("text/plain; charset=utf-8"); - string responseText = await httpResponse.Content.ReadAsStringAsync(); - responseText.Should().Be("Bye."); - } + string responseText = await httpResponse.Content.ReadAsStringAsync(); + responseText.Should().Be("Bye."); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/UnknownResource.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/UnknownResource.cs index 7a0b1e9454..d7d27464fd 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/UnknownResource.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/UnknownResource.cs @@ -2,12 +2,11 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.NonJsonApiControllers +namespace JsonApiDotNetCoreTests.IntegrationTests.NonJsonApiControllers; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.NonJsonApiControllers")] +public sealed class UnknownResource : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - [Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.NonJsonApiControllers")] - public sealed class UnknownResource : Identifiable - { - public string? Value { get; set; } - } + public string? Value { get; set; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/UnknownResourceControllerTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/UnknownResourceControllerTests.cs index d64d37b38a..1cfb3cc34c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/UnknownResourceControllerTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/UnknownResourceControllerTests.cs @@ -1,32 +1,30 @@ -using System; using FluentAssertions; using JsonApiDotNetCore.Errors; using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreTests.IntegrationTests.NonJsonApiControllers +namespace JsonApiDotNetCoreTests.IntegrationTests.NonJsonApiControllers; + +public sealed class UnknownResourceControllerTests : IntegrationTestContext, EmptyDbContext> { - public sealed class UnknownResourceControllerTests : IntegrationTestContext, EmptyDbContext> + public UnknownResourceControllerTests() { - public UnknownResourceControllerTests() - { - UseController(); - } + UseController(); + } - [Fact] - public void Fails_at_startup_when_using_controller_for_resource_type_that_is_not_registered_in_resource_graph() - { - // Act - Action action = () => _ = Factory; + [Fact] + public void Fails_at_startup_when_using_controller_for_resource_type_that_is_not_registered_in_resource_graph() + { + // Act + Action action = () => _ = Factory; - // Assert - action.Should().ThrowExactly().WithMessage($"Controller '{typeof(UnknownResourcesController)}' " + - $"depends on resource type '{typeof(UnknownResource)}', which does not exist in the resource graph."); - } + // Assert + action.Should().ThrowExactly().WithMessage($"Controller '{typeof(UnknownResourcesController)}' " + + $"depends on resource type '{typeof(UnknownResource)}', which does not exist in the resource graph."); + } - public override void Dispose() - { - // Prevents crash when test cleanup tries to access lazily constructed Factory. - } + public override void Dispose() + { + // Prevents crash when test cleanup tries to access lazily constructed Factory. } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/AccountPreferences.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/AccountPreferences.cs index 17b0d478e8..20c95ea769 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/AccountPreferences.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/AccountPreferences.cs @@ -2,12 +2,11 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class AccountPreferences : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class AccountPreferences : Identifiable - { - [Attr] - public bool UseDarkTheme { get; set; } - } + [Attr] + public bool UseDarkTheme { get; set; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Appointment.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Appointment.cs index 0e6371e356..2f90ed2615 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Appointment.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Appointment.cs @@ -1,23 +1,21 @@ -using System; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class Appointment : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Appointment : Identifiable - { - [Attr] - public string Title { get; set; } = null!; + [Attr] + public string Title { get; set; } = null!; - [Attr] - public string? Description { get; set; } + [Attr] + public string? Description { get; set; } - [Attr] - public DateTimeOffset StartTime { get; set; } + [Attr] + public DateTimeOffset StartTime { get; set; } - [Attr] - public DateTimeOffset EndTime { get; set; } - } + [Attr] + public DateTimeOffset EndTime { get; set; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Blog.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Blog.cs index 58fb683086..58a4546ef5 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Blog.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Blog.cs @@ -1,28 +1,25 @@ -using System; -using System.Collections.Generic; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.QueryStrings")] +public sealed class Blog : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - [Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.QueryStrings")] - public sealed class Blog : Identifiable - { - [Attr] - public string Title { get; set; } = null!; + [Attr] + public string Title { get; set; } = null!; - [Attr] - public string PlatformName { get; set; } = null!; + [Attr] + public string PlatformName { get; set; } = null!; - [Attr(Capabilities = AttrCapabilities.All & ~(AttrCapabilities.AllowCreate | AttrCapabilities.AllowChange))] - public bool ShowAdvertisements => PlatformName.EndsWith("(using free account)", StringComparison.Ordinal); + [Attr(Capabilities = AttrCapabilities.All & ~(AttrCapabilities.AllowCreate | AttrCapabilities.AllowChange))] + public bool ShowAdvertisements => PlatformName.EndsWith("(using free account)", StringComparison.Ordinal); - [HasMany] - public IList Posts { get; set; } = new List(); + [HasMany] + public IList Posts { get; set; } = new List(); - [HasOne] - public WebAccount? Owner { get; set; } - } + [HasOne] + public WebAccount? Owner { get; set; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/BlogPost.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/BlogPost.cs index 5542370f23..a5edb3123f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/BlogPost.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/BlogPost.cs @@ -1,33 +1,31 @@ -using System.Collections.Generic; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.QueryStrings")] +public sealed class BlogPost : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - [Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.QueryStrings")] - public sealed class BlogPost : Identifiable - { - [Attr] - public string Caption { get; set; } = null!; + [Attr] + public string Caption { get; set; } = null!; - [Attr] - public string Url { get; set; } = null!; + [Attr] + public string Url { get; set; } = null!; - [HasOne] - public WebAccount? Author { get; set; } + [HasOne] + public WebAccount? Author { get; set; } - [HasOne] - public WebAccount? Reviewer { get; set; } + [HasOne] + public WebAccount? Reviewer { get; set; } - [HasMany] - public ISet