From 758a1918cdd710cfd41b7537e54a945fb58267d5 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Wed, 27 Oct 2021 16:30:50 +0200 Subject: [PATCH] Redesign conversion between JSON objects and ASP.NET models (#1091) * Replaced suppression with fix * Fixed: do not duplicate the list of JSON:API errors in meta stack trace, but do write them when logging * Breaking: Added option to write request body in meta when unable to read it (false by default) * Breaking: Added option to allow unknown attribute/relationship keys in request body (false by default) * Fixed invalid test * Rewrite of reading the request body and converting it into models. The existing validation logic inside controllers, JsonApiReader and RequestDeserializer/BaseDeserializer has been consolidated into a set of adapters that delegate to each other, passing along the current state. The dependency on `HttpContext` has been removed and similar errors are now grouped, in preparation to unify them. Because in this commit we always track the location of errors and write them to `error.source.pointer`, the error messages can be unified and simplified in future commits. In this commit, I've gone through great lenghts to preserve the existing error messages in our tests as much as possible, while keeping all tests green. All error tests in ReadWrite and Operations now have assertions on the source pointer. Added tests for missing/invalid 'data' in resource requests. Removed outdated unit tests and added new one for handling attributes of various CLR types. Updated benchmark code. The synthetic BenchmarkDotNet reports it has become 3-7% slower (we're talking microseconds here) compared to the old code. But when running full requests with our pipeline-instrumentation turned on, the difference falls in the range of background noise and is thus unmeasurable. ``` BEFORE: Read request body ............................... 0:00:00:00.0000167 ... 1.08% } 167 + 434 = 601 JsonSerializer.Deserialize .................... 0:00:00:00.0000560 ... 3.62% + Deserializer.Build (single) ................... 0:00:00:00.0000434 ... 2.80% } AFTER: Read request body ............................... 0:00:00:00.0000511 ... 3.43% JsonSerializer.Deserialize .................... 0:00:00:00.0000537 ... 3.61% ``` * Updated error message for unknown attribute/relationship * Updated error message for unknown resource type * Unified error messages about the absense or presense of 'id' and 'lid' * Unified error messages about the absense of 'type' * Unified error messages about incompatible types * Revert the use of different exception, because this way the request body does not get added to the error response meta * Unified error messages about mismatches in 'id' and 'lid' values * Additional unification of error messages * Unified error messages for failed type conversion * Fix cibuild * Unified error messages about data presense and its value: null/object/array * Unified remaining error messages * Sealed types and reduced dependencies * Fixed broken test on linux * Adapter renames: - IOperationsDocumentAdapter -> IDocumentInOperationsRequestAdapter - IResourceDocumentAdapter -> IDocumentInResourceOrRelationshipRequestAdapter - IOperationResourceDataAdapter -> IResourceDataInOperationsRequestAdapter * Added missing assertions on request body in error meta * Refactorings: - Changed deserializer to accept Error(s) instead of Document (used to be ErrorDocument, but they were merged) - Write request body in error meta instead of top-level meta - Fixed: Log at Error instead of Info when processing an operation throws unknown error * Enhanced existing tests: Assert on resource type when `included` contains mixed types * Fixed the number of resource definition callbacks for sparse fieldsets * Refactor: removed OperationContainer.Kind because IJsonApiRequest.WriteOperation is now populated consistently; applied c# pattern usage * Added test to capture the current behavior to return data:null for void operations. The spec allows an empty object too, but I don't consider this important enough to add yet another JSON converter with all its overhead. * Improved tests for includes * Rewrite of rendering the response body from models - Added temporary bridge class to toggle between old/new code - Fixed: Missing top-level link in error- and operations response - Fixed: `ILinkBuilder.GetResourceLinks` was also used to render relationship links - IJsonApiRequest management: restore backup after processing operations - Refreshed serialization benchmarks Measurement results for GET http://localhost:14140/api/v1/todoItems?include=owner,assignee,tags: Write response body ............................ 0:00:00:00.0010385 -> 0:00:00:00.0013343 = 130% Measurement results for GET http://localhost:14140/api/v1/todoItems?filter=and(startsWith(description,'T'),equals(priority,'Low'),not(equals(owner,null)),not(equals(assignee,null))): Write response body ............................ 0:00:00:00.0006601 -> 0:00:00:00.0004624 = 70% Measurement results for POST http://localhost:14140/api/v1/operations (10x add-resource): Write response body ............................ 0:00:00:00.0003432 -> 0:00:00:00.0003289 = 95% | Method | Mean | Error | StdDev | |---------------------------------- |---------:|--------:|--------:| | SerializeOperationsResponse | 153.8 us | 0.72 us | 0.60 us | | LegacySerializeOperationsResponse | 239.0 us | 3.17 us | 2.81 us | | Method | Mean | Error | StdDev | |-------------------------------- |---------:|--------:|--------:| | SerializeResourceResponse | 101.3 us | 0.31 us | 0.29 us | | LegacySerializeResourceResponse | 177.6 us | 0.56 us | 0.50 us | * Avoid closure in hot code path to reduce allocations * Cleanup reader and writer * Removed old code * Fixed: crash in test serializer on assertion failure * Removed RequestScopedServiceProvider * Use sets for include expressions * Fixed: return Content-Length header in HEAD response https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/HEAD * Reorganized JADNC.Serialization namespace * Created custom exception for remaining errors * Fixed: call ResourceDefinition.OnApplyIncludes for all children, even when empty * Renamed ResourceContext to ResourceType and exposed it through relationship left/right. This enabled to reduce many resource graph lookups from the codebase. * Moved logic to build resource graph from DbContext into ResourceGraphBuilder for easier reuse. * Opened up ResponseModelAdapter for extensibility * Check off roadmap entry * Review feedback * Simplified existing tests * Added extra test for data:null in relationship * Added test for broken resource linkage * Ported existing unit tests and changed how included[] is built. It now always emits related resources in relationship declaration order, even in deeply nested circular chains where a subsequent inclusion chain is deeper and adds more relationships to an already converted resource. Performance impact, summary: - In the endpoint test, this commit improves performance for rendering includes, while slightly decreasing it for the other two scenarios. All are still faster or the same compared to the master branch. - In BenchmarkDotNet, this commit slightly increases rendering time, compared to earlier commits in this PR, but it is still faster than the master branch. Measurement results for GET http://localhost:14140/api/v1/todoItems?include=owner,assignee,tags: Write response body ............................. 0:00:00:00.0010385 -> 0:00:00:00.0008060 ... 77% (was: 130%) Measurement results for GET http://localhost:14140/api/v1/todoItems?filter=and(startsWith(description,'T'),equals(priority,'Low'),not(equals(owner,null)),not(equals(assignee,null))): Write response body ............................. 0:00:00:00.0006601 -> 0:00:00:00.0005629 ... 85% (was: 70%) Measurement results for POST http://localhost:14140/api/v1/operations (10x add-resource): Write response body ............................. 0:00:00:00.0003432 -> 0:00:00:00.0003411 ... 99% (was: 95%) | Method | Mean | Error | StdDev | |---------------------------------- |---------:|--------:|--------:| | LegacySerializeOperationsResponse | 239.0 us | 3.17 us | 2.81 us | | SerializeOperationsResponse | 153.8 us | 0.72 us | 0.60 us | | (new) SerializeOperationsResponse | 168.6 us | 1.74 us | 1.63 us | | Method | Mean | Error | StdDev | |-------------------------------- |---------:|--------:|--------:| | LegacySerializeResourceResponse | 177.6 us | 0.56 us | 0.50 us | | SerializeResourceResponse | 101.3 us | 0.31 us | 0.29 us | | (new) SerializeResourceResponse | 123.7 us | 1.12 us | 1.05 us | * Fixed cibuild --- ROADMAP.md | 2 +- .../DeserializationBenchmarkBase.cs | 122 +++ .../OperationsDeserializationBenchmarks.cs | 285 +++++++ .../ResourceDeserializationBenchmarks.cs | 151 ++++ ...nkBuilderGetNamespaceFromPathBenchmarks.cs | 2 +- benchmarks/Program.cs | 9 +- benchmarks/Query/QueryParserBenchmarks.cs | 2 +- .../JsonApiDeserializerBenchmarks.cs | 57 -- .../JsonApiSerializerBenchmarks.cs | 65 -- .../OperationsSerializationBenchmarks.cs | 137 ++++ .../ResourceSerializationBenchmarks.cs | 143 ++++ .../SerializationBenchmarkBase.cs | 267 +++++++ docs/getting-started/step-by-step.md | 10 +- docs/usage/extensibility/repositories.md | 8 +- .../extensibility/resource-definitions.md | 7 +- src/Examples/GettingStarted/Startup.cs | 14 +- .../JsonApiDotNetCoreExample/Startup.cs | 1 + .../Repositories/DbContextARepository.cs | 4 +- .../Repositories/DbContextBRepository.cs | 4 +- src/Examples/MultiDbContextExample/Startup.cs | 1 + .../NoEntityFrameworkExample/Startup.cs | 4 +- .../AtomicOperations/LocalIdTracker.cs | 26 +- .../AtomicOperations/LocalIdValidator.cs | 22 +- .../OperationProcessorAccessor.cs | 11 +- .../AtomicOperations/OperationsProcessor.cs | 39 +- .../Processors/CreateProcessor.cs | 9 +- .../RevertRequestStateOnDispose.cs | 38 + src/JsonApiDotNetCore/CollectionExtensions.cs | 5 + .../Configuration/IJsonApiOptions.cs | 12 +- .../IRequestScopedServiceProvider.cs | 12 - .../Configuration/IResourceGraph.cs | 41 +- .../InverseNavigationResolver.cs | 6 +- .../JsonApiApplicationBuilder.cs | 53 +- .../JsonApiModelMetadataProvider.cs | 9 +- .../Configuration/JsonApiOptions.cs | 6 + .../Configuration/JsonApiValidationFilter.cs | 32 +- .../RequestScopedServiceProvider.cs | 34 - .../Configuration/ResourceDescriptor.cs | 10 +- .../Configuration/ResourceGraph.cs | 70 +- .../Configuration/ResourceGraphBuilder.cs | 131 ++-- .../Configuration/ResourceNameFormatter.cs | 8 +- .../{ResourceContext.cs => ResourceType.cs} | 43 +- .../ServiceCollectionExtensions.cs | 6 +- .../Configuration/ServiceDiscoveryFacade.cs | 10 +- .../Configuration/TypeLocator.cs | 4 +- .../Controllers/BaseJsonApiController.cs | 5 - .../BaseJsonApiOperationsController.cs | 35 +- .../Controllers/CoreJsonApiController.cs | 14 +- .../Controllers/JsonApiQueryController.cs | 4 +- .../Controllers/ModelStateViolation.cs | 8 +- .../Diagnostics/CodeTimingSessionManager.cs | 9 +- ...annotClearRequiredRelationshipException.cs | 2 +- .../Errors/DuplicateLocalIdValueException.cs | 22 + .../Errors/FailedOperationException.cs | 27 + .../IncompatibleLocalIdTypeException.cs | 22 + .../Errors/InvalidModelStateException.cs | 30 +- .../Errors/InvalidRequestBodyException.cs | 43 +- .../Errors/JsonApiException.cs | 7 +- .../Errors/LocalIdSingleOperationException.cs | 22 + ...ceIdInCreateResourceNotAllowedException.cs | 27 - .../Errors/ResourceIdMismatchException.cs | 22 - .../Errors/ResourceTypeMismatchException.cs | 25 - .../ToManyRelationshipRequiredException.cs | 22 - .../Errors/UnknownLocalIdValueException.cs | 22 + .../Middleware/AsyncJsonApiExceptionFilter.cs | 7 +- .../Middleware/ExceptionHandler.cs | 34 +- .../Middleware/IControllerResourceMapping.cs | 5 +- .../Middleware/IExceptionHandler.cs | 3 +- .../Middleware/IJsonApiRequest.cs | 8 +- .../Middleware/JsonApiInputFormatter.cs | 7 +- .../Middleware/JsonApiMiddleware.cs | 37 +- .../Middleware/JsonApiOutputFormatter.cs | 4 +- .../Middleware/JsonApiRequest.cs | 8 +- .../Middleware/JsonApiRoutingConvention.cs | 49 +- .../Expressions/IncludeChainConverter.cs | 8 +- .../Expressions/IncludeElementExpression.cs | 10 +- .../Queries/Expressions/IncludeExpression.cs | 10 +- .../Expressions/QueryExpressionRewriter.cs | 12 +- .../Expressions/SparseFieldTableExpression.cs | 10 +- .../Queries/IQueryLayerComposer.cs | 14 +- .../Queries/Internal/ISparseFieldSetCache.cs | 40 + .../Queries/Internal/Parsing/FilterParser.cs | 34 +- .../Queries/Internal/Parsing/IncludeParser.cs | 15 +- .../Internal/Parsing/PaginationParser.cs | 15 +- .../Internal/Parsing/QueryExpressionParser.cs | 5 +- .../QueryStringParameterScopeParser.cs | 19 +- .../Parsing/ResourceFieldChainResolver.cs | 129 ++-- .../Queries/Internal/Parsing/SortParser.cs | 17 +- .../Internal/Parsing/SparseFieldSetParser.cs | 17 +- .../Internal/Parsing/SparseFieldTypeParser.cs | 27 +- .../Queries/Internal/QueryLayerComposer.cs | 190 +++-- .../QueryableBuilding/IncludeClauseBuilder.cs | 16 +- .../QueryableBuilding/QueryClauseBuilder.cs | 2 +- .../QueryableBuilding/QueryableBuilder.cs | 20 +- .../QueryableBuilding/SelectClauseBuilder.cs | 23 +- .../Queries/Internal/SparseFieldSetCache.cs | 86 +-- src/JsonApiDotNetCore/Queries/QueryLayer.cs | 10 +- .../FilterQueryStringParameterReader.cs | 12 +- .../IncludeQueryStringParameterReader.cs | 10 +- .../PaginationQueryStringParameterReader.cs | 18 +- .../Internal/QueryStringParameterReader.cs | 17 +- ...ourceDefinitionQueryableParameterReader.cs | 4 +- .../SortQueryStringParameterReader.cs | 12 +- ...parseFieldSetQueryStringParameterReader.cs | 18 +- .../Repositories/DbContextExtensions.cs | 8 +- .../Repositories/DbContextResolver.cs | 12 +- .../EntityFrameworkCoreRepository.cs | 25 +- .../IResourceRepositoryAccessor.cs | 4 +- .../ResourceRepositoryAccessor.cs | 34 +- .../Annotations/RelationshipAttribute.cs | 43 +- .../Resources/IResourceDefinition.cs | 2 +- .../Resources/IResourceDefinitionAccessor.cs | 15 +- .../Resources/IResourceFactory.cs | 4 +- .../Resources/ITargetedFields.cs | 11 +- .../Internal/RuntimeTypeConverter.cs | 3 +- .../Resources/JsonApiResourceDefinition.cs | 6 +- .../Resources/OperationContainer.cs | 6 +- .../Resources/ResourceChangeTracker.cs | 10 +- .../Resources/ResourceDefinitionAccessor.cs | 32 +- .../Resources/ResourceFactory.cs | 18 +- .../Resources/TargetedFields.cs | 23 +- .../AtomicOperationsResponseSerializer.cs | 135 ---- .../Serialization/BaseDeserializer.cs | 359 --------- .../Serialization/BaseSerializer.cs | 102 --- .../IIncludedResourceObjectBuilder.cs | 21 - .../Building/IResourceObjectBuilder.cs | 31 - .../Building/IncludedResourceObjectBuilder.cs | 227 ------ .../Building/ResourceIdentityComparer.cs | 35 - .../Building/ResourceObjectBuilder.cs | 181 ----- .../Building/ResponseResourceObjectBuilder.cs | 142 ---- .../Serialization/FieldsToSerialize.cs | 89 --- .../Serialization/IFieldsToSerialize.cs | 33 - .../Serialization/IJsonApiDeserializer.cs | 18 - .../Serialization/IJsonApiReader.cs | 15 - .../Serialization/IJsonApiSerializer.cs | 18 - .../IJsonApiSerializerFactory.cs | 13 - .../Serialization/IJsonApiWriter.cs | 12 - .../Serialization/JsonApiReader.cs | 278 ------- .../JsonApiSerializationException.cs | 24 - .../Serialization/JsonApiWriter.cs | 189 ----- .../JsonObjectConverter.cs | 2 +- .../JsonConverters/ResourceObjectConverter.cs | 14 +- .../Serialization/Objects/Document.cs | 21 - .../Serialization/Objects/ErrorObject.cs | 19 + .../Adapters/AtomicOperationObjectAdapter.cs | 157 ++++ .../Adapters/AtomicReferenceAdapter.cs | 45 ++ .../Request/Adapters/AtomicReferenceResult.cs | 28 + .../Request/Adapters/BaseDataAdapter.cs | 55 ++ .../Request/Adapters/DocumentAdapter.cs | 42 + .../DocumentInOperationsRequestAdapter.cs | 72 ++ ...tInResourceOrRelationshipRequestAdapter.cs | 77 ++ .../Adapters/IAtomicOperationObjectAdapter.cs | 16 + .../Adapters/IAtomicReferenceAdapter.cs | 16 + .../Request/Adapters/IDocumentAdapter.cs | 38 + .../IDocumentInOperationsRequestAdapter.cs | 17 + ...tInResourceOrRelationshipRequestAdapter.cs | 15 + .../Adapters/IRelationshipDataAdapter.cs | 24 + .../Request/Adapters/IResourceDataAdapter.cs | 16 + ...IResourceDataInOperationsRequestAdapter.cs | 16 + .../IResourceIdentifierObjectAdapter.cs | 16 + .../Adapters/IResourceObjectAdapter.cs | 19 + .../Request/Adapters/JsonElementConstraint.cs | 21 + .../Adapters/RelationshipDataAdapter.cs | 121 +++ .../Adapters/RequestAdapterPosition.cs | 76 ++ .../Request/Adapters/RequestAdapterState.cs | 68 ++ .../Request/Adapters/ResourceDataAdapter.cs | 49 ++ .../ResourceDataInOperationsRequestAdapter.cs | 28 + .../ResourceIdentifierObjectAdapter.cs | 26 + .../Adapters/ResourceIdentityAdapter.cs | 222 ++++++ .../Adapters/ResourceIdentityRequirements.cs | 38 + .../Request/Adapters/ResourceObjectAdapter.cs | 159 ++++ .../Serialization/Request/IJsonApiReader.cs | 19 + .../Serialization/Request/JsonApiReader.cs | 108 +++ .../{ => Request}/JsonInvalidAttributeInfo.cs | 2 +- .../Request/ModelConversionException.cs | 30 + .../Serialization/RequestDeserializer.cs | 524 ------------- .../{ => Response}/ETagGenerator.cs | 2 +- .../{ => Response}/EmptyResponseMeta.cs | 2 +- .../{ => Response}/FingerprintGenerator.cs | 2 +- .../{ => Response}/IETagGenerator.cs | 2 +- .../{ => Response}/IFingerprintGenerator.cs | 2 +- .../Serialization/Response/IJsonApiWriter.cs | 18 + .../{Building => Response}/ILinkBuilder.cs | 5 +- .../{Building => Response}/IMetaBuilder.cs | 2 +- .../{ => Response}/IResponseMeta.cs | 2 +- .../Response/IResponseModelAdapter.cs | 47 ++ .../Serialization/Response/JsonApiWriter.cs | 189 +++++ .../{Building => Response}/LinkBuilder.cs | 87 +-- .../{Building => Response}/MetaBuilder.cs | 2 +- .../Response/ResourceObjectTreeNode.cs | 275 +++++++ .../Response/ResponseModelAdapter.cs | 362 +++++++++ .../Serialization/ResponseSerializer.cs | 179 ----- .../ResponseSerializerFactory.cs | 51 -- .../Services/JsonApiResourceService.cs | 32 +- src/JsonApiDotNetCore/TypeExtensions.cs | 2 +- .../ServiceDiscoveryFacadeTests.cs | 12 +- test/DiscoveryTests/TestResourceRepository.cs | 4 +- .../TelevisionBroadcastDefinition.cs | 6 +- .../CreateMusicTrackOperationsController.cs | 2 +- .../Creating/AtomicCreateResourceTests.cs | 221 +++++- ...reateResourceWithClientGeneratedIdTests.cs | 11 +- ...eateResourceWithToManyRelationshipTests.cs | 93 ++- ...reateResourceWithToOneRelationshipTests.cs | 175 +++-- .../Deleting/AtomicDeleteResourceTests.cs | 32 +- .../LocalIds/AtomicLocalIdTests.cs | 12 +- .../Meta/AtomicResponseMeta.cs | 2 +- .../Meta/AtomicResponseMetaTests.cs | 2 +- .../Mixed/AtomicLoggingTests.cs | 186 +++++ .../Mixed/AtomicRequestBodyTests.cs | 63 +- .../Mixed/AtomicSerializationTests.cs | 65 +- .../Mixed/MaximumOperationsPerRequestTests.cs | 7 +- .../Transactions/LyricRepository.cs | 8 +- .../Transactions/MusicTrackRepository.cs | 4 +- .../AtomicAddToToManyRelationshipTests.cs | 181 ++++- ...AtomicRemoveFromToManyRelationshipTests.cs | 176 ++++- .../AtomicReplaceToManyRelationshipTests.cs | 176 ++++- .../AtomicUpdateToOneRelationshipTests.cs | 129 +++- .../AtomicReplaceToManyRelationshipTests.cs | 153 +++- .../Resources/AtomicUpdateResourceTests.cs | 333 ++++++-- .../AtomicUpdateToOneRelationshipTests.cs | 99 ++- .../IntegrationTests/CompositeKeys/Car.cs | 18 +- .../CarCompositeKeyAwareRepository.cs | 8 +- .../CompositeKeys/CarExpressionRewriter.cs | 6 +- .../ActionResultTests.cs | 2 +- .../EagerLoading/BuildingRepository.cs | 4 +- .../AlternateExceptionHandler.cs | 4 +- .../ExceptionHandlerTests.cs | 37 +- .../IdObfuscation/ObfuscatedIdentifiable.cs | 4 +- .../Meta/ResponseMetaTests.cs | 2 +- .../Meta/SupportResponseMeta.cs | 2 +- .../NonJsonApiController.cs | 6 +- .../Filtering/FilterDepthTests.cs | 10 + .../QueryStrings/Filtering/FilterTests.cs | 4 +- .../QueryStrings/Includes/IncludeTests.cs | 159 +++- .../QueryStrings/LoginAttempt.cs | 17 + .../PaginationWithTotalCountTests.cs | 10 +- .../QueryStrings/QueryStringDbContext.cs | 1 + .../QueryStrings/QueryStringFakers.cs | 7 + .../QueryStrings/Sorting/SortTests.cs | 14 +- .../ResultCapturingRepository.cs | 4 +- .../SparseFieldSets/SparseFieldSetTests.cs | 35 + .../QueryStrings/WebAccount.cs | 3 + .../ReadWrite/Creating/CreateResourceTests.cs | 224 +++++- ...reateResourceWithClientGeneratedIdTests.cs | 2 + ...eateResourceWithToManyRelationshipTests.cs | 88 ++- ...reateResourceWithToOneRelationshipTests.cs | 182 +++-- .../ReadWrite/Deleting/DeleteResourceTests.cs | 2 + .../AddToToManyRelationshipTests.cs | 92 ++- .../RemoveFromToManyRelationshipTests.cs | 92 ++- .../ReplaceToManyRelationshipTests.cs | 90 ++- .../UpdateToOneRelationshipTests.cs | 155 ++-- .../ReplaceToManyRelationshipTests.cs | 91 ++- .../Updating/Resources/UpdateResourceTests.cs | 300 +++++++- .../Resources/UpdateToOneRelationshipTests.cs | 144 ++-- .../DefaultBehaviorTests.cs | 12 +- .../Reading/MoonDefinition.cs | 4 +- .../Reading/PlanetDefinition.cs | 4 +- .../Reading/ResourceDefinitionReadTests.cs | 58 +- .../Serialization/SerializationTests.cs | 118 ++- .../SoftDeletionAwareResourceService.cs | 12 +- .../UnitTests/Links/LinkInclusionTests.cs | 44 +- .../QueryStringParameters/BaseParseTests.cs | 3 +- .../QueryStringParameters/FilterParseTests.cs | 23 +- .../IncludeParseTests.cs | 6 +- .../LegacyFilterParseTests.cs | 21 +- .../PaginationParseTests.cs | 16 +- .../QueryStringParameters/SortParseTests.cs | 17 +- .../SparseFieldSetParseTests.cs | 6 +- .../Serialization/InputConversionTests.cs | 352 +++++++++ .../Serialization/Response/Models/Article.cs | 19 + .../Serialization/Response/Models/Blog.cs | 19 + .../Serialization/Response/Models/Food.cs | 13 + .../Serialization/Response/Models/Person.cs | 23 + .../Serialization/Response/Models/Song.cs | 13 + .../Response/ResponseModelAdapterTests.cs | 718 ++++++++++++++++++ .../Response/ResponseSerializationFakers.cs | 49 ++ .../HttpResponseMessageExtensions.cs | 2 +- test/TestBuildingBlocks/IntegrationTest.cs | 7 +- .../IntegrationTestContext.cs | 4 +- test/TestBuildingBlocks/TestableStartup.cs | 1 + .../Builders/ResourceGraphBuilderTests.cs | 25 +- .../ServiceCollectionExtensionsTests.cs | 18 +- .../ResourceDescriptorAssemblyCacheTests.cs | 12 +- test/UnitTests/Graph/TypeLocatorTests.cs | 12 +- ...orDocumentTests.cs => ErrorObjectTests.cs} | 9 +- .../RequestScopedServiceProviderTests.cs | 35 - .../Internal/ResourceGraphBuilderTests.cs | 14 +- .../Middleware/JsonApiMiddlewareTests.cs | 63 +- .../Middleware/JsonApiRequestTests.cs | 10 +- .../Models/ResourceConstructionTests.cs | 132 ---- .../NeverResourceDefinitionAccessor.cs | 104 --- .../Common/BaseDocumentBuilderTests.cs | 81 -- .../Common/BaseDocumentParserTests.cs | 451 ----------- .../Common/ResourceObjectBuilderTests.cs | 235 ------ .../Serialization/DeserializerTestsSetup.cs | 119 --- .../FakeRequestQueryStringAccessor.cs | 21 - .../SerializationTestsSetupBase.cs | 93 --- .../Serialization/SerializerTestsSetup.cs | 190 ----- .../IncludedResourceObjectBuilderTests.cs | 217 ------ .../Server/RequestDeserializerTests.cs | 105 --- .../ResponseResourceObjectBuilderTests.cs | 122 --- .../Server/ResponseSerializerTests.cs | 455 ----------- test/UnitTests/TestResourceFactory.cs | 25 - test/UnitTests/TestScopedServiceProvider.cs | 28 - wiki/v4/content/serialization.md | 111 --- 305 files changed, 9573 insertions(+), 7588 deletions(-) create mode 100644 benchmarks/Deserialization/DeserializationBenchmarkBase.cs create mode 100644 benchmarks/Deserialization/OperationsDeserializationBenchmarks.cs create mode 100644 benchmarks/Deserialization/ResourceDeserializationBenchmarks.cs rename benchmarks/{LinkBuilder => LinkBuilding}/LinkBuilderGetNamespaceFromPathBenchmarks.cs (98%) delete mode 100644 benchmarks/Serialization/JsonApiDeserializerBenchmarks.cs delete mode 100644 benchmarks/Serialization/JsonApiSerializerBenchmarks.cs create mode 100644 benchmarks/Serialization/OperationsSerializationBenchmarks.cs create mode 100644 benchmarks/Serialization/ResourceSerializationBenchmarks.cs create mode 100644 benchmarks/Serialization/SerializationBenchmarkBase.cs create mode 100644 src/JsonApiDotNetCore/AtomicOperations/RevertRequestStateOnDispose.cs delete mode 100644 src/JsonApiDotNetCore/Configuration/IRequestScopedServiceProvider.cs delete mode 100644 src/JsonApiDotNetCore/Configuration/RequestScopedServiceProvider.cs rename src/JsonApiDotNetCore/Configuration/{ResourceContext.cs => ResourceType.cs} (86%) create mode 100644 src/JsonApiDotNetCore/Errors/DuplicateLocalIdValueException.cs create mode 100644 src/JsonApiDotNetCore/Errors/FailedOperationException.cs create mode 100644 src/JsonApiDotNetCore/Errors/IncompatibleLocalIdTypeException.cs create mode 100644 src/JsonApiDotNetCore/Errors/LocalIdSingleOperationException.cs delete mode 100644 src/JsonApiDotNetCore/Errors/ResourceIdInCreateResourceNotAllowedException.cs delete mode 100644 src/JsonApiDotNetCore/Errors/ResourceIdMismatchException.cs delete mode 100644 src/JsonApiDotNetCore/Errors/ResourceTypeMismatchException.cs delete mode 100644 src/JsonApiDotNetCore/Errors/ToManyRelationshipRequiredException.cs create mode 100644 src/JsonApiDotNetCore/Errors/UnknownLocalIdValueException.cs create mode 100644 src/JsonApiDotNetCore/Queries/Internal/ISparseFieldSetCache.cs delete mode 100644 src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs delete mode 100644 src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs delete mode 100644 src/JsonApiDotNetCore/Serialization/BaseSerializer.cs delete mode 100644 src/JsonApiDotNetCore/Serialization/Building/IIncludedResourceObjectBuilder.cs delete mode 100644 src/JsonApiDotNetCore/Serialization/Building/IResourceObjectBuilder.cs delete mode 100644 src/JsonApiDotNetCore/Serialization/Building/IncludedResourceObjectBuilder.cs delete mode 100644 src/JsonApiDotNetCore/Serialization/Building/ResourceIdentityComparer.cs delete mode 100644 src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilder.cs delete mode 100644 src/JsonApiDotNetCore/Serialization/Building/ResponseResourceObjectBuilder.cs delete mode 100644 src/JsonApiDotNetCore/Serialization/FieldsToSerialize.cs delete mode 100644 src/JsonApiDotNetCore/Serialization/IFieldsToSerialize.cs delete mode 100644 src/JsonApiDotNetCore/Serialization/IJsonApiDeserializer.cs delete mode 100644 src/JsonApiDotNetCore/Serialization/IJsonApiReader.cs delete mode 100644 src/JsonApiDotNetCore/Serialization/IJsonApiSerializer.cs delete mode 100644 src/JsonApiDotNetCore/Serialization/IJsonApiSerializerFactory.cs delete mode 100644 src/JsonApiDotNetCore/Serialization/IJsonApiWriter.cs delete mode 100644 src/JsonApiDotNetCore/Serialization/JsonApiReader.cs delete mode 100644 src/JsonApiDotNetCore/Serialization/JsonApiSerializationException.cs delete mode 100644 src/JsonApiDotNetCore/Serialization/JsonApiWriter.cs rename src/JsonApiDotNetCore/Serialization/{ => JsonConverters}/JsonObjectConverter.cs (95%) create mode 100644 src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicOperationObjectAdapter.cs create mode 100644 src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicReferenceAdapter.cs create mode 100644 src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicReferenceResult.cs create mode 100644 src/JsonApiDotNetCore/Serialization/Request/Adapters/BaseDataAdapter.cs create mode 100644 src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentAdapter.cs create mode 100644 src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentInOperationsRequestAdapter.cs create mode 100644 src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentInResourceOrRelationshipRequestAdapter.cs create mode 100644 src/JsonApiDotNetCore/Serialization/Request/Adapters/IAtomicOperationObjectAdapter.cs create mode 100644 src/JsonApiDotNetCore/Serialization/Request/Adapters/IAtomicReferenceAdapter.cs create mode 100644 src/JsonApiDotNetCore/Serialization/Request/Adapters/IDocumentAdapter.cs create mode 100644 src/JsonApiDotNetCore/Serialization/Request/Adapters/IDocumentInOperationsRequestAdapter.cs create mode 100644 src/JsonApiDotNetCore/Serialization/Request/Adapters/IDocumentInResourceOrRelationshipRequestAdapter.cs create mode 100644 src/JsonApiDotNetCore/Serialization/Request/Adapters/IRelationshipDataAdapter.cs create mode 100644 src/JsonApiDotNetCore/Serialization/Request/Adapters/IResourceDataAdapter.cs create mode 100644 src/JsonApiDotNetCore/Serialization/Request/Adapters/IResourceDataInOperationsRequestAdapter.cs create mode 100644 src/JsonApiDotNetCore/Serialization/Request/Adapters/IResourceIdentifierObjectAdapter.cs create mode 100644 src/JsonApiDotNetCore/Serialization/Request/Adapters/IResourceObjectAdapter.cs create mode 100644 src/JsonApiDotNetCore/Serialization/Request/Adapters/JsonElementConstraint.cs create mode 100644 src/JsonApiDotNetCore/Serialization/Request/Adapters/RelationshipDataAdapter.cs create mode 100644 src/JsonApiDotNetCore/Serialization/Request/Adapters/RequestAdapterPosition.cs create mode 100644 src/JsonApiDotNetCore/Serialization/Request/Adapters/RequestAdapterState.cs create mode 100644 src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceDataAdapter.cs create mode 100644 src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceDataInOperationsRequestAdapter.cs create mode 100644 src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentifierObjectAdapter.cs create mode 100644 src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs create mode 100644 src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityRequirements.cs create mode 100644 src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceObjectAdapter.cs create mode 100644 src/JsonApiDotNetCore/Serialization/Request/IJsonApiReader.cs create mode 100644 src/JsonApiDotNetCore/Serialization/Request/JsonApiReader.cs rename src/JsonApiDotNetCore/Serialization/{ => Request}/JsonInvalidAttributeInfo.cs (95%) create mode 100644 src/JsonApiDotNetCore/Serialization/Request/ModelConversionException.cs delete mode 100644 src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs rename src/JsonApiDotNetCore/Serialization/{ => Response}/ETagGenerator.cs (93%) rename src/JsonApiDotNetCore/Serialization/{ => Response}/EmptyResponseMeta.cs (83%) rename src/JsonApiDotNetCore/Serialization/{ => Response}/FingerprintGenerator.cs (97%) rename src/JsonApiDotNetCore/Serialization/{ => Response}/IETagGenerator.cs (93%) rename src/JsonApiDotNetCore/Serialization/{ => Response}/IFingerprintGenerator.cs (89%) create mode 100644 src/JsonApiDotNetCore/Serialization/Response/IJsonApiWriter.cs rename src/JsonApiDotNetCore/Serialization/{Building => Response}/ILinkBuilder.cs (83%) rename src/JsonApiDotNetCore/Serialization/{Building => Response}/IMetaBuilder.cs (92%) rename src/JsonApiDotNetCore/Serialization/{ => Response}/IResponseMeta.cs (92%) create mode 100644 src/JsonApiDotNetCore/Serialization/Response/IResponseModelAdapter.cs create mode 100644 src/JsonApiDotNetCore/Serialization/Response/JsonApiWriter.cs rename src/JsonApiDotNetCore/Serialization/{Building => Response}/LinkBuilder.cs (79%) rename src/JsonApiDotNetCore/Serialization/{Building => Response}/MetaBuilder.cs (97%) create mode 100644 src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs create mode 100644 src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs delete mode 100644 src/JsonApiDotNetCore/Serialization/ResponseSerializer.cs delete mode 100644 src/JsonApiDotNetCore/Serialization/ResponseSerializerFactory.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicLoggingTests.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/LoginAttempt.cs create mode 100644 test/JsonApiDotNetCoreTests/UnitTests/Serialization/InputConversionTests.cs create mode 100644 test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/Models/Article.cs create mode 100644 test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/Models/Blog.cs create mode 100644 test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/Models/Food.cs create mode 100644 test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/Models/Person.cs create mode 100644 test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/Models/Song.cs create mode 100644 test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/ResponseModelAdapterTests.cs create mode 100644 test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/ResponseSerializationFakers.cs rename test/UnitTests/Internal/{ErrorDocumentTests.cs => ErrorObjectTests.cs} (79%) delete mode 100644 test/UnitTests/Internal/RequestScopedServiceProviderTests.cs delete mode 100644 test/UnitTests/Models/ResourceConstructionTests.cs delete mode 100644 test/UnitTests/NeverResourceDefinitionAccessor.cs delete mode 100644 test/UnitTests/Serialization/Common/BaseDocumentBuilderTests.cs delete mode 100644 test/UnitTests/Serialization/Common/BaseDocumentParserTests.cs delete mode 100644 test/UnitTests/Serialization/Common/ResourceObjectBuilderTests.cs delete mode 100644 test/UnitTests/Serialization/DeserializerTestsSetup.cs delete mode 100644 test/UnitTests/Serialization/FakeRequestQueryStringAccessor.cs delete mode 100644 test/UnitTests/Serialization/SerializationTestsSetupBase.cs delete mode 100644 test/UnitTests/Serialization/SerializerTestsSetup.cs delete mode 100644 test/UnitTests/Serialization/Server/IncludedResourceObjectBuilderTests.cs delete mode 100644 test/UnitTests/Serialization/Server/RequestDeserializerTests.cs delete mode 100644 test/UnitTests/Serialization/Server/ResponseResourceObjectBuilderTests.cs delete mode 100644 test/UnitTests/Serialization/Server/ResponseSerializerTests.cs delete mode 100644 test/UnitTests/TestResourceFactory.cs delete mode 100644 test/UnitTests/TestScopedServiceProvider.cs delete mode 100644 wiki/v4/content/serialization.md diff --git a/ROADMAP.md b/ROADMAP.md index 1c15a33b2b..b3eb75daa6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -20,7 +20,7 @@ The need for breaking changes has blocked several efforts in the v4.x release, s - [x] Instrumentation [#1032](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1032) - [x] Optimized delete to-many [#1030](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1030) - [x] Support System.Text.Json [#664](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/664) [#999](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/999) [1077](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1077) [1078](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1078) -- [ ] Optimize IIdentifiable to ResourceObject conversion [#1028](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1028) [#1024](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1024) [#233](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/233) +- [x] Optimize IIdentifiable to ResourceObject conversion [#1028](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1028) [#1024](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1024) [#233](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/233) - [ ] Nullable reference types [#1029](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1029) 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/Deserialization/DeserializationBenchmarkBase.cs b/benchmarks/Deserialization/DeserializationBenchmarkBase.cs new file mode 100644 index 0000000000..a99861b562 --- /dev/null +++ b/benchmarks/Deserialization/DeserializationBenchmarkBase.cs @@ -0,0 +1,122 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.Design; +using System.Text.Json; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.JsonConverters; +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; + + 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; + + var serviceContainer = new ServiceContainer(); + var resourceFactory = new ResourceFactory(serviceContainer); + var resourceDefinitionAccessor = new ResourceDefinitionAccessor(resourceGraph, serviceContainer); + + serviceContainer.AddService(typeof(IResourceDefinitionAccessor), resourceDefinitionAccessor); + serviceContainer.AddService(typeof(IResourceDefinition), new JsonApiResourceDefinition(resourceGraph)); + + // 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 atomicReferenceAdapter = new AtomicReferenceAdapter(resourceGraph, resourceFactory); + var atomicOperationResourceDataAdapter = new ResourceDataInOperationsRequestAdapter(resourceDefinitionAccessor, resourceObjectAdapter); + + var atomicOperationObjectAdapter = new AtomicOperationObjectAdapter(options, atomicReferenceAdapter, + atomicOperationResourceDataAdapter, relationshipDataAdapter); + + var resourceDocumentAdapter = new DocumentInResourceOrRelationshipRequestAdapter(options, resourceDataAdapter, relationshipDataAdapter); + var operationsDocumentAdapter = new DocumentInOperationsRequestAdapter(options, atomicOperationObjectAdapter); + + DocumentAdapter = new DocumentAdapter(request, targetedFields, resourceDocumentAdapter, operationsDocumentAdapter); + } + + protected abstract JsonApiRequest CreateJsonApiRequest(IResourceGraph resourceGraph); + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class ResourceA : Identifiable + { + [Attr] + public bool Attribute01 { get; set; } + + [Attr] + public char Attribute02 { get; set; } + + [Attr] + public ulong? Attribute03 { get; set; } + + [Attr] + public decimal Attribute04 { get; set; } + + [Attr] + public float? Attribute05 { get; set; } + + [Attr] + public string Attribute06 { get; set; } + + [Attr] + public DateTime? Attribute07 { get; set; } + + [Attr] + public DateTimeOffset? Attribute08 { get; set; } + + [Attr] + public TimeSpan? Attribute09 { get; set; } + + [Attr] + public DayOfWeek Attribute10 { get; set; } + + [HasOne] + public ResourceA Single1 { get; set; } + + [HasOne] + public ResourceA Single2 { get; set; } + + [HasOne] + public ResourceA Single3 { get; set; } + + [HasOne] + public ResourceA Single4 { get; set; } + + [HasOne] + public ResourceA Single5 { get; set; } + + [HasMany] + public ISet Multi1 { get; set; } + + [HasMany] + public ISet Multi2 { get; set; } + + [HasMany] + public ISet Multi3 { get; set; } + + [HasMany] + public ISet Multi4 { get; set; } + + [HasMany] + public ISet Multi5 { get; set; } + } + } +} diff --git a/benchmarks/Deserialization/OperationsDeserializationBenchmarks.cs b/benchmarks/Deserialization/OperationsDeserializationBenchmarks.cs new file mode 100644 index 0000000000..c09b7c77c7 --- /dev/null +++ b/benchmarks/Deserialization/OperationsDeserializationBenchmarks.cs @@ -0,0 +1,285 @@ +using System; +using System.Text.Json; +using BenchmarkDotNet.Attributes; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Serialization.Objects; + +namespace Benchmarks.Deserialization +{ + [MarkdownExporter] + // ReSharper disable once ClassCanBeSealed.Global + public class OperationsDeserializationBenchmarks : DeserializationBenchmarkBase + { + private static readonly string RequestBody = JsonSerializer.Serialize(new + { + atomic__operations = new object[] + { + new + { + op = "add", + data = new + { + type = "resourceAs", + 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 + { + single1 = new + { + data = new + { + type = "resourceAs", + id = "101" + } + }, + single2 = new + { + data = new + { + type = "resourceAs", + id = "102" + } + }, + single3 = new + { + data = new + { + type = "resourceAs", + id = "103" + } + }, + single4 = new + { + data = new + { + type = "resourceAs", + id = "104" + } + }, + single5 = new + { + data = new + { + type = "resourceAs", + id = "105" + } + }, + multi1 = new + { + data = new[] + { + new + { + type = "resourceAs", + id = "201" + } + } + }, + multi2 = new + { + data = new[] + { + new + { + type = "resourceAs", + id = "202" + } + } + }, + multi3 = new + { + data = new[] + { + new + { + type = "resourceAs", + id = "203" + } + } + }, + multi4 = new + { + data = new[] + { + new + { + type = "resourceAs", + id = "204" + } + } + }, + multi5 = new + { + data = new[] + { + new + { + type = "resourceAs", + id = "205" + } + } + } + } + } + }, + new + { + op = "update", + data = new + { + type = "resourceAs", + 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 + { + single1 = new + { + data = new + { + type = "resourceAs", + id = "101" + } + }, + single2 = new + { + data = new + { + type = "resourceAs", + id = "102" + } + }, + single3 = new + { + data = new + { + type = "resourceAs", + id = "103" + } + }, + single4 = new + { + data = new + { + type = "resourceAs", + id = "104" + } + }, + single5 = new + { + data = new + { + type = "resourceAs", + id = "105" + } + }, + multi1 = new + { + data = new[] + { + new + { + type = "resourceAs", + id = "201" + } + } + }, + multi2 = new + { + data = new[] + { + new + { + type = "resourceAs", + id = "202" + } + } + }, + multi3 = new + { + data = new[] + { + new + { + type = "resourceAs", + id = "203" + } + } + }, + multi4 = new + { + data = new[] + { + new + { + type = "resourceAs", + id = "204" + } + } + }, + multi5 = new + { + data = new[] + { + new + { + type = "resourceAs", + id = "205" + } + } + } + } + } + }, + new + { + op = "remove", + @ref = new + { + type = "resourceAs", + lid = "a-1" + } + } + } + }).Replace("atomic__operations", "atomic:operations"); + + [Benchmark] + public object DeserializeOperationsRequest() + { + var document = JsonSerializer.Deserialize(RequestBody, SerializerReadOptions); + return DocumentAdapter.Convert(document); + } + + protected override JsonApiRequest CreateJsonApiRequest(IResourceGraph resourceGraph) + { + return new() + { + Kind = EndpointKind.AtomicOperations + }; + } + } +} diff --git a/benchmarks/Deserialization/ResourceDeserializationBenchmarks.cs b/benchmarks/Deserialization/ResourceDeserializationBenchmarks.cs new file mode 100644 index 0000000000..d3fe50ffa6 --- /dev/null +++ b/benchmarks/Deserialization/ResourceDeserializationBenchmarks.cs @@ -0,0 +1,151 @@ +using System; +using System.Text.Json; +using BenchmarkDotNet.Attributes; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Serialization.Objects; + +namespace Benchmarks.Deserialization +{ + [MarkdownExporter] + // ReSharper disable once ClassCanBeSealed.Global + public class ResourceDeserializationBenchmarks : DeserializationBenchmarkBase + { + private static readonly string RequestBody = JsonSerializer.Serialize(new + { + data = new + { + type = "resourceAs", + 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 + { + single1 = new + { + data = new + { + type = "resourceAs", + id = "101" + } + }, + single2 = new + { + data = new + { + type = "resourceAs", + id = "102" + } + }, + single3 = new + { + data = new + { + type = "resourceAs", + id = "103" + } + }, + single4 = new + { + data = new + { + type = "resourceAs", + id = "104" + } + }, + single5 = new + { + data = new + { + type = "resourceAs", + id = "105" + } + }, + multi1 = new + { + data = new[] + { + new + { + type = "resourceAs", + id = "201" + } + } + }, + multi2 = new + { + data = new[] + { + new + { + type = "resourceAs", + id = "202" + } + } + }, + multi3 = new + { + data = new[] + { + new + { + type = "resourceAs", + id = "203" + } + } + }, + multi4 = new + { + data = new[] + { + new + { + type = "resourceAs", + id = "204" + } + } + }, + multi5 = new + { + data = new[] + { + new + { + type = "resourceAs", + id = "205" + } + } + } + } + } + }); + + [Benchmark] + public object DeserializeResourceRequest() + { + var document = JsonSerializer.Deserialize(RequestBody, SerializerReadOptions); + + return DocumentAdapter.Convert(document); + } + + protected override JsonApiRequest CreateJsonApiRequest(IResourceGraph resourceGraph) + { + return new() + { + Kind = EndpointKind.Primary, + PrimaryResourceType = resourceGraph.GetResourceType(), + WriteOperation = WriteOperationKind.CreateResource + }; + } + } +} diff --git a/benchmarks/LinkBuilder/LinkBuilderGetNamespaceFromPathBenchmarks.cs b/benchmarks/LinkBuilding/LinkBuilderGetNamespaceFromPathBenchmarks.cs similarity index 98% rename from benchmarks/LinkBuilder/LinkBuilderGetNamespaceFromPathBenchmarks.cs rename to benchmarks/LinkBuilding/LinkBuilderGetNamespaceFromPathBenchmarks.cs index c7110bf73e..400fc0dbcf 100644 --- a/benchmarks/LinkBuilder/LinkBuilderGetNamespaceFromPathBenchmarks.cs +++ b/benchmarks/LinkBuilding/LinkBuilderGetNamespaceFromPathBenchmarks.cs @@ -2,7 +2,7 @@ using System.Text; using BenchmarkDotNet.Attributes; -namespace Benchmarks.LinkBuilder +namespace Benchmarks.LinkBuilding { // ReSharper disable once ClassCanBeSealed.Global [MarkdownExporter] diff --git a/benchmarks/Program.cs b/benchmarks/Program.cs index 0d745a795d..995538eb76 100644 --- a/benchmarks/Program.cs +++ b/benchmarks/Program.cs @@ -1,5 +1,6 @@ using BenchmarkDotNet.Running; -using Benchmarks.LinkBuilder; +using Benchmarks.Deserialization; +using Benchmarks.LinkBuilding; using Benchmarks.Query; using Benchmarks.Serialization; @@ -11,8 +12,10 @@ private static void Main(string[] args) { var switcher = new BenchmarkSwitcher(new[] { - typeof(JsonApiDeserializerBenchmarks), - typeof(JsonApiSerializerBenchmarks), + typeof(ResourceDeserializationBenchmarks), + typeof(OperationsDeserializationBenchmarks), + typeof(ResourceSerializationBenchmarks), + typeof(OperationsSerializationBenchmarks), typeof(QueryParserBenchmarks), typeof(LinkBuilderGetNamespaceFromPathBenchmarks) }); diff --git a/benchmarks/Query/QueryParserBenchmarks.cs b/benchmarks/Query/QueryParserBenchmarks.cs index 8f1ec950da..bb6cec20e8 100644 --- a/benchmarks/Query/QueryParserBenchmarks.cs +++ b/benchmarks/Query/QueryParserBenchmarks.cs @@ -36,7 +36,7 @@ public QueryParserBenchmarks() var request = new JsonApiRequest { - PrimaryResource = resourceGraph.GetResourceContext(typeof(BenchmarkResource)), + PrimaryResourceType = resourceGraph.GetResourceType(typeof(BenchmarkResource)), IsCollection = true }; diff --git a/benchmarks/Serialization/JsonApiDeserializerBenchmarks.cs b/benchmarks/Serialization/JsonApiDeserializerBenchmarks.cs deleted file mode 100644 index 2c2cb62223..0000000000 --- a/benchmarks/Serialization/JsonApiDeserializerBenchmarks.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System.ComponentModel.Design; -using System.Text.Json; -using BenchmarkDotNet.Attributes; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Serialization; -using Microsoft.AspNetCore.Http; - -namespace Benchmarks.Serialization -{ - // ReSharper disable once ClassCanBeSealed.Global - [MarkdownExporter] - public class JsonApiDeserializerBenchmarks - { - private static readonly string RequestBody = JsonSerializer.Serialize(new - { - data = new - { - type = BenchmarkResourcePublicNames.Type, - id = "1", - attributes = new - { - } - } - }); - - private readonly DependencyFactory _dependencyFactory = new(); - private readonly IJsonApiDeserializer _jsonApiDeserializer; - - public JsonApiDeserializerBenchmarks() - { - var options = new JsonApiOptions(); - IResourceGraph resourceGraph = _dependencyFactory.CreateResourceGraph(options); - - var serviceContainer = new ServiceContainer(); - var resourceDefinitionAccessor = new ResourceDefinitionAccessor(resourceGraph, serviceContainer); - - serviceContainer.AddService(typeof(IResourceDefinitionAccessor), resourceDefinitionAccessor); - serviceContainer.AddService(typeof(IResourceDefinition), new JsonApiResourceDefinition(resourceGraph)); - - var targetedFields = new TargetedFields(); - var request = new JsonApiRequest(); - var resourceFactory = new ResourceFactory(serviceContainer); - var httpContextAccessor = new HttpContextAccessor(); - - _jsonApiDeserializer = new RequestDeserializer(resourceGraph, resourceFactory, targetedFields, httpContextAccessor, request, options, - resourceDefinitionAccessor); - } - - [Benchmark] - public object DeserializeSimpleObject() - { - return _jsonApiDeserializer.Deserialize(RequestBody); - } - } -} diff --git a/benchmarks/Serialization/JsonApiSerializerBenchmarks.cs b/benchmarks/Serialization/JsonApiSerializerBenchmarks.cs deleted file mode 100644 index 0fa58c272e..0000000000 --- a/benchmarks/Serialization/JsonApiSerializerBenchmarks.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System; -using BenchmarkDotNet.Attributes; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Queries; -using JsonApiDotNetCore.QueryStrings.Internal; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Serialization; -using JsonApiDotNetCore.Serialization.Building; -using Moq; - -namespace Benchmarks.Serialization -{ - // ReSharper disable once ClassCanBeSealed.Global - [MarkdownExporter] - public class JsonApiSerializerBenchmarks - { - private static readonly BenchmarkResource Content = new() - { - Id = 123, - Name = Guid.NewGuid().ToString() - }; - - private readonly DependencyFactory _dependencyFactory = new(); - private readonly IJsonApiSerializer _jsonApiSerializer; - - public JsonApiSerializerBenchmarks() - { - var options = new JsonApiOptions(); - IResourceGraph resourceGraph = _dependencyFactory.CreateResourceGraph(options); - IFieldsToSerialize fieldsToSerialize = CreateFieldsToSerialize(resourceGraph); - - IMetaBuilder metaBuilder = new Mock().Object; - ILinkBuilder linkBuilder = new Mock().Object; - IIncludedResourceObjectBuilder includeBuilder = new Mock().Object; - - var resourceObjectBuilder = new ResourceObjectBuilder(resourceGraph, options); - - IResourceDefinitionAccessor resourceDefinitionAccessor = new Mock().Object; - - _jsonApiSerializer = new ResponseSerializer(metaBuilder, linkBuilder, includeBuilder, fieldsToSerialize, resourceObjectBuilder, - resourceDefinitionAccessor, options); - } - - private static FieldsToSerialize CreateFieldsToSerialize(IResourceGraph resourceGraph) - { - var request = new JsonApiRequest(); - - var constraintProviders = new IQueryConstraintProvider[] - { - new SparseFieldSetQueryStringParameterReader(request, resourceGraph) - }; - - IResourceDefinitionAccessor accessor = new Mock().Object; - - return new FieldsToSerialize(resourceGraph, constraintProviders, accessor, request); - } - - [Benchmark] - public object SerializeSimpleObject() - { - return _jsonApiSerializer.Serialize(Content); - } - } -} diff --git a/benchmarks/Serialization/OperationsSerializationBenchmarks.cs b/benchmarks/Serialization/OperationsSerializationBenchmarks.cs new file mode 100644 index 0000000000..fbcdf0b0a9 --- /dev/null +++ b/benchmarks/Serialization/OperationsSerializationBenchmarks.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using BenchmarkDotNet.Attributes; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries.Internal; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; + +namespace Benchmarks.Serialization +{ + [MarkdownExporter] + // ReSharper disable once ClassCanBeSealed.Global + public class OperationsSerializationBenchmarks : SerializationBenchmarkBase + { + private readonly IEnumerable _responseOperations; + + public OperationsSerializationBenchmarks() + { + // ReSharper disable once VirtualMemberCallInConstructor + JsonApiRequest request = CreateJsonApiRequest(ResourceGraph); + + _responseOperations = CreateResponseOperations(request); + } + + private static IEnumerable CreateResponseOperations(IJsonApiRequest request) + { + var resource1 = new ResourceA + { + 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 ResourceA + { + 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 ResourceA + { + 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 ResourceA + { + 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 ResourceA + { + 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 targetedFields = new TargetedFields(); + + return new List + { + new(resource1, targetedFields, request), + new(resource2, targetedFields, request), + new(resource3, targetedFields, request), + new(resource4, targetedFields, request), + new(resource5, targetedFields, request) + }; + } + + [Benchmark] + public string SerializeOperationsResponse() + { + Document responseDocument = ResponseModelAdapter.Convert(_responseOperations); + return JsonSerializer.Serialize(responseDocument, SerializerWriteOptions); + } + + protected override JsonApiRequest CreateJsonApiRequest(IResourceGraph resourceGraph) + { + return new() + { + 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 new file mode 100644 index 0000000000..8f538cc9a2 --- /dev/null +++ b/benchmarks/Serialization/ResourceSerializationBenchmarks.cs @@ -0,0 +1,143 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Text.Json; +using BenchmarkDotNet.Attributes; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Internal; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Objects; + +namespace Benchmarks.Serialization +{ + [MarkdownExporter] + // ReSharper disable once ClassCanBeSealed.Global + public class ResourceSerializationBenchmarks : SerializationBenchmarkBase + { + private static readonly ResourceA ResponseResource = CreateResponseResource(); + + private static ResourceA CreateResponseResource() + { + var resource1 = new ResourceA + { + 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 ResourceA + { + 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 ResourceA + { + 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 ResourceA + { + 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 ResourceA + { + 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() + { + Kind = EndpointKind.Primary, + PrimaryResourceType = resourceGraph.GetResourceType() + }; + } + + protected override IEvaluatedIncludeCache CreateEvaluatedIncludeCache(IResourceGraph resourceGraph) + { + ResourceType resourceAType = resourceGraph.GetResourceType(); + + RelationshipAttribute single2 = resourceAType.GetRelationshipByPropertyName(nameof(ResourceA.Single2)); + RelationshipAttribute single3 = resourceAType.GetRelationshipByPropertyName(nameof(ResourceA.Single3)); + RelationshipAttribute multi4 = resourceAType.GetRelationshipByPropertyName(nameof(ResourceA.Multi4)); + RelationshipAttribute multi5 = resourceAType.GetRelationshipByPropertyName(nameof(ResourceA.Multi5)); + + ImmutableArray chain = ArrayFactory.Create(single2, single3, multi4, multi5).ToImmutableArray(); + IEnumerable chains = new ResourceFieldChainExpression(chain).AsEnumerable(); + + var converter = new IncludeChainConverter(); + IncludeExpression include = converter.FromRelationshipChains(chains); + + var cache = new EvaluatedIncludeCache(); + cache.Set(include); + return cache; + } + } +} diff --git a/benchmarks/Serialization/SerializationBenchmarkBase.cs b/benchmarks/Serialization/SerializationBenchmarkBase.cs new file mode 100644 index 0000000000..716169423b --- /dev/null +++ b/benchmarks/Serialization/SerializationBenchmarkBase.cs @@ -0,0 +1,267 @@ +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; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Internal; +using JsonApiDotNetCore.QueryStrings; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCore.Serialization.Response; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Benchmarks.Serialization +{ + public abstract class SerializationBenchmarkBase + { + protected readonly JsonSerializerOptions SerializerWriteOptions; + protected readonly IResponseModelAdapter ResponseModelAdapter; + protected readonly IResourceGraph ResourceGraph; + + protected SerializationBenchmarkBase() + { + var options = new JsonApiOptions + { + SerializerOptions = + { + Converters = + { + new JsonStringEnumConverter() + } + } + }; + + 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 + + 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); + } + + protected abstract JsonApiRequest CreateJsonApiRequest(IResourceGraph resourceGraph); + + protected abstract IEvaluatedIncludeCache CreateEvaluatedIncludeCache(IResourceGraph resourceGraph); + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class ResourceA : Identifiable + { + [Attr] + public bool Attribute01 { get; set; } + + [Attr] + public char Attribute02 { get; set; } + + [Attr] + public ulong? Attribute03 { get; set; } + + [Attr] + public decimal Attribute04 { get; set; } + + [Attr] + public float? Attribute05 { get; set; } + + [Attr] + public string Attribute06 { get; set; } + + [Attr] + public DateTime? Attribute07 { get; set; } + + [Attr] + public DateTimeOffset? Attribute08 { get; set; } + + [Attr] + public TimeSpan? Attribute09 { get; set; } + + [Attr] + public DayOfWeek Attribute10 { get; set; } + + [HasOne] + public ResourceA Single1 { get; set; } + + [HasOne] + public ResourceA Single2 { get; set; } + + [HasOne] + public ResourceA Single3 { get; set; } + + [HasOne] + public ResourceA Single4 { get; set; } + + [HasOne] + public ResourceA Single5 { get; set; } + + [HasMany] + public ISet Multi1 { get; set; } + + [HasMany] + public ISet Multi2 { get; set; } + + [HasMany] + public ISet Multi3 { get; set; } + + [HasMany] + public ISet Multi4 { get; set; } + + [HasMany] + public ISet Multi5 { get; set; } + } + + private sealed class FakeResourceDefinitionAccessor : IResourceDefinitionAccessor + { + public IImmutableSet OnApplyIncludes(ResourceType resourceType, IImmutableSet existingIncludes) + { + return existingIncludes; + } + + public FilterExpression OnApplyFilter(ResourceType resourceType, FilterExpression existingFilter) + { + return existingFilter; + } + + public SortExpression OnApplySort(ResourceType resourceType, SortExpression existingSort) + { + return existingSort; + } + + public PaginationExpression OnApplyPagination(ResourceType resourceType, PaginationExpression existingPagination) + { + return existingPagination; + } + + public SparseFieldSetExpression OnApplySparseFieldSet(ResourceType resourceType, SparseFieldSetExpression existingSparseFieldSet) + { + return existingSparseFieldSet; + } + + public object GetQueryableHandlerForQueryStringParameter(Type resourceClrType, string parameterName) + { + return null; + } + + public IDictionary GetMeta(ResourceType resourceType, IIdentifiable resourceInstance) + { + return null; + } + + public Task OnPrepareWriteAsync(TResource resource, 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 OnSetToManyRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, + ISet rightResourceIds, WriteOperationKind writeOperation, 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 OnRemoveFromRelationshipAsync(TResource leftResource, 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 OnWriteSucceededAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + return Task.CompletedTask; + } + + public void OnDeserialize(IIdentifiable resource) + { + } + + public void OnSerialize(IIdentifiable resource) + { + } + } + + private sealed class FakeLinkBuilder : ILinkBuilder + { + public TopLevelLinks GetTopLevelLinks() + { + return new() + { + Self = "TopLevel:Self" + }; + } + + public ResourceLinks GetResourceLinks(ResourceType resourceType, string id) + { + return new() + { + Self = "Resource:Self" + }; + } + + public RelationshipLinks GetRelationshipLinks(RelationshipAttribute relationship, IIdentifiable leftResource) + { + return new() + { + Self = "Relationship:Self", + Related = "Relationship:Related" + }; + } + } + + private sealed class FakeMetaBuilder : IMetaBuilder + { + public void Add(IReadOnlyDictionary values) + { + } + + public IDictionary Build() + { + 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 9273de6eb1..1443409b7f 100644 --- a/docs/getting-started/step-by-step.md +++ b/docs/getting-started/step-by-step.md @@ -114,18 +114,18 @@ public void Configure(IApplicationBuilder app) One way to seed the database is in your Configure method: ```c# -public void Configure(IApplicationBuilder app, AppDbContext context) +public void Configure(IApplicationBuilder app, AppDbContext dbContext) { - context.Database.EnsureCreated(); + dbContext.Database.EnsureCreated(); - if (!context.People.Any()) + if (!dbContext.People.Any()) { - context.People.Add(new Person + dbContext.People.Add(new Person { Name = "John Doe" }); - context.SaveChanges(); + dbContext.SaveChanges(); } app.UseRouting(); diff --git a/docs/usage/extensibility/repositories.md b/docs/usage/extensibility/repositories.md index 623c959510..1ddd025ac5 100644 --- a/docs/usage/extensibility/repositories.md +++ b/docs/usage/extensibility/repositories.md @@ -39,12 +39,12 @@ public class ArticleRepository : EntityFrameworkCoreRepository
private readonly IAuthenticationService _authenticationService; public ArticleRepository(IAuthenticationService authenticationService, - ITargetedFields targetedFields, IDbContextResolver contextResolver, + ITargetedFields targetedFields, IDbContextResolver dbContextResolver, IResourceGraph resourceGraph, IGenericServiceFactory genericServiceFactory, IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory) - : base(targetedFields, contextResolver, resourceGraph, genericServiceFactory, + : base(targetedFields, dbContextResolver, resourceGraph, genericServiceFactory, resourceFactory, constraintProviders, loggerFactory) { _authenticationService = authenticationService; @@ -68,13 +68,13 @@ public class DbContextARepository : EntityFrameworkCoreRepository { public DbContextARepository(ITargetedFields targetedFields, - DbContextResolver contextResolver, + DbContextResolver dbContextResolver, // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ IResourceGraph resourceGraph, IGenericServiceFactory genericServiceFactory, IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory) - : base(targetedFields, contextResolver, resourceGraph, genericServiceFactory, + : base(targetedFields, dbContextResolver, resourceGraph, genericServiceFactory, resourceFactory, constraintProviders, loggerFactory) { } diff --git a/docs/usage/extensibility/resource-definitions.md b/docs/usage/extensibility/resource-definitions.md index 5f0ca406be..6ebace8d52 100644 --- a/docs/usage/extensibility/resource-definitions.md +++ b/docs/usage/extensibility/resource-definitions.md @@ -172,11 +172,8 @@ public class AccountDefinition : JsonApiResourceDefinition public override FilterExpression OnApplyFilter(FilterExpression existingFilter) { - var resourceContext = ResourceGraph.GetResourceContext(); - - var isSuspendedAttribute = - resourceContext.Attributes.Single(account => - account.Property.Name == nameof(Account.IsSuspended)); + var isSuspendedAttribute = ResourceType.Attributes.Single(account => + account.Property.Name == nameof(Account.IsSuspended)); var isNotSuspended = new ComparisonExpression(ComparisonOperator.Equals, new ResourceFieldChainExpression(isSuspendedAttribute), diff --git a/src/Examples/GettingStarted/Startup.cs b/src/Examples/GettingStarted/Startup.cs index 10d2f338f0..13beab63fe 100644 --- a/src/Examples/GettingStarted/Startup.cs +++ b/src/Examples/GettingStarted/Startup.cs @@ -26,22 +26,22 @@ public void ConfigureServices(IServiceCollection services) // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. [UsedImplicitly] - public void Configure(IApplicationBuilder app, SampleDbContext context) + public void Configure(IApplicationBuilder app, SampleDbContext dbContext) { - context.Database.EnsureDeleted(); - context.Database.EnsureCreated(); - CreateSampleData(context); + dbContext.Database.EnsureDeleted(); + dbContext.Database.EnsureCreated(); + CreateSampleData(dbContext); app.UseRouting(); app.UseJsonApi(); app.UseEndpoints(endpoints => endpoints.MapControllers()); } - private static void CreateSampleData(SampleDbContext context) + private static void CreateSampleData(SampleDbContext dbContext) { // Note: The generate-examples.ps1 script (to create example requests in documentation) depends on these. - context.Books.AddRange(new Book + dbContext.Books.AddRange(new Book { Title = "Frankenstein", PublishYear = 1818, @@ -67,7 +67,7 @@ private static void CreateSampleData(SampleDbContext context) } }); - context.SaveChanges(); + dbContext.SaveChanges(); } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Startup.cs b/src/Examples/JsonApiDotNetCoreExample/Startup.cs index fb75cfaef8..f14c1df8ce 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Startup.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Startup.cs @@ -55,6 +55,7 @@ public void ConfigureServices(IServiceCollection services) options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); #if DEBUG options.IncludeExceptionStackTraceInErrors = true; + options.IncludeRequestBodyInErrors = true; #endif }, discovery => discovery.AddCurrentAssembly()); } diff --git a/src/Examples/MultiDbContextExample/Repositories/DbContextARepository.cs b/src/Examples/MultiDbContextExample/Repositories/DbContextARepository.cs index 5b07948005..fc5d58efd9 100644 --- a/src/Examples/MultiDbContextExample/Repositories/DbContextARepository.cs +++ b/src/Examples/MultiDbContextExample/Repositories/DbContextARepository.cs @@ -13,10 +13,10 @@ namespace MultiDbContextExample.Repositories public sealed class DbContextARepository : EntityFrameworkCoreRepository where TResource : class, IIdentifiable { - public DbContextARepository(ITargetedFields targetedFields, DbContextResolver contextResolver, IResourceGraph resourceGraph, + public DbContextARepository(ITargetedFields targetedFields, DbContextResolver dbContextResolver, IResourceGraph resourceGraph, IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory, IResourceDefinitionAccessor resourceDefinitionAccessor) - : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, 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 afa7ed4bde..7c1ef16ec7 100644 --- a/src/Examples/MultiDbContextExample/Repositories/DbContextBRepository.cs +++ b/src/Examples/MultiDbContextExample/Repositories/DbContextBRepository.cs @@ -13,10 +13,10 @@ namespace MultiDbContextExample.Repositories public sealed class DbContextBRepository : EntityFrameworkCoreRepository where TResource : class, IIdentifiable { - public DbContextBRepository(ITargetedFields targetedFields, DbContextResolver contextResolver, IResourceGraph resourceGraph, + public DbContextBRepository(ITargetedFields targetedFields, DbContextResolver dbContextResolver, IResourceGraph resourceGraph, IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory, IResourceDefinitionAccessor resourceDefinitionAccessor) - : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor) + : base(targetedFields, dbContextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor) { } } diff --git a/src/Examples/MultiDbContextExample/Startup.cs b/src/Examples/MultiDbContextExample/Startup.cs index bc76c3cd9a..705bf8ef4c 100644 --- a/src/Examples/MultiDbContextExample/Startup.cs +++ b/src/Examples/MultiDbContextExample/Startup.cs @@ -25,6 +25,7 @@ public void ConfigureServices(IServiceCollection services) services.AddJsonApi(options => { options.IncludeExceptionStackTraceInErrors = true; + options.IncludeRequestBodyInErrors = true; }, dbContextTypes: new[] { typeof(DbContextA), diff --git a/src/Examples/NoEntityFrameworkExample/Startup.cs b/src/Examples/NoEntityFrameworkExample/Startup.cs index c51985f5f2..758c31f731 100644 --- a/src/Examples/NoEntityFrameworkExample/Startup.cs +++ b/src/Examples/NoEntityFrameworkExample/Startup.cs @@ -34,9 +34,9 @@ public void ConfigureServices(IServiceCollection services) // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. [UsedImplicitly] - public void Configure(IApplicationBuilder app, AppDbContext context) + public void Configure(IApplicationBuilder app, AppDbContext dbContext) { - context.Database.EnsureCreated(); + dbContext.Database.EnsureCreated(); app.UseRouting(); app.UseJsonApi(); diff --git a/src/JsonApiDotNetCore/AtomicOperations/LocalIdTracker.cs b/src/JsonApiDotNetCore/AtomicOperations/LocalIdTracker.cs index 744a03a9e8..972440c4bc 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/LocalIdTracker.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/LocalIdTracker.cs @@ -1,8 +1,6 @@ using System; using System.Collections.Generic; -using System.Net; using JsonApiDotNetCore.Errors; -using JsonApiDotNetCore.Serialization.Objects; namespace JsonApiDotNetCore.AtomicOperations { @@ -32,11 +30,7 @@ private void AssertIsNotDeclared(string localId) { if (_idsTracked.ContainsKey(localId)) { - throw new JsonApiException(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." - }); + throw new DuplicateLocalIdValueException(localId); } } @@ -75,11 +69,7 @@ public string GetValue(string localId, string resourceType) if (item.ServerId == null) { - throw new JsonApiException(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." - }); + throw new LocalIdSingleOperationException(localId); } return item.ServerId; @@ -89,11 +79,7 @@ private void AssertIsDeclared(string localId) { if (!_idsTracked.ContainsKey(localId)) { - throw new JsonApiException(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." - }); + throw new UnknownLocalIdValueException(localId); } } @@ -101,11 +87,7 @@ private static void AssertSameResourceType(string currentType, string declaredTy { if (declaredType != currentType) { - throw new JsonApiException(new ErrorObject(HttpStatusCode.BadRequest) - { - Title = "Type mismatch in local ID usage.", - Detail = $"Local ID '{localId}' belongs to resource type '{declaredType}' instead of '{currentType}'." - }); + throw new IncompatibleLocalIdTypeException(localId, declaredType, currentType); } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/LocalIdValidator.cs b/src/JsonApiDotNetCore/AtomicOperations/LocalIdValidator.cs index d880ab7b42..5fd790a318 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/LocalIdValidator.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/LocalIdValidator.cs @@ -57,9 +57,9 @@ public void Validate(IEnumerable operations) private void ValidateOperation(OperationContainer operation) { - if (operation.Kind == WriteOperationKind.CreateResource) + if (operation.Request.WriteOperation == WriteOperationKind.CreateResource) { - DeclareLocalId(operation.Resource); + DeclareLocalId(operation.Resource, operation.Request.PrimaryResourceType); } else { @@ -71,27 +71,25 @@ private void ValidateOperation(OperationContainer operation) AssertLocalIdIsAssigned(secondaryResource); } - if (operation.Kind == WriteOperationKind.CreateResource) + if (operation.Request.WriteOperation == WriteOperationKind.CreateResource) { - AssignLocalId(operation); + AssignLocalId(operation, operation.Request.PrimaryResourceType); } } - private void DeclareLocalId(IIdentifiable resource) + private void DeclareLocalId(IIdentifiable resource, ResourceType resourceType) { if (resource.LocalId != null) { - ResourceContext resourceContext = _resourceGraph.GetResourceContext(resource.GetType()); - _localIdTracker.Declare(resource.LocalId, resourceContext.PublicName); + _localIdTracker.Declare(resource.LocalId, resourceType.PublicName); } } - private void AssignLocalId(OperationContainer operation) + private void AssignLocalId(OperationContainer operation, ResourceType resourceType) { if (operation.Resource.LocalId != null) { - ResourceContext resourceContext = _resourceGraph.GetResourceContext(operation.Resource.GetType()); - _localIdTracker.Assign(operation.Resource.LocalId, resourceContext.PublicName, "placeholder"); + _localIdTracker.Assign(operation.Resource.LocalId, resourceType.PublicName, "placeholder"); } } @@ -99,8 +97,8 @@ private void AssertLocalIdIsAssigned(IIdentifiable resource) { if (resource.LocalId != null) { - ResourceContext resourceContext = _resourceGraph.GetResourceContext(resource.GetType()); - _localIdTracker.GetValue(resource.LocalId, resourceContext.PublicName); + ResourceType resourceType = _resourceGraph.GetResourceType(resource.GetType()); + _localIdTracker.GetValue(resource.LocalId, resourceType.PublicName); } } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/OperationProcessorAccessor.cs b/src/JsonApiDotNetCore/AtomicOperations/OperationProcessorAccessor.cs index a71fa906cd..68cfb752b2 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/OperationProcessorAccessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/OperationProcessorAccessor.cs @@ -14,15 +14,12 @@ namespace JsonApiDotNetCore.AtomicOperations [PublicAPI] public class OperationProcessorAccessor : IOperationProcessorAccessor { - private readonly IResourceGraph _resourceGraph; private readonly IServiceProvider _serviceProvider; - public OperationProcessorAccessor(IResourceGraph resourceGraph, IServiceProvider serviceProvider) + public OperationProcessorAccessor(IServiceProvider serviceProvider) { - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); ArgumentGuard.NotNull(serviceProvider, nameof(serviceProvider)); - _resourceGraph = resourceGraph; _serviceProvider = serviceProvider; } @@ -37,10 +34,10 @@ public Task ProcessAsync(OperationContainer operation, Cance protected virtual IOperationProcessor ResolveProcessor(OperationContainer operation) { - Type processorInterface = GetProcessorInterface(operation.Kind); - ResourceContext resourceContext = _resourceGraph.GetResourceContext(operation.Resource.GetType()); + Type processorInterface = GetProcessorInterface(operation.Request.WriteOperation.GetValueOrDefault()); + ResourceType resourceType = operation.Request.PrimaryResourceType; - Type processorType = processorInterface.MakeGenericType(resourceContext.ResourceType, resourceContext.IdentityType); + Type processorType = processorInterface.MakeGenericType(resourceType.ClrType, resourceType.IdentityClrType); return (IOperationProcessor)_serviceProvider.GetRequiredService(processorType); } diff --git a/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs index 8d531ea231..be266286db 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs @@ -1,12 +1,12 @@ 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; using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries.Internal; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; @@ -22,10 +22,12 @@ public class OperationsProcessor : IOperationsProcessor 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) + ILocalIdTracker localIdTracker, IResourceGraph resourceGraph, IJsonApiRequest request, ITargetedFields targetedFields, + ISparseFieldSetCache sparseFieldSetCache) { ArgumentGuard.NotNull(operationProcessorAccessor, nameof(operationProcessorAccessor)); ArgumentGuard.NotNull(operationsTransactionFactory, nameof(operationsTransactionFactory)); @@ -33,6 +35,7 @@ public OperationsProcessor(IOperationProcessorAccessor operationProcessorAccesso ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); ArgumentGuard.NotNull(request, nameof(request)); ArgumentGuard.NotNull(targetedFields, nameof(targetedFields)); + ArgumentGuard.NotNull(sparseFieldSetCache, nameof(sparseFieldSetCache)); _operationProcessorAccessor = operationProcessorAccessor; _operationsTransactionFactory = operationsTransactionFactory; @@ -40,6 +43,7 @@ public OperationsProcessor(IOperationProcessorAccessor operationProcessorAccesso _resourceGraph = resourceGraph; _request = request; _targetedFields = targetedFields; + _sparseFieldSetCache = sparseFieldSetCache; _localIdValidator = new LocalIdValidator(_localIdTracker, _resourceGraph); } @@ -57,6 +61,8 @@ public virtual async Task> ProcessAsync(IList> ProcessAsync(IList> ProcessAsync(IList ProcessOperationAsync(Operation TrackLocalIdsForOperation(operation); - _targetedFields.Attributes = operation.TargetedFields.Attributes; - _targetedFields.Relationships = operation.TargetedFields.Relationships; - + _targetedFields.CopyFrom(operation.TargetedFields); _request.CopyFrom(operation.Request); return await _operationProcessorAccessor.ProcessAsync(operation, cancellationToken); @@ -119,9 +117,9 @@ protected virtual async Task ProcessOperationAsync(Operation protected void TrackLocalIdsForOperation(OperationContainer operation) { - if (operation.Kind == WriteOperationKind.CreateResource) + if (operation.Request.WriteOperation == WriteOperationKind.CreateResource) { - DeclareLocalId(operation.Resource); + DeclareLocalId(operation.Resource, operation.Request.PrimaryResourceType); } else { @@ -134,12 +132,11 @@ protected void TrackLocalIdsForOperation(OperationContainer operation) } } - private void DeclareLocalId(IIdentifiable resource) + private void DeclareLocalId(IIdentifiable resource, ResourceType resourceType) { if (resource.LocalId != null) { - ResourceContext resourceContext = _resourceGraph.GetResourceContext(resource.GetType()); - _localIdTracker.Declare(resource.LocalId, resourceContext.PublicName); + _localIdTracker.Declare(resource.LocalId, resourceType.PublicName); } } @@ -147,8 +144,8 @@ private void AssignStringId(IIdentifiable resource) { if (resource.LocalId != null) { - ResourceContext resourceContext = _resourceGraph.GetResourceContext(resource.GetType()); - resource.StringId = _localIdTracker.GetValue(resource.LocalId, resourceContext.PublicName); + ResourceType resourceType = _resourceGraph.GetResourceType(resource.GetType()); + resource.StringId = _localIdTracker.GetValue(resource.LocalId, resourceType.PublicName); } } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateProcessor.cs index 2e113561ab..4a408f368e 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateProcessor.cs @@ -14,17 +14,14 @@ public class CreateProcessor : ICreateProcessor { private readonly ICreateService _service; private readonly ILocalIdTracker _localIdTracker; - private readonly IResourceGraph _resourceGraph; - public CreateProcessor(ICreateService service, ILocalIdTracker localIdTracker, IResourceGraph resourceGraph) + public CreateProcessor(ICreateService service, ILocalIdTracker localIdTracker) { ArgumentGuard.NotNull(service, nameof(service)); ArgumentGuard.NotNull(localIdTracker, nameof(localIdTracker)); - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); _service = service; _localIdTracker = localIdTracker; - _resourceGraph = resourceGraph; } /// @@ -37,9 +34,9 @@ public virtual async Task ProcessAsync(OperationContainer op if (operation.Resource.LocalId != null) { string serverId = newResource != null ? newResource.StringId : operation.Resource.StringId; - ResourceContext resourceContext = _resourceGraph.GetResourceContext(); + ResourceType resourceType = operation.Request.PrimaryResourceType; - _localIdTracker.Assign(operation.Resource.LocalId, resourceContext.PublicName, serverId); + _localIdTracker.Assign(operation.Resource.LocalId, resourceType.PublicName, serverId); } return newResource == null ? null : operation.WithResource(newResource); diff --git a/src/JsonApiDotNetCore/AtomicOperations/RevertRequestStateOnDispose.cs b/src/JsonApiDotNetCore/AtomicOperations/RevertRequestStateOnDispose.cs new file mode 100644 index 0000000000..8c2e150a23 --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/RevertRequestStateOnDispose.cs @@ -0,0 +1,38 @@ +using System; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.AtomicOperations +{ + /// + /// 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 _backupRequest = new JsonApiRequest(); + private readonly ITargetedFields _backupTargetedFields = new TargetedFields(); + + public RevertRequestStateOnDispose(IJsonApiRequest request, ITargetedFields targetedFields) + { + ArgumentGuard.NotNull(request, nameof(request)); + + _sourceRequest = request; + _backupRequest.CopyFrom(request); + + if (targetedFields != null) + { + _sourceTargetedFields = targetedFields; + _backupTargetedFields.CopyFrom(targetedFields); + } + } + + public void Dispose() + { + _sourceRequest.CopyFrom(_backupRequest); + _sourceTargetedFields?.CopyFrom(_backupTargetedFields); + } + } +} diff --git a/src/JsonApiDotNetCore/CollectionExtensions.cs b/src/JsonApiDotNetCore/CollectionExtensions.cs index ca69755c1c..863b22d5fd 100644 --- a/src/JsonApiDotNetCore/CollectionExtensions.cs +++ b/src/JsonApiDotNetCore/CollectionExtensions.cs @@ -71,6 +71,11 @@ public static bool DictionaryEqual(this IReadOnlyDictionary EmptyIfNull(this IEnumerable source) + { + return source ?? Enumerable.Empty(); + } + public static void AddRange(this ICollection source, IEnumerable itemsToAdd) { ArgumentGuard.NotNull(source, nameof(source)); diff --git a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs index 4b5d36c421..6e8e2d981e 100644 --- a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs @@ -30,10 +30,15 @@ public interface IJsonApiOptions bool IncludeJsonApiVersion { get; } /// - /// Whether or not stack traces should be serialized in . False by default. + /// 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. /// @@ -113,6 +118,11 @@ public interface IJsonApiOptions /// 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. /// diff --git a/src/JsonApiDotNetCore/Configuration/IRequestScopedServiceProvider.cs b/src/JsonApiDotNetCore/Configuration/IRequestScopedServiceProvider.cs deleted file mode 100644 index 327eeca353..0000000000 --- a/src/JsonApiDotNetCore/Configuration/IRequestScopedServiceProvider.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System; - -namespace JsonApiDotNetCore.Configuration -{ - /// - /// An interface used to separate the registration of the global from a request-scoped service provider. This is useful - /// in cases when we need to manually resolve services from the request scope (e.g. operation processors). - /// - public interface IRequestScopedServiceProvider : IServiceProvider - { - } -} diff --git a/src/JsonApiDotNetCore/Configuration/IResourceGraph.cs b/src/JsonApiDotNetCore/Configuration/IResourceGraph.cs index b7216f0f8c..1c2c058150 100644 --- a/src/JsonApiDotNetCore/Configuration/IResourceGraph.cs +++ b/src/JsonApiDotNetCore/Configuration/IResourceGraph.cs @@ -17,43 +17,44 @@ public interface IResourceGraph /// /// Gets the metadata for all registered resources. /// - IReadOnlySet GetResourceContexts(); + IReadOnlySet GetResourceTypes(); /// - /// Gets the resource metadata for the resource that is publicly exposed by the specified name. Throws an when - /// not found. + /// Gets the metadata for the resource that is publicly exposed by the specified name. Throws an when not found. /// - ResourceContext GetResourceContext(string publicName); + ResourceType GetResourceType(string publicName); /// - /// Gets the resource metadata for the specified resource type. Throws an when not found. + /// Gets the metadata for the resource of the specified CLR type. Throws an when not found. /// - ResourceContext GetResourceContext(Type resourceType); + ResourceType GetResourceType(Type resourceClrType); /// - /// Gets the resource metadata for the specified resource type. Throws an when not found. + /// Gets the metadata for the resource of the specified CLR type. Throws an when not found. /// - ResourceContext GetResourceContext() + ResourceType GetResourceType() where TResource : class, IIdentifiable; /// - /// Attempts to get the resource metadata for the resource that is publicly exposed by the specified name. Returns null when not found. + /// Attempts to get the metadata for the resource that is publicly exposed by the specified name. Returns null when not found. /// - ResourceContext TryGetResourceContext(string publicName); + ResourceType TryGetResourceType(string publicName); /// - /// Attempts to get the resource metadata for the specified resource type. Returns null when not found. + /// Attempts to get metadata for the resource of the specified CLR type. Returns null when not found. /// - ResourceContext TryGetResourceContext(Type resourceType); + ResourceType TryGetResourceType(Type resourceClrType); /// /// Gets the fields (attributes and relationships) for that are targeted by the selector. /// /// - /// The resource type for which to retrieve fields. + /// The resource CLR type for which to retrieve fields. /// /// - /// Should be of the form: (TResource r) => new { r.Field1, r.Field2 } + /// Should be of the form: new { resource.Attribute1, resource.Relationship2 } + /// ]]> /// IReadOnlyCollection GetFields(Expression> selector) where TResource : class, IIdentifiable; @@ -62,10 +63,12 @@ IReadOnlyCollection GetFields(Expression that are targeted by the selector. /// /// - /// The resource type for which to retrieve attributes. + /// The resource CLR type for which to retrieve attributes. /// /// - /// Should be of the form: (TResource r) => new { r.Attribute1, r.Attribute2 } + /// Should be of the form: new { resource.attribute1, resource.Attribute2 } + /// ]]> /// IReadOnlyCollection GetAttributes(Expression> selector) where TResource : class, IIdentifiable; @@ -74,10 +77,12 @@ IReadOnlyCollection GetAttributes(Expression that are targeted by the selector. /// /// - /// The resource type for which to retrieve relationships. + /// The resource CLR type for which to retrieve relationships. /// /// - /// Should be of the form: (TResource r) => new { r.Relationship1, r.Relationship2 } + /// 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 abe95d00bf..a3461cfdcf 100644 --- a/src/JsonApiDotNetCore/Configuration/InverseNavigationResolver.cs +++ b/src/JsonApiDotNetCore/Configuration/InverseNavigationResolver.cs @@ -36,14 +36,14 @@ public void Resolve() private void Resolve(DbContext dbContext) { - foreach (ResourceContext resourceContext in _resourceGraph.GetResourceContexts().Where(context => context.Relationships.Any())) + foreach (ResourceType resourceType in _resourceGraph.GetResourceTypes().Where(resourceType => resourceType.Relationships.Any())) { - IEntityType entityType = dbContext.Model.FindEntityType(resourceContext.ResourceType); + IEntityType entityType = dbContext.Model.FindEntityType(resourceType.ClrType); if (entityType != null) { IDictionary navigationMap = GetNavigations(entityType); - ResolveRelationships(resourceContext.Relationships, navigationMap); + ResolveRelationships(resourceType.Relationships, navigationMap); } } } diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs index 9200149b25..8db88c4ee2 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs @@ -10,16 +10,15 @@ using JsonApiDotNetCore.QueryStrings.Internal; using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Serialization; -using JsonApiDotNetCore.Serialization.Building; using JsonApiDotNetCore.Serialization.JsonConverters; +using JsonApiDotNetCore.Serialization.Request; +using JsonApiDotNetCore.Serialization.Request.Adapters; +using JsonApiDotNetCore.Serialization.Response; using JsonApiDotNetCore.Services; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Metadata.Internal; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; @@ -82,7 +81,7 @@ public void AddResourceGraph(ICollection dbContextTypes, Action dbContextTypes) foreach (Type dbContextType in dbContextTypes) { - Type contextResolverType = typeof(DbContextResolver<>).MakeGenericType(dbContextType); - _services.AddScoped(typeof(IDbContextResolver), contextResolverType); + Type dbContextResolverType = typeof(DbContextResolver<>).MakeGenericType(dbContextType); + _services.AddScoped(typeof(IDbContextResolver), dbContextResolverType); } _services.AddScoped(); @@ -156,6 +155,7 @@ public void ConfigureServiceContainer(ICollection dbContextTypes) _services.AddScoped(typeof(IResourceChangeTracker<>), typeof(ResourceChangeTracker<>)); _services.AddScoped(); _services.AddScoped(); + _services.AddScoped(); _services.AddScoped(); _services.AddScoped(); } @@ -173,12 +173,10 @@ private void AddMiddlewareLayer() _services.AddSingleton(); _services.AddSingleton(sp => sp.GetRequiredService()); _services.AddSingleton(); - _services.AddSingleton(); _services.AddScoped(); _services.AddScoped(); _services.AddScoped(); _services.AddScoped(); - _services.AddScoped(); } private void AddResourceLayer() @@ -250,18 +248,23 @@ private void RegisterDependentService() 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.AddScoped(); _services.AddScoped(); _services.AddScoped(); - _services.AddScoped(typeof(ResponseSerializer<>)); - _services.AddScoped(typeof(AtomicOperationsResponseSerializer)); - _services.AddScoped(sp => sp.GetRequiredService().GetSerializer()); - _services.AddScoped(); _services.AddSingleton(); _services.AddSingleton(); + _services.AddScoped(); } private void AddOperationsLayer() @@ -278,24 +281,6 @@ private void AddOperationsLayer() _services.AddScoped(); } - private void AddResourcesFromDbContext(DbContext dbContext, ResourceGraphBuilder builder) - { - foreach (IEntityType entityType in dbContext.Model.GetEntityTypes()) - { - if (!IsImplicitManyToManyJoinEntity(entityType)) - { - builder.Add(entityType.ClrType); - } - } - } - - private static bool IsImplicitManyToManyJoinEntity(IEntityType entityType) - { -#pragma warning disable EF1001 // Internal EF Core API usage. - return entityType is EntityType { IsImplicitlyCreatedJoinEntityType: true }; -#pragma warning restore EF1001 // Internal EF Core API usage. - } - public void Dispose() { _intermediateProvider.Dispose(); diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiModelMetadataProvider.cs b/src/JsonApiDotNetCore/Configuration/JsonApiModelMetadataProvider.cs index 19f9edc531..07f15db8a6 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiModelMetadataProvider.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiModelMetadataProvider.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; @@ -13,18 +14,18 @@ internal sealed class JsonApiModelMetadataProvider : DefaultModelMetadataProvide private readonly JsonApiValidationFilter _jsonApiValidationFilter; /// - public JsonApiModelMetadataProvider(ICompositeMetadataDetailsProvider detailsProvider, IRequestScopedServiceProvider serviceProvider) + public JsonApiModelMetadataProvider(ICompositeMetadataDetailsProvider detailsProvider, IHttpContextAccessor httpContextAccessor) : base(detailsProvider) { - _jsonApiValidationFilter = new JsonApiValidationFilter(serviceProvider); + _jsonApiValidationFilter = new JsonApiValidationFilter(httpContextAccessor); } /// public JsonApiModelMetadataProvider(ICompositeMetadataDetailsProvider detailsProvider, IOptions optionsAccessor, - IRequestScopedServiceProvider serviceProvider) + IHttpContextAccessor httpContextAccessor) : base(detailsProvider, optionsAccessor) { - _jsonApiValidationFilter = new JsonApiValidationFilter(serviceProvider); + _jsonApiValidationFilter = new JsonApiValidationFilter(httpContextAccessor); } /// diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs index 4806c36248..322e2cd725 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs @@ -38,6 +38,9 @@ public sealed class JsonApiOptions : IJsonApiOptions /// public bool IncludeExceptionStackTraceInErrors { get; set; } + /// + public bool IncludeRequestBodyInErrors { get; set; } + /// public bool UseRelativeLinks { get; set; } @@ -71,6 +74,9 @@ public sealed class JsonApiOptions : IJsonApiOptions /// public bool AllowUnknownQueryStringParameters { get; set; } + /// + public bool AllowUnknownFieldsInRequestBody { get; set; } + /// public bool EnableLegacyFilterNotation { get; set; } diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiValidationFilter.cs b/src/JsonApiDotNetCore/Configuration/JsonApiValidationFilter.cs index 1a661aaf1e..6829e35788 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiValidationFilter.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiValidationFilter.cs @@ -13,19 +13,21 @@ namespace JsonApiDotNetCore.Configuration /// internal sealed class JsonApiValidationFilter : IPropertyValidationFilter { - private readonly IRequestScopedServiceProvider _serviceProvider; + private readonly IHttpContextAccessor _httpContextAccessor; - public JsonApiValidationFilter(IRequestScopedServiceProvider serviceProvider) + public JsonApiValidationFilter(IHttpContextAccessor httpContextAccessor) { - ArgumentGuard.NotNull(serviceProvider, nameof(serviceProvider)); + ArgumentGuard.NotNull(httpContextAccessor, nameof(httpContextAccessor)); - _serviceProvider = serviceProvider; + _httpContextAccessor = httpContextAccessor; } /// public bool ShouldValidateEntry(ValidationEntry entry, ValidationEntry parentEntry) { - var request = _serviceProvider.GetRequiredService(); + IServiceProvider serviceProvider = GetScopedServiceProvider(); + + var request = serviceProvider.GetRequiredService(); if (IsId(entry.Key)) { @@ -39,17 +41,27 @@ public bool ShouldValidateEntry(ValidationEntry entry, ValidationEntry parentEnt return false; } - var httpContextAccessor = _serviceProvider.GetRequiredService(); - - if (httpContextAccessor.HttpContext!.Request.Method == HttpMethods.Patch || request.WriteOperation == WriteOperationKind.UpdateResource) + if (_httpContextAccessor.HttpContext!.Request.Method == HttpMethods.Patch || request.WriteOperation == WriteOperationKind.UpdateResource) { - var targetedFields = _serviceProvider.GetRequiredService(); + var targetedFields = serviceProvider.GetRequiredService(); return IsFieldTargeted(entry, targetedFields); } 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."); + } + + return httpContext.RequestServices; + } + private static bool IsId(string key) { return key == nameof(Identifiable.Id) || key.EndsWith($".{nameof(Identifiable.Id)}", StringComparison.Ordinal); @@ -57,7 +69,7 @@ private static bool IsId(string key) private static bool IsAtPrimaryEndpoint(IJsonApiRequest request) { - return request.Kind == EndpointKind.Primary || request.Kind == EndpointKind.AtomicOperations; + return request.Kind is EndpointKind.Primary or EndpointKind.AtomicOperations; } private static bool IsFieldTargeted(ValidationEntry entry, ITargetedFields targetedFields) diff --git a/src/JsonApiDotNetCore/Configuration/RequestScopedServiceProvider.cs b/src/JsonApiDotNetCore/Configuration/RequestScopedServiceProvider.cs deleted file mode 100644 index 649a219c0b..0000000000 --- a/src/JsonApiDotNetCore/Configuration/RequestScopedServiceProvider.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System; -using Microsoft.AspNetCore.Http; - -namespace JsonApiDotNetCore.Configuration -{ - /// - public sealed class RequestScopedServiceProvider : IRequestScopedServiceProvider - { - private readonly IHttpContextAccessor _httpContextAccessor; - - public RequestScopedServiceProvider(IHttpContextAccessor httpContextAccessor) - { - ArgumentGuard.NotNull(httpContextAccessor, nameof(httpContextAccessor)); - - _httpContextAccessor = httpContextAccessor; - } - - /// - public object GetService(Type serviceType) - { - ArgumentGuard.NotNull(serviceType, nameof(serviceType)); - - if (_httpContextAccessor.HttpContext == null) - { - throw new InvalidOperationException($"Cannot resolve scoped service '{serviceType.FullName}' outside the context of an HTTP request. " + - "If you are hitting this error in automated tests, you should instead inject your own " + - "IRequestScopedServiceProvider implementation. See the GitHub repository for how we do this internally. " + - "https://github.com/json-api-dotnet/JsonApiDotNetCore/search?q=TestScopedServiceProvider&unscoped_q=TestScopedServiceProvider"); - } - - return _httpContextAccessor.HttpContext.RequestServices.GetService(serviceType); - } - } -} diff --git a/src/JsonApiDotNetCore/Configuration/ResourceDescriptor.cs b/src/JsonApiDotNetCore/Configuration/ResourceDescriptor.cs index 70a14513ae..d673fe11a7 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceDescriptor.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceDescriptor.cs @@ -4,13 +4,13 @@ namespace JsonApiDotNetCore.Configuration { internal sealed class ResourceDescriptor { - public Type ResourceType { get; } - public Type IdType { get; } + public Type ResourceClrType { get; } + public Type IdClrType { get; } - public ResourceDescriptor(Type resourceType, Type idType) + public ResourceDescriptor(Type resourceClrType, Type idClrType) { - ResourceType = resourceType; - IdType = idType; + ResourceClrType = resourceClrType; + IdClrType = idClrType; } } } diff --git a/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs index f65755b38d..90df89c576 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs @@ -15,82 +15,82 @@ public sealed class ResourceGraph : IResourceGraph { private static readonly Type ProxyTargetAccessorType = Type.GetType("Castle.DynamicProxy.IProxyTargetAccessor, Castle.Core"); - private readonly IReadOnlySet _resourceContextSet; - private readonly Dictionary _resourceContextsByType = new(); - private readonly Dictionary _resourceContextsByPublicName = new(); + private readonly IReadOnlySet _resourceTypeSet; + private readonly Dictionary _resourceTypesByClrType = new(); + private readonly Dictionary _resourceTypesByPublicName = new(); - public ResourceGraph(IReadOnlySet resourceContexts) + public ResourceGraph(IReadOnlySet resourceTypeSet) { - ArgumentGuard.NotNull(resourceContexts, nameof(resourceContexts)); + ArgumentGuard.NotNull(resourceTypeSet, nameof(resourceTypeSet)); - _resourceContextSet = resourceContexts; + _resourceTypeSet = resourceTypeSet; - foreach (ResourceContext resourceContext in resourceContexts) + foreach (ResourceType resourceType in resourceTypeSet) { - _resourceContextsByType.Add(resourceContext.ResourceType, resourceContext); - _resourceContextsByPublicName.Add(resourceContext.PublicName, resourceContext); + _resourceTypesByClrType.Add(resourceType.ClrType, resourceType); + _resourceTypesByPublicName.Add(resourceType.PublicName, resourceType); } } /// - public IReadOnlySet GetResourceContexts() + public IReadOnlySet GetResourceTypes() { - return _resourceContextSet; + return _resourceTypeSet; } /// - public ResourceContext GetResourceContext(string publicName) + public ResourceType GetResourceType(string publicName) { - ResourceContext resourceContext = TryGetResourceContext(publicName); + ResourceType resourceType = TryGetResourceType(publicName); - if (resourceContext == null) + if (resourceType == null) { throw new InvalidOperationException($"Resource type '{publicName}' does not exist."); } - return resourceContext; + return resourceType; } /// - public ResourceContext TryGetResourceContext(string publicName) + public ResourceType TryGetResourceType(string publicName) { - ArgumentGuard.NotNullNorEmpty(publicName, nameof(publicName)); + ArgumentGuard.NotNull(publicName, nameof(publicName)); - return _resourceContextsByPublicName.TryGetValue(publicName, out ResourceContext resourceContext) ? resourceContext : null; + return _resourceTypesByPublicName.TryGetValue(publicName, out ResourceType resourceType) ? resourceType : null; } /// - public ResourceContext GetResourceContext(Type resourceType) + public ResourceType GetResourceType(Type resourceClrType) { - ResourceContext resourceContext = TryGetResourceContext(resourceType); + ResourceType resourceType = TryGetResourceType(resourceClrType); - if (resourceContext == null) + if (resourceType == null) { - throw new InvalidOperationException($"Resource of type '{resourceType.Name}' does not exist."); + throw new InvalidOperationException($"Resource of type '{resourceClrType.Name}' does not exist."); } - return resourceContext; + return resourceType; } /// - public ResourceContext TryGetResourceContext(Type resourceType) + public ResourceType TryGetResourceType(Type resourceClrType) { - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + ArgumentGuard.NotNull(resourceClrType, nameof(resourceClrType)); - Type typeToFind = IsLazyLoadingProxyForResourceType(resourceType) ? resourceType.BaseType : resourceType; - return _resourceContextsByType.TryGetValue(typeToFind!, out ResourceContext resourceContext) ? resourceContext : null; + Type typeToFind = IsLazyLoadingProxyForResourceType(resourceClrType) ? resourceClrType.BaseType : resourceClrType; + return _resourceTypesByClrType.TryGetValue(typeToFind!, out ResourceType resourceType) ? resourceType : null; } - private bool IsLazyLoadingProxyForResourceType(Type resourceType) + private bool IsLazyLoadingProxyForResourceType(Type resourceClrType) { - return ProxyTargetAccessorType?.IsAssignableFrom(resourceType) ?? false; + return ProxyTargetAccessorType?.IsAssignableFrom(resourceClrType) ?? false; } /// - public ResourceContext GetResourceContext() + public ResourceType GetResourceType() where TResource : class, IIdentifiable { - return GetResourceContext(typeof(TResource)); + return GetResourceType(typeof(TResource)); } /// @@ -145,19 +145,19 @@ private IReadOnlyCollection FilterFields(Expression GetFieldsOfType() where TKind : ResourceFieldAttribute { - ResourceContext resourceContext = GetResourceContext(typeof(TResource)); + ResourceType resourceType = GetResourceType(typeof(TResource)); if (typeof(TKind) == typeof(AttrAttribute)) { - return (IReadOnlyCollection)resourceContext.Attributes; + return (IReadOnlyCollection)resourceType.Attributes; } if (typeof(TKind) == typeof(RelationshipAttribute)) { - return (IReadOnlyCollection)resourceContext.Relationships; + return (IReadOnlyCollection)resourceType.Relationships; } - return (IReadOnlyCollection)resourceContext.Fields; + return (IReadOnlyCollection)resourceType.Fields; } private IEnumerable ToMemberNames(Expression> selector) diff --git a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs index 0ea1fb1a14..c063444d88 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs @@ -5,6 +5,9 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Metadata.Internal; using Microsoft.Extensions.Logging; namespace JsonApiDotNetCore.Configuration @@ -17,7 +20,7 @@ public class ResourceGraphBuilder { private readonly IJsonApiOptions _options; private readonly ILogger _logger; - private readonly HashSet _resourceContexts = new(); + private readonly HashSet _resourceTypes = new(); private readonly TypeLocator _typeLocator = new(); public ResourceGraphBuilder(IJsonApiOptions options, ILoggerFactory loggerFactory) @@ -34,18 +37,48 @@ public ResourceGraphBuilder(IJsonApiOptions options, ILoggerFactory loggerFactor /// public IResourceGraph Build() { - return new ResourceGraph(_resourceContexts); + var resourceGraph = new ResourceGraph(_resourceTypes); + + foreach (RelationshipAttribute relationship in _resourceTypes.SelectMany(resourceType => resourceType.Relationships)) + { + relationship.LeftType = resourceGraph.GetResourceType(relationship.LeftClrType); + relationship.RightType = resourceGraph.GetResourceType(relationship.RightClrType); + } + + return resourceGraph; + } + + public ResourceGraphBuilder Add(DbContext dbContext) + { + ArgumentGuard.NotNull(dbContext, nameof(dbContext)); + + foreach (IEntityType entityType in dbContext.Model.GetEntityTypes()) + { + if (!IsImplicitManyToManyJoinEntity(entityType)) + { + Add(entityType.ClrType); + } + } + + return this; + } + + private static bool IsImplicitManyToManyJoinEntity(IEntityType entityType) + { +#pragma warning disable EF1001 // Internal EF Core API usage. + return entityType is EntityType { IsImplicitlyCreatedJoinEntityType: true }; +#pragma warning restore EF1001 // Internal EF Core API usage. } /// - /// Adds a JSON:API resource with int as the identifier type. + /// Adds a JSON:API resource with int as the identifier CLR type. /// /// - /// The resource model type. + /// The resource CLR type. /// /// - /// The name under which the resource is publicly exposed by the API. If nothing is specified, the configured naming convention formatter will be - /// applied. + /// 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 @@ -57,14 +90,14 @@ public ResourceGraphBuilder Add(string publicName = null) /// Adds a JSON:API resource. /// /// - /// The resource model type. + /// The resource CLR type. /// /// - /// The resource model identifier type. + /// The resource identifier CLR type. /// /// - /// The name under which the resource is publicly exposed by the API. If nothing is specified, the configured naming convention formatter will be - /// applied. + /// 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 @@ -75,60 +108,60 @@ public ResourceGraphBuilder Add(string publicName = null) /// /// Adds a JSON:API resource. /// - /// - /// The resource model type. + /// + /// The resource CLR type. /// - /// - /// The resource model identifier type. + /// + /// The resource identifier CLR type. /// /// - /// The name under which the resource is publicly exposed by the API. If nothing is specified, the configured naming convention formatter will be - /// applied. + /// 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 resourceType, Type idType = null, string publicName = null) + public ResourceGraphBuilder Add(Type resourceClrType, Type idClrType = null, string publicName = null) { - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + ArgumentGuard.NotNull(resourceClrType, nameof(resourceClrType)); - if (_resourceContexts.Any(resourceContext => resourceContext.ResourceType == resourceType)) + if (_resourceTypes.Any(resourceType => resourceType.ClrType == resourceClrType)) { return this; } - if (resourceType.IsOrImplementsInterface(typeof(IIdentifiable))) + if (resourceClrType.IsOrImplementsInterface(typeof(IIdentifiable))) { - string effectivePublicName = publicName ?? FormatResourceName(resourceType); - Type effectiveIdType = idType ?? _typeLocator.TryGetIdType(resourceType); + string effectivePublicName = publicName ?? FormatResourceName(resourceClrType); + Type effectiveIdType = idClrType ?? _typeLocator.TryGetIdType(resourceClrType) ?? typeof(int); - ResourceContext resourceContext = CreateResourceContext(effectivePublicName, resourceType, effectiveIdType); - _resourceContexts.Add(resourceContext); + ResourceType resourceType = CreateResourceType(effectivePublicName, resourceClrType, effectiveIdType); + _resourceTypes.Add(resourceType); } else { - _logger.LogWarning($"Entity '{resourceType}' does not implement '{nameof(IIdentifiable)}'."); + _logger.LogWarning($"Entity '{resourceClrType}' does not implement '{nameof(IIdentifiable)}'."); } return this; } - private ResourceContext CreateResourceContext(string publicName, Type resourceType, Type idType) + private ResourceType CreateResourceType(string publicName, Type resourceClrType, Type idClrType) { - IReadOnlyCollection attributes = GetAttributes(resourceType); - IReadOnlyCollection relationships = GetRelationships(resourceType); - IReadOnlyCollection eagerLoads = GetEagerLoads(resourceType); + IReadOnlyCollection attributes = GetAttributes(resourceClrType); + IReadOnlyCollection relationships = GetRelationships(resourceClrType); + IReadOnlyCollection eagerLoads = GetEagerLoads(resourceClrType); - var linksAttribute = (ResourceLinksAttribute)resourceType.GetCustomAttribute(typeof(ResourceLinksAttribute)); + var linksAttribute = (ResourceLinksAttribute)resourceClrType.GetCustomAttribute(typeof(ResourceLinksAttribute)); return linksAttribute == null - ? new ResourceContext(publicName, resourceType, idType, attributes, relationships, eagerLoads) - : new ResourceContext(publicName, resourceType, idType, attributes, relationships, eagerLoads, linksAttribute.TopLevelLinks, + ? 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 resourceType) + private IReadOnlyCollection GetAttributes(Type resourceClrType) { var attributes = new List(); - foreach (PropertyInfo property in resourceType.GetProperties()) + foreach (PropertyInfo property in resourceClrType.GetProperties()) { var attribute = (AttrAttribute)property.GetCustomAttribute(typeof(AttrAttribute)); @@ -167,27 +200,27 @@ private IReadOnlyCollection GetAttributes(Type resourceType) return attributes; } - private IReadOnlyCollection GetRelationships(Type resourceType) + private IReadOnlyCollection GetRelationships(Type resourceClrType) { - var attributes = new List(); - PropertyInfo[] properties = resourceType.GetProperties(); + var relationships = new List(); + PropertyInfo[] properties = resourceClrType.GetProperties(); foreach (PropertyInfo property in properties) { - var attribute = (RelationshipAttribute)property.GetCustomAttribute(typeof(RelationshipAttribute)); + var relationship = (RelationshipAttribute)property.GetCustomAttribute(typeof(RelationshipAttribute)); - if (attribute != null) + if (relationship != null) { - attribute.Property = property; - attribute.PublicName ??= FormatPropertyName(property); - attribute.LeftType = resourceType; - attribute.RightType = GetRelationshipType(attribute, property); + relationship.Property = property; + relationship.PublicName ??= FormatPropertyName(property); + relationship.LeftClrType = resourceClrType; + relationship.RightClrType = GetRelationshipType(relationship, property); - attributes.Add(attribute); + relationships.Add(relationship); } } - return attributes; + return relationships; } private Type GetRelationshipType(RelationshipAttribute relationship, PropertyInfo property) @@ -198,12 +231,12 @@ private Type GetRelationshipType(RelationshipAttribute relationship, PropertyInf return relationship is HasOneAttribute ? property.PropertyType : property.PropertyType.GetGenericArguments()[0]; } - private IReadOnlyCollection GetEagerLoads(Type resourceType, int recursionDepth = 0) + private IReadOnlyCollection GetEagerLoads(Type resourceClrType, int recursionDepth = 0) { AssertNoInfiniteRecursion(recursionDepth); var attributes = new List(); - PropertyInfo[] properties = resourceType.GetProperties(); + PropertyInfo[] properties = resourceClrType.GetProperties(); foreach (PropertyInfo property in properties) { @@ -241,10 +274,10 @@ private Type TypeOrElementType(Type type) return interfaces.Length == 1 ? interfaces.Single().GenericTypeArguments[0] : type; } - private string FormatResourceName(Type resourceType) + private string FormatResourceName(Type resourceClrType) { var formatter = new ResourceNameFormatter(_options.SerializerOptions.PropertyNamingPolicy); - return formatter.FormatResourceName(resourceType); + return formatter.FormatResourceName(resourceClrType); } private string FormatPropertyName(PropertyInfo resourceProperty) diff --git a/src/JsonApiDotNetCore/Configuration/ResourceNameFormatter.cs b/src/JsonApiDotNetCore/Configuration/ResourceNameFormatter.cs index 93e5dda4ff..d968c8eda7 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceNameFormatter.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceNameFormatter.cs @@ -16,16 +16,16 @@ public ResourceNameFormatter(JsonNamingPolicy namingPolicy) } /// - /// Gets the publicly visible resource name for the internal type name using the configured naming convention. + /// Gets the publicly exposed resource name by applying the configured naming convention on the pluralized CLR type name. /// - public string FormatResourceName(Type resourceType) + public string FormatResourceName(Type resourceClrType) { - if (resourceType.GetCustomAttribute(typeof(ResourceAttribute)) is ResourceAttribute attribute) + if (resourceClrType.GetCustomAttribute(typeof(ResourceAttribute)) is ResourceAttribute attribute) { return attribute.PublicName; } - string publicName = resourceType.Name.Pluralize(); + string publicName = resourceClrType.Name.Pluralize(); return _namingPolicy != null ? _namingPolicy.ConvertName(publicName) : publicName; } } diff --git a/src/JsonApiDotNetCore/Configuration/ResourceContext.cs b/src/JsonApiDotNetCore/Configuration/ResourceType.cs similarity index 86% rename from src/JsonApiDotNetCore/Configuration/ResourceContext.cs rename to src/JsonApiDotNetCore/Configuration/ResourceType.cs index f5b42e5499..75dddfbcc4 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceContext.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceType.cs @@ -10,7 +10,7 @@ namespace JsonApiDotNetCore.Configuration /// Metadata about the shape of a JSON:API resource in the resource graph. /// [PublicAPI] - public sealed class ResourceContext + public sealed class ResourceType { private readonly Dictionary _fieldsByPublicName = new(); private readonly Dictionary _fieldsByPropertyName = new(); @@ -23,12 +23,12 @@ public sealed class ResourceContext /// /// The CLR type of the resource. /// - public Type ResourceType { get; } + public Type ClrType { get; } /// - /// The identity type of the resource. + /// The CLR type of the resource identity. /// - public Type IdentityType { get; } + public Type IdentityClrType { get; } /// /// Exposed resource attributes and relationships. See https://jsonapi.org/format/#document-resource-object-fields. @@ -78,28 +78,25 @@ public sealed class ResourceContext /// public LinkTypes RelationshipLinks { get; } - public ResourceContext(string publicName, Type resourceType, Type identityType, IReadOnlyCollection attributes, - IReadOnlyCollection relationships, IReadOnlyCollection eagerLoads, + 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(resourceType, nameof(resourceType)); - ArgumentGuard.NotNull(identityType, nameof(identityType)); - ArgumentGuard.NotNull(attributes, nameof(attributes)); - ArgumentGuard.NotNull(relationships, nameof(relationships)); - ArgumentGuard.NotNull(eagerLoads, nameof(eagerLoads)); + ArgumentGuard.NotNull(clrType, nameof(clrType)); + ArgumentGuard.NotNull(identityClrType, nameof(identityClrType)); PublicName = publicName; - ResourceType = resourceType; - IdentityType = identityType; - Fields = attributes.Cast().Concat(relationships).ToArray(); - Attributes = attributes; - Relationships = relationships; - EagerLoads = eagerLoads; + 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) { @@ -126,7 +123,7 @@ public AttrAttribute GetAttributeByPropertyName(string propertyName) AttrAttribute attribute = TryGetAttributeByPropertyName(propertyName); return attribute ?? - throw new InvalidOperationException($"Attribute for property '{propertyName}' does not exist on resource type '{ResourceType.Name}'."); + throw new InvalidOperationException($"Attribute for property '{propertyName}' does not exist on resource type '{ClrType.Name}'."); } public AttrAttribute TryGetAttributeByPropertyName(string propertyName) @@ -156,7 +153,7 @@ public RelationshipAttribute GetRelationshipByPropertyName(string propertyName) RelationshipAttribute relationship = TryGetRelationshipByPropertyName(propertyName); return relationship ?? - throw new InvalidOperationException($"Relationship for property '{propertyName}' does not exist on resource type '{ResourceType.Name}'."); + throw new InvalidOperationException($"Relationship for property '{propertyName}' does not exist on resource type '{ClrType.Name}'."); } public RelationshipAttribute TryGetRelationshipByPropertyName(string propertyName) @@ -185,9 +182,9 @@ public override bool Equals(object obj) return false; } - var other = (ResourceContext)obj; + var other = (ResourceType)obj; - return PublicName == other.PublicName && ResourceType == other.ResourceType && IdentityType == other.IdentityType && + 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; } @@ -197,8 +194,8 @@ public override int GetHashCode() var hashCode = new HashCode(); hashCode.Add(PublicName); - hashCode.Add(ResourceType); - hashCode.Add(IdentityType); + hashCode.Add(ClrType); + hashCode.Add(IdentityClrType); foreach (AttrAttribute attribute in Attributes) { diff --git a/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs b/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs index 2c8c1fc5a2..9accf54219 100644 --- a/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs +++ b/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs @@ -107,15 +107,15 @@ private static void RegisterForConstructedType(IServiceCollection services, Type // e.g. IResourceService is the shorthand for IResourceService bool isShorthandInterface = openGenericInterface.GetTypeInfo().GenericTypeParameters.Length == 1; - if (isShorthandInterface && resourceDescriptor.IdType != typeof(int)) + if (isShorthandInterface && resourceDescriptor.IdClrType != typeof(int)) { // We can't create a shorthand for ID types other than int. continue; } Type constructedType = isShorthandInterface - ? openGenericInterface.MakeGenericType(resourceDescriptor.ResourceType) - : openGenericInterface.MakeGenericType(resourceDescriptor.ResourceType, resourceDescriptor.IdType); + ? openGenericInterface.MakeGenericType(resourceDescriptor.ResourceClrType) + : openGenericInterface.MakeGenericType(resourceDescriptor.ResourceClrType, resourceDescriptor.IdClrType); if (constructedType.IsAssignableFrom(implementationType)) { diff --git a/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs b/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs index 7a179e5784..3a8e7bd8d5 100644 --- a/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs +++ b/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs @@ -137,14 +137,14 @@ private void AddDbContextResolvers(Assembly assembly) foreach (Type dbContextType in dbContextTypes) { - Type resolverType = typeof(DbContextResolver<>).MakeGenericType(dbContextType); - _services.AddScoped(typeof(IDbContextResolver), resolverType); + Type dbContextResolverType = typeof(DbContextResolver<>).MakeGenericType(dbContextType); + _services.AddScoped(typeof(IDbContextResolver), dbContextResolverType); } } private void AddResource(ResourceDescriptor resourceDescriptor) { - _resourceGraphBuilder.Add(resourceDescriptor.ResourceType, resourceDescriptor.IdType); + _resourceGraphBuilder.Add(resourceDescriptor.ResourceClrType, resourceDescriptor.IdClrType); } private void AddServices(Assembly assembly, ResourceDescriptor resourceDescriptor) @@ -174,8 +174,8 @@ private void AddResourceDefinitions(Assembly assembly, ResourceDescriptor resour private void RegisterImplementations(Assembly assembly, Type interfaceType, ResourceDescriptor resourceDescriptor) { Type[] genericArguments = interfaceType.GetTypeInfo().GenericTypeParameters.Length == 2 - ? ArrayFactory.Create(resourceDescriptor.ResourceType, resourceDescriptor.IdType) - : ArrayFactory.Create(resourceDescriptor.ResourceType); + ? ArrayFactory.Create(resourceDescriptor.ResourceClrType, resourceDescriptor.IdClrType) + : ArrayFactory.Create(resourceDescriptor.ResourceClrType); (Type implementation, Type registrationInterface)? result = _typeLocator.GetGenericInterfaceImplementation(assembly, interfaceType, genericArguments); diff --git a/src/JsonApiDotNetCore/Configuration/TypeLocator.cs b/src/JsonApiDotNetCore/Configuration/TypeLocator.cs index 7f6d266aa4..7f0e85371e 100644 --- a/src/JsonApiDotNetCore/Configuration/TypeLocator.cs +++ b/src/JsonApiDotNetCore/Configuration/TypeLocator.cs @@ -14,9 +14,9 @@ internal sealed class TypeLocator /// /// Attempts to lookup the ID type of the specified resource type. Returns null if it does not implement . /// - public Type TryGetIdType(Type resourceType) + public Type TryGetIdType(Type resourceClrType) { - Type identifiableInterface = resourceType.GetInterfaces().FirstOrDefault(@interface => + Type identifiableInterface = resourceClrType.GetInterfaces().FirstOrDefault(@interface => @interface.IsGenericType && @interface.GetGenericTypeDefinition() == typeof(IIdentifiable<>)); return identifiableInterface?.GetGenericArguments()[0]; diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index 98a4c58afe..90a8f3078f 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs @@ -182,11 +182,6 @@ public virtual async Task PostAsync([FromBody] TResource resource throw new RequestMethodNotAllowedException(HttpMethod.Post); } - if (!_options.AllowClientGeneratedIds && resource.StringId != null) - { - throw new ResourceIdInCreateResourceNotAllowedException(); - } - if (_options.ValidateModelState && !ModelState.IsValid) { throw new InvalidModelStateException(ModelState, typeof(TResource), _options.IncludeExceptionStackTraceInErrors, diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs index 0ed536eb15..2960b23447 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs @@ -113,8 +113,6 @@ public virtual async Task PostOperationsAsync([FromBody] IList PostOperationsAsync([FromBody] IList result != null) ? Ok(results) : NoContent(); } - protected virtual void ValidateClientGeneratedIds(IEnumerable operations) - { - if (!_options.AllowClientGeneratedIds) - { - int index = 0; - - foreach (OperationContainer operation in operations) - { - if (operation.Kind == WriteOperationKind.CreateResource && operation.Resource.StringId != null) - { - throw new ResourceIdInCreateResourceNotAllowedException(index); - } - - index++; - } - } - } - protected virtual void ValidateModelState(IEnumerable operations) { // We must validate the resource inside each operation manually, because they are typed as IIdentifiable. @@ -150,14 +130,13 @@ protected virtual void ValidateModelState(IEnumerable operat var violations = new List(); int index = 0; + using IDisposable _ = new RevertRequestStateOnDispose(_request, _targetedFields); foreach (OperationContainer operation in operations) { - if (operation.Kind == WriteOperationKind.CreateResource || operation.Kind == WriteOperationKind.UpdateResource) + if (operation.Request.WriteOperation is WriteOperationKind.CreateResource or WriteOperationKind.UpdateResource) { - _targetedFields.Attributes = operation.TargetedFields.Attributes; - _targetedFields.Relationships = operation.TargetedFields.Relationships; - + _targetedFields.CopyFrom(operation.TargetedFields); _request.CopyFrom(operation.Request); var validationContext = new ActionContext(); @@ -178,21 +157,21 @@ protected virtual void ValidateModelState(IEnumerable operat } } - private static void AddValidationErrors(ModelStateDictionary modelState, Type resourceType, int operationIndex, List violations) + private static void AddValidationErrors(ModelStateDictionary modelState, Type resourceClrType, int operationIndex, List violations) { foreach ((string propertyName, ModelStateEntry entry) in modelState) { - AddValidationErrors(entry, propertyName, resourceType, operationIndex, violations); + AddValidationErrors(entry, propertyName, resourceClrType, operationIndex, violations); } } - private static void AddValidationErrors(ModelStateEntry entry, string propertyName, Type resourceType, int operationIndex, + private static void AddValidationErrors(ModelStateEntry entry, string propertyName, Type resourceClrType, int operationIndex, List violations) { foreach (ModelError error in entry.Errors) { string prefix = $"/atomic:operations[{operationIndex}]/data/attributes/"; - var violation = new ModelStateViolation(prefix, propertyName, resourceType, error); + var violation = new ModelStateViolation(prefix, propertyName, resourceClrType, error); violations.Add(violation); } diff --git a/src/JsonApiDotNetCore/Controllers/CoreJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/CoreJsonApiController.cs index 88d9614cc7..0f7ae00488 100644 --- a/src/JsonApiDotNetCore/Controllers/CoreJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/CoreJsonApiController.cs @@ -14,21 +14,21 @@ protected IActionResult Error(ErrorObject error) { ArgumentGuard.NotNull(error, nameof(error)); - return Error(error.AsEnumerable()); + return new ObjectResult(error) + { + StatusCode = (int)error.StatusCode + }; } protected IActionResult Error(IEnumerable errors) { ArgumentGuard.NotNull(errors, nameof(errors)); - var document = new Document - { - Errors = errors.ToList() - }; + ErrorObject[] errorArray = errors.ToArray(); - return new ObjectResult(document) + return new ObjectResult(errorArray) { - StatusCode = (int)document.GetErrorStatusCode() + StatusCode = (int)ErrorObject.GetResponseStatusCode(errorArray) }; } } diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiQueryController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiQueryController.cs index 7ab85612c8..65d69c9d30 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiQueryController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiQueryController.cs @@ -25,8 +25,8 @@ public abstract class JsonApiQueryController : BaseJsonApiContro /// /// Creates an instance from a read-only service. /// - protected JsonApiQueryController(IJsonApiOptions context, ILoggerFactory loggerFactory, IResourceQueryService queryService) - : base(context, loggerFactory, queryService) + protected JsonApiQueryController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceQueryService queryService) + : base(options, loggerFactory, queryService) { } diff --git a/src/JsonApiDotNetCore/Controllers/ModelStateViolation.cs b/src/JsonApiDotNetCore/Controllers/ModelStateViolation.cs index 2a4c8cfb84..49a935a7ef 100644 --- a/src/JsonApiDotNetCore/Controllers/ModelStateViolation.cs +++ b/src/JsonApiDotNetCore/Controllers/ModelStateViolation.cs @@ -12,19 +12,19 @@ public sealed class ModelStateViolation { public string Prefix { get; } public string PropertyName { get; } - public Type ResourceType { get; set; } + public Type ResourceClrType { get; set; } public ModelError Error { get; } - public ModelStateViolation(string prefix, string propertyName, Type resourceType, ModelError error) + public ModelStateViolation(string prefix, string propertyName, Type resourceClrType, ModelError error) { ArgumentGuard.NotNullNorEmpty(prefix, nameof(prefix)); ArgumentGuard.NotNullNorEmpty(propertyName, nameof(propertyName)); - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + ArgumentGuard.NotNull(resourceClrType, nameof(resourceClrType)); ArgumentGuard.NotNull(error, nameof(error)); Prefix = prefix; PropertyName = propertyName; - ResourceType = resourceType; + ResourceClrType = resourceClrType; Error = error; } } diff --git a/src/JsonApiDotNetCore/Diagnostics/CodeTimingSessionManager.cs b/src/JsonApiDotNetCore/Diagnostics/CodeTimingSessionManager.cs index 9160791f87..47e64d8338 100644 --- a/src/JsonApiDotNetCore/Diagnostics/CodeTimingSessionManager.cs +++ b/src/JsonApiDotNetCore/Diagnostics/CodeTimingSessionManager.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using System.Reflection; #pragma warning disable AV1008 // Class should not be static @@ -32,7 +33,7 @@ public static ICodeTimer Current static CodeTimingSessionManager() { #if DEBUG - IsEnabled = !IsRunningInTest(); + IsEnabled = !IsRunningInTest() && !IsRunningInBenchmark(); #else IsEnabled = false; #endif @@ -47,6 +48,12 @@ private static bool IsRunningInTest() assembly.FullName != null && assembly.FullName.StartsWith(testAssemblyName, StringComparison.Ordinal)); } + // ReSharper disable once UnusedMember.Local + private static bool IsRunningInBenchmark() + { + return Assembly.GetEntryAssembly()?.GetName().Name == "Benchmarks"; + } + private static void AssertHasActiveSession() { if (_session == null) diff --git a/src/JsonApiDotNetCore/Errors/CannotClearRequiredRelationshipException.cs b/src/JsonApiDotNetCore/Errors/CannotClearRequiredRelationshipException.cs index 782cf1f2ea..4a7c6b5e66 100644 --- a/src/JsonApiDotNetCore/Errors/CannotClearRequiredRelationshipException.cs +++ b/src/JsonApiDotNetCore/Errors/CannotClearRequiredRelationshipException.cs @@ -14,7 +14,7 @@ public CannotClearRequiredRelationshipException(string relationshipName, string : base(new ErrorObject(HttpStatusCode.BadRequest) { Title = "Failed to clear a required relationship.", - Detail = $"The relationship '{relationshipName}' of resource type '{resourceType}' " + + 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 new file mode 100644 index 0000000000..ebf9a2fadd --- /dev/null +++ b/src/JsonApiDotNetCore/Errors/DuplicateLocalIdValueException.cs @@ -0,0 +1,22 @@ +using System.Net; +using JetBrains.Annotations; +using JsonApiDotNetCore.Serialization.Objects; + +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 + { + 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 new file mode 100644 index 0000000000..4ae21ef469 --- /dev/null +++ b/src/JsonApiDotNetCore/Errors/FailedOperationException.cs @@ -0,0 +1,27 @@ +using System; +using System.Net; +using JetBrains.Annotations; +using JsonApiDotNetCore.Serialization.Objects; + +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 + { + 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 new file mode 100644 index 0000000000..9b2e46357c --- /dev/null +++ b/src/JsonApiDotNetCore/Errors/IncompatibleLocalIdTypeException.cs @@ -0,0 +1,22 @@ +using System.Net; +using JetBrains.Annotations; +using JsonApiDotNetCore.Serialization.Objects; + +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 + { + 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/InvalidModelStateException.cs b/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs index 4ca8586b17..44066d430e 100644 --- a/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs +++ b/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs @@ -19,9 +19,9 @@ namespace JsonApiDotNetCore.Errors [PublicAPI] public sealed class InvalidModelStateException : JsonApiException { - public InvalidModelStateException(ModelStateDictionary modelState, Type resourceType, bool includeExceptionStackTraceInErrors, + public InvalidModelStateException(ModelStateDictionary modelState, Type resourceClrType, bool includeExceptionStackTraceInErrors, JsonNamingPolicy namingPolicy) - : this(FromModelStateDictionary(modelState, resourceType), includeExceptionStackTraceInErrors, namingPolicy) + : this(FromModelStateDictionary(modelState, resourceClrType), includeExceptionStackTraceInErrors, namingPolicy) { } @@ -30,26 +30,26 @@ public InvalidModelStateException(IEnumerable violations, b { } - private static IEnumerable FromModelStateDictionary(ModelStateDictionary modelState, Type resourceType) + private static IEnumerable FromModelStateDictionary(ModelStateDictionary modelState, Type resourceClrType) { ArgumentGuard.NotNull(modelState, nameof(modelState)); - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + ArgumentGuard.NotNull(resourceClrType, nameof(resourceClrType)); var violations = new List(); foreach ((string propertyName, ModelStateEntry entry) in modelState) { - AddValidationErrors(entry, propertyName, resourceType, violations); + AddValidationErrors(entry, propertyName, resourceClrType, violations); } return violations; } - private static void AddValidationErrors(ModelStateEntry entry, string propertyName, Type resourceType, List violations) + private static void AddValidationErrors(ModelStateEntry entry, string propertyName, Type resourceClrType, List violations) { foreach (ModelError error in entry.Errors) { - var violation = new ModelStateViolation("/data/attributes/", propertyName, resourceType, error); + var violation = new ModelStateViolation("/data/attributes/", propertyName, resourceClrType, error); violations.Add(violation); } } @@ -74,16 +74,16 @@ private static IEnumerable FromModelStateViolation(ModelStateViolat } else { - string attributeName = GetDisplayNameForProperty(violation.PropertyName, violation.ResourceType, namingPolicy); + string attributeName = GetDisplayNameForProperty(violation.PropertyName, violation.ResourceClrType, namingPolicy); string attributePath = $"{violation.Prefix}{attributeName}"; yield return FromModelError(violation.Error, attributePath, includeExceptionStackTraceInErrors); } } - private static string GetDisplayNameForProperty(string propertyName, Type resourceType, JsonNamingPolicy namingPolicy) + private static string GetDisplayNameForProperty(string propertyName, Type resourceClrType, JsonNamingPolicy namingPolicy) { - PropertyInfo property = resourceType.GetProperty(propertyName); + PropertyInfo property = resourceClrType.GetProperty(propertyName); if (property != null) { @@ -116,10 +116,14 @@ private static ErrorObject FromModelError(ModelError modelError, string attribut if (includeExceptionStackTraceInErrors && modelError.Exception != null) { - string[] stackTraceLines = modelError.Exception.Demystify().ToString().Split(Environment.NewLine); + Exception exception = modelError.Exception.Demystify(); + string[] stackTraceLines = exception.ToString().Split(Environment.NewLine); - error.Meta ??= new Dictionary(); - error.Meta["StackTrace"] = stackTraceLines; + if (stackTraceLines.Any()) + { + error.Meta ??= new Dictionary(); + error.Meta["StackTrace"] = stackTraceLines; + } } return error; diff --git a/src/JsonApiDotNetCore/Errors/InvalidRequestBodyException.cs b/src/JsonApiDotNetCore/Errors/InvalidRequestBodyException.cs index c435c66b2d..a508a82ad2 100644 --- a/src/JsonApiDotNetCore/Errors/InvalidRequestBodyException.cs +++ b/src/JsonApiDotNetCore/Errors/InvalidRequestBodyException.cs @@ -1,6 +1,6 @@ using System; +using System.Collections.Generic; using System.Net; -using System.Text; using JetBrains.Annotations; using JsonApiDotNetCore.Serialization.Objects; @@ -12,33 +12,26 @@ namespace JsonApiDotNetCore.Errors [PublicAPI] public sealed class InvalidRequestBodyException : JsonApiException { - public InvalidRequestBodyException(string reason, string details, string requestBody, Exception innerException = null) - : base(new ErrorObject(HttpStatusCode.UnprocessableEntity) + public InvalidRequestBodyException(string requestBody, string genericMessage, string specificMessage, string sourcePointer, + HttpStatusCode? alternativeStatusCode = null, Exception innerException = null) + : base(new ErrorObject(alternativeStatusCode ?? HttpStatusCode.UnprocessableEntity) { - Title = reason != null ? $"Failed to deserialize request body: {reason}" : "Failed to deserialize request body.", - Detail = FormatErrorDetail(details, requestBody, innerException) + 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) { } - - private static string FormatErrorDetail(string details, string requestBody, Exception innerException) - { - var builder = new StringBuilder(); - builder.Append(details ?? innerException?.Message); - - if (requestBody != null) - { - if (builder.Length > 0) - { - builder.Append(" - "); - } - - builder.Append("Request body: <<"); - builder.Append(requestBody); - builder.Append(">>"); - } - - return builder.Length > 0 ? builder.ToString() : null; - } } } diff --git a/src/JsonApiDotNetCore/Errors/JsonApiException.cs b/src/JsonApiDotNetCore/Errors/JsonApiException.cs index 13d3b6a745..ae2cbcdca0 100644 --- a/src/JsonApiDotNetCore/Errors/JsonApiException.cs +++ b/src/JsonApiDotNetCore/Errors/JsonApiException.cs @@ -24,8 +24,6 @@ public class JsonApiException : Exception public IReadOnlyList Errors { get; } - public override string Message => $"Errors = {JsonSerializer.Serialize(Errors, SerializerOptions)}"; - public JsonApiException(ErrorObject error, Exception innerException = null) : base(null, innerException) { @@ -42,5 +40,10 @@ public JsonApiException(IEnumerable errors, Exception innerExceptio Errors = errorList; } + + public string GetSummary() + { + return $"{nameof(JsonApiException)}: Errors = {JsonSerializer.Serialize(Errors, SerializerOptions)}"; + } } } diff --git a/src/JsonApiDotNetCore/Errors/LocalIdSingleOperationException.cs b/src/JsonApiDotNetCore/Errors/LocalIdSingleOperationException.cs new file mode 100644 index 0000000000..8dfa1bd842 --- /dev/null +++ b/src/JsonApiDotNetCore/Errors/LocalIdSingleOperationException.cs @@ -0,0 +1,22 @@ +using System.Net; +using JetBrains.Annotations; +using JsonApiDotNetCore.Serialization.Objects; + +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 + { + 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/ResourceIdInCreateResourceNotAllowedException.cs b/src/JsonApiDotNetCore/Errors/ResourceIdInCreateResourceNotAllowedException.cs deleted file mode 100644 index 11a96cc436..0000000000 --- a/src/JsonApiDotNetCore/Errors/ResourceIdInCreateResourceNotAllowedException.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System.Net; -using JetBrains.Annotations; -using JsonApiDotNetCore.Serialization.Objects; - -namespace JsonApiDotNetCore.Errors -{ - /// - /// The error that is thrown when a resource creation request or operation is received that contains a client-generated ID. - /// - [PublicAPI] - public sealed class ResourceIdInCreateResourceNotAllowedException : JsonApiException - { - public ResourceIdInCreateResourceNotAllowedException(int? atomicOperationIndex = null) - : base(new ErrorObject(HttpStatusCode.Forbidden) - { - Title = atomicOperationIndex == null - ? "Specifying the resource ID in POST requests is not allowed." - : "Specifying the resource ID in operations that create a resource is not allowed.", - Source = new ErrorSource - { - Pointer = atomicOperationIndex != null ? $"/atomic:operations[{atomicOperationIndex}]/data/id" : "/data/id" - } - }) - { - } - } -} diff --git a/src/JsonApiDotNetCore/Errors/ResourceIdMismatchException.cs b/src/JsonApiDotNetCore/Errors/ResourceIdMismatchException.cs deleted file mode 100644 index fdfd6b6fe9..0000000000 --- a/src/JsonApiDotNetCore/Errors/ResourceIdMismatchException.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Net; -using JetBrains.Annotations; -using JsonApiDotNetCore.Serialization.Objects; - -namespace JsonApiDotNetCore.Errors -{ - /// - /// The error that is thrown when the resource ID in the request body does not match the ID in the current endpoint URL. - /// - [PublicAPI] - public sealed class ResourceIdMismatchException : JsonApiException - { - public ResourceIdMismatchException(string bodyId, string endpointId, string requestPath) - : base(new ErrorObject(HttpStatusCode.Conflict) - { - Title = "Resource ID mismatch between request body and endpoint URL.", - Detail = $"Expected resource ID '{endpointId}' in PATCH request body at endpoint '{requestPath}', instead of '{bodyId}'." - }) - { - } - } -} diff --git a/src/JsonApiDotNetCore/Errors/ResourceTypeMismatchException.cs b/src/JsonApiDotNetCore/Errors/ResourceTypeMismatchException.cs deleted file mode 100644 index 9957694d0d..0000000000 --- a/src/JsonApiDotNetCore/Errors/ResourceTypeMismatchException.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.Net; -using System.Net.Http; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Serialization.Objects; - -namespace JsonApiDotNetCore.Errors -{ - /// - /// The error that is thrown when the resource type in the request body does not match the type expected at the current endpoint URL. - /// - [PublicAPI] - public sealed class ResourceTypeMismatchException : JsonApiException - { - public ResourceTypeMismatchException(HttpMethod method, string requestPath, ResourceContext expected, ResourceContext actual) - : base(new ErrorObject(HttpStatusCode.Conflict) - { - Title = "Resource type mismatch between request body and endpoint URL.", - Detail = $"Expected resource of type '{expected.PublicName}' in {method} request body at endpoint " + - $"'{requestPath}', instead of '{actual.PublicName}'." - }) - { - } - } -} diff --git a/src/JsonApiDotNetCore/Errors/ToManyRelationshipRequiredException.cs b/src/JsonApiDotNetCore/Errors/ToManyRelationshipRequiredException.cs deleted file mode 100644 index c5b100904f..0000000000 --- a/src/JsonApiDotNetCore/Errors/ToManyRelationshipRequiredException.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Net; -using JetBrains.Annotations; -using JsonApiDotNetCore.Serialization.Objects; - -namespace JsonApiDotNetCore.Errors -{ - /// - /// The error that is thrown when an attempt is made to update a to-one relationship from a to-many relationship endpoint. - /// - [PublicAPI] - public sealed class ToManyRelationshipRequiredException : JsonApiException - { - public ToManyRelationshipRequiredException(string relationshipName) - : base(new ErrorObject(HttpStatusCode.Forbidden) - { - Title = "Only to-many relationships can be updated through this endpoint.", - Detail = $"Relationship '{relationshipName}' must be a to-many relationship." - }) - { - } - } -} diff --git a/src/JsonApiDotNetCore/Errors/UnknownLocalIdValueException.cs b/src/JsonApiDotNetCore/Errors/UnknownLocalIdValueException.cs new file mode 100644 index 0000000000..418acac662 --- /dev/null +++ b/src/JsonApiDotNetCore/Errors/UnknownLocalIdValueException.cs @@ -0,0 +1,22 @@ +using System.Net; +using JetBrains.Annotations; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Errors +{ + /// + /// 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." + }) + { + } + } +} diff --git a/src/JsonApiDotNetCore/Middleware/AsyncJsonApiExceptionFilter.cs b/src/JsonApiDotNetCore/Middleware/AsyncJsonApiExceptionFilter.cs index d7d6349b68..d82cbddeed 100644 --- a/src/JsonApiDotNetCore/Middleware/AsyncJsonApiExceptionFilter.cs +++ b/src/JsonApiDotNetCore/Middleware/AsyncJsonApiExceptionFilter.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Threading.Tasks; using JetBrains.Annotations; using JsonApiDotNetCore.Serialization.Objects; @@ -26,11 +27,11 @@ public Task OnExceptionAsync(ExceptionContext context) if (context.HttpContext.IsJsonApiRequest()) { - Document document = _exceptionHandler.HandleException(context.Exception); + IReadOnlyList errors = _exceptionHandler.HandleException(context.Exception); - context.Result = new ObjectResult(document) + context.Result = new ObjectResult(errors) { - StatusCode = (int)document.GetErrorStatusCode() + StatusCode = (int)ErrorObject.GetResponseStatusCode(errors) }; } diff --git a/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs b/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs index 5dc6bf6e5d..3d7630b9bf 100644 --- a/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs +++ b/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs @@ -27,7 +27,7 @@ public ExceptionHandler(ILoggerFactory loggerFactory, IJsonApiOptions options) _logger = loggerFactory.CreateLogger(); } - public Document HandleException(Exception exception) + public IReadOnlyList HandleException(Exception exception) { ArgumentGuard.NotNull(exception, nameof(exception)); @@ -35,7 +35,7 @@ public Document HandleException(Exception exception) LogException(demystified); - return CreateErrorDocument(demystified); + return CreateErrorResponse(demystified); } private void LogException(Exception exception) @@ -55,7 +55,7 @@ protected virtual LogLevel GetLogLevel(Exception exception) return LogLevel.None; } - if (exception is JsonApiException) + if (exception is JsonApiException and not FailedOperationException) { return LogLevel.Information; } @@ -67,10 +67,10 @@ protected virtual string GetLogMessage(Exception exception) { ArgumentGuard.NotNull(exception, nameof(exception)); - return exception.Message; + return exception is JsonApiException jsonApiException ? jsonApiException.GetSummary() : exception.Message; } - protected virtual Document CreateErrorDocument(Exception exception) + protected virtual IReadOnlyList CreateErrorResponse(Exception exception) { ArgumentGuard.NotNull(exception, nameof(exception)); @@ -84,27 +84,25 @@ protected virtual Document CreateErrorDocument(Exception exception) Detail = exception.Message }.AsArray(); - foreach (ErrorObject error in errors) + if (_options.IncludeExceptionStackTraceInErrors && exception is not InvalidModelStateException) { - ApplyOptions(error, exception); + IncludeStackTraces(exception, errors); } - return new Document - { - Errors = errors.ToList() - }; + return errors; } - private void ApplyOptions(ErrorObject error, Exception exception) + private void IncludeStackTraces(Exception exception, IReadOnlyList errors) { - Exception resultException = exception is InvalidModelStateException ? null : exception; + string[] stackTraceLines = exception.ToString().Split(Environment.NewLine); - if (resultException != null && _options.IncludeExceptionStackTraceInErrors) + if (stackTraceLines.Any()) { - string[] stackTraceLines = resultException.ToString().Split(Environment.NewLine); - - error.Meta ??= new Dictionary(); - error.Meta["StackTrace"] = stackTraceLines; + foreach (ErrorObject error in errors) + { + error.Meta ??= new Dictionary(); + error.Meta["StackTrace"] = stackTraceLines; + } } } } diff --git a/src/JsonApiDotNetCore/Middleware/IControllerResourceMapping.cs b/src/JsonApiDotNetCore/Middleware/IControllerResourceMapping.cs index 4290b3b771..2ff155d324 100644 --- a/src/JsonApiDotNetCore/Middleware/IControllerResourceMapping.cs +++ b/src/JsonApiDotNetCore/Middleware/IControllerResourceMapping.cs @@ -1,4 +1,5 @@ using System; +using JsonApiDotNetCore.Configuration; namespace JsonApiDotNetCore.Middleware { @@ -10,11 +11,11 @@ public interface IControllerResourceMapping /// /// Gets the associated resource type for the provided controller type. /// - Type GetResourceTypeForController(Type controllerType); + ResourceType TryGetResourceTypeForController(Type controllerType); /// /// Gets the associated controller name for the provided resource type. /// - string GetControllerNameForResourceType(Type resourceType); + string TryGetControllerNameForResourceType(ResourceType resourceType); } } diff --git a/src/JsonApiDotNetCore/Middleware/IExceptionHandler.cs b/src/JsonApiDotNetCore/Middleware/IExceptionHandler.cs index 9f44e33a96..a962d8cfdd 100644 --- a/src/JsonApiDotNetCore/Middleware/IExceptionHandler.cs +++ b/src/JsonApiDotNetCore/Middleware/IExceptionHandler.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using JsonApiDotNetCore.Serialization.Objects; namespace JsonApiDotNetCore.Middleware @@ -8,6 +9,6 @@ namespace JsonApiDotNetCore.Middleware /// public interface IExceptionHandler { - Document HandleException(Exception exception); + IReadOnlyList HandleException(Exception exception); } } diff --git a/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs b/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs index 888c01544a..f3f7dfcf81 100644 --- a/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs +++ b/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs @@ -19,15 +19,15 @@ public interface IJsonApiRequest string PrimaryId { get; } /// - /// The primary (top-level) resource for this request. This would be "blogs" in "/blogs", "/blogs/123" or "/blogs/123/author". + /// The primary (top-level) resource type for this request. This would be "blogs" in "/blogs", "/blogs/123" or "/blogs/123/author". /// - ResourceContext PrimaryResource { get; } + ResourceType PrimaryResourceType { get; } /// - /// The secondary (nested) resource for this request. This would be null in "/blogs", "/blogs/123" and "/blogs/123/unknownResource" or "people" in + /// The secondary (nested) 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". /// - ResourceContext SecondaryResource { get; } + ResourceType SecondaryResourceType { get; } /// /// The relationship for this nested request. This would be null in "/blogs", "/blogs/123" and "/blogs/123/unknownResource" or "author" in diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiInputFormatter.cs b/src/JsonApiDotNetCore/Middleware/JsonApiInputFormatter.cs index fc5a1e2230..d22904bebc 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiInputFormatter.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiInputFormatter.cs @@ -1,5 +1,5 @@ using System.Threading.Tasks; -using JsonApiDotNetCore.Serialization; +using JsonApiDotNetCore.Serialization.Request; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.Extensions.DependencyInjection; @@ -22,7 +22,10 @@ public async Task ReadAsync(InputFormatterContext context) ArgumentGuard.NotNull(context, nameof(context)); var reader = context.HttpContext.RequestServices.GetRequiredService(); - return await reader.ReadAsync(context); + + object model = await reader.ReadAsync(context.HttpContext.Request); + + 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 30ea5d9108..a41e172d18 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs @@ -39,13 +39,12 @@ public JsonApiMiddleware(RequestDelegate next, IHttpContextAccessor httpContextA } public async Task InvokeAsync(HttpContext httpContext, IControllerResourceMapping controllerResourceMapping, IJsonApiOptions options, - IJsonApiRequest request, IResourceGraph resourceGraph, ILogger logger) + 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(resourceGraph, nameof(resourceGraph)); ArgumentGuard.NotNull(logger, nameof(logger)); using (CodeTimingSessionManager.Current.Measure("JSON:API middleware")) @@ -56,9 +55,9 @@ public async Task InvokeAsync(HttpContext httpContext, IControllerResourceMappin } RouteValueDictionary routeValues = httpContext.GetRouteData().Values; - ResourceContext primaryResourceContext = TryCreatePrimaryResourceContext(httpContext, controllerResourceMapping, resourceGraph); + ResourceType primaryResourceType = TryCreatePrimaryResourceType(httpContext, controllerResourceMapping); - if (primaryResourceContext != null) + if (primaryResourceType != null) { if (!await ValidateContentTypeHeaderAsync(HeaderConstants.MediaType, httpContext, options.SerializerWriteOptions) || !await ValidateAcceptHeaderAsync(MediaType, httpContext, options.SerializerWriteOptions)) @@ -66,7 +65,7 @@ public async Task InvokeAsync(HttpContext httpContext, IControllerResourceMappin return; } - SetupResourceRequest((JsonApiRequest)request, primaryResourceContext, routeValues, resourceGraph, httpContext.Request); + SetupResourceRequest((JsonApiRequest)request, primaryResourceType, routeValues, httpContext.Request); httpContext.RegisterJsonApiRequest(); } @@ -119,24 +118,14 @@ private async Task ValidateIfMatchHeaderAsync(HttpContext httpContext, Jso return true; } - private static ResourceContext TryCreatePrimaryResourceContext(HttpContext httpContext, IControllerResourceMapping controllerResourceMapping, - IResourceGraph resourceGraph) + private static ResourceType TryCreatePrimaryResourceType(HttpContext httpContext, IControllerResourceMapping controllerResourceMapping) { Endpoint endpoint = httpContext.GetEndpoint(); var controllerActionDescriptor = endpoint?.Metadata.GetMetadata(); - if (controllerActionDescriptor != null) - { - Type controllerType = controllerActionDescriptor.ControllerTypeInfo; - Type resourceType = controllerResourceMapping.GetResourceTypeForController(controllerType); - - if (resourceType != null) - { - return resourceGraph.GetResourceContext(resourceType); - } - } - - return null; + return controllerActionDescriptor != null + ? controllerResourceMapping.TryGetResourceTypeForController(controllerActionDescriptor.ControllerTypeInfo) + : null; } private static async Task ValidateContentTypeHeaderAsync(string allowedContentType, HttpContext httpContext, @@ -228,11 +217,11 @@ private static async Task FlushResponseAsync(HttpResponse httpResponse, JsonSeri await httpResponse.Body.FlushAsync(); } - private static void SetupResourceRequest(JsonApiRequest request, ResourceContext primaryResourceContext, RouteValueDictionary routeValues, - IResourceGraph resourceGraph, HttpRequest httpRequest) + 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.PrimaryResource = primaryResourceContext; + request.PrimaryResourceType = primaryResourceType; request.PrimaryId = GetPrimaryRequestId(routeValues); string relationshipName = GetRelationshipNameForSecondaryRequest(routeValues); @@ -252,12 +241,12 @@ private static void SetupResourceRequest(JsonApiRequest request, ResourceContext // @formatter:keep_existing_linebreaks restore // @formatter:wrap_chained_method_calls restore - RelationshipAttribute requestRelationship = primaryResourceContext.TryGetRelationshipByPublicName(relationshipName); + RelationshipAttribute requestRelationship = primaryResourceType.TryGetRelationshipByPublicName(relationshipName); if (requestRelationship != null) { request.Relationship = requestRelationship; - request.SecondaryResource = resourceGraph.GetResourceContext(requestRelationship.RightType); + request.SecondaryResourceType = requestRelationship.RightType; } } else diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiOutputFormatter.cs b/src/JsonApiDotNetCore/Middleware/JsonApiOutputFormatter.cs index bd66f66067..93d531dc58 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiOutputFormatter.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiOutputFormatter.cs @@ -1,5 +1,5 @@ using System.Threading.Tasks; -using JsonApiDotNetCore.Serialization; +using JsonApiDotNetCore.Serialization.Response; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.Extensions.DependencyInjection; @@ -22,7 +22,7 @@ public async Task WriteAsync(OutputFormatterWriteContext context) ArgumentGuard.NotNull(context, nameof(context)); var writer = context.HttpContext.RequestServices.GetRequiredService(); - await writer.WriteAsync(context); + await writer.WriteAsync(context.Object, context.HttpContext); } } } diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs b/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs index 89bd6fa722..0c61b7ff38 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs @@ -15,10 +15,10 @@ public sealed class JsonApiRequest : IJsonApiRequest public string PrimaryId { get; set; } /// - public ResourceContext PrimaryResource { get; set; } + public ResourceType PrimaryResourceType { get; set; } /// - public ResourceContext SecondaryResource { get; set; } + public ResourceType SecondaryResourceType { get; set; } /// public RelationshipAttribute Relationship { get; set; } @@ -42,8 +42,8 @@ public void CopyFrom(IJsonApiRequest other) Kind = other.Kind; PrimaryId = other.PrimaryId; - PrimaryResource = other.PrimaryResource; - SecondaryResource = other.SecondaryResource; + PrimaryResourceType = other.PrimaryResourceType; + SecondaryResourceType = other.SecondaryResourceType; Relationship = other.Relationship; IsCollection = other.IsCollection; IsReadOnly = other.IsReadOnly; diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs b/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs index af38f89dad..fee1edad0f 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs @@ -34,8 +34,8 @@ public sealed class JsonApiRoutingConvention : IJsonApiRoutingConvention private readonly IJsonApiOptions _options; private readonly IResourceGraph _resourceGraph; private readonly Dictionary _registeredControllerNameByTemplate = new(); - private readonly Dictionary _resourceContextPerControllerTypeMap = new(); - private readonly Dictionary _controllerPerResourceContextMap = new(); + private readonly Dictionary _resourceTypePerControllerTypeMap = new(); + private readonly Dictionary _controllerPerResourceTypeMap = new(); public JsonApiRoutingConvention(IJsonApiOptions options, IResourceGraph resourceGraph) { @@ -47,32 +47,19 @@ public JsonApiRoutingConvention(IJsonApiOptions options, IResourceGraph resource } /// - public Type GetResourceTypeForController(Type controllerType) + public ResourceType TryGetResourceTypeForController(Type controllerType) { ArgumentGuard.NotNull(controllerType, nameof(controllerType)); - if (_resourceContextPerControllerTypeMap.TryGetValue(controllerType, out ResourceContext resourceContext)) - { - return resourceContext.ResourceType; - } - - return null; + return _resourceTypePerControllerTypeMap.TryGetValue(controllerType, out ResourceType resourceType) ? resourceType : null; } /// - public string GetControllerNameForResourceType(Type resourceType) + public string TryGetControllerNameForResourceType(ResourceType resourceType) { ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - ResourceContext resourceContext = _resourceGraph.GetResourceContext(resourceType); - - if (_controllerPerResourceContextMap.TryGetValue(resourceContext, out ControllerModel controllerModel)) - - { - return controllerModel.ControllerName; - } - - return null; + return _controllerPerResourceTypeMap.TryGetValue(resourceType, out ControllerModel controllerModel) ? controllerModel.ControllerName : null; } /// @@ -86,16 +73,16 @@ public void Apply(ApplicationModel application) if (!isOperationsController) { - Type resourceType = ExtractResourceTypeFromController(controller.ControllerType); + Type resourceClrType = ExtractResourceClrTypeFromController(controller.ControllerType); - if (resourceType != null) + if (resourceClrType != null) { - ResourceContext resourceContext = _resourceGraph.TryGetResourceContext(resourceType); + ResourceType resourceType = _resourceGraph.TryGetResourceType(resourceClrType); - if (resourceContext != null) + if (resourceType != null) { - _resourceContextPerControllerTypeMap.Add(controller.ControllerType, resourceContext); - _controllerPerResourceContextMap.Add(resourceContext, controller); + _resourceTypePerControllerTypeMap.Add(controller.ControllerType, resourceType); + _controllerPerResourceTypeMap.Add(resourceType, controller); } } } @@ -133,9 +120,9 @@ private bool IsRoutingConventionEnabled(ControllerModel controller) /// private string TemplateFromResource(ControllerModel model) { - if (_resourceContextPerControllerTypeMap.TryGetValue(model.ControllerType, out ResourceContext resourceContext)) + if (_resourceTypePerControllerTypeMap.TryGetValue(model.ControllerType, out ResourceType resourceType)) { - return $"{_options.Namespace}/{resourceContext.PublicName}"; + return $"{_options.Namespace}/{resourceType.PublicName}"; } return null; @@ -156,7 +143,7 @@ private string TemplateFromController(ControllerModel model) /// /// Determines the resource associated to a controller by inspecting generic arguments in its inheritance tree. /// - private Type ExtractResourceTypeFromController(Type type) + private Type ExtractResourceClrTypeFromController(Type type) { Type aspNetControllerType = typeof(ControllerBase); Type coreControllerType = typeof(CoreJsonApiController); @@ -169,12 +156,12 @@ private Type ExtractResourceTypeFromController(Type type) if ((nextBaseType == aspNetControllerType || nextBaseType == coreControllerType) && currentType.IsGenericType) { - Type resourceType = currentType.GetGenericArguments() + Type resourceClrType = currentType.GetGenericArguments() .FirstOrDefault(typeArgument => typeArgument.IsOrImplementsInterface(typeof(IIdentifiable))); - if (resourceType != null) + if (resourceClrType != null) { - return resourceType; + return resourceClrType; } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/IncludeChainConverter.cs b/src/JsonApiDotNetCore/Queries/Expressions/IncludeChainConverter.cs index dbe3b9b0dd..d1c097676b 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/IncludeChainConverter.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/IncludeChainConverter.cs @@ -68,11 +68,11 @@ public IncludeExpression FromRelationshipChains(IEnumerable elements = ConvertChainsToElements(chains); + IImmutableSet elements = ConvertChainsToElements(chains); return elements.Any() ? new IncludeExpression(elements) : IncludeExpression.Empty; } - private static IImmutableList ConvertChainsToElements(IEnumerable chains) + private static IImmutableSet ConvertChainsToElements(IEnumerable chains) { var rootNode = new MutableIncludeNode(null); @@ -81,7 +81,7 @@ private static IImmutableList ConvertChainsToElements( ConvertChainToElement(chain, rootNode); } - return rootNode.Children.Values.Select(child => child.ToExpression()).ToImmutableArray(); + return rootNode.Children.Values.Select(child => child.ToExpression()).ToImmutableHashSet(); } private static void ConvertChainToElement(ResourceFieldChainExpression chain, MutableIncludeNode rootNode) @@ -161,7 +161,7 @@ public MutableIncludeNode(RelationshipAttribute relationship) public IncludeElementExpression ToExpression() { - ImmutableArray elementChildren = Children.Values.Select(child => child.ToExpression()).ToImmutableArray(); + 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 a63db4c707..648986fe18 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/IncludeElementExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/IncludeElementExpression.cs @@ -14,14 +14,14 @@ namespace JsonApiDotNetCore.Queries.Expressions public class IncludeElementExpression : QueryExpression { public RelationshipAttribute Relationship { get; } - public IImmutableList Children { get; } + public IImmutableSet Children { get; } public IncludeElementExpression(RelationshipAttribute relationship) - : this(relationship, ImmutableArray.Empty) + : this(relationship, ImmutableHashSet.Empty) { } - public IncludeElementExpression(RelationshipAttribute relationship, IImmutableList children) + public IncludeElementExpression(RelationshipAttribute relationship, IImmutableSet children) { ArgumentGuard.NotNull(relationship, nameof(relationship)); ArgumentGuard.NotNull(children, nameof(children)); @@ -43,7 +43,7 @@ public override string ToString() if (Children.Any()) { builder.Append('{'); - builder.Append(string.Join(",", Children.Select(child => child.ToString()))); + builder.Append(string.Join(",", Children.Select(child => child.ToString()).OrderBy(name => name))); builder.Append('}'); } @@ -64,7 +64,7 @@ public override bool Equals(object obj) var other = (IncludeElementExpression)obj; - return Relationship.Equals(other.Relationship) == Children.SequenceEqual(other.Children); + return Relationship.Equals(other.Relationship) && Children.SetEquals(other.Children); } public override int GetHashCode() diff --git a/src/JsonApiDotNetCore/Queries/Expressions/IncludeExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/IncludeExpression.cs index 482ba0158d..632e04af30 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/IncludeExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/IncludeExpression.cs @@ -16,9 +16,9 @@ public class IncludeExpression : QueryExpression public static readonly IncludeExpression Empty = new(); - public IImmutableList Elements { get; } + public IImmutableSet Elements { get; } - public IncludeExpression(IImmutableList elements) + public IncludeExpression(IImmutableSet elements) { ArgumentGuard.NotNullNorEmpty(elements, nameof(elements)); @@ -27,7 +27,7 @@ public IncludeExpression(IImmutableList elements) private IncludeExpression() { - Elements = ImmutableArray.Empty; + Elements = ImmutableHashSet.Empty; } public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) @@ -38,7 +38,7 @@ public override TResult Accept(QueryExpressionVisitor chains = IncludeChainConverter.GetRelationshipChains(this); - return string.Join(",", chains.Select(child => child.ToString())); + return string.Join(",", chains.Select(child => child.ToString()).OrderBy(name => name)); } public override bool Equals(object obj) @@ -55,7 +55,7 @@ public override bool Equals(object obj) var other = (IncludeExpression)obj; - return Elements.SequenceEqual(other.Elements); + return Elements.SetEquals(other.Elements); } public override int GetHashCode() diff --git a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs index bd4d1e4de8..e2049795f9 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs @@ -197,14 +197,14 @@ public override QueryExpression VisitSparseFieldTable(SparseFieldTableExpression { if (expression != null) { - ImmutableDictionary.Builder newTable = - ImmutableDictionary.CreateBuilder(); + ImmutableDictionary.Builder newTable = + ImmutableDictionary.CreateBuilder(); - foreach ((ResourceContext resourceContext, SparseFieldSetExpression sparseFieldSet) in expression.Table) + foreach ((ResourceType resourceType, SparseFieldSetExpression sparseFieldSet) in expression.Table) { if (Visit(sparseFieldSet, argument) is SparseFieldSetExpression newSparseFieldSet) { - newTable[resourceContext] = newSparseFieldSet; + newTable[resourceType] = newSparseFieldSet; } } @@ -268,7 +268,7 @@ public override QueryExpression VisitInclude(IncludeExpression expression, TArgu { if (expression != null) { - IImmutableList newElements = VisitList(expression.Elements, argument); + IImmutableSet newElements = VisitSet(expression.Elements, argument); if (newElements.Count == 0) { @@ -286,7 +286,7 @@ public override QueryExpression VisitIncludeElement(IncludeElementExpression exp { if (expression != null) { - IImmutableList newElements = VisitList(expression.Children, argument); + IImmutableSet newElements = VisitSet(expression.Children, argument); var newExpression = new IncludeElementExpression(expression.Relationship, newElements); return newExpression.Equals(expression) ? expression : newExpression; diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldTableExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldTableExpression.cs index 9cf7922349..4f1bca8127 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldTableExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldTableExpression.cs @@ -12,9 +12,9 @@ namespace JsonApiDotNetCore.Queries.Expressions [PublicAPI] public class SparseFieldTableExpression : QueryExpression { - public IImmutableDictionary Table { get; } + public IImmutableDictionary Table { get; } - public SparseFieldTableExpression(IImmutableDictionary table) + public SparseFieldTableExpression(IImmutableDictionary table) { ArgumentGuard.NotNullNorEmpty(table, nameof(table), "entries"); @@ -30,7 +30,7 @@ public override string ToString() { var builder = new StringBuilder(); - foreach ((ResourceContext resource, SparseFieldSetExpression fields) in Table) + foreach ((ResourceType resource, SparseFieldSetExpression fields) in Table) { if (builder.Length > 0) { @@ -67,9 +67,9 @@ public override int GetHashCode() { var hashCode = new HashCode(); - foreach ((ResourceContext resourceContext, SparseFieldSetExpression sparseFieldSet) in Table) + foreach ((ResourceType resourceType, SparseFieldSetExpression sparseFieldSet) in Table) { - hashCode.Add(resourceContext); + hashCode.Add(resourceType); hashCode.Add(sparseFieldSet); } diff --git a/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs index c3fa8428e4..a6ef61605d 100644 --- a/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs @@ -14,34 +14,34 @@ public interface IQueryLayerComposer /// /// Builds a top-level filter from constraints, used to determine total resource count. /// - FilterExpression GetTopFilterFromConstraints(ResourceContext resourceContext); + FilterExpression GetTopFilterFromConstraints(ResourceType primaryResourceType); /// /// Collects constraints and builds a out of them, used to retrieve the actual resources. /// - QueryLayer ComposeFromConstraints(ResourceContext requestResource); + QueryLayer ComposeFromConstraints(ResourceType requestResourceType); /// /// Collects constraints and builds a out of them, used to retrieve one resource. /// - QueryLayer ComposeForGetById(TId id, ResourceContext resourceContext, TopFieldSelection fieldSelection); + QueryLayer ComposeForGetById(TId id, ResourceType primaryResourceType, TopFieldSelection fieldSelection); /// /// Collects constraints and builds the secondary layer for a relationship endpoint. /// - QueryLayer ComposeSecondaryLayerForRelationship(ResourceContext secondaryResourceContext); + QueryLayer ComposeSecondaryLayerForRelationship(ResourceType secondaryResourceType); /// /// Wraps a layer for a secondary endpoint into a primary layer, rewriting top-level includes. /// - QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer, ResourceContext primaryResourceContext, TId primaryId, - RelationshipAttribute secondaryRelationship); + 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, ResourceContext primaryResource); + QueryLayer ComposeForUpdate(TId id, ResourceType primaryResource); /// /// Builds a query for each targeted relationship with a filter to match on its right resource IDs. diff --git a/src/JsonApiDotNetCore/Queries/Internal/ISparseFieldSetCache.cs b/src/JsonApiDotNetCore/Queries/Internal/ISparseFieldSetCache.cs new file mode 100644 index 0000000000..cb3ab1f2d3 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Internal/ISparseFieldSetCache.cs @@ -0,0 +1,40 @@ +using System.Collections.Immutable; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +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 + { + /// + /// 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); + + /// + /// 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); + + /// + /// Resets the cached results from resource definition callbacks. + /// + void Reset(); + } +} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs index d375f72a16..ef6ffd234e 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs @@ -13,28 +13,23 @@ namespace JsonApiDotNetCore.Queries.Internal.Parsing [PublicAPI] public class FilterParser : QueryExpressionParser { - private readonly IResourceGraph _resourceGraph; private readonly IResourceFactory _resourceFactory; - private readonly Action _validateSingleFieldCallback; - private ResourceContext _resourceContextInScope; + private readonly Action _validateSingleFieldCallback; + private ResourceType _resourceTypeInScope; - public FilterParser(IResourceGraph resourceGraph, IResourceFactory resourceFactory, - Action validateSingleFieldCallback = null) - : base(resourceGraph) + public FilterParser(IResourceFactory resourceFactory, Action validateSingleFieldCallback = null) { - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); ArgumentGuard.NotNull(resourceFactory, nameof(resourceFactory)); - _resourceGraph = resourceGraph; _resourceFactory = resourceFactory; _validateSingleFieldCallback = validateSingleFieldCallback; } - public FilterExpression Parse(string source, ResourceContext resourceContextInScope) + public FilterExpression Parse(string source, ResourceType resourceTypeInScope) { - ArgumentGuard.NotNull(resourceContextInScope, nameof(resourceContextInScope)); + ArgumentGuard.NotNull(resourceTypeInScope, nameof(resourceTypeInScope)); - _resourceContextInScope = resourceContextInScope; + _resourceTypeInScope = resourceTypeInScope; Tokenize(source); @@ -265,14 +260,13 @@ protected HasExpression ParseHas() private FilterExpression ParseFilterInHas(HasManyAttribute hasManyRelationship) { - ResourceContext outerScopeBackup = _resourceContextInScope; + ResourceType outerScopeBackup = _resourceTypeInScope; - Type innerResourceType = hasManyRelationship.RightType; - _resourceContextInScope = _resourceGraph.GetResourceContext(innerResourceType); + _resourceTypeInScope = hasManyRelationship.RightType; FilterExpression filter = ParseFilter(); - _resourceContextInScope = outerScopeBackup; + _resourceTypeInScope = outerScopeBackup; return filter; } @@ -337,9 +331,9 @@ protected LiteralConstantExpression ParseConstant() throw new QueryParseException("Value between quotes expected."); } - private string DeObfuscateStringId(Type resourceType, string stringId) + private string DeObfuscateStringId(Type resourceClrType, string stringId) { - IIdentifiable tempResource = _resourceFactory.CreateInstance(resourceType); + IIdentifiable tempResource = _resourceFactory.CreateInstance(resourceClrType); tempResource.StringId = stringId; return tempResource.GetTypedId().ToString(); } @@ -348,17 +342,17 @@ protected override IImmutableList OnResolveFieldChain(st { if (chainRequirements == FieldChainRequirements.EndsInToMany) { - return ChainResolver.ResolveToOneChainEndingInToMany(_resourceContextInScope, path, _validateSingleFieldCallback); + return ChainResolver.ResolveToOneChainEndingInToMany(_resourceTypeInScope, path, _validateSingleFieldCallback); } if (chainRequirements == FieldChainRequirements.EndsInAttribute) { - return ChainResolver.ResolveToOneChainEndingInAttribute(_resourceContextInScope, path, _validateSingleFieldCallback); + return ChainResolver.ResolveToOneChainEndingInAttribute(_resourceTypeInScope, path, _validateSingleFieldCallback); } if (chainRequirements.HasFlag(FieldChainRequirements.EndsInAttribute) && chainRequirements.HasFlag(FieldChainRequirements.EndsInToOne)) { - return ChainResolver.ResolveToOneChainEndingInAttributeOrToOne(_resourceContextInScope, path, _validateSingleFieldCallback); + 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 9d6f394d75..1646c3bcb0 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/IncludeParser.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/IncludeParser.cs @@ -14,20 +14,19 @@ public class IncludeParser : QueryExpressionParser { private static readonly IncludeChainConverter IncludeChainConverter = new(); - private readonly Action _validateSingleRelationshipCallback; - private ResourceContext _resourceContextInScope; + private readonly Action _validateSingleRelationshipCallback; + private ResourceType _resourceTypeInScope; - public IncludeParser(IResourceGraph resourceGraph, Action validateSingleRelationshipCallback = null) - : base(resourceGraph) + public IncludeParser(Action validateSingleRelationshipCallback = null) { _validateSingleRelationshipCallback = validateSingleRelationshipCallback; } - public IncludeExpression Parse(string source, ResourceContext resourceContextInScope, int? maximumDepth) + public IncludeExpression Parse(string source, ResourceType resourceTypeInScope, int? maximumDepth) { - ArgumentGuard.NotNull(resourceContextInScope, nameof(resourceContextInScope)); + ArgumentGuard.NotNull(resourceTypeInScope, nameof(resourceTypeInScope)); - _resourceContextInScope = resourceContextInScope; + _resourceTypeInScope = resourceTypeInScope; Tokenize(source); @@ -74,7 +73,7 @@ private static void ValidateMaximumIncludeDepth(int? maximumDepth, IEnumerable OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) { - return ChainResolver.ResolveRelationshipChain(_resourceContextInScope, path, _validateSingleRelationshipCallback); + return ChainResolver.ResolveRelationshipChain(_resourceTypeInScope, path, _validateSingleRelationshipCallback); } } } diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/PaginationParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/PaginationParser.cs index 62f8dd6a91..980ec8450a 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/PaginationParser.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/PaginationParser.cs @@ -11,20 +11,19 @@ namespace JsonApiDotNetCore.Queries.Internal.Parsing [PublicAPI] public class PaginationParser : QueryExpressionParser { - private readonly Action _validateSingleFieldCallback; - private ResourceContext _resourceContextInScope; + private readonly Action _validateSingleFieldCallback; + private ResourceType _resourceTypeInScope; - public PaginationParser(IResourceGraph resourceGraph, Action validateSingleFieldCallback = null) - : base(resourceGraph) + public PaginationParser(Action validateSingleFieldCallback = null) { _validateSingleFieldCallback = validateSingleFieldCallback; } - public PaginationQueryStringValueExpression Parse(string source, ResourceContext resourceContextInScope) + public PaginationQueryStringValueExpression Parse(string source, ResourceType resourceTypeInScope) { - ArgumentGuard.NotNull(resourceContextInScope, nameof(resourceContextInScope)); + ArgumentGuard.NotNull(resourceTypeInScope, nameof(resourceTypeInScope)); - _resourceContextInScope = resourceContextInScope; + _resourceTypeInScope = resourceTypeInScope; Tokenize(source); @@ -107,7 +106,7 @@ protected PaginationElementQueryStringValueExpression ParsePaginationElement() protected override IImmutableList OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) { - return ChainResolver.ResolveToManyChain(_resourceContextInScope, path, _validateSingleFieldCallback); + 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 65cf321347..b33e01aaf3 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryExpressionParser.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryExpressionParser.cs @@ -2,7 +2,6 @@ using System.Collections.Immutable; using System.Linq; using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources.Annotations; @@ -21,9 +20,9 @@ public abstract class QueryExpressionParser protected Stack TokenStack { get; private set; } private protected ResourceFieldChainResolver ChainResolver { get; } - protected QueryExpressionParser(IResourceGraph resourceGraph) + protected QueryExpressionParser() { - ChainResolver = new ResourceFieldChainResolver(resourceGraph); + ChainResolver = new ResourceFieldChainResolver(); } /// diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryStringParameterScopeParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryStringParameterScopeParser.cs index 7d98110362..7d9f848862 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryStringParameterScopeParser.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryStringParameterScopeParser.cs @@ -11,22 +11,21 @@ namespace JsonApiDotNetCore.Queries.Internal.Parsing public class QueryStringParameterScopeParser : QueryExpressionParser { private readonly FieldChainRequirements _chainRequirements; - private readonly Action _validateSingleFieldCallback; - private ResourceContext _resourceContextInScope; + private readonly Action _validateSingleFieldCallback; + private ResourceType _resourceTypeInScope; - public QueryStringParameterScopeParser(IResourceGraph resourceGraph, FieldChainRequirements chainRequirements, - Action validateSingleFieldCallback = null) - : base(resourceGraph) + public QueryStringParameterScopeParser(FieldChainRequirements chainRequirements, + Action validateSingleFieldCallback = null) { _chainRequirements = chainRequirements; _validateSingleFieldCallback = validateSingleFieldCallback; } - public QueryStringParameterScopeExpression Parse(string source, ResourceContext resourceContextInScope) + public QueryStringParameterScopeExpression Parse(string source, ResourceType resourceTypeInScope) { - ArgumentGuard.NotNull(resourceContextInScope, nameof(resourceContextInScope)); + ArgumentGuard.NotNull(resourceTypeInScope, nameof(resourceTypeInScope)); - _resourceContextInScope = resourceContextInScope; + _resourceTypeInScope = resourceTypeInScope; Tokenize(source); @@ -65,12 +64,12 @@ protected override IImmutableList OnResolveFieldChain(st if (chainRequirements == FieldChainRequirements.EndsInToMany) { // The mismatch here (ends-in-to-many being interpreted as entire-chain-must-be-to-many) is intentional. - return ChainResolver.ResolveToManyChain(_resourceContextInScope, path, _validateSingleFieldCallback); + return ChainResolver.ResolveToManyChain(_resourceTypeInScope, path, _validateSingleFieldCallback); } if (chainRequirements == FieldChainRequirements.IsRelationship) { - return ChainResolver.ResolveRelationshipChain(_resourceContextInScope, path, _validateSingleFieldCallback); + return ChainResolver.ResolveRelationshipChain(_resourceTypeInScope, path, _validateSingleFieldCallback); } throw new InvalidOperationException($"Unexpected combination of chain requirement flags '{chainRequirements}'."); diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldChainResolver.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldChainResolver.cs index edb7a774f2..6b0900ef96 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldChainResolver.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldChainResolver.cs @@ -11,40 +11,31 @@ namespace JsonApiDotNetCore.Queries.Internal.Parsing /// internal sealed class ResourceFieldChainResolver { - private readonly IResourceGraph _resourceGraph; - - public ResourceFieldChainResolver(IResourceGraph resourceGraph) - { - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); - - _resourceGraph = resourceGraph; - } - /// /// Resolves a chain of relationships that ends in a to-many relationship, for example: blogs.owner.articles.comments /// - public IImmutableList ResolveToManyChain(ResourceContext resourceContext, string path, - Action validateCallback = null) + public IImmutableList ResolveToManyChain(ResourceType resourceType, string path, + Action validateCallback = null) { ImmutableArray.Builder chainBuilder = ImmutableArray.CreateBuilder(); string[] publicNameParts = path.Split("."); - ResourceContext nextResourceContext = resourceContext; + ResourceType nextResourceType = resourceType; foreach (string publicName in publicNameParts[..^1]) { - RelationshipAttribute relationship = GetRelationship(publicName, nextResourceContext, path); + RelationshipAttribute relationship = GetRelationship(publicName, nextResourceType, path); - validateCallback?.Invoke(relationship, nextResourceContext, path); + validateCallback?.Invoke(relationship, nextResourceType, path); chainBuilder.Add(relationship); - nextResourceContext = _resourceGraph.GetResourceContext(relationship.RightType); + nextResourceType = relationship.RightType; } string lastName = publicNameParts[^1]; - RelationshipAttribute lastToManyRelationship = GetToManyRelationship(lastName, nextResourceContext, path); + RelationshipAttribute lastToManyRelationship = GetToManyRelationship(lastName, nextResourceType, path); - validateCallback?.Invoke(lastToManyRelationship, nextResourceContext, path); + validateCallback?.Invoke(lastToManyRelationship, nextResourceType, path); chainBuilder.Add(lastToManyRelationship); return chainBuilder.ToImmutable(); @@ -62,20 +53,20 @@ public IImmutableList ResolveToManyChain(ResourceContext /// articles.revisions.author /// /// - public IImmutableList ResolveRelationshipChain(ResourceContext resourceContext, string path, - Action validateCallback = null) + public IImmutableList ResolveRelationshipChain(ResourceType resourceType, string path, + Action validateCallback = null) { ImmutableArray.Builder chainBuilder = ImmutableArray.CreateBuilder(); - ResourceContext nextResourceContext = resourceContext; + ResourceType nextResourceType = resourceType; foreach (string publicName in path.Split(".")) { - RelationshipAttribute relationship = GetRelationship(publicName, nextResourceContext, path); + RelationshipAttribute relationship = GetRelationship(publicName, nextResourceType, path); - validateCallback?.Invoke(relationship, nextResourceContext, path); + validateCallback?.Invoke(relationship, nextResourceType, path); chainBuilder.Add(relationship); - nextResourceContext = _resourceGraph.GetResourceContext(relationship.RightType); + nextResourceType = relationship.RightType; } return chainBuilder.ToImmutable(); @@ -88,28 +79,28 @@ public IImmutableList ResolveRelationshipChain(ResourceC /// /// name /// - public IImmutableList ResolveToOneChainEndingInAttribute(ResourceContext resourceContext, string path, - Action validateCallback = null) + public IImmutableList ResolveToOneChainEndingInAttribute(ResourceType resourceType, string path, + Action validateCallback = null) { ImmutableArray.Builder chainBuilder = ImmutableArray.CreateBuilder(); string[] publicNameParts = path.Split("."); - ResourceContext nextResourceContext = resourceContext; + ResourceType nextResourceType = resourceType; foreach (string publicName in publicNameParts[..^1]) { - RelationshipAttribute toOneRelationship = GetToOneRelationship(publicName, nextResourceContext, path); + RelationshipAttribute toOneRelationship = GetToOneRelationship(publicName, nextResourceType, path); - validateCallback?.Invoke(toOneRelationship, nextResourceContext, path); + validateCallback?.Invoke(toOneRelationship, nextResourceType, path); chainBuilder.Add(toOneRelationship); - nextResourceContext = _resourceGraph.GetResourceContext(toOneRelationship.RightType); + nextResourceType = toOneRelationship.RightType; } string lastName = publicNameParts[^1]; - AttrAttribute lastAttribute = GetAttribute(lastName, nextResourceContext, path); + AttrAttribute lastAttribute = GetAttribute(lastName, nextResourceType, path); - validateCallback?.Invoke(lastAttribute, nextResourceContext, path); + validateCallback?.Invoke(lastAttribute, nextResourceType, path); chainBuilder.Add(lastAttribute); return chainBuilder.ToImmutable(); @@ -124,29 +115,29 @@ public IImmutableList ResolveToOneChainEndingInAttribute /// comments /// /// - public IImmutableList ResolveToOneChainEndingInToMany(ResourceContext resourceContext, string path, - Action validateCallback = null) + public IImmutableList ResolveToOneChainEndingInToMany(ResourceType resourceType, string path, + Action validateCallback = null) { ImmutableArray.Builder chainBuilder = ImmutableArray.CreateBuilder(); string[] publicNameParts = path.Split("."); - ResourceContext nextResourceContext = resourceContext; + ResourceType nextResourceType = resourceType; foreach (string publicName in publicNameParts[..^1]) { - RelationshipAttribute toOneRelationship = GetToOneRelationship(publicName, nextResourceContext, path); + RelationshipAttribute toOneRelationship = GetToOneRelationship(publicName, nextResourceType, path); - validateCallback?.Invoke(toOneRelationship, nextResourceContext, path); + validateCallback?.Invoke(toOneRelationship, nextResourceType, path); chainBuilder.Add(toOneRelationship); - nextResourceContext = _resourceGraph.GetResourceContext(toOneRelationship.RightType); + nextResourceType = toOneRelationship.RightType; } string lastName = publicNameParts[^1]; - RelationshipAttribute toManyRelationship = GetToManyRelationship(lastName, nextResourceContext, path); + RelationshipAttribute toManyRelationship = GetToManyRelationship(lastName, nextResourceType, path); - validateCallback?.Invoke(toManyRelationship, nextResourceContext, path); + validateCallback?.Invoke(toManyRelationship, nextResourceType, path); chainBuilder.Add(toManyRelationship); return chainBuilder.ToImmutable(); @@ -161,105 +152,105 @@ public IImmutableList ResolveToOneChainEndingInToMany(Re /// author.address /// /// - public IImmutableList ResolveToOneChainEndingInAttributeOrToOne(ResourceContext resourceContext, string path, - Action validateCallback = null) + public IImmutableList ResolveToOneChainEndingInAttributeOrToOne(ResourceType resourceType, string path, + Action validateCallback = null) { ImmutableArray.Builder chainBuilder = ImmutableArray.CreateBuilder(); string[] publicNameParts = path.Split("."); - ResourceContext nextResourceContext = resourceContext; + ResourceType nextResourceType = resourceType; foreach (string publicName in publicNameParts[..^1]) { - RelationshipAttribute toOneRelationship = GetToOneRelationship(publicName, nextResourceContext, path); + RelationshipAttribute toOneRelationship = GetToOneRelationship(publicName, nextResourceType, path); - validateCallback?.Invoke(toOneRelationship, nextResourceContext, path); + validateCallback?.Invoke(toOneRelationship, nextResourceType, path); chainBuilder.Add(toOneRelationship); - nextResourceContext = _resourceGraph.GetResourceContext(toOneRelationship.RightType); + nextResourceType = toOneRelationship.RightType; } string lastName = publicNameParts[^1]; - ResourceFieldAttribute lastField = GetField(lastName, nextResourceContext, path); + 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 '{nextResourceContext.PublicName}'." - : $"Field '{lastName}' in '{path}' must be an attribute or a to-one relationship on resource '{nextResourceContext.PublicName}'."); + ? $"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, nextResourceContext, path); + validateCallback?.Invoke(lastField, nextResourceType, path); chainBuilder.Add(lastField); return chainBuilder.ToImmutable(); } - private RelationshipAttribute GetRelationship(string publicName, ResourceContext resourceContext, string path) + private RelationshipAttribute GetRelationship(string publicName, ResourceType resourceType, string path) { - RelationshipAttribute relationship = resourceContext.TryGetRelationshipByPublicName(publicName); + RelationshipAttribute relationship = resourceType.TryGetRelationshipByPublicName(publicName); if (relationship == null) { throw new QueryParseException(path == publicName - ? $"Relationship '{publicName}' does not exist on resource '{resourceContext.PublicName}'." - : $"Relationship '{publicName}' in '{path}' does not exist on resource '{resourceContext.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; } - private RelationshipAttribute GetToManyRelationship(string publicName, ResourceContext resourceContext, string path) + private RelationshipAttribute GetToManyRelationship(string publicName, ResourceType resourceType, string path) { - RelationshipAttribute relationship = GetRelationship(publicName, resourceContext, path); + RelationshipAttribute relationship = GetRelationship(publicName, resourceType, path); if (relationship is not HasManyAttribute) { throw new QueryParseException(path == publicName - ? $"Relationship '{publicName}' must be a to-many relationship on resource '{resourceContext.PublicName}'." - : $"Relationship '{publicName}' in '{path}' must be a to-many relationship on resource '{resourceContext.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}'."); } return relationship; } - private RelationshipAttribute GetToOneRelationship(string publicName, ResourceContext resourceContext, string path) + private RelationshipAttribute GetToOneRelationship(string publicName, ResourceType resourceType, string path) { - RelationshipAttribute relationship = GetRelationship(publicName, resourceContext, path); + RelationshipAttribute relationship = GetRelationship(publicName, resourceType, path); if (relationship is not HasOneAttribute) { throw new QueryParseException(path == publicName - ? $"Relationship '{publicName}' must be a to-one relationship on resource '{resourceContext.PublicName}'." - : $"Relationship '{publicName}' in '{path}' must be a to-one relationship on resource '{resourceContext.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}'."); } return relationship; } - private AttrAttribute GetAttribute(string publicName, ResourceContext resourceContext, string path) + private AttrAttribute GetAttribute(string publicName, ResourceType resourceType, string path) { - AttrAttribute attribute = resourceContext.TryGetAttributeByPublicName(publicName); + AttrAttribute attribute = resourceType.TryGetAttributeByPublicName(publicName); if (attribute == null) { throw new QueryParseException(path == publicName - ? $"Attribute '{publicName}' does not exist on resource '{resourceContext.PublicName}'." - : $"Attribute '{publicName}' in '{path}' does not exist on resource '{resourceContext.PublicName}'."); + ? $"Attribute '{publicName}' does not exist on resource type '{resourceType.PublicName}'." + : $"Attribute '{publicName}' in '{path}' does not exist on resource type '{resourceType.PublicName}'."); } return attribute; } - public ResourceFieldAttribute GetField(string publicName, ResourceContext resourceContext, string path) + public ResourceFieldAttribute GetField(string publicName, ResourceType resourceType, string path) { - ResourceFieldAttribute field = resourceContext.Fields.FirstOrDefault(nextField => nextField.PublicName == publicName); + ResourceFieldAttribute field = resourceType.Fields.FirstOrDefault(nextField => nextField.PublicName == publicName); if (field == null) { throw new QueryParseException(path == publicName - ? $"Field '{publicName}' does not exist on resource '{resourceContext.PublicName}'." - : $"Field '{publicName}' in '{path}' does not exist on resource '{resourceContext.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 4d588bacbb..4b14f2d996 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/SortParser.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/SortParser.cs @@ -11,20 +11,19 @@ namespace JsonApiDotNetCore.Queries.Internal.Parsing [PublicAPI] public class SortParser : QueryExpressionParser { - private readonly Action _validateSingleFieldCallback; - private ResourceContext _resourceContextInScope; + private readonly Action _validateSingleFieldCallback; + private ResourceType _resourceTypeInScope; - public SortParser(IResourceGraph resourceGraph, Action validateSingleFieldCallback = null) - : base(resourceGraph) + public SortParser(Action validateSingleFieldCallback = null) { _validateSingleFieldCallback = validateSingleFieldCallback; } - public SortExpression Parse(string source, ResourceContext resourceContextInScope) + public SortExpression Parse(string source, ResourceType resourceTypeInScope) { - ArgumentGuard.NotNull(resourceContextInScope, nameof(resourceContextInScope)); + ArgumentGuard.NotNull(resourceTypeInScope, nameof(resourceTypeInScope)); - _resourceContextInScope = resourceContextInScope; + _resourceTypeInScope = resourceTypeInScope; Tokenize(source); @@ -79,12 +78,12 @@ protected override IImmutableList OnResolveFieldChain(st { if (chainRequirements == FieldChainRequirements.EndsInToMany) { - return ChainResolver.ResolveToOneChainEndingInToMany(_resourceContextInScope, path); + return ChainResolver.ResolveToOneChainEndingInToMany(_resourceTypeInScope, path); } if (chainRequirements == FieldChainRequirements.EndsInAttribute) { - return ChainResolver.ResolveToOneChainEndingInAttribute(_resourceContextInScope, path, _validateSingleFieldCallback); + 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 ea44dca7e5..be8daf350c 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldSetParser.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldSetParser.cs @@ -11,20 +11,19 @@ namespace JsonApiDotNetCore.Queries.Internal.Parsing [PublicAPI] public class SparseFieldSetParser : QueryExpressionParser { - private readonly Action _validateSingleFieldCallback; - private ResourceContext _resourceContext; + private readonly Action _validateSingleFieldCallback; + private ResourceType _resourceType; - public SparseFieldSetParser(IResourceGraph resourceGraph, Action validateSingleFieldCallback = null) - : base(resourceGraph) + public SparseFieldSetParser(Action validateSingleFieldCallback = null) { _validateSingleFieldCallback = validateSingleFieldCallback; } - public SparseFieldSetExpression Parse(string source, ResourceContext resourceContext) + public SparseFieldSetExpression Parse(string source, ResourceType resourceType) { - ArgumentGuard.NotNull(resourceContext, nameof(resourceContext)); + ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - _resourceContext = resourceContext; + _resourceType = resourceType; Tokenize(source); @@ -56,9 +55,9 @@ protected SparseFieldSetExpression ParseSparseFieldSet() protected override IImmutableList OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) { - ResourceFieldAttribute field = ChainResolver.GetField(path, _resourceContext, path); + ResourceFieldAttribute field = ChainResolver.GetField(path, _resourceType, path); - _validateSingleFieldCallback?.Invoke(field, _resourceContext, path); + _validateSingleFieldCallback?.Invoke(field, _resourceType, path); return ImmutableArray.Create(field); } diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldTypeParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldTypeParser.cs index fec5356282..d071514a1a 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldTypeParser.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldTypeParser.cs @@ -12,23 +12,24 @@ public class SparseFieldTypeParser : QueryExpressionParser private readonly IResourceGraph _resourceGraph; public SparseFieldTypeParser(IResourceGraph resourceGraph) - : base(resourceGraph) { + ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); + _resourceGraph = resourceGraph; } - public ResourceContext Parse(string source) + public ResourceType Parse(string source) { Tokenize(source); - ResourceContext resourceContext = ParseSparseFieldTarget(); + ResourceType resourceType = ParseSparseFieldTarget(); AssertTokenStackIsEmpty(); - return resourceContext; + return resourceType; } - private ResourceContext ParseSparseFieldTarget() + private ResourceType ParseSparseFieldTarget() { if (!TokenStack.TryPop(out Token token) || token.Kind != TokenKind.Text) { @@ -37,33 +38,33 @@ private ResourceContext ParseSparseFieldTarget() EatSingleCharacterToken(TokenKind.OpenBracket); - ResourceContext resourceContext = ParseResourceName(); + ResourceType resourceType = ParseResourceName(); EatSingleCharacterToken(TokenKind.CloseBracket); - return resourceContext; + return resourceType; } - private ResourceContext ParseResourceName() + private ResourceType ParseResourceName() { if (TokenStack.TryPop(out Token token) && token.Kind == TokenKind.Text) { - return GetResourceContext(token.Value); + return GetResourceType(token.Value); } throw new QueryParseException("Resource type expected."); } - private ResourceContext GetResourceContext(string publicName) + private ResourceType GetResourceType(string publicName) { - ResourceContext resourceContext = _resourceGraph.TryGetResourceContext(publicName); + ResourceType resourceType = _resourceGraph.TryGetResourceType(publicName); - if (resourceContext == null) + if (resourceType == null) { throw new QueryParseException($"Resource type '{publicName}' does not exist."); } - return resourceContext; + return resourceType; } protected override IImmutableList OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs index 6bc933cfc0..165ce5b032 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs @@ -17,38 +17,36 @@ public class QueryLayerComposer : IQueryLayerComposer { private readonly CollectionConverter _collectionConverter = new(); private readonly IEnumerable _constraintProviders; - private readonly IResourceGraph _resourceGraph; private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; private readonly IJsonApiOptions _options; private readonly IPaginationContext _paginationContext; private readonly ITargetedFields _targetedFields; private readonly IEvaluatedIncludeCache _evaluatedIncludeCache; - private readonly SparseFieldSetCache _sparseFieldSetCache; + private readonly ISparseFieldSetCache _sparseFieldSetCache; - public QueryLayerComposer(IEnumerable constraintProviders, IResourceGraph resourceGraph, - IResourceDefinitionAccessor resourceDefinitionAccessor, IJsonApiOptions options, IPaginationContext paginationContext, - ITargetedFields targetedFields, IEvaluatedIncludeCache evaluatedIncludeCache) + public QueryLayerComposer(IEnumerable constraintProviders, IResourceDefinitionAccessor resourceDefinitionAccessor, + IJsonApiOptions options, IPaginationContext paginationContext, ITargetedFields targetedFields, IEvaluatedIncludeCache evaluatedIncludeCache, + ISparseFieldSetCache sparseFieldSetCache) { ArgumentGuard.NotNull(constraintProviders, nameof(constraintProviders)); - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); 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; - _resourceGraph = resourceGraph; _resourceDefinitionAccessor = resourceDefinitionAccessor; _options = options; _paginationContext = paginationContext; _targetedFields = targetedFields; _evaluatedIncludeCache = evaluatedIncludeCache; - _sparseFieldSetCache = new SparseFieldSetCache(_constraintProviders, resourceDefinitionAccessor); + _sparseFieldSetCache = sparseFieldSetCache; } /// - public FilterExpression GetTopFilterFromConstraints(ResourceContext resourceContext) + public FilterExpression GetTopFilterFromConstraints(ResourceType primaryResourceType) { ExpressionInScope[] constraints = _constraintProviders.SelectMany(provider => provider.GetConstraints()).ToArray(); @@ -64,17 +62,17 @@ public FilterExpression GetTopFilterFromConstraints(ResourceContext resourceCont // @formatter:keep_existing_linebreaks restore // @formatter:wrap_chained_method_calls restore - return GetFilter(filtersInTopScope, resourceContext); + return GetFilter(filtersInTopScope, primaryResourceType); } /// - public QueryLayer ComposeFromConstraints(ResourceContext requestResource) + public QueryLayer ComposeFromConstraints(ResourceType requestResourceType) { - ArgumentGuard.NotNull(requestResource, nameof(requestResource)); + ArgumentGuard.NotNull(requestResourceType, nameof(requestResourceType)); ExpressionInScope[] constraints = _constraintProviders.SelectMany(provider => provider.GetConstraints()).ToArray(); - QueryLayer topLayer = ComposeTopLayer(constraints, requestResource); + QueryLayer topLayer = ComposeTopLayer(constraints, requestResourceType); topLayer.Include = ComposeChildren(topLayer, constraints); _evaluatedIncludeCache.Set(topLayer.Include); @@ -82,7 +80,7 @@ public QueryLayer ComposeFromConstraints(ResourceContext requestResource) return topLayer; } - private QueryLayer ComposeTopLayer(IEnumerable constraints, ResourceContext resourceContext) + private QueryLayer ComposeTopLayer(IEnumerable constraints, ResourceType resourceType) { using IDisposable _ = CodeTimingSessionManager.Current.Measure("Top-level query composition"); @@ -97,7 +95,7 @@ private QueryLayer ComposeTopLayer(IEnumerable constraints, R // @formatter:keep_existing_linebreaks restore // @formatter:wrap_chained_method_calls restore - PaginationExpression topPagination = GetPagination(expressionsInTopScope, resourceContext); + PaginationExpression topPagination = GetPagination(expressionsInTopScope, resourceType); if (topPagination != null) { @@ -105,12 +103,12 @@ private QueryLayer ComposeTopLayer(IEnumerable constraints, R _paginationContext.PageNumber = topPagination.PageNumber; } - return new QueryLayer(resourceContext) + return new QueryLayer(resourceType) { - Filter = GetFilter(expressionsInTopScope, resourceContext), - Sort = GetSort(expressionsInTopScope, resourceContext), + Filter = GetFilter(expressionsInTopScope, resourceType), + Sort = GetSort(expressionsInTopScope, resourceType), Pagination = ((JsonApiOptions)_options).DisableTopPagination ? null : topPagination, - Projection = GetProjectionForSparseAttributeSet(resourceContext) + Projection = GetProjectionForSparseAttributeSet(resourceType) }; } @@ -130,7 +128,7 @@ private IncludeExpression ComposeChildren(QueryLayer topLayer, ICollection includeElements = + IImmutableSet includeElements = ProcessIncludeSet(include.Elements, topLayer, new List(), constraints); return !ReferenceEquals(includeElements, include.Elements) @@ -138,13 +136,13 @@ private IncludeExpression ComposeChildren(QueryLayer topLayer, ICollection ProcessIncludeSet(IImmutableList includeElements, QueryLayer parentLayer, + private IImmutableSet ProcessIncludeSet(IImmutableSet includeElements, QueryLayer parentLayer, ICollection parentRelationshipChain, ICollection constraints) { - IImmutableList includeElementsEvaluated = - GetIncludeElements(includeElements, parentLayer.ResourceContext) ?? ImmutableArray.Empty; + IImmutableSet includeElementsEvaluated = + GetIncludeElements(includeElements, parentLayer.ResourceType) ?? ImmutableHashSet.Empty; - var updatesInChildren = new Dictionary>(); + var updatesInChildren = new Dictionary>(); foreach (IncludeElementExpression includeElement in includeElementsEvaluated) { @@ -169,30 +167,26 @@ private IImmutableList ProcessIncludeSet(IImmutableLis // @formatter:keep_existing_linebreaks restore // @formatter:wrap_chained_method_calls restore - ResourceContext resourceContext = _resourceGraph.GetResourceContext(includeElement.Relationship.RightType); + ResourceType resourceType = includeElement.Relationship.RightType; bool isToManyRelationship = includeElement.Relationship is HasManyAttribute; - var child = new QueryLayer(resourceContext) + var child = new QueryLayer(resourceType) { - Filter = isToManyRelationship ? GetFilter(expressionsInCurrentScope, resourceContext) : null, - Sort = isToManyRelationship ? GetSort(expressionsInCurrentScope, resourceContext) : null, + Filter = isToManyRelationship ? GetFilter(expressionsInCurrentScope, resourceType) : null, + Sort = isToManyRelationship ? GetSort(expressionsInCurrentScope, resourceType) : null, Pagination = isToManyRelationship - ? ((JsonApiOptions)_options).DisableChildrenPagination ? null : GetPagination(expressionsInCurrentScope, resourceContext) + ? ((JsonApiOptions)_options).DisableChildrenPagination ? null : GetPagination(expressionsInCurrentScope, resourceType) : null, - Projection = GetProjectionForSparseAttributeSet(resourceContext) + Projection = GetProjectionForSparseAttributeSet(resourceType) }; parentLayer.Projection.Add(includeElement.Relationship, child); - if (includeElement.Children.Any()) - { - IImmutableList 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); } } } @@ -200,29 +194,29 @@ private IImmutableList ProcessIncludeSet(IImmutableLis return !updatesInChildren.Any() ? includeElementsEvaluated : ApplyIncludeElementUpdates(includeElementsEvaluated, updatesInChildren); } - private static IImmutableList ApplyIncludeElementUpdates(IImmutableList includeElements, - IDictionary> updatesInChildren) + private static IImmutableSet ApplyIncludeElementUpdates(IImmutableSet includeElements, + IDictionary> updatesInChildren) { - ImmutableArray.Builder newElementsBuilder = ImmutableArray.CreateBuilder(includeElements.Count); + ImmutableHashSet.Builder newElementsBuilder = ImmutableHashSet.CreateBuilder(); newElementsBuilder.AddRange(includeElements); - foreach ((IncludeElementExpression existingElement, IImmutableList updatedChildren) in updatesInChildren) + foreach ((IncludeElementExpression existingElement, IImmutableSet updatedChildren) in updatesInChildren) { - int existingIndex = newElementsBuilder.IndexOf(existingElement); - newElementsBuilder[existingIndex] = new IncludeElementExpression(existingElement.Relationship, updatedChildren); + newElementsBuilder.Remove(existingElement); + newElementsBuilder.Add(new IncludeElementExpression(existingElement.Relationship, updatedChildren)); } return newElementsBuilder.ToImmutable(); } /// - public QueryLayer ComposeForGetById(TId id, ResourceContext resourceContext, TopFieldSelection fieldSelection) + public QueryLayer ComposeForGetById(TId id, ResourceType primaryResourceType, TopFieldSelection fieldSelection) { - ArgumentGuard.NotNull(resourceContext, nameof(resourceContext)); + ArgumentGuard.NotNull(primaryResourceType, nameof(primaryResourceType)); - AttrAttribute idAttribute = GetIdAttribute(resourceContext); + AttrAttribute idAttribute = GetIdAttribute(primaryResourceType); - QueryLayer queryLayer = ComposeFromConstraints(resourceContext); + QueryLayer queryLayer = ComposeFromConstraints(primaryResourceType); queryLayer.Sort = null; queryLayer.Pagination = null; queryLayer.Filter = CreateFilterByIds(id.AsArray(), idAttribute, queryLayer.Filter); @@ -247,48 +241,48 @@ public QueryLayer ComposeForGetById(TId id, ResourceContext resourceContext } /// - public QueryLayer ComposeSecondaryLayerForRelationship(ResourceContext secondaryResourceContext) + public QueryLayer ComposeSecondaryLayerForRelationship(ResourceType secondaryResourceType) { - ArgumentGuard.NotNull(secondaryResourceContext, nameof(secondaryResourceContext)); + ArgumentGuard.NotNull(secondaryResourceType, nameof(secondaryResourceType)); - QueryLayer secondaryLayer = ComposeFromConstraints(secondaryResourceContext); - secondaryLayer.Projection = GetProjectionForRelationship(secondaryResourceContext); + QueryLayer secondaryLayer = ComposeFromConstraints(secondaryResourceType); + secondaryLayer.Projection = GetProjectionForRelationship(secondaryResourceType); secondaryLayer.Include = null; return secondaryLayer; } - private IDictionary GetProjectionForRelationship(ResourceContext secondaryResourceContext) + private IDictionary GetProjectionForRelationship(ResourceType secondaryResourceType) { - IImmutableSet secondaryAttributeSet = _sparseFieldSetCache.GetIdAttributeSetForRelationshipQuery(secondaryResourceContext); + IImmutableSet secondaryAttributeSet = _sparseFieldSetCache.GetIdAttributeSetForRelationshipQuery(secondaryResourceType); return secondaryAttributeSet.ToDictionary(key => (ResourceFieldAttribute)key, _ => (QueryLayer)null); } /// - public QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer, ResourceContext primaryResourceContext, TId primaryId, - RelationshipAttribute secondaryRelationship) + public QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer, ResourceType primaryResourceType, TId primaryId, + RelationshipAttribute relationship) { ArgumentGuard.NotNull(secondaryLayer, nameof(secondaryLayer)); - ArgumentGuard.NotNull(primaryResourceContext, nameof(primaryResourceContext)); - ArgumentGuard.NotNull(secondaryRelationship, nameof(secondaryRelationship)); + ArgumentGuard.NotNull(primaryResourceType, nameof(primaryResourceType)); + ArgumentGuard.NotNull(relationship, nameof(relationship)); IncludeExpression innerInclude = secondaryLayer.Include; secondaryLayer.Include = null; - IImmutableSet primaryAttributeSet = _sparseFieldSetCache.GetIdAttributeSetForRelationshipQuery(primaryResourceContext); + IImmutableSet primaryAttributeSet = _sparseFieldSetCache.GetIdAttributeSetForRelationshipQuery(primaryResourceType); Dictionary primaryProjection = primaryAttributeSet.ToDictionary(key => (ResourceFieldAttribute)key, _ => (QueryLayer)null); - primaryProjection[secondaryRelationship] = secondaryLayer; + primaryProjection[relationship] = secondaryLayer; - FilterExpression primaryFilter = GetFilter(Array.Empty(), primaryResourceContext); - AttrAttribute primaryIdAttribute = GetIdAttribute(primaryResourceContext); + FilterExpression primaryFilter = GetFilter(Array.Empty(), primaryResourceType); + AttrAttribute primaryIdAttribute = GetIdAttribute(primaryResourceType); - return new QueryLayer(primaryResourceContext) + return new QueryLayer(primaryResourceType) { - Include = RewriteIncludeForSecondaryEndpoint(innerInclude, secondaryRelationship), + Include = RewriteIncludeForSecondaryEndpoint(innerInclude, relationship), Filter = CreateFilterByIds(primaryId.AsArray(), primaryIdAttribute, primaryFilter), Projection = primaryProjection }; @@ -300,7 +294,7 @@ private IncludeExpression RewriteIncludeForSecondaryEndpoint(IncludeExpression r ? new IncludeElementExpression(secondaryRelationship, relativeInclude.Elements) : new IncludeElementExpression(secondaryRelationship); - return new IncludeExpression(ImmutableArray.Create(parentElement)); + return new IncludeExpression(ImmutableHashSet.Create(parentElement)); } private FilterExpression CreateFilterByIds(IReadOnlyCollection ids, AttrAttribute idAttribute, FilterExpression existingFilter) @@ -324,12 +318,12 @@ private FilterExpression CreateFilterByIds(IReadOnlyCollection ids, At } /// - public QueryLayer ComposeForUpdate(TId id, ResourceContext primaryResource) + public QueryLayer ComposeForUpdate(TId id, ResourceType primaryResource) { ArgumentGuard.NotNull(primaryResource, nameof(primaryResource)); - ImmutableArray includeElements = _targetedFields.Relationships - .Select(relationship => new IncludeElementExpression(relationship)).ToImmutableArray(); + IImmutableSet includeElements = _targetedFields.Relationships + .Select(relationship => new IncludeElementExpression(relationship)).ToImmutableHashSet(); AttrAttribute primaryIdAttribute = GetIdAttribute(primaryResource); @@ -367,15 +361,14 @@ public QueryLayer ComposeForGetRelationshipRightIds(RelationshipAttribute relati ArgumentGuard.NotNull(relationship, nameof(relationship)); ArgumentGuard.NotNull(rightResourceIds, nameof(rightResourceIds)); - ResourceContext rightResourceContext = _resourceGraph.GetResourceContext(relationship.RightType); - AttrAttribute rightIdAttribute = GetIdAttribute(rightResourceContext); + AttrAttribute rightIdAttribute = GetIdAttribute(relationship.RightType); object[] typedIds = rightResourceIds.Select(resource => resource.GetTypedId()).ToArray(); - FilterExpression baseFilter = GetFilter(Array.Empty(), rightResourceContext); + FilterExpression baseFilter = GetFilter(Array.Empty(), relationship.RightType); FilterExpression filter = CreateFilterByIds(typedIds, rightIdAttribute, baseFilter); - return new QueryLayer(rightResourceContext) + return new QueryLayer(relationship.RightType) { Include = IncludeExpression.Empty, Filter = filter, @@ -392,23 +385,20 @@ public QueryLayer ComposeForHasMany(HasManyAttribute hasManyRelationship, T ArgumentGuard.NotNull(hasManyRelationship, nameof(hasManyRelationship)); ArgumentGuard.NotNull(rightResourceIds, nameof(rightResourceIds)); - ResourceContext leftResourceContext = _resourceGraph.GetResourceContext(hasManyRelationship.LeftType); - AttrAttribute leftIdAttribute = GetIdAttribute(leftResourceContext); - - ResourceContext rightResourceContext = _resourceGraph.GetResourceContext(hasManyRelationship.RightType); - AttrAttribute rightIdAttribute = GetIdAttribute(rightResourceContext); + 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); - return new QueryLayer(leftResourceContext) + return new QueryLayer(hasManyRelationship.LeftType) { - Include = new IncludeExpression(ImmutableArray.Create(new IncludeElementExpression(hasManyRelationship))), + Include = new IncludeExpression(ImmutableHashSet.Create(new IncludeElementExpression(hasManyRelationship))), Filter = leftFilter, Projection = new Dictionary { - [hasManyRelationship] = new(rightResourceContext) + [hasManyRelationship] = new(hasManyRelationship.RightType) { Filter = rightFilter, Projection = new Dictionary @@ -421,37 +411,37 @@ public QueryLayer ComposeForHasMany(HasManyAttribute hasManyRelationship, T }; } - protected virtual IImmutableList GetIncludeElements(IImmutableList includeElements, - ResourceContext resourceContext) + protected virtual IImmutableSet GetIncludeElements(IImmutableSet includeElements, + ResourceType resourceType) { - ArgumentGuard.NotNull(resourceContext, nameof(resourceContext)); + ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - return _resourceDefinitionAccessor.OnApplyIncludes(resourceContext.ResourceType, includeElements); + return _resourceDefinitionAccessor.OnApplyIncludes(resourceType, includeElements); } - protected virtual FilterExpression GetFilter(IReadOnlyCollection expressionsInScope, ResourceContext resourceContext) + protected virtual FilterExpression GetFilter(IReadOnlyCollection expressionsInScope, ResourceType resourceType) { ArgumentGuard.NotNull(expressionsInScope, nameof(expressionsInScope)); - ArgumentGuard.NotNull(resourceContext, nameof(resourceContext)); + ArgumentGuard.NotNull(resourceType, nameof(resourceType)); ImmutableArray filters = expressionsInScope.OfType().ToImmutableArray(); FilterExpression filter = filters.Length > 1 ? new LogicalExpression(LogicalOperator.And, filters) : filters.FirstOrDefault(); - return _resourceDefinitionAccessor.OnApplyFilter(resourceContext.ResourceType, filter); + return _resourceDefinitionAccessor.OnApplyFilter(resourceType, filter); } - protected virtual SortExpression GetSort(IReadOnlyCollection expressionsInScope, ResourceContext resourceContext) + protected virtual SortExpression GetSort(IReadOnlyCollection expressionsInScope, ResourceType resourceType) { ArgumentGuard.NotNull(expressionsInScope, nameof(expressionsInScope)); - ArgumentGuard.NotNull(resourceContext, nameof(resourceContext)); + ArgumentGuard.NotNull(resourceType, nameof(resourceType)); SortExpression sort = expressionsInScope.OfType().FirstOrDefault(); - sort = _resourceDefinitionAccessor.OnApplySort(resourceContext.ResourceType, sort); + sort = _resourceDefinitionAccessor.OnApplySort(resourceType, sort); if (sort == null) { - AttrAttribute idAttribute = GetIdAttribute(resourceContext); + AttrAttribute idAttribute = GetIdAttribute(resourceType); var idAscendingSort = new SortElementExpression(new ResourceFieldChainExpression(idAttribute), true); sort = new SortExpression(ImmutableArray.Create(idAscendingSort)); } @@ -459,25 +449,25 @@ protected virtual SortExpression GetSort(IReadOnlyCollection ex return sort; } - protected virtual PaginationExpression GetPagination(IReadOnlyCollection expressionsInScope, ResourceContext resourceContext) + protected virtual PaginationExpression GetPagination(IReadOnlyCollection expressionsInScope, ResourceType resourceType) { ArgumentGuard.NotNull(expressionsInScope, nameof(expressionsInScope)); - ArgumentGuard.NotNull(resourceContext, nameof(resourceContext)); + ArgumentGuard.NotNull(resourceType, nameof(resourceType)); PaginationExpression pagination = expressionsInScope.OfType().FirstOrDefault(); - pagination = _resourceDefinitionAccessor.OnApplyPagination(resourceContext.ResourceType, pagination); + pagination = _resourceDefinitionAccessor.OnApplyPagination(resourceType, pagination); pagination ??= new PaginationExpression(PageNumber.ValueOne, _options.DefaultPageSize); return pagination; } - protected virtual IDictionary GetProjectionForSparseAttributeSet(ResourceContext resourceContext) + protected virtual IDictionary GetProjectionForSparseAttributeSet(ResourceType resourceType) { - ArgumentGuard.NotNull(resourceContext, nameof(resourceContext)); + ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - IImmutableSet fieldSet = _sparseFieldSetCache.GetSparseFieldSetForQuery(resourceContext); + IImmutableSet fieldSet = _sparseFieldSetCache.GetSparseFieldSetForQuery(resourceType); if (!fieldSet.Any()) { @@ -485,15 +475,15 @@ protected virtual IDictionary GetProjectionF } HashSet attributeSet = fieldSet.OfType().ToHashSet(); - AttrAttribute idAttribute = GetIdAttribute(resourceContext); + AttrAttribute idAttribute = GetIdAttribute(resourceType); attributeSet.Add(idAttribute); return attributeSet.ToDictionary(key => (ResourceFieldAttribute)key, _ => (QueryLayer)null); } - private static AttrAttribute GetIdAttribute(ResourceContext resourceContext) + private static AttrAttribute GetIdAttribute(ResourceType resourceType) { - return resourceContext.GetAttributeByPropertyName(nameof(Identifiable.Id)); + 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 081cf0be34..4dce446d7c 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/IncludeClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/IncludeClauseBuilder.cs @@ -18,19 +18,16 @@ public class IncludeClauseBuilder : QueryClauseBuilder private static readonly IncludeChainConverter IncludeChainConverter = new(); private readonly Expression _source; - private readonly ResourceContext _resourceContext; - private readonly IResourceGraph _resourceGraph; + private readonly ResourceType _resourceType; - public IncludeClauseBuilder(Expression source, LambdaScope lambdaScope, ResourceContext resourceContext, IResourceGraph resourceGraph) + public IncludeClauseBuilder(Expression source, LambdaScope lambdaScope, ResourceType resourceType) : base(lambdaScope) { ArgumentGuard.NotNull(source, nameof(source)); - ArgumentGuard.NotNull(resourceContext, nameof(resourceContext)); - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); + ArgumentGuard.NotNull(resourceType, nameof(resourceType)); _source = source; - _resourceContext = resourceContext; - _resourceGraph = resourceGraph; + _resourceType = resourceType; } public Expression ApplyInclude(IncludeExpression include) @@ -42,7 +39,7 @@ public Expression ApplyInclude(IncludeExpression include) public override Expression VisitInclude(IncludeExpression expression, object argument) { - Expression source = ApplyEagerLoads(_source, _resourceContext.EagerLoads, null); + Expression source = ApplyEagerLoads(_source, _resourceType.EagerLoads, null); foreach (ResourceFieldChainExpression chain in IncludeChainConverter.GetRelationshipChains(expression)) { @@ -61,8 +58,7 @@ private Expression ProcessRelationshipChain(ResourceFieldChainExpression chain, { path = path == null ? relationship.Property.Name : $"{path}.{relationship.Property.Name}"; - ResourceContext resourceContext = _resourceGraph.GetResourceContext(relationship.RightType); - result = ApplyEagerLoads(result, resourceContext.EagerLoads, path); + result = ApplyEagerLoads(result, relationship.RightType.EagerLoads, path); } return IncludeExtensionMethodCall(result, path); diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryClauseBuilder.cs index 2a51b561c9..1defb30729 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryClauseBuilder.cs @@ -49,7 +49,7 @@ private static Expression TryGetCollectionCount(Expression collectionExpression) foreach (PropertyInfo property in properties) { - if (property.Name == "Count" || property.Name == "Length") + if (property.Name is "Count" or "Length") { return Expression.Property(collectionExpression, property); } diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryableBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryableBuilder.cs index b32c4246d3..d1412eee39 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryableBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryableBuilder.cs @@ -21,19 +21,17 @@ public class QueryableBuilder private readonly Type _extensionType; private readonly LambdaParameterNameFactory _nameFactory; private readonly IResourceFactory _resourceFactory; - private readonly IResourceGraph _resourceGraph; private readonly IModel _entityModel; private readonly LambdaScopeFactory _lambdaScopeFactory; public QueryableBuilder(Expression source, Type elementType, Type extensionType, LambdaParameterNameFactory nameFactory, - IResourceFactory resourceFactory, IResourceGraph resourceGraph, IModel entityModel, LambdaScopeFactory lambdaScopeFactory = null) + 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(resourceGraph, nameof(resourceGraph)); ArgumentGuard.NotNull(entityModel, nameof(entityModel)); _source = source; @@ -41,7 +39,6 @@ public QueryableBuilder(Expression source, Type elementType, Type extensionType, _extensionType = extensionType; _nameFactory = nameFactory; _resourceFactory = resourceFactory; - _resourceGraph = resourceGraph; _entityModel = entityModel; _lambdaScopeFactory = lambdaScopeFactory ?? new LambdaScopeFactory(_nameFactory); } @@ -54,7 +51,7 @@ public virtual Expression ApplyQuery(QueryLayer layer) if (layer.Include != null) { - expression = ApplyInclude(expression, layer.Include, layer.ResourceContext); + expression = ApplyInclude(expression, layer.Include, layer.ResourceType); } if (layer.Filter != null) @@ -74,17 +71,17 @@ public virtual Expression ApplyQuery(QueryLayer layer) if (!layer.Projection.IsNullOrEmpty()) { - expression = ApplyProjection(expression, layer.Projection, layer.ResourceContext); + expression = ApplyProjection(expression, layer.Projection, layer.ResourceType); } return expression; } - protected virtual Expression ApplyInclude(Expression source, IncludeExpression include, ResourceContext resourceContext) + protected virtual Expression ApplyInclude(Expression source, IncludeExpression include, ResourceType resourceType) { using LambdaScope lambdaScope = _lambdaScopeFactory.CreateScope(_elementType); - var builder = new IncludeClauseBuilder(source, lambdaScope, resourceContext, _resourceGraph); + var builder = new IncludeClauseBuilder(source, lambdaScope, resourceType); return builder.ApplyInclude(include); } @@ -112,13 +109,12 @@ protected virtual Expression ApplyPagination(Expression source, PaginationExpres return builder.ApplySkipTake(pagination); } - protected virtual Expression ApplyProjection(Expression source, IDictionary projection, - ResourceContext resourceContext) + 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, _resourceGraph); - return builder.ApplySelect(projection, resourceContext); + 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 f37b2f329e..2fc9c773fc 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs @@ -28,10 +28,9 @@ public class SelectClauseBuilder : QueryClauseBuilder private readonly Type _extensionType; private readonly LambdaParameterNameFactory _nameFactory; private readonly IResourceFactory _resourceFactory; - private readonly IResourceGraph _resourceGraph; public SelectClauseBuilder(Expression source, LambdaScope lambdaScope, IModel entityModel, Type extensionType, LambdaParameterNameFactory nameFactory, - IResourceFactory resourceFactory, IResourceGraph resourceGraph) + IResourceFactory resourceFactory) : base(lambdaScope) { ArgumentGuard.NotNull(source, nameof(source)); @@ -39,17 +38,15 @@ public SelectClauseBuilder(Expression source, LambdaScope lambdaScope, IModel en ArgumentGuard.NotNull(extensionType, nameof(extensionType)); ArgumentGuard.NotNull(nameFactory, nameof(nameFactory)); ArgumentGuard.NotNull(resourceFactory, nameof(resourceFactory)); - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); _source = source; _entityModel = entityModel; _extensionType = extensionType; _nameFactory = nameFactory; _resourceFactory = resourceFactory; - _resourceGraph = resourceGraph; } - public Expression ApplySelect(IDictionary selectors, ResourceContext resourceContext) + public Expression ApplySelect(IDictionary selectors, ResourceType resourceType) { ArgumentGuard.NotNull(selectors, nameof(selectors)); @@ -58,17 +55,17 @@ public Expression ApplySelect(IDictionary se return _source; } - Expression bodyInitializer = CreateLambdaBodyInitializer(selectors, resourceContext, LambdaScope, false); + Expression bodyInitializer = CreateLambdaBodyInitializer(selectors, resourceType, LambdaScope, false); LambdaExpression lambda = Expression.Lambda(bodyInitializer, LambdaScope.Parameter); return SelectExtensionMethodCall(_source, LambdaScope.Parameter.Type, lambda); } - private Expression CreateLambdaBodyInitializer(IDictionary selectors, ResourceContext resourceContext, + private Expression CreateLambdaBodyInitializer(IDictionary selectors, ResourceType resourceType, LambdaScope lambdaScope, bool lambdaAccessorRequiresTestForNull) { - ICollection propertySelectors = ToPropertySelectors(selectors, resourceContext, lambdaScope.Accessor.Type); + ICollection propertySelectors = ToPropertySelectors(selectors, resourceType, lambdaScope.Accessor.Type); MemberBinding[] propertyAssignments = propertySelectors.Select(selector => CreatePropertyAssignment(selector, lambdaScope)).Cast().ToArray(); @@ -85,7 +82,7 @@ private Expression CreateLambdaBodyInitializer(IDictionary ToPropertySelectors(IDictionary resourceFieldSelectors, - ResourceContext resourceContext, Type elementType) + ResourceType resourceType, Type elementType) { var propertySelectors = new Dictionary(); @@ -121,7 +118,7 @@ private ICollection ToPropertySelectors(IDictionary - /// Takes sparse fieldsets from s and invokes - /// on them. - /// - [PublicAPI] - public sealed class SparseFieldSetCache + /// + public sealed class SparseFieldSetCache : ISparseFieldSetCache { private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; - private readonly Lazy>> _lazySourceTable; - private readonly IDictionary> _visitedTable; + private readonly Lazy>> _lazySourceTable; + private readonly IDictionary> _visitedTable; public SparseFieldSetCache(IEnumerable constraintProviders, IResourceDefinitionAccessor resourceDefinitionAccessor) { @@ -27,17 +22,17 @@ public SparseFieldSetCache(IEnumerable constraintProvi ArgumentGuard.NotNull(resourceDefinitionAccessor, nameof(resourceDefinitionAccessor)); _resourceDefinitionAccessor = resourceDefinitionAccessor; - _lazySourceTable = new Lazy>>(() => BuildSourceTable(constraintProviders)); - _visitedTable = new Dictionary>(); + _lazySourceTable = new Lazy>>(() => BuildSourceTable(constraintProviders)); + _visitedTable = new Dictionary>(); } - private static IDictionary> BuildSourceTable( + private static IDictionary> BuildSourceTable( IEnumerable constraintProviders) { // @formatter:wrap_chained_method_calls chop_always // @formatter:keep_existing_linebreaks true - KeyValuePair[] sparseFieldTables = constraintProviders + KeyValuePair[] sparseFieldTables = constraintProviders .SelectMany(provider => provider.GetConstraints()) .Where(constraint => constraint.Scope == null) .Select(constraint => constraint.Expression) @@ -49,16 +44,16 @@ private static IDictionary.Builder>(); + var mergedTable = new Dictionary.Builder>(); - foreach ((ResourceContext resourceContext, SparseFieldSetExpression sparseFieldSet) in sparseFieldTables) + foreach ((ResourceType resourceType, SparseFieldSetExpression sparseFieldSet) in sparseFieldTables) { - if (!mergedTable.ContainsKey(resourceContext)) + if (!mergedTable.ContainsKey(resourceType)) { - mergedTable[resourceContext] = ImmutableHashSet.CreateBuilder(); + mergedTable[resourceType] = ImmutableHashSet.CreateBuilder(); } - AddSparseFieldsToSet(sparseFieldSet.Fields, mergedTable[resourceContext]); + AddSparseFieldsToSet(sparseFieldSet.Fields, mergedTable[resourceType]); } return mergedTable.ToDictionary(pair => pair.Key, pair => (IImmutableSet)pair.Value.ToImmutable()); @@ -73,37 +68,39 @@ private static void AddSparseFieldsToSet(IImmutableSet s } } - public IImmutableSet GetSparseFieldSetForQuery(ResourceContext resourceContext) + /// + public IImmutableSet GetSparseFieldSetForQuery(ResourceType resourceType) { - ArgumentGuard.NotNull(resourceContext, nameof(resourceContext)); + ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - if (!_visitedTable.ContainsKey(resourceContext)) + if (!_visitedTable.ContainsKey(resourceType)) { - SparseFieldSetExpression inputExpression = _lazySourceTable.Value.ContainsKey(resourceContext) - ? new SparseFieldSetExpression(_lazySourceTable.Value[resourceContext]) + SparseFieldSetExpression inputExpression = _lazySourceTable.Value.ContainsKey(resourceType) + ? new SparseFieldSetExpression(_lazySourceTable.Value[resourceType]) : null; - SparseFieldSetExpression outputExpression = _resourceDefinitionAccessor.OnApplySparseFieldSet(resourceContext.ResourceType, inputExpression); + SparseFieldSetExpression outputExpression = _resourceDefinitionAccessor.OnApplySparseFieldSet(resourceType, inputExpression); IImmutableSet outputFields = outputExpression == null ? ImmutableHashSet.Empty : outputExpression.Fields; - _visitedTable[resourceContext] = outputFields; + _visitedTable[resourceType] = outputFields; } - return _visitedTable[resourceContext]; + return _visitedTable[resourceType]; } - public IImmutableSet GetIdAttributeSetForRelationshipQuery(ResourceContext resourceContext) + /// + public IImmutableSet GetIdAttributeSetForRelationshipQuery(ResourceType resourceType) { - ArgumentGuard.NotNull(resourceContext, nameof(resourceContext)); + ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - AttrAttribute idAttribute = resourceContext.GetAttributeByPropertyName(nameof(Identifiable.Id)); + 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(resourceContext.ResourceType, inputExpression); + SparseFieldSetExpression outputExpression = _resourceDefinitionAccessor.OnApplySparseFieldSet(resourceType, inputExpression); ImmutableHashSet outputAttributes = outputExpression == null ? ImmutableHashSet.Empty @@ -113,40 +110,41 @@ public IImmutableSet GetIdAttributeSetForRelationshipQuery(Resour return outputAttributes; } - public IImmutableSet GetSparseFieldSetForSerializer(ResourceContext resourceContext) + /// + public IImmutableSet GetSparseFieldSetForSerializer(ResourceType resourceType) { - ArgumentGuard.NotNull(resourceContext, nameof(resourceContext)); + ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - if (!_visitedTable.ContainsKey(resourceContext)) + if (!_visitedTable.ContainsKey(resourceType)) { - IImmutableSet inputFields = _lazySourceTable.Value.ContainsKey(resourceContext) - ? _lazySourceTable.Value[resourceContext] - : GetResourceFields(resourceContext); + IImmutableSet inputFields = _lazySourceTable.Value.ContainsKey(resourceType) + ? _lazySourceTable.Value[resourceType] + : GetResourceFields(resourceType); var inputExpression = new SparseFieldSetExpression(inputFields); - SparseFieldSetExpression outputExpression = _resourceDefinitionAccessor.OnApplySparseFieldSet(resourceContext.ResourceType, inputExpression); + SparseFieldSetExpression outputExpression = _resourceDefinitionAccessor.OnApplySparseFieldSet(resourceType, inputExpression); IImmutableSet outputFields = - outputExpression == null ? GetResourceFields(resourceContext) : inputFields.Intersect(outputExpression.Fields); + outputExpression == null ? GetResourceFields(resourceType) : inputFields.Intersect(outputExpression.Fields); - _visitedTable[resourceContext] = outputFields; + _visitedTable[resourceType] = outputFields; } - return _visitedTable[resourceContext]; + return _visitedTable[resourceType]; } - private IImmutableSet GetResourceFields(ResourceContext resourceContext) + private IImmutableSet GetResourceFields(ResourceType resourceType) { - ArgumentGuard.NotNull(resourceContext, nameof(resourceContext)); + ArgumentGuard.NotNull(resourceType, nameof(resourceType)); ImmutableHashSet.Builder fieldSetBuilder = ImmutableHashSet.CreateBuilder(); - foreach (AttrAttribute attribute in resourceContext.Attributes.Where(attr => attr.Capabilities.HasFlag(AttrCapabilities.AllowView))) + foreach (AttrAttribute attribute in resourceType.Attributes.Where(attr => attr.Capabilities.HasFlag(AttrCapabilities.AllowView))) { fieldSetBuilder.Add(attribute); } - fieldSetBuilder.AddRange(resourceContext.Relationships); + fieldSetBuilder.AddRange(resourceType.Relationships); return fieldSetBuilder.ToImmutable(); } diff --git a/src/JsonApiDotNetCore/Queries/QueryLayer.cs b/src/JsonApiDotNetCore/Queries/QueryLayer.cs index 9340181743..e8fa0e6cfa 100644 --- a/src/JsonApiDotNetCore/Queries/QueryLayer.cs +++ b/src/JsonApiDotNetCore/Queries/QueryLayer.cs @@ -14,7 +14,7 @@ namespace JsonApiDotNetCore.Queries [PublicAPI] public sealed class QueryLayer { - public ResourceContext ResourceContext { get; } + public ResourceType ResourceType { get; } public IncludeExpression Include { get; set; } public FilterExpression Filter { get; set; } @@ -22,11 +22,11 @@ public sealed class QueryLayer public PaginationExpression Pagination { get; set; } public IDictionary Projection { get; set; } - public QueryLayer(ResourceContext resourceContext) + public QueryLayer(ResourceType resourceType) { - ArgumentGuard.NotNull(resourceContext, nameof(resourceContext)); + ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - ResourceContext = resourceContext; + ResourceType = resourceType; } public override string ToString() @@ -41,7 +41,7 @@ public override string ToString() private static void WriteLayer(IndentingStringWriter writer, QueryLayer layer, string prefix = null) { - writer.WriteLine($"{prefix}{nameof(QueryLayer)}<{layer.ResourceContext.ResourceType.Name}>"); + writer.WriteLine($"{prefix}{nameof(QueryLayer)}<{layer.ResourceType.ClrType.Name}>"); using (writer.Indent()) { diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/FilterQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/FilterQueryStringParameterReader.cs index 354cb4b8ec..9cf8ede3dc 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/FilterQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/FilterQueryStringParameterReader.cs @@ -36,11 +36,11 @@ public FilterQueryStringParameterReader(IJsonApiRequest request, IResourceGraph ArgumentGuard.NotNull(options, nameof(options)); _options = options; - _scopeParser = new QueryStringParameterScopeParser(resourceGraph, FieldChainRequirements.EndsInToMany); - _filterParser = new FilterParser(resourceGraph, resourceFactory, ValidateSingleField); + _scopeParser = new QueryStringParameterScopeParser(FieldChainRequirements.EndsInToMany); + _filterParser = new FilterParser(resourceFactory, ValidateSingleField); } - protected void ValidateSingleField(ResourceFieldAttribute field, ResourceContext resourceContext, string path) + protected void ValidateSingleField(ResourceFieldAttribute field, ResourceType resourceType, string path) { if (field is AttrAttribute attribute && !attribute.Capabilities.HasFlag(AttrCapabilities.AllowFilter)) { @@ -117,7 +117,7 @@ private void ReadSingleValue(string parameterName, string parameterValue) private ResourceFieldChainExpression GetScope(string parameterName) { - QueryStringParameterScopeExpression parameterScope = _scopeParser.Parse(parameterName, RequestResource); + QueryStringParameterScopeExpression parameterScope = _scopeParser.Parse(parameterName, RequestResourceType); if (parameterScope.Scope == null) { @@ -129,8 +129,8 @@ private ResourceFieldChainExpression GetScope(string parameterName) private FilterExpression GetFilter(string parameterValue, ResourceFieldChainExpression scope) { - ResourceContext resourceContextInScope = GetResourceContextForScope(scope); - return _filterParser.Parse(parameterValue, resourceContextInScope); + ResourceType resourceTypeInScope = GetResourceTypeForScope(scope); + return _filterParser.Parse(parameterValue, resourceTypeInScope); } private void StoreFilterInScope(FilterExpression filter, ResourceFieldChainExpression scope) diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/IncludeQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/IncludeQueryStringParameterReader.cs index 2bed425170..9bdf16851a 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/IncludeQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/IncludeQueryStringParameterReader.cs @@ -27,17 +27,17 @@ public IncludeQueryStringParameterReader(IJsonApiRequest request, IResourceGraph ArgumentGuard.NotNull(options, nameof(options)); _options = options; - _includeParser = new IncludeParser(resourceGraph, ValidateSingleRelationship); + _includeParser = new IncludeParser(ValidateSingleRelationship); } - protected void ValidateSingleRelationship(RelationshipAttribute relationship, ResourceContext resourceContext, string path) + protected void ValidateSingleRelationship(RelationshipAttribute relationship, ResourceType resourceType, string path) { if (!relationship.CanInclude) { throw new InvalidQueryStringParameterException(_lastParameterName, "Including the requested relationship is not allowed.", path == relationship.PublicName - ? $"Including the relationship '{relationship.PublicName}' on '{resourceContext.PublicName}' is not allowed." - : $"Including the relationship '{relationship.PublicName}' in '{path}' on '{resourceContext.PublicName}' is not allowed."); + ? $"Including the relationship '{relationship.PublicName}' on '{resourceType.PublicName}' is not allowed." + : $"Including the relationship '{relationship.PublicName}' in '{path}' on '{resourceType.PublicName}' is not allowed."); } } @@ -72,7 +72,7 @@ public virtual void Read(string parameterName, StringValues parameterValue) private IncludeExpression GetInclude(string parameterValue) { - return _includeParser.Parse(parameterValue, RequestResource, _options.MaximumIncludeDepth); + return _includeParser.Parse(parameterValue, RequestResourceType, _options.MaximumIncludeDepth); } /// diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/PaginationQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/PaginationQueryStringParameterReader.cs index 47c6ec595e..e54d9617b0 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/PaginationQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/PaginationQueryStringParameterReader.cs @@ -31,7 +31,7 @@ public PaginationQueryStringParameterReader(IJsonApiRequest request, IResourceGr ArgumentGuard.NotNull(options, nameof(options)); _options = options; - _paginationParser = new PaginationParser(resourceGraph); + _paginationParser = new PaginationParser(); } /// @@ -45,7 +45,7 @@ public virtual bool IsEnabled(DisableQueryStringAttribute disableQueryStringAttr /// public virtual bool CanRead(string parameterName) { - return parameterName == PageSizeParameterName || parameterName == PageNumberParameterName; + return parameterName is PageSizeParameterName or PageNumberParameterName; } /// @@ -79,7 +79,7 @@ public virtual void Read(string parameterName, StringValues parameterValue) private PaginationQueryStringValueExpression GetPageConstraint(string parameterValue) { - return _paginationParser.Parse(parameterValue, RequestResource); + return _paginationParser.Parse(parameterValue, RequestResourceType); } protected virtual void ValidatePageSize(PaginationQueryStringValueExpression constraint) @@ -120,12 +120,12 @@ protected virtual void ValidatePageNumber(PaginationQueryStringValueExpression c /// public virtual IReadOnlyCollection GetConstraints() { - var context = new PaginationContext(); + var paginationState = new PaginationState(); foreach (PaginationElementQueryStringValueExpression element in _pageSizeConstraint?.Elements ?? ImmutableArray.Empty) { - MutablePaginationEntry entry = context.ResolveEntryInScope(element.Scope); + MutablePaginationEntry entry = paginationState.ResolveEntryInScope(element.Scope); entry.PageSize = element.Value == 0 ? null : new PageSize(element.Value); entry.HasSetPageSize = true; } @@ -133,16 +133,16 @@ public virtual IReadOnlyCollection GetConstraints() foreach (PaginationElementQueryStringValueExpression element in _pageNumberConstraint?.Elements ?? ImmutableArray.Empty) { - MutablePaginationEntry entry = context.ResolveEntryInScope(element.Scope); + MutablePaginationEntry entry = paginationState.ResolveEntryInScope(element.Scope); entry.PageNumber = new PageNumber(element.Value); } - context.ApplyOptions(_options); + paginationState.ApplyOptions(_options); - return context.GetExpressionsInScope(); + return paginationState.GetExpressionsInScope(); } - private sealed class PaginationContext + private sealed class PaginationState { private readonly MutablePaginationEntry _globalScope = new(); private readonly Dictionary _nestedScopes = new(); diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/QueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/QueryStringParameterReader.cs index b026ae7587..615bf8ced8 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/QueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/QueryStringParameterReader.cs @@ -1,4 +1,3 @@ -using System; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries.Expressions; @@ -12,7 +11,7 @@ public abstract class QueryStringParameterReader private readonly IResourceGraph _resourceGraph; private readonly bool _isCollectionRequest; - protected ResourceContext RequestResource { get; } + protected ResourceType RequestResourceType { get; } protected bool IsAtomicOperationsRequest { get; } protected QueryStringParameterReader(IJsonApiRequest request, IResourceGraph resourceGraph) @@ -22,21 +21,25 @@ protected QueryStringParameterReader(IJsonApiRequest request, IResourceGraph res _resourceGraph = resourceGraph; _isCollectionRequest = request.IsCollection; - RequestResource = request.SecondaryResource ?? request.PrimaryResource; + RequestResourceType = request.SecondaryResourceType ?? request.PrimaryResourceType; IsAtomicOperationsRequest = request.Kind == EndpointKind.AtomicOperations; } - protected ResourceContext GetResourceContextForScope(ResourceFieldChainExpression scope) + protected ResourceType GetResourceTypeForScope(ResourceFieldChainExpression scope) { if (scope == null) { - return RequestResource; + return RequestResourceType; } ResourceFieldAttribute lastField = scope.Fields[^1]; - Type type = lastField is RelationshipAttribute relationship ? relationship.RightType : lastField.Property.PropertyType; - return _resourceGraph.GetResourceContext(type); + if (lastField is RelationshipAttribute relationship) + { + return relationship.RightType; + } + + return _resourceGraph.GetResourceType(lastField.Property.PropertyType); } protected void AssertIsCollectionRequest() diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/ResourceDefinitionQueryableParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/ResourceDefinitionQueryableParameterReader.cs index 5a3e1cab3c..98994c4c6c 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/ResourceDefinitionQueryableParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/ResourceDefinitionQueryableParameterReader.cs @@ -56,8 +56,8 @@ public virtual void Read(string parameterName, StringValues parameterValue) private object GetQueryableHandler(string parameterName) { - Type resourceType = (_request.SecondaryResource ?? _request.PrimaryResource).ResourceType; - object handler = _resourceDefinitionAccessor.GetQueryableHandlerForQueryStringParameter(resourceType, parameterName); + Type resourceClrType = (_request.SecondaryResourceType ?? _request.PrimaryResourceType).ClrType; + object handler = _resourceDefinitionAccessor.GetQueryableHandlerForQueryStringParameter(resourceClrType, parameterName); if (handler != null && _request.Kind != EndpointKind.Primary) { diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/SortQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/SortQueryStringParameterReader.cs index e1ca5e0cd8..c9583b5edf 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/SortQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/SortQueryStringParameterReader.cs @@ -24,11 +24,11 @@ public class SortQueryStringParameterReader : QueryStringParameterReader, ISortQ public SortQueryStringParameterReader(IJsonApiRequest request, IResourceGraph resourceGraph) : base(request, resourceGraph) { - _scopeParser = new QueryStringParameterScopeParser(resourceGraph, FieldChainRequirements.EndsInToMany); - _sortParser = new SortParser(resourceGraph, ValidateSingleField); + _scopeParser = new QueryStringParameterScopeParser(FieldChainRequirements.EndsInToMany); + _sortParser = new SortParser(ValidateSingleField); } - protected void ValidateSingleField(ResourceFieldAttribute field, ResourceContext resourceContext, string path) + protected void ValidateSingleField(ResourceFieldAttribute field, ResourceType resourceType, string path) { if (field is AttrAttribute attribute && !attribute.Capabilities.HasFlag(AttrCapabilities.AllowSort)) { @@ -75,7 +75,7 @@ public virtual void Read(string parameterName, StringValues parameterValue) private ResourceFieldChainExpression GetScope(string parameterName) { - QueryStringParameterScopeExpression parameterScope = _scopeParser.Parse(parameterName, RequestResource); + QueryStringParameterScopeExpression parameterScope = _scopeParser.Parse(parameterName, RequestResourceType); if (parameterScope.Scope == null) { @@ -87,8 +87,8 @@ private ResourceFieldChainExpression GetScope(string parameterName) private SortExpression GetSort(string parameterValue, ResourceFieldChainExpression scope) { - ResourceContext resourceContextInScope = GetResourceContextForScope(scope); - return _sortParser.Parse(parameterValue, resourceContextInScope); + ResourceType resourceTypeInScope = GetResourceTypeForScope(scope); + return _sortParser.Parse(parameterValue, resourceTypeInScope); } /// diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/SparseFieldSetQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/SparseFieldSetQueryStringParameterReader.cs index 096b31a7a1..2872351bd9 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/SparseFieldSetQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/SparseFieldSetQueryStringParameterReader.cs @@ -22,8 +22,8 @@ public class SparseFieldSetQueryStringParameterReader : QueryStringParameterRead 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; @@ -34,10 +34,10 @@ public SparseFieldSetQueryStringParameterReader(IJsonApiRequest request, IResour : base(request, resourceGraph) { _sparseFieldTypeParser = new SparseFieldTypeParser(resourceGraph); - _sparseFieldSetParser = new SparseFieldSetParser(resourceGraph, ValidateSingleField); + _sparseFieldSetParser = new SparseFieldSetParser(ValidateSingleField); } - protected void ValidateSingleField(ResourceFieldAttribute field, ResourceContext resourceContext, string path) + protected void ValidateSingleField(ResourceFieldAttribute field, ResourceType resourceType, string path) { if (field is AttrAttribute attribute && !attribute.Capabilities.HasFlag(AttrCapabilities.AllowView)) { @@ -69,7 +69,7 @@ public virtual void Read(string parameterName, StringValues parameterValue) try { - ResourceContext targetResource = GetSparseFieldType(parameterName); + ResourceType targetResource = GetSparseFieldType(parameterName); SparseFieldSetExpression sparseFieldSet = GetSparseFieldSet(parameterValue, targetResource); _sparseFieldTableBuilder[targetResource] = sparseFieldSet; @@ -80,19 +80,19 @@ public virtual void Read(string parameterName, StringValues parameterValue) } } - private ResourceContext GetSparseFieldType(string parameterName) + private ResourceType GetSparseFieldType(string parameterName) { return _sparseFieldTypeParser.Parse(parameterName); } - private SparseFieldSetExpression GetSparseFieldSet(string parameterValue, ResourceContext resourceContext) + private SparseFieldSetExpression GetSparseFieldSet(string parameterValue, ResourceType resourceType) { - SparseFieldSetExpression sparseFieldSet = _sparseFieldSetParser.Parse(parameterValue, resourceContext); + SparseFieldSetExpression sparseFieldSet = _sparseFieldSetParser.Parse(parameterValue, resourceType); 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 = resourceContext.GetAttributeByPropertyName(nameof(Identifiable.Id)); + AttrAttribute idAttribute = resourceType.GetAttributeByPropertyName(nameof(Identifiable.Id)); return new SparseFieldSetExpression(ImmutableHashSet.Create(idAttribute)); } diff --git a/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs b/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs index 4610beb0e6..f80bb3833c 100644 --- a/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs +++ b/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs @@ -37,17 +37,17 @@ public static object GetTrackedIdentifiable(this DbContext dbContext, IIdentifia ArgumentGuard.NotNull(dbContext, nameof(dbContext)); ArgumentGuard.NotNull(identifiable, nameof(identifiable)); - Type resourceType = identifiable.GetType(); + Type resourceClrType = identifiable.GetType(); string stringId = identifiable.StringId; - EntityEntry entityEntry = dbContext.ChangeTracker.Entries().FirstOrDefault(entry => IsResource(entry, resourceType, stringId)); + EntityEntry entityEntry = dbContext.ChangeTracker.Entries().FirstOrDefault(entry => IsResource(entry, resourceClrType, stringId)); return entityEntry?.Entity; } - private static bool IsResource(EntityEntry entry, Type resourceType, string stringId) + private static bool IsResource(EntityEntry entry, Type resourceClrType, string stringId) { - return entry.Entity.GetType() == resourceType && ((IIdentifiable)entry.Entity).StringId == stringId; + return entry.Entity.GetType() == resourceClrType && ((IIdentifiable)entry.Entity).StringId == stringId; } /// diff --git a/src/JsonApiDotNetCore/Repositories/DbContextResolver.cs b/src/JsonApiDotNetCore/Repositories/DbContextResolver.cs index 4e1c7b4552..61dfcb388d 100644 --- a/src/JsonApiDotNetCore/Repositories/DbContextResolver.cs +++ b/src/JsonApiDotNetCore/Repositories/DbContextResolver.cs @@ -8,23 +8,23 @@ namespace JsonApiDotNetCore.Repositories public sealed class DbContextResolver : IDbContextResolver where TDbContext : DbContext { - private readonly TDbContext _context; + private readonly TDbContext _dbContext; - public DbContextResolver(TDbContext context) + public DbContextResolver(TDbContext dbContext) { - ArgumentGuard.NotNull(context, nameof(context)); + ArgumentGuard.NotNull(dbContext, nameof(dbContext)); - _context = context; + _dbContext = dbContext; } public DbContext GetContext() { - return _context; + return _dbContext; } public TDbContext GetTypedContext() { - return _context; + return _dbContext; } } } diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index c6ba15f10d..802d9cb100 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -41,12 +41,12 @@ public class EntityFrameworkCoreRepository : IResourceRepository /// public virtual string TransactionId => _dbContext.Database.CurrentTransaction?.TransactionId.ToString(); - public EntityFrameworkCoreRepository(ITargetedFields targetedFields, IDbContextResolver contextResolver, IResourceGraph resourceGraph, + public EntityFrameworkCoreRepository(ITargetedFields targetedFields, IDbContextResolver dbContextResolver, IResourceGraph resourceGraph, IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory, IResourceDefinitionAccessor resourceDefinitionAccessor) { ArgumentGuard.NotNull(targetedFields, nameof(targetedFields)); - ArgumentGuard.NotNull(contextResolver, nameof(contextResolver)); + ArgumentGuard.NotNull(dbContextResolver, nameof(dbContextResolver)); ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); ArgumentGuard.NotNull(resourceFactory, nameof(resourceFactory)); ArgumentGuard.NotNull(constraintProviders, nameof(constraintProviders)); @@ -54,7 +54,7 @@ public EntityFrameworkCoreRepository(ITargetedFields targetedFields, IDbContextR ArgumentGuard.NotNull(resourceDefinitionAccessor, nameof(resourceDefinitionAccessor)); _targetedFields = targetedFields; - _dbContext = contextResolver.GetContext(); + _dbContext = dbContextResolver.GetContext(); _resourceGraph = resourceGraph; _resourceFactory = resourceFactory; _constraintProviders = constraintProviders; @@ -93,9 +93,9 @@ public virtual async Task CountAsync(FilterExpression topFilter, Cancellati using (CodeTimingSessionManager.Current.Measure("Repository - Count resources")) { - ResourceContext resourceContext = _resourceGraph.GetResourceContext(); + ResourceType resourceType = _resourceGraph.GetResourceType(); - var layer = new QueryLayer(resourceContext) + var layer = new QueryLayer(resourceType) { Filter = topFilter }; @@ -142,8 +142,7 @@ protected virtual IQueryable ApplyQueryLayer(QueryLayer layer) var nameFactory = new LambdaParameterNameFactory(); - var builder = new QueryableBuilder(source.Expression, source.ElementType, typeof(Queryable), nameFactory, _resourceFactory, _resourceGraph, - _dbContext.Model); + var builder = new QueryableBuilder(source.Expression, source.ElementType, typeof(Queryable), nameFactory, _resourceFactory, _dbContext.Model); Expression expression = builder.ApplyQuery(layer); @@ -297,8 +296,8 @@ protected void AssertIsNotClearingRequiredRelationship(RelationshipAttribute rel if (relationshipIsRequired && relationshipIsBeingCleared) { - string resourceType = _resourceGraph.GetResourceContext().PublicName; - throw new CannotClearRequiredRelationshipException(relationship.PublicName, leftResource.StringId, resourceType); + string resourceName = _resourceGraph.GetResourceType().PublicName; + throw new CannotClearRequiredRelationshipException(relationship.PublicName, leftResource.StringId, resourceName); } } @@ -335,7 +334,7 @@ public virtual async Task DeleteAsync(TId id, CancellationToken cancellationToke var resourceTracked = (TResource)_dbContext.GetTrackedOrAttach(placeholderResource); - foreach (RelationshipAttribute relationship in _resourceGraph.GetResourceContext().Relationships) + foreach (RelationshipAttribute relationship in _resourceGraph.GetResourceType().Relationships) { // Loads the data of the relationship, if in EF Core it is configured in such a way that loading the related // entities into memory is required for successfully executing the selected deletion behavior. @@ -570,7 +569,7 @@ protected virtual async Task SaveChangesAsync(CancellationToken cancellationToke await _dbContext.SaveChangesAsync(cancellationToken); } - catch (Exception exception) when (exception is DbUpdateException || exception is InvalidOperationException) + catch (Exception exception) when (exception is DbUpdateException or InvalidOperationException) { if (_dbContext.Database.CurrentTransaction != null) { @@ -593,10 +592,10 @@ protected virtual async Task SaveChangesAsync(CancellationToken cancellationToke public class EntityFrameworkCoreRepository : EntityFrameworkCoreRepository, IResourceRepository where TResource : class, IIdentifiable { - public EntityFrameworkCoreRepository(ITargetedFields targetedFields, IDbContextResolver contextResolver, IResourceGraph resourceGraph, + public EntityFrameworkCoreRepository(ITargetedFields targetedFields, IDbContextResolver dbContextResolver, IResourceGraph resourceGraph, IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory, IResourceDefinitionAccessor resourceDefinitionAccessor) - : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor) + : base(targetedFields, dbContextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor) { } } diff --git a/src/JsonApiDotNetCore/Repositories/IResourceRepositoryAccessor.cs b/src/JsonApiDotNetCore/Repositories/IResourceRepositoryAccessor.cs index 4925697112..bf67a0a480 100644 --- a/src/JsonApiDotNetCore/Repositories/IResourceRepositoryAccessor.cs +++ b/src/JsonApiDotNetCore/Repositories/IResourceRepositoryAccessor.cs @@ -1,7 +1,7 @@ -using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources; @@ -22,7 +22,7 @@ Task> GetAsync(QueryLayer layer, Cance /// /// Invokes for the specified resource type. /// - Task> GetAsync(Type resourceType, QueryLayer layer, CancellationToken cancellationToken); + Task> GetAsync(ResourceType resourceType, QueryLayer layer, CancellationToken cancellationToken); /// /// Invokes for the specified resource type. diff --git a/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs b/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs index d1ccdb418d..1e71016a15 100644 --- a/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs +++ b/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs @@ -41,7 +41,7 @@ public async Task> GetAsync(QueryLayer } /// - public async Task> GetAsync(Type resourceType, QueryLayer layer, CancellationToken cancellationToken) + public async Task> GetAsync(ResourceType resourceType, QueryLayer layer, CancellationToken cancellationToken) { ArgumentGuard.NotNull(resourceType, nameof(resourceType)); @@ -122,13 +122,17 @@ public async Task RemoveFromToManyRelationshipAsync(TResource leftRes await repository.RemoveFromToManyRelationshipAsync(leftResource, rightResourceIds, cancellationToken); } - protected virtual object ResolveReadRepository(Type resourceType) + protected object ResolveReadRepository(Type resourceClrType) { - ResourceContext resourceContext = _resourceGraph.GetResourceContext(resourceType); + ResourceType resourceType = _resourceGraph.GetResourceType(resourceClrType); + return ResolveReadRepository(resourceType); + } - if (resourceContext.IdentityType == typeof(int)) + protected virtual object ResolveReadRepository(ResourceType resourceType) + { + if (resourceType.IdentityClrType == typeof(int)) { - Type intRepositoryType = typeof(IResourceReadRepository<>).MakeGenericType(resourceContext.ResourceType); + Type intRepositoryType = typeof(IResourceReadRepository<>).MakeGenericType(resourceType.ClrType); object intRepository = _serviceProvider.GetService(intRepositoryType); if (intRepository != null) @@ -137,20 +141,20 @@ protected virtual object ResolveReadRepository(Type resourceType) } } - Type resourceDefinitionType = typeof(IResourceReadRepository<,>).MakeGenericType(resourceContext.ResourceType, resourceContext.IdentityType); + Type resourceDefinitionType = typeof(IResourceReadRepository<,>).MakeGenericType(resourceType.ClrType, resourceType.IdentityClrType); return _serviceProvider.GetRequiredService(resourceDefinitionType); } - private object GetWriteRepository(Type resourceType) + private object GetWriteRepository(Type resourceClrType) { - object writeRepository = ResolveWriteRepository(resourceType); + object writeRepository = ResolveWriteRepository(resourceClrType); if (_request.TransactionId != null) { if (writeRepository is not IRepositorySupportsTransaction repository) { - ResourceContext resourceContext = _resourceGraph.GetResourceContext(resourceType); - throw new MissingTransactionSupportException(resourceContext.PublicName); + ResourceType resourceType = _resourceGraph.GetResourceType(resourceClrType); + throw new MissingTransactionSupportException(resourceType.PublicName); } if (repository.TransactionId != _request.TransactionId) @@ -162,13 +166,13 @@ private object GetWriteRepository(Type resourceType) return writeRepository; } - protected virtual object ResolveWriteRepository(Type resourceType) + protected virtual object ResolveWriteRepository(Type resourceClrType) { - ResourceContext resourceContext = _resourceGraph.GetResourceContext(resourceType); + ResourceType resourceType = _resourceGraph.GetResourceType(resourceClrType); - if (resourceContext.IdentityType == typeof(int)) + if (resourceType.IdentityClrType == typeof(int)) { - Type intRepositoryType = typeof(IResourceWriteRepository<>).MakeGenericType(resourceContext.ResourceType); + Type intRepositoryType = typeof(IResourceWriteRepository<>).MakeGenericType(resourceType.ClrType); object intRepository = _serviceProvider.GetService(intRepositoryType); if (intRepository != null) @@ -177,7 +181,7 @@ protected virtual object ResolveWriteRepository(Type resourceType) } } - Type resourceDefinitionType = typeof(IResourceWriteRepository<,>).MakeGenericType(resourceContext.ResourceType, resourceContext.IdentityType); + Type resourceDefinitionType = typeof(IResourceWriteRepository<,>).MakeGenericType(resourceType.ClrType, resourceType.IdentityClrType); return _serviceProvider.GetRequiredService(resourceDefinitionType); } } diff --git a/src/JsonApiDotNetCore/Resources/Annotations/RelationshipAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/RelationshipAttribute.cs index eaeb10360d..732da4c4fc 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/RelationshipAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/RelationshipAttribute.cs @@ -16,19 +16,36 @@ public abstract class RelationshipAttribute : ResourceFieldAttribute private protected static readonly CollectionConverter CollectionConverter = new(); /// - /// The property name of the EF Core inverse navigation, which may or may not exist. Even if it exists, it may not be exposed as a JSON:API relationship. + /// The CLR type in which this relationship is declared. + /// + 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 of the EF Core inverse navigation, which may or may not exist. Even if it exists, it may not be exposed as a JSON:API + /// relationship. /// /// /// Articles { get; set; } /// } /// ]]> @@ -36,20 +53,15 @@ public abstract class RelationshipAttribute : ResourceFieldAttribute public PropertyInfo InverseNavigationProperty { get; set; } /// - /// The containing type in which this relationship is declared. + /// The containing resource type in which this relationship is declared. /// - public Type LeftType { get; internal set; } + public ResourceType LeftType { get; internal set; } /// - /// The type this relationship points to. This does not necessarily match the relationship property type. In the case of a - /// relationship, this value will be the collection element type. + /// The resource type this relationship points to. In the case of a relationship, this value will be the collection + /// element type. /// - /// - /// Tags { get; set; } // RightType == typeof(Tag) - /// ]]> - /// - public Type RightType { get; internal set; } + public ResourceType RightType { get; internal set; } /// /// Configures which links to show in the object for this relationship. Defaults to @@ -101,12 +113,13 @@ public override bool Equals(object obj) var other = (RelationshipAttribute)obj; - return LeftType == other.LeftType && RightType == other.RightType && Links == other.Links && CanInclude == other.CanInclude && base.Equals(other); + return LeftClrType == other.LeftClrType && RightClrType == other.RightClrType && Links == other.Links && CanInclude == other.CanInclude && + base.Equals(other); } public override int GetHashCode() { - return HashCode.Combine(LeftType, RightType, Links, CanInclude, base.GetHashCode()); + return HashCode.Combine(LeftClrType, RightClrType, Links, CanInclude, base.GetHashCode()); } } } diff --git a/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs b/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs index 4fae023853..a310790fdc 100644 --- a/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs +++ b/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs @@ -44,7 +44,7 @@ public interface IResourceDefinition /// /// The new set of includes. Return an empty collection to remove all inclusions (never return null). /// - IImmutableList OnApplyIncludes(IImmutableList existingIncludes); + IImmutableSet OnApplyIncludes(IImmutableSet existingIncludes); /// /// Enables to extend, replace or remove a filter that is being applied on a set of this resource type. diff --git a/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs b/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs index 8c804d3602..c4b91365bd 100644 --- a/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs +++ b/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources.Annotations; @@ -18,38 +19,38 @@ public interface IResourceDefinitionAccessor /// /// Invokes for the specified resource type. /// - IImmutableList OnApplyIncludes(Type resourceType, IImmutableList existingIncludes); + IImmutableSet OnApplyIncludes(ResourceType resourceType, IImmutableSet existingIncludes); /// /// Invokes for the specified resource type. /// - FilterExpression OnApplyFilter(Type resourceType, FilterExpression existingFilter); + FilterExpression OnApplyFilter(ResourceType resourceType, FilterExpression existingFilter); /// /// Invokes for the specified resource type. /// - SortExpression OnApplySort(Type resourceType, SortExpression existingSort); + SortExpression OnApplySort(ResourceType resourceType, SortExpression existingSort); /// /// Invokes for the specified resource type. /// - PaginationExpression OnApplyPagination(Type resourceType, PaginationExpression existingPagination); + PaginationExpression OnApplyPagination(ResourceType resourceType, PaginationExpression existingPagination); /// /// Invokes for the specified resource type. /// - SparseFieldSetExpression OnApplySparseFieldSet(Type resourceType, SparseFieldSetExpression existingSparseFieldSet); + SparseFieldSetExpression OnApplySparseFieldSet(ResourceType resourceType, SparseFieldSetExpression existingSparseFieldSet); /// /// Invokes for the specified resource type, then /// returns the expression for the specified parameter name. /// - object GetQueryableHandlerForQueryStringParameter(Type resourceType, string parameterName); + object GetQueryableHandlerForQueryStringParameter(Type resourceClrType, string parameterName); /// /// Invokes for the specified resource. /// - IDictionary GetMeta(Type resourceType, IIdentifiable resourceInstance); + IDictionary GetMeta(ResourceType resourceType, IIdentifiable resourceInstance); /// /// Invokes for the specified resource. diff --git a/src/JsonApiDotNetCore/Resources/IResourceFactory.cs b/src/JsonApiDotNetCore/Resources/IResourceFactory.cs index 38a25ad996..1e37304528 100644 --- a/src/JsonApiDotNetCore/Resources/IResourceFactory.cs +++ b/src/JsonApiDotNetCore/Resources/IResourceFactory.cs @@ -11,7 +11,7 @@ public interface IResourceFactory /// /// Creates a new resource object instance. /// - public IIdentifiable CreateInstance(Type resourceType); + public IIdentifiable CreateInstance(Type resourceClrType); /// /// Creates a new resource object instance. @@ -22,6 +22,6 @@ public TResource CreateInstance() /// /// Returns an expression tree that represents creating a new resource object instance. /// - public NewExpression CreateNewExpression(Type resourceType); + public NewExpression CreateNewExpression(Type resourceClrType); } } diff --git a/src/JsonApiDotNetCore/Resources/ITargetedFields.cs b/src/JsonApiDotNetCore/Resources/ITargetedFields.cs index 5cdb36950d..1498f96744 100644 --- a/src/JsonApiDotNetCore/Resources/ITargetedFields.cs +++ b/src/JsonApiDotNetCore/Resources/ITargetedFields.cs @@ -4,18 +4,23 @@ namespace JsonApiDotNetCore.Resources { /// - /// Container to register which resource attributes and relationships are targeted by a request. + /// Container to register which resource fields (attributes and relationships) are targeted by a request. /// public interface ITargetedFields { /// /// The set of attributes that are targeted by a request. /// - ISet Attributes { get; set; } + IReadOnlySet Attributes { get; } /// /// The set of relationships that are targeted by a request. /// - ISet Relationships { get; set; } + IReadOnlySet Relationships { get; } + + /// + /// Performs a shallow copy. + /// + void CopyFrom(ITargetedFields other); } } diff --git a/src/JsonApiDotNetCore/Resources/Internal/RuntimeTypeConverter.cs b/src/JsonApiDotNetCore/Resources/Internal/RuntimeTypeConverter.cs index f7aaad9192..826b375bd3 100644 --- a/src/JsonApiDotNetCore/Resources/Internal/RuntimeTypeConverter.cs +++ b/src/JsonApiDotNetCore/Resources/Internal/RuntimeTypeConverter.cs @@ -71,8 +71,7 @@ public static object ConvertType(object value, Type type) // https://bradwilson.typepad.com/blog/2008/07/creating-nullab.html return Convert.ChangeType(stringValue, nonNullableType); } - catch (Exception exception) when (exception is FormatException || exception is OverflowException || exception is InvalidCastException || - exception is ArgumentException) + catch (Exception exception) when (exception is FormatException or OverflowException or InvalidCastException or ArgumentException) { string runtimeTypeName = runtimeType.GetFriendlyTypeName(); string targetTypeName = type.GetFriendlyTypeName(); diff --git a/src/JsonApiDotNetCore/Resources/JsonApiResourceDefinition.cs b/src/JsonApiDotNetCore/Resources/JsonApiResourceDefinition.cs index 755ce781cb..b4110b28cd 100644 --- a/src/JsonApiDotNetCore/Resources/JsonApiResourceDefinition.cs +++ b/src/JsonApiDotNetCore/Resources/JsonApiResourceDefinition.cs @@ -41,18 +41,18 @@ public class JsonApiResourceDefinition : IResourceDefinition /// Provides metadata for the resource type . /// - protected ResourceContext ResourceContext { get; } + protected ResourceType ResourceType { get; } public JsonApiResourceDefinition(IResourceGraph resourceGraph) { ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); ResourceGraph = resourceGraph; - ResourceContext = resourceGraph.GetResourceContext(); + ResourceType = resourceGraph.GetResourceType(); } /// - public virtual IImmutableList OnApplyIncludes(IImmutableList existingIncludes) + public virtual IImmutableSet OnApplyIncludes(IImmutableSet existingIncludes) { return existingIncludes; } diff --git a/src/JsonApiDotNetCore/Resources/OperationContainer.cs b/src/JsonApiDotNetCore/Resources/OperationContainer.cs index d5350a34dc..ada612de59 100644 --- a/src/JsonApiDotNetCore/Resources/OperationContainer.cs +++ b/src/JsonApiDotNetCore/Resources/OperationContainer.cs @@ -13,18 +13,16 @@ public sealed class OperationContainer { private static readonly CollectionConverter CollectionConverter = new(); - public WriteOperationKind Kind { get; } public IIdentifiable Resource { get; } public ITargetedFields TargetedFields { get; } public IJsonApiRequest Request { get; } - public OperationContainer(WriteOperationKind kind, IIdentifiable resource, ITargetedFields targetedFields, IJsonApiRequest request) + public OperationContainer(IIdentifiable resource, ITargetedFields targetedFields, IJsonApiRequest request) { ArgumentGuard.NotNull(resource, nameof(resource)); ArgumentGuard.NotNull(targetedFields, nameof(targetedFields)); ArgumentGuard.NotNull(request, nameof(request)); - Kind = kind; Resource = resource; TargetedFields = targetedFields; Request = request; @@ -39,7 +37,7 @@ public OperationContainer WithResource(IIdentifiable resource) { ArgumentGuard.NotNull(resource, nameof(resource)); - return new OperationContainer(Kind, resource, TargetedFields, Request); + return new OperationContainer(resource, TargetedFields, Request); } public ISet GetSecondaryResources() diff --git a/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs b/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs index b29fe33ef1..1158df6183 100644 --- a/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs +++ b/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs @@ -11,7 +11,7 @@ namespace JsonApiDotNetCore.Resources public sealed class ResourceChangeTracker : IResourceChangeTracker where TResource : class, IIdentifiable { - private readonly IResourceGraph _resourceGraph; + private readonly ResourceType _resourceType; private readonly ITargetedFields _targetedFields; private IDictionary _initiallyStoredAttributeValues; @@ -23,7 +23,7 @@ public ResourceChangeTracker(IResourceGraph resourceGraph, ITargetedFields targe ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); ArgumentGuard.NotNull(targetedFields, nameof(targetedFields)); - _resourceGraph = resourceGraph; + _resourceType = resourceGraph.GetResourceType(); _targetedFields = targetedFields; } @@ -32,8 +32,7 @@ public void SetInitiallyStoredAttributeValues(TResource resource) { ArgumentGuard.NotNull(resource, nameof(resource)); - ResourceContext resourceContext = _resourceGraph.GetResourceContext(); - _initiallyStoredAttributeValues = CreateAttributeDictionary(resource, resourceContext.Attributes); + _initiallyStoredAttributeValues = CreateAttributeDictionary(resource, _resourceType.Attributes); } /// @@ -49,8 +48,7 @@ public void SetFinallyStoredAttributeValues(TResource resource) { ArgumentGuard.NotNull(resource, nameof(resource)); - ResourceContext resourceContext = _resourceGraph.GetResourceContext(); - _finallyStoredAttributeValues = CreateAttributeDictionary(resource, resourceContext.Attributes); + _finallyStoredAttributeValues = CreateAttributeDictionary(resource, _resourceType.Attributes); } private IDictionary CreateAttributeDictionary(TResource resource, IEnumerable attributes) diff --git a/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs b/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs index 1923c33156..36e8f008d3 100644 --- a/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs +++ b/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs @@ -29,7 +29,7 @@ public ResourceDefinitionAccessor(IResourceGraph resourceGraph, IServiceProvider } /// - public IImmutableList OnApplyIncludes(Type resourceType, IImmutableList existingIncludes) + public IImmutableSet OnApplyIncludes(ResourceType resourceType, IImmutableSet existingIncludes) { ArgumentGuard.NotNull(resourceType, nameof(resourceType)); @@ -38,7 +38,7 @@ public IImmutableList OnApplyIncludes(Type resourceTyp } /// - public FilterExpression OnApplyFilter(Type resourceType, FilterExpression existingFilter) + public FilterExpression OnApplyFilter(ResourceType resourceType, FilterExpression existingFilter) { ArgumentGuard.NotNull(resourceType, nameof(resourceType)); @@ -47,7 +47,7 @@ public FilterExpression OnApplyFilter(Type resourceType, FilterExpression existi } /// - public SortExpression OnApplySort(Type resourceType, SortExpression existingSort) + public SortExpression OnApplySort(ResourceType resourceType, SortExpression existingSort) { ArgumentGuard.NotNull(resourceType, nameof(resourceType)); @@ -56,7 +56,7 @@ public SortExpression OnApplySort(Type resourceType, SortExpression existingSort } /// - public PaginationExpression OnApplyPagination(Type resourceType, PaginationExpression existingPagination) + public PaginationExpression OnApplyPagination(ResourceType resourceType, PaginationExpression existingPagination) { ArgumentGuard.NotNull(resourceType, nameof(resourceType)); @@ -65,7 +65,7 @@ public PaginationExpression OnApplyPagination(Type resourceType, PaginationExpre } /// - public SparseFieldSetExpression OnApplySparseFieldSet(Type resourceType, SparseFieldSetExpression existingSparseFieldSet) + public SparseFieldSetExpression OnApplySparseFieldSet(ResourceType resourceType, SparseFieldSetExpression existingSparseFieldSet) { ArgumentGuard.NotNull(resourceType, nameof(resourceType)); @@ -74,19 +74,19 @@ public SparseFieldSetExpression OnApplySparseFieldSet(Type resourceType, SparseF } /// - public object GetQueryableHandlerForQueryStringParameter(Type resourceType, string parameterName) + public object GetQueryableHandlerForQueryStringParameter(Type resourceClrType, string parameterName) { - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + ArgumentGuard.NotNull(resourceClrType, nameof(resourceClrType)); ArgumentGuard.NotNullNorEmpty(parameterName, nameof(parameterName)); - dynamic resourceDefinition = ResolveResourceDefinition(resourceType); + dynamic resourceDefinition = ResolveResourceDefinition(resourceClrType); dynamic handlers = resourceDefinition.OnRegisterQueryableHandlersForQueryStringParameters(); return handlers != null && handlers.ContainsKey(parameterName) ? handlers[parameterName] : null; } /// - public IDictionary GetMeta(Type resourceType, IIdentifiable resourceInstance) + public IDictionary GetMeta(ResourceType resourceType, IIdentifiable resourceInstance) { ArgumentGuard.NotNull(resourceType, nameof(resourceType)); @@ -192,13 +192,17 @@ public void OnSerialize(IIdentifiable resource) resourceDefinition.OnSerialize((dynamic)resource); } - protected virtual object ResolveResourceDefinition(Type resourceType) + protected object ResolveResourceDefinition(Type resourceClrType) { - ResourceContext resourceContext = _resourceGraph.GetResourceContext(resourceType); + ResourceType resourceType = _resourceGraph.GetResourceType(resourceClrType); + return ResolveResourceDefinition(resourceType); + } - if (resourceContext.IdentityType == typeof(int)) + protected virtual object ResolveResourceDefinition(ResourceType resourceType) + { + if (resourceType.IdentityClrType == typeof(int)) { - Type intResourceDefinitionType = typeof(IResourceDefinition<>).MakeGenericType(resourceContext.ResourceType); + Type intResourceDefinitionType = typeof(IResourceDefinition<>).MakeGenericType(resourceType.ClrType); object intResourceDefinition = _serviceProvider.GetService(intResourceDefinitionType); if (intResourceDefinition != null) @@ -207,7 +211,7 @@ protected virtual object ResolveResourceDefinition(Type resourceType) } } - Type resourceDefinitionType = typeof(IResourceDefinition<,>).MakeGenericType(resourceContext.ResourceType, resourceContext.IdentityType); + 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 6ab75ded5b..dd5a03306e 100644 --- a/src/JsonApiDotNetCore/Resources/ResourceFactory.cs +++ b/src/JsonApiDotNetCore/Resources/ResourceFactory.cs @@ -20,11 +20,11 @@ public ResourceFactory(IServiceProvider serviceProvider) } /// - public IIdentifiable CreateInstance(Type resourceType) + public IIdentifiable CreateInstance(Type resourceClrType) { - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + ArgumentGuard.NotNull(resourceClrType, nameof(resourceClrType)); - return InnerCreateInstance(resourceType, _serviceProvider); + return InnerCreateInstance(resourceClrType, _serviceProvider); } /// @@ -56,18 +56,18 @@ private static IIdentifiable InnerCreateInstance(Type type, IServiceProvider ser } /// - public NewExpression CreateNewExpression(Type resourceType) + public NewExpression CreateNewExpression(Type resourceClrType) { - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + ArgumentGuard.NotNull(resourceClrType, nameof(resourceClrType)); - if (HasSingleConstructorWithoutParameters(resourceType)) + if (HasSingleConstructorWithoutParameters(resourceClrType)) { - return Expression.New(resourceType); + return Expression.New(resourceClrType); } var constructorArguments = new List(); - ConstructorInfo longestConstructor = GetLongestConstructor(resourceType); + ConstructorInfo longestConstructor = GetLongestConstructor(resourceClrType); foreach (ParameterInfo constructorParameter in longestConstructor.GetParameters()) { @@ -84,7 +84,7 @@ public NewExpression CreateNewExpression(Type resourceType) #pragma warning restore AV1210 // Catch a specific exception instead of Exception, SystemException or ApplicationException { throw new InvalidOperationException( - $"Failed to create an instance of '{resourceType.FullName}': Parameter '{constructorParameter.Name}' could not be resolved.", + $"Failed to create an instance of '{resourceClrType.FullName}': Parameter '{constructorParameter.Name}' could not be resolved.", exception); } } diff --git a/src/JsonApiDotNetCore/Resources/TargetedFields.cs b/src/JsonApiDotNetCore/Resources/TargetedFields.cs index 46cd2fed6a..4e2d571e5c 100644 --- a/src/JsonApiDotNetCore/Resources/TargetedFields.cs +++ b/src/JsonApiDotNetCore/Resources/TargetedFields.cs @@ -1,15 +1,32 @@ using System.Collections.Generic; +using JetBrains.Annotations; using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCore.Resources { /// + [PublicAPI] public sealed class TargetedFields : ITargetedFields { - /// - public ISet Attributes { get; set; } = new HashSet(); + IReadOnlySet ITargetedFields.Attributes => Attributes; + IReadOnlySet ITargetedFields.Relationships => Relationships; + + public HashSet Attributes { get; } = new(); + public HashSet Relationships { get; } = new(); /// - public ISet Relationships { get; set; } = new HashSet(); + public void CopyFrom(ITargetedFields other) + { + Clear(); + + Attributes.AddRange(other.Attributes); + Relationships.AddRange(other.Relationships); + } + + public void Clear() + { + Attributes.Clear(); + Relationships.Clear(); + } } } diff --git a/src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs b/src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs deleted file mode 100644 index a7892755c5..0000000000 --- a/src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs +++ /dev/null @@ -1,135 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Queries.Internal; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCore.Serialization.Building; -using JsonApiDotNetCore.Serialization.Objects; - -namespace JsonApiDotNetCore.Serialization -{ - /// - /// Server serializer implementation of for atomic:operations responses. - /// - [PublicAPI] - public sealed class AtomicOperationsResponseSerializer : BaseSerializer, IJsonApiSerializer - { - private readonly IMetaBuilder _metaBuilder; - private readonly ILinkBuilder _linkBuilder; - private readonly IFieldsToSerialize _fieldsToSerialize; - private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; - private readonly IEvaluatedIncludeCache _evaluatedIncludeCache; - private readonly IJsonApiRequest _request; - private readonly IJsonApiOptions _options; - - /// - public string ContentType { get; } = HeaderConstants.AtomicOperationsMediaType; - - public AtomicOperationsResponseSerializer(IResourceObjectBuilder resourceObjectBuilder, IMetaBuilder metaBuilder, ILinkBuilder linkBuilder, - IFieldsToSerialize fieldsToSerialize, IResourceDefinitionAccessor resourceDefinitionAccessor, IEvaluatedIncludeCache evaluatedIncludeCache, - IJsonApiRequest request, IJsonApiOptions options) - : base(resourceObjectBuilder) - { - ArgumentGuard.NotNull(metaBuilder, nameof(metaBuilder)); - ArgumentGuard.NotNull(linkBuilder, nameof(linkBuilder)); - ArgumentGuard.NotNull(fieldsToSerialize, nameof(fieldsToSerialize)); - ArgumentGuard.NotNull(resourceDefinitionAccessor, nameof(resourceDefinitionAccessor)); - ArgumentGuard.NotNull(evaluatedIncludeCache, nameof(evaluatedIncludeCache)); - ArgumentGuard.NotNull(request, nameof(request)); - ArgumentGuard.NotNull(options, nameof(options)); - - _metaBuilder = metaBuilder; - _linkBuilder = linkBuilder; - _fieldsToSerialize = fieldsToSerialize; - _resourceDefinitionAccessor = resourceDefinitionAccessor; - _evaluatedIncludeCache = evaluatedIncludeCache; - _request = request; - _options = options; - } - - /// - public string Serialize(object content) - { - if (content is IList operations) - { - return SerializeOperationsDocument(operations); - } - - if (content is Document errorDocument) - { - return SerializeErrorDocument(errorDocument); - } - - throw new InvalidOperationException("Data being returned must be errors or operations."); - } - - private string SerializeOperationsDocument(IEnumerable operations) - { - var document = new Document - { - Results = operations.Select(SerializeOperation).ToList(), - Meta = _metaBuilder.Build() - }; - - SetApiVersion(document); - - return SerializeObject(document, _options.SerializerWriteOptions); - } - - private void SetApiVersion(Document document) - { - if (_options.IncludeJsonApiVersion) - { - document.JsonApi = new JsonApiObject - { - Version = "1.1", - Ext = new List - { - "https://jsonapi.org/ext/atomic" - } - }; - } - } - - private AtomicResultObject SerializeOperation(OperationContainer operation) - { - ResourceObject resourceObject = null; - - if (operation != null) - { - _request.CopyFrom(operation.Request); - _fieldsToSerialize.ResetCache(); - _evaluatedIncludeCache.Set(null); - - _resourceDefinitionAccessor.OnSerialize(operation.Resource); - - Type resourceType = operation.Resource.GetType(); - IReadOnlyCollection attributes = _fieldsToSerialize.GetAttributes(resourceType); - IReadOnlyCollection relationships = _fieldsToSerialize.GetRelationships(resourceType); - - resourceObject = ResourceObjectBuilder.Build(operation.Resource, attributes, relationships); - } - - if (resourceObject != null) - { - resourceObject.Links = _linkBuilder.GetResourceLinks(resourceObject.Type, resourceObject.Id); - } - - return new AtomicResultObject - { - Data = new SingleOrManyData(resourceObject) - }; - } - - private string SerializeErrorDocument(Document document) - { - SetApiVersion(document); - - return SerializeObject(document, _options.SerializerWriteOptions); - } - } -} diff --git a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs deleted file mode 100644 index dfba94691c..0000000000 --- a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs +++ /dev/null @@ -1,359 +0,0 @@ -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using System.Text.Json; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Diagnostics; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCore.Serialization.Objects; - -namespace JsonApiDotNetCore.Serialization -{ - /// - /// Abstract base class for deserialization. Deserializes JSON content into s and constructs instances of the resource(s) - /// in the document body. - /// - [PublicAPI] - public abstract class BaseDeserializer - { - private protected static readonly CollectionConverter CollectionConverter = new(); - - protected IResourceGraph ResourceGraph { get; } - protected IResourceFactory ResourceFactory { get; } - protected int? AtomicOperationIndex { get; set; } - - protected BaseDeserializer(IResourceGraph resourceGraph, IResourceFactory resourceFactory) - { - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); - ArgumentGuard.NotNull(resourceFactory, nameof(resourceFactory)); - - ResourceGraph = resourceGraph; - ResourceFactory = resourceFactory; - } - - /// - /// This method is called each time a is constructed from the serialized content, which is used to do additional processing - /// depending on the type of deserializer. - /// - /// - /// See the implementation of this method in for usage. - /// - /// - /// The resource that was constructed from the document's body. - /// - /// - /// The metadata for the exposed field. - /// - /// - /// Relationship data for . Is null when is not a . - /// - protected abstract void AfterProcessField(IIdentifiable resource, ResourceFieldAttribute field, RelationshipObject data = null); - - protected Document DeserializeDocument(string body, JsonSerializerOptions serializerOptions) - { - ArgumentGuard.NotNullNorEmpty(body, nameof(body)); - - try - { - using (CodeTimingSessionManager.Current.Measure("JsonSerializer.Deserialize", MeasurementSettings.ExcludeJsonSerializationInPercentages)) - { - return JsonSerializer.Deserialize(body, serializerOptions); - } - } - 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 JsonApiSerializationException(null, exception.Message, exception); - } - } - - protected object DeserializeData(string body, JsonSerializerOptions serializerOptions) - { - Document document = DeserializeDocument(body, serializerOptions); - - if (document != null) - { - if (document.Data.ManyValue != null) - { - using (CodeTimingSessionManager.Current.Measure("Deserializer.Build (list)")) - { - return document.Data.ManyValue.Select(ParseResourceObject).ToHashSet(IdentifiableComparer.Instance); - } - } - - if (document.Data.SingleValue != null) - { - using (CodeTimingSessionManager.Current.Measure("Deserializer.Build (single)")) - { - return ParseResourceObject(document.Data.SingleValue); - } - } - } - - return null; - } - - /// - /// Sets the attributes on a parsed resource. - /// - /// - /// The parsed resource. - /// - /// - /// Attributes and their values, as in the serialized content. - /// - /// - /// Exposed attributes for . - /// - private IIdentifiable SetAttributes(IIdentifiable resource, IDictionary attributeValues, IReadOnlyCollection attributes) - { - ArgumentGuard.NotNull(resource, nameof(resource)); - ArgumentGuard.NotNull(attributes, nameof(attributes)); - - if (attributeValues.IsNullOrEmpty()) - { - return resource; - } - - foreach (AttrAttribute attr in attributes) - { - if (attributeValues.TryGetValue(attr.PublicName, out object newValue)) - { - if (attr.Property.SetMethod == null) - { - throw new JsonApiSerializationException("Attribute is read-only.", $"Attribute '{attr.PublicName}' is read-only.", - atomicOperationIndex: AtomicOperationIndex); - } - - if (newValue is JsonInvalidAttributeInfo info) - { - if (newValue == JsonInvalidAttributeInfo.Id) - { - throw new JsonApiSerializationException(null, "Resource ID is read-only.", atomicOperationIndex: AtomicOperationIndex); - } - - string typeName = info.AttributeType.GetFriendlyTypeName(); - - throw new JsonApiSerializationException(null, - $"Failed to convert attribute '{info.AttributeName}' with value '{info.JsonValue}' of type '{info.JsonType}' to type '{typeName}'.", - atomicOperationIndex: AtomicOperationIndex); - } - - attr.SetValue(resource, newValue); - AfterProcessField(resource, attr); - } - } - - return resource; - } - - /// - /// Sets the relationships on a parsed resource. - /// - /// - /// The parsed resource. - /// - /// - /// Relationships and their values, as in the serialized content. - /// - /// - /// Exposed relationships for . - /// - private IIdentifiable SetRelationships(IIdentifiable resource, IDictionary relationshipValues, - IReadOnlyCollection relationshipAttributes) - { - ArgumentGuard.NotNull(resource, nameof(resource)); - ArgumentGuard.NotNull(relationshipAttributes, nameof(relationshipAttributes)); - - if (relationshipValues.IsNullOrEmpty()) - { - return resource; - } - - foreach (RelationshipAttribute attr in relationshipAttributes) - { - bool relationshipIsProvided = relationshipValues.TryGetValue(attr.PublicName, out RelationshipObject relationshipData); - - if (!relationshipIsProvided || !relationshipData.Data.IsAssigned) - { - continue; - } - - if (attr is HasOneAttribute hasOneAttribute) - { - SetHasOneRelationship(resource, hasOneAttribute, relationshipData); - } - else if (attr is HasManyAttribute hasManyAttribute) - { - SetHasManyRelationship(resource, hasManyAttribute, relationshipData); - } - } - - return resource; - } - - /// - /// Creates an instance of the referenced type in and sets its attributes and relationships. - /// - /// - /// The parsed resource. - /// - protected IIdentifiable ParseResourceObject(ResourceObject data) - { - AssertHasType(data, null); - - if (AtomicOperationIndex == null) - { - AssertHasNoLid(data); - } - - ResourceContext resourceContext = GetExistingResourceContext(data.Type); - IIdentifiable resource = ResourceFactory.CreateInstance(resourceContext.ResourceType); - - resource = SetAttributes(resource, data.Attributes, resourceContext.Attributes); - resource = SetRelationships(resource, data.Relationships, resourceContext.Relationships); - - if (data.Id != null) - { - resource.StringId = data.Id; - } - - resource.LocalId = data.Lid; - - return resource; - } - - protected ResourceContext GetExistingResourceContext(string publicName) - { - ResourceContext resourceContext = ResourceGraph.TryGetResourceContext(publicName); - - if (resourceContext == null) - { - throw new JsonApiSerializationException("Request body includes unknown resource type.", $"Resource type '{publicName}' does not exist.", - atomicOperationIndex: AtomicOperationIndex); - } - - return resourceContext; - } - - /// - /// Sets a HasOne relationship on a parsed resource. - /// - private void SetHasOneRelationship(IIdentifiable resource, HasOneAttribute hasOneRelationship, RelationshipObject relationshipData) - { - if (relationshipData.Data.ManyValue != null) - { - throw new JsonApiSerializationException("Expected single data element for to-one relationship.", - $"Expected single data element for '{hasOneRelationship.PublicName}' relationship.", atomicOperationIndex: AtomicOperationIndex); - } - - IIdentifiable rightResource = CreateRightResource(hasOneRelationship, relationshipData.Data.SingleValue); - hasOneRelationship.SetValue(resource, rightResource); - - // depending on if this base parser is used client-side or server-side, - // different additional processing per field needs to be executed. - AfterProcessField(resource, hasOneRelationship, relationshipData); - } - - /// - /// Sets a HasMany relationship. - /// - private void SetHasManyRelationship(IIdentifiable resource, HasManyAttribute hasManyRelationship, RelationshipObject relationshipData) - { - if (relationshipData.Data.ManyValue == null) - { - throw new JsonApiSerializationException("Expected data[] element for to-many relationship.", - $"Expected data[] element for '{hasManyRelationship.PublicName}' relationship.", atomicOperationIndex: AtomicOperationIndex); - } - - HashSet rightResources = relationshipData.Data.ManyValue.Select(rio => CreateRightResource(hasManyRelationship, rio)) - .ToHashSet(IdentifiableComparer.Instance); - - IEnumerable convertedCollection = CollectionConverter.CopyToTypedCollection(rightResources, hasManyRelationship.Property.PropertyType); - hasManyRelationship.SetValue(resource, convertedCollection); - - AfterProcessField(resource, hasManyRelationship, relationshipData); - } - - private IIdentifiable CreateRightResource(RelationshipAttribute relationship, ResourceIdentifierObject resourceIdentifierObject) - { - if (resourceIdentifierObject != null) - { - AssertHasType(resourceIdentifierObject, relationship); - AssertHasIdOrLid(resourceIdentifierObject, relationship); - - ResourceContext rightResourceContext = GetExistingResourceContext(resourceIdentifierObject.Type); - AssertRightTypeIsCompatible(rightResourceContext, relationship); - - IIdentifiable rightInstance = ResourceFactory.CreateInstance(rightResourceContext.ResourceType); - rightInstance.StringId = resourceIdentifierObject.Id; - rightInstance.LocalId = resourceIdentifierObject.Lid; - - return rightInstance; - } - - return null; - } - - [AssertionMethod] - private void AssertHasType(IResourceIdentity resourceIdentity, RelationshipAttribute relationship) - { - if (resourceIdentity.Type == null) - { - string details = relationship != null - ? $"Expected 'type' element in '{relationship.PublicName}' relationship." - : "Expected 'type' element in 'data' element."; - - throw new JsonApiSerializationException("Request body must include 'type' element.", details, atomicOperationIndex: AtomicOperationIndex); - } - } - - private void AssertHasIdOrLid(ResourceIdentifierObject resourceIdentifierObject, RelationshipAttribute relationship) - { - if (AtomicOperationIndex != null) - { - bool hasNone = resourceIdentifierObject.Id == null && resourceIdentifierObject.Lid == null; - bool hasBoth = resourceIdentifierObject.Id != null && resourceIdentifierObject.Lid != null; - - if (hasNone || hasBoth) - { - throw new JsonApiSerializationException("Request body must include 'id' or 'lid' element.", - $"Expected 'id' or 'lid' element in '{relationship.PublicName}' relationship.", atomicOperationIndex: AtomicOperationIndex); - } - } - else - { - if (resourceIdentifierObject.Id == null) - { - throw new JsonApiSerializationException("Request body must include 'id' element.", - $"Expected 'id' element in '{relationship.PublicName}' relationship.", atomicOperationIndex: AtomicOperationIndex); - } - - AssertHasNoLid(resourceIdentifierObject); - } - } - - [AssertionMethod] - private void AssertHasNoLid(IResourceIdentity resourceIdentityObject) - { - if (resourceIdentityObject.Lid != null) - { - throw new JsonApiSerializationException(null, "Local IDs cannot be used at this endpoint.", atomicOperationIndex: AtomicOperationIndex); - } - } - - private void AssertRightTypeIsCompatible(ResourceContext rightResourceContext, RelationshipAttribute relationship) - { - if (!relationship.RightType.IsAssignableFrom(rightResourceContext.ResourceType)) - { - throw new JsonApiSerializationException("Relationship contains incompatible resource type.", - $"Relationship '{relationship.PublicName}' contains incompatible resource type '{rightResourceContext.PublicName}'.", - atomicOperationIndex: AtomicOperationIndex); - } - } - } -} diff --git a/src/JsonApiDotNetCore/Serialization/BaseSerializer.cs b/src/JsonApiDotNetCore/Serialization/BaseSerializer.cs deleted file mode 100644 index d096a4ea6c..0000000000 --- a/src/JsonApiDotNetCore/Serialization/BaseSerializer.cs +++ /dev/null @@ -1,102 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text.Json; -using JsonApiDotNetCore.Diagnostics; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCore.Serialization.Building; -using JsonApiDotNetCore.Serialization.Objects; - -namespace JsonApiDotNetCore.Serialization -{ - /// - /// Abstract base class for serialization. Uses to convert resources into s and wraps - /// them in a . - /// - public abstract class BaseSerializer - { - protected IResourceObjectBuilder ResourceObjectBuilder { get; } - - protected BaseSerializer(IResourceObjectBuilder resourceObjectBuilder) - { - ArgumentGuard.NotNull(resourceObjectBuilder, nameof(resourceObjectBuilder)); - - ResourceObjectBuilder = resourceObjectBuilder; - } - - /// - /// Builds a for . Adds the attributes and relationships that are enlisted in - /// and . - /// - /// - /// Resource to build a for. - /// - /// - /// Attributes to include in the building process. - /// - /// - /// Relationships to include in the building process. - /// - /// - /// The resource object that was built. - /// - protected Document Build(IIdentifiable resource, IReadOnlyCollection attributes, - IReadOnlyCollection relationships) - { - using IDisposable _ = CodeTimingSessionManager.Current.Measure("Serializer.Build (single)"); - - ResourceObject resourceObject = resource != null ? ResourceObjectBuilder.Build(resource, attributes, relationships) : null; - - return new Document - { - Data = new SingleOrManyData(resourceObject) - }; - } - - /// - /// Builds a for . Adds the attributes and relationships that are enlisted in - /// and . - /// - /// - /// Resource to build a for. - /// - /// - /// Attributes to include in the building process. - /// - /// - /// Relationships to include in the building process. - /// - /// - /// The resource object that was built. - /// - protected Document Build(IReadOnlyCollection resources, IReadOnlyCollection attributes, - IReadOnlyCollection relationships) - { - ArgumentGuard.NotNull(resources, nameof(resources)); - - using IDisposable _ = CodeTimingSessionManager.Current.Measure("Serializer.Build (list)"); - - var resourceObjects = new List(); - - foreach (IIdentifiable resource in resources) - { - resourceObjects.Add(ResourceObjectBuilder.Build(resource, attributes, relationships)); - } - - return new Document - { - Data = new SingleOrManyData(resourceObjects) - }; - } - - protected string SerializeObject(object value, JsonSerializerOptions serializerOptions) - { - ArgumentGuard.NotNull(serializerOptions, nameof(serializerOptions)); - - using IDisposable _ = - CodeTimingSessionManager.Current.Measure("JsonSerializer.Serialize", MeasurementSettings.ExcludeJsonSerializationInPercentages); - - return JsonSerializer.Serialize(value, serializerOptions); - } - } -} diff --git a/src/JsonApiDotNetCore/Serialization/Building/IIncludedResourceObjectBuilder.cs b/src/JsonApiDotNetCore/Serialization/Building/IIncludedResourceObjectBuilder.cs deleted file mode 100644 index 3a49f0d413..0000000000 --- a/src/JsonApiDotNetCore/Serialization/Building/IIncludedResourceObjectBuilder.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Collections.Generic; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCore.Serialization.Objects; - -namespace JsonApiDotNetCore.Serialization.Building -{ - public interface IIncludedResourceObjectBuilder - { - /// - /// Gets the list of resource objects representing the included resources. - /// - IList Build(); - - /// - /// Extracts the included resources from using the (arbitrarily deeply nested) included relationships in - /// . - /// - void IncludeRelationshipChain(IReadOnlyCollection inclusionChain, IIdentifiable rootResource); - } -} diff --git a/src/JsonApiDotNetCore/Serialization/Building/IResourceObjectBuilder.cs b/src/JsonApiDotNetCore/Serialization/Building/IResourceObjectBuilder.cs deleted file mode 100644 index ff182c2dab..0000000000 --- a/src/JsonApiDotNetCore/Serialization/Building/IResourceObjectBuilder.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System.Collections.Generic; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCore.Serialization.Objects; - -namespace JsonApiDotNetCore.Serialization.Building -{ - /// - /// Responsible for converting resources into s given a collection of attributes and relationships. - /// - public interface IResourceObjectBuilder - { - /// - /// Converts into a . Adds the attributes and relationships that are enlisted in - /// and . - /// - /// - /// Resource to build a for. - /// - /// - /// Attributes to include in the building process. - /// - /// - /// Relationships to include in the building process. - /// - /// - /// The resource object that was built. - /// - ResourceObject Build(IIdentifiable resource, IReadOnlyCollection attributes, IReadOnlyCollection relationships); - } -} diff --git a/src/JsonApiDotNetCore/Serialization/Building/IncludedResourceObjectBuilder.cs b/src/JsonApiDotNetCore/Serialization/Building/IncludedResourceObjectBuilder.cs deleted file mode 100644 index 299c270f91..0000000000 --- a/src/JsonApiDotNetCore/Serialization/Building/IncludedResourceObjectBuilder.cs +++ /dev/null @@ -1,227 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Queries; -using JsonApiDotNetCore.Queries.Internal; -using JsonApiDotNetCore.QueryStrings; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCore.Serialization.Objects; - -namespace JsonApiDotNetCore.Serialization.Building -{ - [PublicAPI] - public class IncludedResourceObjectBuilder : ResourceObjectBuilder, IIncludedResourceObjectBuilder - { - private readonly HashSet _included; - private readonly IFieldsToSerialize _fieldsToSerialize; - private readonly ILinkBuilder _linkBuilder; - private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; - private readonly IRequestQueryStringAccessor _queryStringAccessor; - private readonly SparseFieldSetCache _sparseFieldSetCache; - - public IncludedResourceObjectBuilder(IFieldsToSerialize fieldsToSerialize, ILinkBuilder linkBuilder, IResourceGraph resourceGraph, - IEnumerable constraintProviders, IResourceDefinitionAccessor resourceDefinitionAccessor, - IRequestQueryStringAccessor queryStringAccessor, IJsonApiOptions options) - : base(resourceGraph, options) - { - ArgumentGuard.NotNull(fieldsToSerialize, nameof(fieldsToSerialize)); - ArgumentGuard.NotNull(linkBuilder, nameof(linkBuilder)); - ArgumentGuard.NotNull(constraintProviders, nameof(constraintProviders)); - ArgumentGuard.NotNull(resourceDefinitionAccessor, nameof(resourceDefinitionAccessor)); - ArgumentGuard.NotNull(queryStringAccessor, nameof(queryStringAccessor)); - - _included = new HashSet(ResourceIdentityComparer.Instance); - _fieldsToSerialize = fieldsToSerialize; - _linkBuilder = linkBuilder; - _resourceDefinitionAccessor = resourceDefinitionAccessor; - _queryStringAccessor = queryStringAccessor; - _sparseFieldSetCache = new SparseFieldSetCache(constraintProviders, resourceDefinitionAccessor); - } - - /// - public IList Build() - { - if (_included.Any()) - { - // Cleans relationship dictionaries and adds links of resources. - foreach (ResourceObject resourceObject in _included) - { - if (resourceObject.Relationships != null) - { - UpdateRelationships(resourceObject); - } - - resourceObject.Links = _linkBuilder.GetResourceLinks(resourceObject.Type, resourceObject.Id); - } - - return _included.ToArray(); - } - - return _queryStringAccessor.Query.ContainsKey("include") ? Array.Empty() : null; - } - - private void UpdateRelationships(ResourceObject resourceObject) - { - foreach (string relationshipName in resourceObject.Relationships.Keys) - { - ResourceContext resourceContext = ResourceGraph.GetResourceContext(resourceObject.Type); - RelationshipAttribute relationship = resourceContext.GetRelationshipByPublicName(relationshipName); - - if (!IsRelationshipInSparseFieldSet(relationship)) - { - resourceObject.Relationships.Remove(relationshipName); - } - } - - resourceObject.Relationships = PruneRelationshipObjects(resourceObject); - } - - private static IDictionary PruneRelationshipObjects(ResourceObject resourceObject) - { - Dictionary pruned = resourceObject.Relationships.Where(pair => pair.Value.Data.IsAssigned || pair.Value.Links != null) - .ToDictionary(pair => pair.Key, pair => pair.Value); - - return !pruned.Any() ? null : pruned; - } - - private bool IsRelationshipInSparseFieldSet(RelationshipAttribute relationship) - { - ResourceContext resourceContext = ResourceGraph.GetResourceContext(relationship.LeftType); - - IImmutableSet fieldSet = _sparseFieldSetCache.GetSparseFieldSetForSerializer(resourceContext); - return fieldSet.Contains(relationship); - } - - /// - public override ResourceObject Build(IIdentifiable resource, IReadOnlyCollection attributes = null, - IReadOnlyCollection relationships = null) - { - ResourceObject resourceObject = base.Build(resource, attributes, relationships); - - resourceObject.Meta = _resourceDefinitionAccessor.GetMeta(resource.GetType(), resource); - - return resourceObject; - } - - /// - public void IncludeRelationshipChain(IReadOnlyCollection inclusionChain, IIdentifiable rootResource) - { - ArgumentGuard.NotNull(inclusionChain, nameof(inclusionChain)); - ArgumentGuard.NotNull(rootResource, nameof(rootResource)); - - // We don't have to build a resource object for the root resource because - // this one is already encoded in the documents primary data, so we process the chain - // starting from the first related resource. - RelationshipAttribute relationship = inclusionChain.First(); - IList chainRemainder = ShiftChain(inclusionChain); - object related = relationship.GetValue(rootResource); - ProcessChain(related, chainRemainder); - } - - private void ProcessChain(object related, IList inclusionChain) - { - if (related is IEnumerable children) - { - foreach (IIdentifiable child in children) - { - ProcessRelationship(child, inclusionChain); - } - } - else - { - ProcessRelationship((IIdentifiable)related, inclusionChain); - } - } - - private void ProcessRelationship(IIdentifiable parent, IList inclusionChain) - { - if (parent == null) - { - return; - } - - ResourceObject resourceObject = TryGetBuiltResourceObjectFor(parent); - - if (resourceObject == null) - { - _resourceDefinitionAccessor.OnSerialize(parent); - - resourceObject = BuildCachedResourceObjectFor(parent); - } - - if (!inclusionChain.Any()) - { - return; - } - - RelationshipAttribute nextRelationship = inclusionChain.First(); - List chainRemainder = inclusionChain.ToList(); - chainRemainder.RemoveAt(0); - - string nextRelationshipName = nextRelationship.PublicName; - IDictionary relationshipsObject = resourceObject.Relationships; - - if (!relationshipsObject.TryGetValue(nextRelationshipName, out RelationshipObject relationshipObject)) - { - relationshipObject = GetRelationshipData(nextRelationship, parent); - relationshipsObject[nextRelationshipName] = relationshipObject; - } - - relationshipObject.Data = GetRelatedResourceLinkage(nextRelationship, parent); - - if (relationshipObject.Data.IsAssigned && relationshipObject.Data.Value != null) - { - // if the relationship is set, continue parsing the chain. - object related = nextRelationship.GetValue(parent); - ProcessChain(related, chainRemainder); - } - } - - private IList ShiftChain(IReadOnlyCollection chain) - { - List chainRemainder = chain.ToList(); - chainRemainder.RemoveAt(0); - return chainRemainder; - } - - /// - /// We only need an empty relationship object here. It will be populated in the ProcessRelationships method. - /// - protected override RelationshipObject GetRelationshipData(RelationshipAttribute relationship, IIdentifiable resource) - { - ArgumentGuard.NotNull(relationship, nameof(relationship)); - ArgumentGuard.NotNull(resource, nameof(resource)); - - return new RelationshipObject - { - Links = _linkBuilder.GetRelationshipLinks(relationship, resource) - }; - } - - private ResourceObject TryGetBuiltResourceObjectFor(IIdentifiable resource) - { - Type resourceType = resource.GetType(); - ResourceContext resourceContext = ResourceGraph.GetResourceContext(resourceType); - - return _included.SingleOrDefault(resourceObject => resourceObject.Type == resourceContext.PublicName && resourceObject.Id == resource.StringId); - } - - private ResourceObject BuildCachedResourceObjectFor(IIdentifiable resource) - { - Type resourceType = resource.GetType(); - IReadOnlyCollection attributes = _fieldsToSerialize.GetAttributes(resourceType); - IReadOnlyCollection relationships = _fieldsToSerialize.GetRelationships(resourceType); - - ResourceObject resourceObject = Build(resource, attributes, relationships); - - _included.Add(resourceObject); - - return resourceObject; - } - } -} diff --git a/src/JsonApiDotNetCore/Serialization/Building/ResourceIdentityComparer.cs b/src/JsonApiDotNetCore/Serialization/Building/ResourceIdentityComparer.cs deleted file mode 100644 index 722008815e..0000000000 --- a/src/JsonApiDotNetCore/Serialization/Building/ResourceIdentityComparer.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System; -using System.Collections.Generic; -using JsonApiDotNetCore.Serialization.Objects; - -namespace JsonApiDotNetCore.Serialization.Building -{ - internal sealed class ResourceIdentityComparer : IEqualityComparer - { - public static readonly ResourceIdentityComparer Instance = new(); - - private ResourceIdentityComparer() - { - } - - public bool Equals(IResourceIdentity x, IResourceIdentity y) - { - 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; - } - - public int GetHashCode(IResourceIdentity obj) - { - return HashCode.Combine(obj.Type, obj.Id, obj.Lid); - } - } -} diff --git a/src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilder.cs b/src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilder.cs deleted file mode 100644 index 6f78367108..0000000000 --- a/src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilder.cs +++ /dev/null @@ -1,181 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Text.Json.Serialization; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCore.Resources.Internal; -using JsonApiDotNetCore.Serialization.Objects; - -namespace JsonApiDotNetCore.Serialization.Building -{ - /// - [PublicAPI] - public class ResourceObjectBuilder : IResourceObjectBuilder - { - private static readonly CollectionConverter CollectionConverter = new(); - private readonly IJsonApiOptions _options; - - protected IResourceGraph ResourceGraph { get; } - - public ResourceObjectBuilder(IResourceGraph resourceGraph, IJsonApiOptions options) - { - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); - ArgumentGuard.NotNull(options, nameof(options)); - - ResourceGraph = resourceGraph; - _options = options; - } - - /// - public virtual ResourceObject Build(IIdentifiable resource, IReadOnlyCollection attributes = null, - IReadOnlyCollection relationships = null) - { - ArgumentGuard.NotNull(resource, nameof(resource)); - - ResourceContext resourceContext = ResourceGraph.GetResourceContext(resource.GetType()); - - // populating the top-level "type" and "id" members. - var resourceObject = new ResourceObject - { - Type = resourceContext.PublicName, - Id = resource.StringId - }; - - // populating the top-level "attribute" member of a resource object. never include "id" as an attribute - if (attributes != null) - { - AttrAttribute[] attributesWithoutId = attributes.Where(attr => attr.Property.Name != nameof(Identifiable.Id)).ToArray(); - - if (attributesWithoutId.Any()) - { - ProcessAttributes(resource, attributesWithoutId, resourceObject); - } - } - - // populating the top-level "relationship" member of a resource object. - if (relationships != null) - { - ProcessRelationships(resource, relationships, resourceObject); - } - - return resourceObject; - } - - /// - /// Builds a . The default behavior is to just construct a resource linkage with the "data" field populated with - /// "single" or "many" data. - /// - protected virtual RelationshipObject GetRelationshipData(RelationshipAttribute relationship, IIdentifiable resource) - { - ArgumentGuard.NotNull(relationship, nameof(relationship)); - ArgumentGuard.NotNull(resource, nameof(resource)); - - return new RelationshipObject - { - Data = GetRelatedResourceLinkage(relationship, resource) - }; - } - - /// - /// Gets the value for the data property. - /// - protected SingleOrManyData GetRelatedResourceLinkage(RelationshipAttribute relationship, IIdentifiable resource) - { - ArgumentGuard.NotNull(relationship, nameof(relationship)); - ArgumentGuard.NotNull(resource, nameof(resource)); - - return relationship is HasOneAttribute hasOne - ? GetRelatedResourceLinkageForHasOne(hasOne, resource) - : GetRelatedResourceLinkageForHasMany((HasManyAttribute)relationship, resource); - } - - /// - /// Builds a for a HasOne relationship. - /// - private SingleOrManyData GetRelatedResourceLinkageForHasOne(HasOneAttribute relationship, IIdentifiable resource) - { - var relatedResource = (IIdentifiable)relationship.GetValue(resource); - ResourceIdentifierObject resourceIdentifierObject = relatedResource != null ? GetResourceIdentifier(relatedResource) : null; - return new SingleOrManyData(resourceIdentifierObject); - } - - /// - /// Builds the s for a HasMany relationship. - /// - private SingleOrManyData GetRelatedResourceLinkageForHasMany(HasManyAttribute relationship, IIdentifiable resource) - { - object value = relationship.GetValue(resource); - ICollection relatedResources = CollectionConverter.ExtractResources(value); - - var manyData = new List(); - - if (relatedResources != null) - { - foreach (IIdentifiable relatedResource in relatedResources) - { - manyData.Add(GetResourceIdentifier(relatedResource)); - } - } - - return new SingleOrManyData(manyData); - } - - /// - /// Creates a from . - /// - private ResourceIdentifierObject GetResourceIdentifier(IIdentifiable resource) - { - string publicName = ResourceGraph.GetResourceContext(resource.GetType()).PublicName; - - return new ResourceIdentifierObject - { - Type = publicName, - Id = resource.StringId - }; - } - - /// - /// Puts the relationships of the resource into the resource object. - /// - private void ProcessRelationships(IIdentifiable resource, IEnumerable relationships, ResourceObject ro) - { - foreach (RelationshipAttribute rel in relationships) - { - RelationshipObject relData = GetRelationshipData(rel, resource); - - if (relData != null) - { - (ro.Relationships ??= new Dictionary()).Add(rel.PublicName, relData); - } - } - } - - /// - /// Puts the attributes of the resource into the resource object. - /// - private void ProcessAttributes(IIdentifiable resource, IEnumerable attributes, ResourceObject ro) - { - ro.Attributes = new Dictionary(); - - foreach (AttrAttribute attr in attributes) - { - object value = attr.GetValue(resource); - - if (_options.SerializerOptions.DefaultIgnoreCondition == JsonIgnoreCondition.WhenWritingNull && value == null) - { - continue; - } - - if (_options.SerializerOptions.DefaultIgnoreCondition == JsonIgnoreCondition.WhenWritingDefault && - Equals(value, RuntimeTypeConverter.GetDefaultValue(attr.Property.PropertyType))) - { - continue; - } - - ro.Attributes.Add(attr.PublicName, value); - } - } - } -} diff --git a/src/JsonApiDotNetCore/Serialization/Building/ResponseResourceObjectBuilder.cs b/src/JsonApiDotNetCore/Serialization/Building/ResponseResourceObjectBuilder.cs deleted file mode 100644 index 7138b6a12b..0000000000 --- a/src/JsonApiDotNetCore/Serialization/Building/ResponseResourceObjectBuilder.cs +++ /dev/null @@ -1,142 +0,0 @@ -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Queries; -using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Queries.Internal; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCore.Serialization.Objects; - -namespace JsonApiDotNetCore.Serialization.Building -{ - [PublicAPI] - public class ResponseResourceObjectBuilder : ResourceObjectBuilder - { - private static readonly IncludeChainConverter IncludeChainConverter = new(); - - private readonly ILinkBuilder _linkBuilder; - private readonly IIncludedResourceObjectBuilder _includedBuilder; - private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; - private readonly IEvaluatedIncludeCache _evaluatedIncludeCache; - private readonly SparseFieldSetCache _sparseFieldSetCache; - - private RelationshipAttribute _requestRelationship; - - public ResponseResourceObjectBuilder(ILinkBuilder linkBuilder, IIncludedResourceObjectBuilder includedBuilder, - IEnumerable constraintProviders, IResourceGraph resourceGraph, IResourceDefinitionAccessor resourceDefinitionAccessor, - IJsonApiOptions options, IEvaluatedIncludeCache evaluatedIncludeCache) - : base(resourceGraph, options) - { - ArgumentGuard.NotNull(linkBuilder, nameof(linkBuilder)); - ArgumentGuard.NotNull(includedBuilder, nameof(includedBuilder)); - ArgumentGuard.NotNull(constraintProviders, nameof(constraintProviders)); - ArgumentGuard.NotNull(resourceDefinitionAccessor, nameof(resourceDefinitionAccessor)); - ArgumentGuard.NotNull(evaluatedIncludeCache, nameof(evaluatedIncludeCache)); - - _linkBuilder = linkBuilder; - _includedBuilder = includedBuilder; - _resourceDefinitionAccessor = resourceDefinitionAccessor; - _evaluatedIncludeCache = evaluatedIncludeCache; - _sparseFieldSetCache = new SparseFieldSetCache(constraintProviders, resourceDefinitionAccessor); - } - - public RelationshipObject Build(IIdentifiable resource, RelationshipAttribute requestRelationship) - { - ArgumentGuard.NotNull(resource, nameof(resource)); - ArgumentGuard.NotNull(requestRelationship, nameof(requestRelationship)); - - _requestRelationship = requestRelationship; - return GetRelationshipData(requestRelationship, resource); - } - - /// - public override ResourceObject Build(IIdentifiable resource, IReadOnlyCollection attributes = null, - IReadOnlyCollection relationships = null) - { - ResourceObject resourceObject = base.Build(resource, attributes, relationships); - - resourceObject.Meta = _resourceDefinitionAccessor.GetMeta(resource.GetType(), resource); - - return resourceObject; - } - - /// - /// Builds a for the specified relationship on a resource. The serializer only populates the "data" member when the - /// relationship is included, and adds links unless these are turned off. This means that if a relationship is not included and links are turned off, the - /// object would be completely empty, ie { }, which is not conform JSON:API spec. In that case we return null, which will omit the object from the - /// output. - /// - protected override RelationshipObject GetRelationshipData(RelationshipAttribute relationship, IIdentifiable resource) - { - ArgumentGuard.NotNull(relationship, nameof(relationship)); - ArgumentGuard.NotNull(resource, nameof(resource)); - - RelationshipObject relationshipObject = null; - IReadOnlyCollection> relationshipChains = GetInclusionChainsStartingWith(relationship); - - if (Equals(relationship, _requestRelationship) || relationshipChains.Any()) - { - relationshipObject = base.GetRelationshipData(relationship, resource); - - if (relationshipChains.Any() && relationshipObject.Data.Value != null) - { - foreach (IReadOnlyCollection chain in relationshipChains) - { - // traverses (recursively) and extracts all (nested) related resources for the current inclusion chain. - _includedBuilder.IncludeRelationshipChain(chain, resource); - } - } - } - - if (!IsRelationshipInSparseFieldSet(relationship)) - { - return null; - } - - RelationshipLinks links = _linkBuilder.GetRelationshipLinks(relationship, resource); - - if (links != null) - { - // if relationshipLinks should be built, populate the "links" field. - relationshipObject ??= new RelationshipObject(); - relationshipObject.Links = links; - } - - // if neither "links" nor "data" was populated, return null, which will omit this object from the output. - // (see the NullValueHandling settings on ) - return relationshipObject; - } - - private bool IsRelationshipInSparseFieldSet(RelationshipAttribute relationship) - { - ResourceContext resourceContext = ResourceGraph.GetResourceContext(relationship.LeftType); - - IImmutableSet fieldSet = _sparseFieldSetCache.GetSparseFieldSetForSerializer(resourceContext); - return fieldSet.Contains(relationship); - } - - /// - /// Inspects the included relationship chains and selects the ones that starts with the specified relationship. - /// - private IReadOnlyCollection> GetInclusionChainsStartingWith(RelationshipAttribute relationship) - { - IncludeExpression include = _evaluatedIncludeCache.Get() ?? IncludeExpression.Empty; - IReadOnlyCollection chains = IncludeChainConverter.GetRelationshipChains(include); - - var inclusionChains = new List>(); - - foreach (ResourceFieldChainExpression chain in chains) - { - if (chain.Fields[0].Equals(relationship)) - { - inclusionChains.Add(chain.Fields.Cast().ToArray()); - } - } - - return inclusionChains; - } - } -} diff --git a/src/JsonApiDotNetCore/Serialization/FieldsToSerialize.cs b/src/JsonApiDotNetCore/Serialization/FieldsToSerialize.cs deleted file mode 100644 index e19ff666d3..0000000000 --- a/src/JsonApiDotNetCore/Serialization/FieldsToSerialize.cs +++ /dev/null @@ -1,89 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Queries; -using JsonApiDotNetCore.Queries.Internal; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCore.Serialization -{ - /// - [PublicAPI] - public class FieldsToSerialize : IFieldsToSerialize - { - private readonly IResourceGraph _resourceGraph; - private readonly IJsonApiRequest _request; - private readonly SparseFieldSetCache _sparseFieldSetCache; - - /// - public bool ShouldSerialize => _request.Kind != EndpointKind.Relationship; - - public FieldsToSerialize(IResourceGraph resourceGraph, IEnumerable constraintProviders, - IResourceDefinitionAccessor resourceDefinitionAccessor, IJsonApiRequest request) - { - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); - ArgumentGuard.NotNull(request, nameof(request)); - - _resourceGraph = resourceGraph; - _request = request; - _sparseFieldSetCache = new SparseFieldSetCache(constraintProviders, resourceDefinitionAccessor); - } - - /// - public IReadOnlyCollection GetAttributes(Type resourceType) - { - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - - if (!ShouldSerialize) - { - return Array.Empty(); - } - - ResourceContext resourceContext = _resourceGraph.GetResourceContext(resourceType); - IImmutableSet fieldSet = _sparseFieldSetCache.GetSparseFieldSetForSerializer(resourceContext); - - return SortAttributesInDeclarationOrder(fieldSet, resourceContext).ToArray(); - } - - private IEnumerable SortAttributesInDeclarationOrder(IImmutableSet fieldSet, ResourceContext resourceContext) - { - foreach (AttrAttribute attribute in resourceContext.Attributes) - { - if (fieldSet.Contains(attribute)) - { - yield return attribute; - } - } - } - - /// - /// - /// Note: this method does NOT check if a relationship is included to determine if it should be serialized. This is because completely hiding a - /// relationship is not the same as not including. In the case of the latter, we may still want to add the relationship to expose the navigation link to - /// the client. - /// - public IReadOnlyCollection GetRelationships(Type resourceType) - { - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - - if (!ShouldSerialize) - { - return Array.Empty(); - } - - ResourceContext resourceContext = _resourceGraph.GetResourceContext(resourceType); - return resourceContext.Relationships; - } - - /// - public void ResetCache() - { - _sparseFieldSetCache.Reset(); - } - } -} diff --git a/src/JsonApiDotNetCore/Serialization/IFieldsToSerialize.cs b/src/JsonApiDotNetCore/Serialization/IFieldsToSerialize.cs deleted file mode 100644 index 682301b040..0000000000 --- a/src/JsonApiDotNetCore/Serialization/IFieldsToSerialize.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System; -using System.Collections.Generic; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCore.Serialization -{ - /// - /// Responsible for getting the set of fields that are to be included for a given type in the serialization result. Typically combines various sources of - /// information, like application-wide and request-wide sparse fieldsets. - /// - public interface IFieldsToSerialize - { - /// - /// Indicates whether attributes and relationships should be serialized, based on the current endpoint. - /// - bool ShouldSerialize { get; } - - /// - /// Gets the collection of attributes that are to be serialized for resources of type . - /// - IReadOnlyCollection GetAttributes(Type resourceType); - - /// - /// Gets the collection of relationships that are to be serialized for resources of type . - /// - IReadOnlyCollection GetRelationships(Type resourceType); - - /// - /// Clears internal caches. - /// - void ResetCache(); - } -} diff --git a/src/JsonApiDotNetCore/Serialization/IJsonApiDeserializer.cs b/src/JsonApiDotNetCore/Serialization/IJsonApiDeserializer.cs deleted file mode 100644 index ea392575b9..0000000000 --- a/src/JsonApiDotNetCore/Serialization/IJsonApiDeserializer.cs +++ /dev/null @@ -1,18 +0,0 @@ -using JsonApiDotNetCore.Serialization.Objects; - -namespace JsonApiDotNetCore.Serialization -{ - /// - /// Deserializer used internally in JsonApiDotNetCore to deserialize requests. - /// - public interface IJsonApiDeserializer - { - /// - /// Deserializes JSON into a and constructs resources from the 'data' element. - /// - /// - /// The JSON to be deserialized. - /// - object Deserialize(string body); - } -} diff --git a/src/JsonApiDotNetCore/Serialization/IJsonApiReader.cs b/src/JsonApiDotNetCore/Serialization/IJsonApiReader.cs deleted file mode 100644 index dbc851a492..0000000000 --- a/src/JsonApiDotNetCore/Serialization/IJsonApiReader.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.Threading.Tasks; -using JetBrains.Annotations; -using Microsoft.AspNetCore.Mvc.Formatters; - -namespace JsonApiDotNetCore.Serialization -{ - /// - /// The deserializer of the body, used in ASP.NET Core internally to process `FromBody`. - /// - [PublicAPI] - public interface IJsonApiReader - { - Task ReadAsync(InputFormatterContext context); - } -} diff --git a/src/JsonApiDotNetCore/Serialization/IJsonApiSerializer.cs b/src/JsonApiDotNetCore/Serialization/IJsonApiSerializer.cs deleted file mode 100644 index 97f0a15747..0000000000 --- a/src/JsonApiDotNetCore/Serialization/IJsonApiSerializer.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace JsonApiDotNetCore.Serialization -{ - /// - /// Serializer used internally in JsonApiDotNetCore to serialize responses. - /// - public interface IJsonApiSerializer - { - /// - /// Gets the Content-Type HTTP header value. - /// - string ContentType { get; } - - /// - /// Serializes a single resource or a collection of resources. - /// - string Serialize(object content); - } -} diff --git a/src/JsonApiDotNetCore/Serialization/IJsonApiSerializerFactory.cs b/src/JsonApiDotNetCore/Serialization/IJsonApiSerializerFactory.cs deleted file mode 100644 index 38796a596e..0000000000 --- a/src/JsonApiDotNetCore/Serialization/IJsonApiSerializerFactory.cs +++ /dev/null @@ -1,13 +0,0 @@ -using JetBrains.Annotations; - -namespace JsonApiDotNetCore.Serialization -{ - [PublicAPI] - public interface IJsonApiSerializerFactory - { - /// - /// Instantiates the serializer to process the servers response. - /// - IJsonApiSerializer GetSerializer(); - } -} diff --git a/src/JsonApiDotNetCore/Serialization/IJsonApiWriter.cs b/src/JsonApiDotNetCore/Serialization/IJsonApiWriter.cs deleted file mode 100644 index ac29395115..0000000000 --- a/src/JsonApiDotNetCore/Serialization/IJsonApiWriter.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.Threading.Tasks; -using JetBrains.Annotations; -using Microsoft.AspNetCore.Mvc.Formatters; - -namespace JsonApiDotNetCore.Serialization -{ - [PublicAPI] - public interface IJsonApiWriter - { - Task WriteAsync(OutputFormatterWriteContext context); - } -} diff --git a/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs b/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs deleted file mode 100644 index af634f9876..0000000000 --- a/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs +++ /dev/null @@ -1,278 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Diagnostics; -using JsonApiDotNetCore.Errors; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Extensions; -using Microsoft.AspNetCore.Mvc.Formatters; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCore.Serialization -{ - /// - [PublicAPI] - public class JsonApiReader : IJsonApiReader - { - private readonly IJsonApiDeserializer _deserializer; - private readonly IJsonApiRequest _request; - private readonly IResourceGraph _resourceGraph; - private readonly TraceLogWriter _traceWriter; - - public JsonApiReader(IJsonApiDeserializer deserializer, IJsonApiRequest request, IResourceGraph resourceGraph, ILoggerFactory loggerFactory) - { - ArgumentGuard.NotNull(deserializer, nameof(deserializer)); - ArgumentGuard.NotNull(request, nameof(request)); - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); - ArgumentGuard.NotNull(loggerFactory, nameof(loggerFactory)); - - _deserializer = deserializer; - _request = request; - _resourceGraph = resourceGraph; - _traceWriter = new TraceLogWriter(loggerFactory); - } - - public async Task ReadAsync(InputFormatterContext context) - { - ArgumentGuard.NotNull(context, nameof(context)); - - using IDisposable _ = CodeTimingSessionManager.Current.Measure("Read request body"); - - string body = await GetRequestBodyAsync(context.HttpContext.Request.Body); - - string url = context.HttpContext.Request.GetEncodedUrl(); - _traceWriter.LogMessage(() => $"Received {context.HttpContext.Request.Method} request at '{url}' with body: <<{body}>>"); - - object model = null; - - if (!string.IsNullOrWhiteSpace(body)) - { - try - { - model = _deserializer.Deserialize(body); - } - catch (JsonApiSerializationException exception) - { - throw ToInvalidRequestBodyException(exception, body); - } -#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 InvalidRequestBodyException(null, null, body, exception); - } - } - - if (_request.Kind == EndpointKind.AtomicOperations) - { - AssertHasRequestBody(model, body); - } - else if (RequiresRequestBody(context.HttpContext.Request.Method)) - { - ValidateRequestBody(model, body, context.HttpContext.Request); - } - - // ReSharper disable once AssignNullToNotNullAttribute - // Justification: According to JSON:API we must return 200 OK without a body in some cases. - return await InputFormatterResult.SuccessAsync(model); - } - - private async Task GetRequestBodyAsync(Stream bodyStream) - { - using var reader = new StreamReader(bodyStream, leaveOpen: true); - return await reader.ReadToEndAsync(); - } - - private InvalidRequestBodyException ToInvalidRequestBodyException(JsonApiSerializationException exception, string body) - { - if (_request.Kind != EndpointKind.AtomicOperations) - { - return new InvalidRequestBodyException(exception.GenericMessage, exception.SpecificMessage, body, exception); - } - - // In contrast to resource endpoints, we don't include the request body for operations because they are usually very long. - var requestException = new InvalidRequestBodyException(exception.GenericMessage, exception.SpecificMessage, null, exception.InnerException); - - if (exception.AtomicOperationIndex != null) - { - foreach (ErrorObject error in requestException.Errors) - { - error.Source ??= new ErrorSource(); - error.Source.Pointer = $"/atomic:operations[{exception.AtomicOperationIndex}]"; - } - } - - return requestException; - } - - private bool RequiresRequestBody(string requestMethod) - { - if (requestMethod == HttpMethods.Post || requestMethod == HttpMethods.Patch) - { - return true; - } - - return requestMethod == HttpMethods.Delete && _request.Kind == EndpointKind.Relationship; - } - - private void ValidateRequestBody(object model, string body, HttpRequest httpRequest) - { - AssertHasRequestBody(model, body); - - ValidateIncomingResourceType(model, httpRequest); - - if (httpRequest.Method != HttpMethods.Post || _request.Kind == EndpointKind.Relationship) - { - ValidateRequestIncludesId(model, body); - ValidatePrimaryIdValue(model, httpRequest.Path); - } - - if (_request.Kind == EndpointKind.Relationship) - { - ValidateForRelationshipType(httpRequest.Method, model, body); - } - } - - [AssertionMethod] - private static void AssertHasRequestBody(object model, string body) - { - if (model == null && string.IsNullOrWhiteSpace(body)) - { - throw new JsonApiException(new ErrorObject(HttpStatusCode.BadRequest) - { - Title = "Missing request body." - }); - } - } - - private void ValidateIncomingResourceType(object model, HttpRequest httpRequest) - { - Type endpointResourceType = GetResourceTypeFromEndpoint(); - - if (endpointResourceType == null) - { - return; - } - - IEnumerable bodyResourceTypes = GetResourceTypesFromRequestBody(model); - - foreach (Type bodyResourceType in bodyResourceTypes) - { - if (!endpointResourceType.IsAssignableFrom(bodyResourceType)) - { - ResourceContext resourceFromEndpoint = _resourceGraph.GetResourceContext(endpointResourceType); - ResourceContext resourceFromBody = _resourceGraph.GetResourceContext(bodyResourceType); - - throw new ResourceTypeMismatchException(new HttpMethod(httpRequest.Method), httpRequest.Path, resourceFromEndpoint, resourceFromBody); - } - } - } - - private Type GetResourceTypeFromEndpoint() - { - return _request.Kind == EndpointKind.Primary ? _request.PrimaryResource.ResourceType : _request.SecondaryResource?.ResourceType; - } - - private IEnumerable GetResourceTypesFromRequestBody(object model) - { - if (model is IEnumerable resourceCollection) - { - return resourceCollection.Select(resource => resource.GetType()).Distinct(); - } - - return model == null ? Enumerable.Empty() : model.GetType().AsEnumerable(); - } - - private void ValidateRequestIncludesId(object model, string body) - { - bool hasMissingId = model is IEnumerable list ? HasMissingId(list) : HasMissingId(model); - - if (hasMissingId) - { - throw new InvalidRequestBodyException("Request body must include 'id' element.", null, body); - } - } - - private void ValidatePrimaryIdValue(object model, PathString requestPath) - { - if (_request.Kind == EndpointKind.Primary) - { - if (TryGetId(model, out string bodyId) && bodyId != _request.PrimaryId) - { - throw new ResourceIdMismatchException(bodyId, _request.PrimaryId, requestPath); - } - } - } - - /// - /// Checks if the deserialized request body has an ID included. - /// - private bool HasMissingId(object model) - { - return TryGetId(model, out string id) && id == null; - } - - /// - /// Checks if all elements in the deserialized request body have an ID included. - /// - private bool HasMissingId(IEnumerable models) - { - foreach (object model in models) - { - if (TryGetId(model, out string id) && id == null) - { - return true; - } - } - - return false; - } - - private static bool TryGetId(object model, out string id) - { - if (model is IIdentifiable identifiable) - { - id = identifiable.StringId; - return true; - } - - id = null; - return false; - } - - [AssertionMethod] - private void ValidateForRelationshipType(string requestMethod, object model, string body) - { - if (_request.Relationship is HasOneAttribute) - { - if (requestMethod == HttpMethods.Post || requestMethod == HttpMethods.Delete) - { - throw new ToManyRelationshipRequiredException(_request.Relationship.PublicName); - } - - if (model is { } and not IIdentifiable) - { - throw new InvalidRequestBodyException("Expected single data element for to-one relationship.", - $"Expected single data element for '{_request.Relationship.PublicName}' relationship.", body); - } - } - - if (_request.Relationship is HasManyAttribute && model is not IEnumerable) - { - throw new InvalidRequestBodyException("Expected data[] element for to-many relationship.", - $"Expected data[] element for '{_request.Relationship.PublicName}' relationship.", body); - } - } - } -} diff --git a/src/JsonApiDotNetCore/Serialization/JsonApiSerializationException.cs b/src/JsonApiDotNetCore/Serialization/JsonApiSerializationException.cs deleted file mode 100644 index 15fd8c8075..0000000000 --- a/src/JsonApiDotNetCore/Serialization/JsonApiSerializationException.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System; -using JetBrains.Annotations; - -namespace JsonApiDotNetCore.Serialization -{ - /// - /// The error that is thrown when (de)serialization of a JSON:API body fails. - /// - [PublicAPI] - public sealed class JsonApiSerializationException : Exception - { - public string GenericMessage { get; } - public string SpecificMessage { get; } - public int? AtomicOperationIndex { get; } - - public JsonApiSerializationException(string genericMessage, string specificMessage, Exception innerException = null, int? atomicOperationIndex = null) - : base(genericMessage, innerException) - { - GenericMessage = genericMessage; - SpecificMessage = specificMessage; - AtomicOperationIndex = atomicOperationIndex; - } - } -} diff --git a/src/JsonApiDotNetCore/Serialization/JsonApiWriter.cs b/src/JsonApiDotNetCore/Serialization/JsonApiWriter.cs deleted file mode 100644 index 5ca93865c7..0000000000 --- a/src/JsonApiDotNetCore/Serialization/JsonApiWriter.cs +++ /dev/null @@ -1,189 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Text; -using System.Threading.Tasks; -using JetBrains.Annotations; -using JsonApiDotNetCore.Diagnostics; -using JsonApiDotNetCore.Errors; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Extensions; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Formatters; -using Microsoft.Extensions.Logging; -using Microsoft.Net.Http.Headers; - -namespace JsonApiDotNetCore.Serialization -{ - /// - /// Formats the response data used (see https://docs.microsoft.com/en-us/aspnet/core/web-api/advanced/formatting?view=aspnetcore-3.0). It was intended to - /// have as little dependencies as possible in formatting layer for greater extensibility. - /// - [PublicAPI] - public class JsonApiWriter : IJsonApiWriter - { - private readonly IJsonApiSerializer _serializer; - private readonly IExceptionHandler _exceptionHandler; - private readonly IETagGenerator _eTagGenerator; - private readonly TraceLogWriter _traceWriter; - - public JsonApiWriter(IJsonApiSerializer serializer, IExceptionHandler exceptionHandler, IETagGenerator eTagGenerator, ILoggerFactory loggerFactory) - { - ArgumentGuard.NotNull(serializer, nameof(serializer)); - ArgumentGuard.NotNull(exceptionHandler, nameof(exceptionHandler)); - ArgumentGuard.NotNull(eTagGenerator, nameof(eTagGenerator)); - ArgumentGuard.NotNull(loggerFactory, nameof(loggerFactory)); - - _serializer = serializer; - _exceptionHandler = exceptionHandler; - _eTagGenerator = eTagGenerator; - _traceWriter = new TraceLogWriter(loggerFactory); - } - - public async Task WriteAsync(OutputFormatterWriteContext context) - { - ArgumentGuard.NotNull(context, nameof(context)); - - using IDisposable _ = CodeTimingSessionManager.Current.Measure("Write response body"); - - HttpRequest request = context.HttpContext.Request; - HttpResponse response = context.HttpContext.Response; - - await using TextWriter writer = context.WriterFactory(response.Body, Encoding.UTF8); - string responseContent; - - try - { - responseContent = SerializeResponse(context.Object, (HttpStatusCode)response.StatusCode); - } -#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 = _exceptionHandler.HandleException(exception); - responseContent = _serializer.Serialize(document); - - response.StatusCode = (int)document.GetErrorStatusCode(); - } - - bool hasMatchingETag = SetETagResponseHeader(request, response, responseContent); - - if (hasMatchingETag) - { - response.StatusCode = (int)HttpStatusCode.NotModified; - responseContent = string.Empty; - } - - if (request.Method == HttpMethod.Head.Method) - { - responseContent = string.Empty; - } - - string url = request.GetEncodedUrl(); - - if (!string.IsNullOrEmpty(responseContent)) - { - response.ContentType = _serializer.ContentType; - } - - _traceWriter.LogMessage(() => $"Sending {response.StatusCode} response for {request.Method} request at '{url}' with body: <<{responseContent}>>"); - - await writer.WriteAsync(responseContent); - await writer.FlushAsync(); - } - - private string SerializeResponse(object contextObject, HttpStatusCode statusCode) - { - if (contextObject is ProblemDetails problemDetails) - { - throw new UnsuccessfulActionResultException(problemDetails); - } - - if (contextObject == null) - { - if (!IsSuccessStatusCode(statusCode)) - { - throw new UnsuccessfulActionResultException(statusCode); - } - - if (statusCode == HttpStatusCode.NoContent || statusCode == HttpStatusCode.ResetContent || statusCode == HttpStatusCode.NotModified) - { - // Prevent exception from Kestrel server, caused by writing data:null json response. - return null; - } - } - - object contextObjectWrapped = WrapErrors(contextObject); - - return _serializer.Serialize(contextObjectWrapped); - } - - private bool IsSuccessStatusCode(HttpStatusCode statusCode) - { - return new HttpResponseMessage(statusCode).IsSuccessStatusCode; - } - - private static object WrapErrors(object contextObject) - { - if (contextObject is IEnumerable errors) - { - return new Document - { - Errors = errors.ToList() - }; - } - - if (contextObject is ErrorObject error) - { - return new Document - { - Errors = error.AsList() - }; - } - - return contextObject; - } - - private bool SetETagResponseHeader(HttpRequest request, HttpResponse response, string responseContent) - { - bool isReadOnly = request.Method == HttpMethod.Get.Method || request.Method == HttpMethod.Head.Method; - - if (isReadOnly && response.StatusCode == (int)HttpStatusCode.OK) - { - string url = request.GetEncodedUrl(); - EntityTagHeaderValue responseETag = _eTagGenerator.Generate(url, responseContent); - - if (responseETag != null) - { - response.Headers.Add(HeaderNames.ETag, responseETag.ToString()); - - return RequestContainsMatchingETag(request.Headers, 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)) - { - foreach (EntityTagHeaderValue requestETag in requestETags) - { - if (responseETag.Equals(requestETag)) - { - return true; - } - } - } - - return false; - } - } -} diff --git a/src/JsonApiDotNetCore/Serialization/JsonObjectConverter.cs b/src/JsonApiDotNetCore/Serialization/JsonConverters/JsonObjectConverter.cs similarity index 95% rename from src/JsonApiDotNetCore/Serialization/JsonObjectConverter.cs rename to src/JsonApiDotNetCore/Serialization/JsonConverters/JsonObjectConverter.cs index 2a365317c4..51f19ac274 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonObjectConverter.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonConverters/JsonObjectConverter.cs @@ -1,7 +1,7 @@ using System.Text.Json; using System.Text.Json.Serialization; -namespace JsonApiDotNetCore.Serialization +namespace JsonApiDotNetCore.Serialization.JsonConverters { public abstract class JsonObjectConverter : JsonConverter { diff --git a/src/JsonApiDotNetCore/Serialization/JsonConverters/ResourceObjectConverter.cs b/src/JsonApiDotNetCore/Serialization/JsonConverters/ResourceObjectConverter.cs index 4f0758fff0..a5e12175ba 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonConverters/ResourceObjectConverter.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonConverters/ResourceObjectConverter.cs @@ -7,6 +7,7 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCore.Serialization.Request; namespace JsonApiDotNetCore.Serialization.JsonConverters { @@ -28,6 +29,8 @@ public sealed class ResourceObjectConverter : JsonObjectConverter ReadAttributes(ref Utf8JsonReader reader, JsonSerializerOptions options, ResourceContext resourceContext) + private static IDictionary ReadAttributes(ref Utf8JsonReader reader, JsonSerializerOptions options, ResourceType resourceType) { var attributes = new Dictionary(); @@ -173,7 +176,7 @@ private static IDictionary ReadAttributes(ref Utf8JsonReader rea string attributeName = reader.GetString(); reader.Read(); - AttrAttribute attribute = resourceContext.TryGetAttributeByPublicName(attributeName); + AttrAttribute attribute = resourceType.TryGetAttributeByPublicName(attributeName); PropertyInfo property = attribute?.Property; if (property != null) @@ -206,6 +209,7 @@ private static IDictionary ReadAttributes(ref Utf8JsonReader rea } else { + attributes.Add(attributeName!, null); reader.Skip(); } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/Document.cs b/src/JsonApiDotNetCore/Serialization/Objects/Document.cs index ae3a09b9b1..995b2070e8 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/Document.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/Document.cs @@ -1,7 +1,4 @@ -using System; using System.Collections.Generic; -using System.Linq; -using System.Net; using System.Text.Json.Serialization; namespace JsonApiDotNetCore.Serialization.Objects @@ -42,23 +39,5 @@ public sealed class Document [JsonPropertyName("meta")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public IDictionary Meta { get; set; } - - internal HttpStatusCode GetErrorStatusCode() - { - if (Errors.IsNullOrEmpty()) - { - throw new InvalidOperationException("No errors found."); - } - - int[] statusCodes = Errors.Select(error => (int)error.StatusCode).Distinct().ToArray(); - - 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/ErrorObject.cs b/src/JsonApiDotNetCore/Serialization/Objects/ErrorObject.cs index a5ac6be1a8..38326d2ae5 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/ErrorObject.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/ErrorObject.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Net; using System.Text.Json.Serialization; using JetBrains.Annotations; @@ -55,5 +56,23 @@ public ErrorObject(HttpStatusCode statusCode) { StatusCode = statusCode; } + + public static HttpStatusCode GetResponseStatusCode(IReadOnlyList errorObjects) + { + if (errorObjects.IsNullOrEmpty()) + { + return HttpStatusCode.InternalServerError; + } + + int[] statusCodes = errorObjects.Select(error => (int)error.StatusCode).Distinct().ToArray(); + + 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/Request/Adapters/AtomicOperationObjectAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicOperationObjectAdapter.cs new file mode 100644 index 0000000000..317404fafe --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicOperationObjectAdapter.cs @@ -0,0 +1,157 @@ +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; + + 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 OperationContainer Convert(AtomicOperationObject atomicOperationObject, RequestAdapterState state) + { + AssertNoHref(atomicOperationObject, state); + + WriteOperationKind writeOperation = ConvertOperationCode(atomicOperationObject, state); + + state.WritableTargetedFields = new TargetedFields(); + + state.WritableRequest = new JsonApiRequest + { + Kind = EndpointKind.AtomicOperations, + WriteOperation = writeOperation + }; + + (ResourceIdentityRequirements requirements, IIdentifiable primaryResource) = ConvertRef(atomicOperationObject, state); + + if (writeOperation is WriteOperationKind.CreateResource or WriteOperationKind.UpdateResource) + { + primaryResource = _resourceDataInOperationsRequestAdapter.Convert(atomicOperationObject.Data, requirements, state); + } + + return new OperationContainer(primaryResource, state.WritableTargetedFields, state.Request); + } + + private static void AssertNoHref(AtomicOperationObject atomicOperationObject, RequestAdapterState state) + { + if (atomicOperationObject.Href != 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) + { + switch (atomicOperationObject.Code) + { + case AtomicOperationCode.Add: + { + if (atomicOperationObject.Ref is { 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; + } + case AtomicOperationCode.Update: + { + return atomicOperationObject.Ref?.Relationship != null ? WriteOperationKind.SetRelationship : WriteOperationKind.UpdateResource; + } + 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; + } + } + + throw new NotSupportedException($"Unknown operation code '{atomicOperationObject.Code}'."); + } + + private (ResourceIdentityRequirements requirements, IIdentifiable primaryResource) ConvertRef(AtomicOperationObject atomicOperationObject, + RequestAdapterState state) + { + ResourceIdentityRequirements requirements = CreateIdentityRequirements(state); + IIdentifiable primaryResource = null; + + AtomicReferenceResult refResult = atomicOperationObject.Ref != null + ? _atomicReferenceAdapter.Convert(atomicOperationObject.Ref, requirements, state) + : null; + + if (refResult != null) + { + requirements = new ResourceIdentityRequirements + { + ResourceType = refResult.ResourceType, + IdConstraint = requirements.IdConstraint, + IdValue = refResult.Resource.StringId, + LidValue = refResult.Resource.LocalId, + RelationshipName = refResult.Relationship?.PublicName + }; + + state.WritableRequest.PrimaryId = refResult.Resource.StringId; + state.WritableRequest.PrimaryResourceType = refResult.ResourceType; + state.WritableRequest.Relationship = refResult.Relationship; + state.WritableRequest.IsCollection = refResult.Relationship is HasManyAttribute; + + ConvertRefRelationship(atomicOperationObject.Data, refResult, state); + + primaryResource = refResult.Resource; + } + + return (requirements, primaryResource); + } + + private ResourceIdentityRequirements CreateIdentityRequirements(RequestAdapterState state) + { + JsonElementConstraint? idConstraint = state.Request.WriteOperation == WriteOperationKind.CreateResource + ? _options.AllowClientGeneratedIds ? null : JsonElementConstraint.Forbidden + : JsonElementConstraint.Required; + + return new ResourceIdentityRequirements + { + IdConstraint = idConstraint + }; + } + + 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); + + 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 new file mode 100644 index 0000000000..9ff01bc770 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicReferenceAdapter.cs @@ -0,0 +1,45 @@ +using System; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Serialization.Request.Adapters +{ + /// + 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) + { + using IDisposable _ = state.Position.PushElement("relationship"); + RelationshipAttribute relationship = resourceType.TryGetRelationshipByPublicName(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 new file mode 100644 index 0000000000..1b85f7021b --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicReferenceResult.cs @@ -0,0 +1,28 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +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 + { + 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)); + + Resource = resource; + ResourceType = resourceType; + Relationship = relationship; + } + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/BaseDataAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/BaseDataAdapter.cs new file mode 100644 index 0000000000..2dffca2653 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/BaseDataAdapter.cs @@ -0,0 +1,55 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Serialization.Request.Adapters +{ + /// + /// Contains shared assertions for derived types. + /// + public abstract class BaseDataAdapter + { + [AssertionMethod] + protected static void AssertHasData(SingleOrManyData data, RequestAdapterState state) + where T : class, IResourceIdentity + { + if (!data.IsAssigned) + { + throw new ModelConversionException(state.Position, "The 'data' element is required.", null); + } + } + + [AssertionMethod] + protected static void AssertHasSingleValue(SingleOrManyData data, bool allowNull, RequestAdapterState state) + where T : class, IResourceIdentity + { + if (data.SingleValue == null) + { + if (!allowNull) + { + throw new ModelConversionException(state.Position, + data.ManyValue == null + ? "Expected an object in 'data' element, instead of 'null'." + : "Expected an object in 'data' element, instead of an array.", null); + } + + if (data.ManyValue != null) + { + throw new ModelConversionException(state.Position, "Expected an object or 'null' in 'data' element, instead of an array.", null); + } + } + } + + [AssertionMethod] + protected static void AssertHasManyValue(SingleOrManyData data, RequestAdapterState state) + where T : class, IResourceIdentity + { + if (data.ManyValue == null) + { + throw new ModelConversionException(state.Position, + data.SingleValue == null + ? "Expected an array in 'data' element, instead of 'null'." + : "Expected an array in 'data' element, instead of an object.", null); + } + } + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentAdapter.cs new file mode 100644 index 0000000000..eea4c7849b --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentAdapter.cs @@ -0,0 +1,42 @@ +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Serialization.Request.Adapters +{ + /// + public sealed class DocumentAdapter : IDocumentAdapter + { + 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)); + + _request = request; + _targetedFields = targetedFields; + _documentInResourceOrRelationshipRequestAdapter = documentInResourceOrRelationshipRequestAdapter; + _documentInOperationsRequestAdapter = documentInOperationsRequestAdapter; + } + + /// + public object Convert(Document document) + { + ArgumentGuard.NotNull(document, nameof(document)); + + using var adapterState = new RequestAdapterState(_request, _targetedFields); + + 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 new file mode 100644 index 0000000000..0c283e9bf0 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentInOperationsRequestAdapter.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Serialization.Request.Adapters +{ + /// + public sealed class DocumentInOperationsRequestAdapter : IDocumentInOperationsRequestAdapter + { + private readonly IJsonApiOptions _options; + private readonly IAtomicOperationObjectAdapter _atomicOperationObjectAdapter; + + public DocumentInOperationsRequestAdapter(IJsonApiOptions options, IAtomicOperationObjectAdapter atomicOperationObjectAdapter) + { + ArgumentGuard.NotNull(options, nameof(options)); + ArgumentGuard.NotNull(atomicOperationObjectAdapter, nameof(atomicOperationObjectAdapter)); + + _options = options; + _atomicOperationObjectAdapter = atomicOperationObjectAdapter; + } + + /// + 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); + + return ConvertOperations(document.Operations, state); + } + + private static void AssertHasOperations(IEnumerable atomicOperationObjects, RequestAdapterState state) + { + if (atomicOperationObjects.IsNullOrEmpty()) + { + throw new ModelConversionException(state.Position, "No operations found.", null); + } + } + + private void AssertMaxOperationsNotExceeded(ICollection atomicOperationObjects, RequestAdapterState state) + { + 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}."); + } + } + + 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); + + OperationContainer operation = _atomicOperationObjectAdapter.Convert(atomicOperationObject, state); + operations.Add(operation); + + operationIndex++; + } + + return operations; + } + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentInResourceOrRelationshipRequestAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentInResourceOrRelationshipRequestAdapter.cs new file mode 100644 index 0000000000..2a0080cc4b --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentInResourceOrRelationshipRequestAdapter.cs @@ -0,0 +1,77 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Serialization.Request.Adapters +{ + /// + 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) + { + ArgumentGuard.NotNull(options, nameof(options)); + ArgumentGuard.NotNull(resourceDataAdapter, nameof(resourceDataAdapter)); + ArgumentGuard.NotNull(relationshipDataAdapter, nameof(relationshipDataAdapter)); + + _options = options; + _resourceDataAdapter = resourceDataAdapter; + _relationshipDataAdapter = relationshipDataAdapter; + } + + /// + public object Convert(Document document, RequestAdapterState state) + { + 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: + { + 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); + + 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; + + 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 new file mode 100644 index 0000000000..5fb2c1d680 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IAtomicOperationObjectAdapter.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Serialization.Request.Adapters +{ + /// + /// Validates and converts a single operation inside an atomic:operations request. + /// + public interface IAtomicOperationObjectAdapter + { + /// + /// Validates and converts the specified . + /// + OperationContainer Convert(AtomicOperationObject atomicOperationObject, RequestAdapterState state); + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/IAtomicReferenceAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IAtomicReferenceAdapter.cs new file mode 100644 index 0000000000..bd4a12b2de --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IAtomicReferenceAdapter.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Serialization.Objects; + +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 the specified . + /// + 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 new file mode 100644 index 0000000000..3f13b25685 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IDocumentAdapter.cs @@ -0,0 +1,38 @@ +using JsonApiDotNetCore.Serialization.Objects; + +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 + { + /// + /// 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); + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/IDocumentInOperationsRequestAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IDocumentInOperationsRequestAdapter.cs new file mode 100644 index 0000000000..de39fa6c91 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IDocumentInOperationsRequestAdapter.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Serialization.Request.Adapters +{ + /// + /// Validates and converts a belonging to an atomic:operations request. + /// + public interface IDocumentInOperationsRequestAdapter + { + /// + /// Validates and converts the specified . + /// + IList Convert(Document document, RequestAdapterState state); + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/IDocumentInResourceOrRelationshipRequestAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IDocumentInResourceOrRelationshipRequestAdapter.cs new file mode 100644 index 0000000000..da6222e166 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IDocumentInResourceOrRelationshipRequestAdapter.cs @@ -0,0 +1,15 @@ +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Serialization.Request.Adapters +{ + /// + /// Validates and converts a belonging to a resource or relationship request. + /// + public interface IDocumentInResourceOrRelationshipRequestAdapter + { + /// + /// Validates and converts the specified . + /// + object Convert(Document document, RequestAdapterState state); + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/IRelationshipDataAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IRelationshipDataAdapter.cs new file mode 100644 index 0000000000..cc4da5bc5e --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IRelationshipDataAdapter.cs @@ -0,0 +1,24 @@ +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Objects; + +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 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 new file mode 100644 index 0000000000..e7dd737cfb --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IResourceDataAdapter.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; + +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 specified . + /// + 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 new file mode 100644 index 0000000000..38a841d45d --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IResourceDataInOperationsRequestAdapter.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Serialization.Request.Adapters +{ + /// + /// Validates and converts the data from an entry in an atomic:operations request that creates or updates a resource. + /// + public interface IResourceDataInOperationsRequestAdapter + { + /// + /// Validates and converts the specified . + /// + 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 new file mode 100644 index 0000000000..3105143908 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IResourceIdentifierObjectAdapter.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; + +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 the specified . + /// + 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 new file mode 100644 index 0000000000..8245444e08 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IResourceObjectAdapter.cs @@ -0,0 +1,19 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; + +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 the specified . + /// + (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 new file mode 100644 index 0000000000..ebdd76945a --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/JsonElementConstraint.cs @@ -0,0 +1,21 @@ +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.Serialization.Request.Adapters +{ + /// + /// Lists constraints for the presence or absence of a JSON element. + /// + [PublicAPI] + public enum JsonElementConstraint + { + /// + /// A value for the element is not allowed. + /// + Forbidden, + + /// + /// 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 new file mode 100644 index 0000000000..ad5aebac9e --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/RelationshipDataAdapter.cs @@ -0,0 +1,121 @@ +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 +{ + /// + public sealed class RelationshipDataAdapter : BaseDataAdapter, IRelationshipDataAdapter + { + private static readonly CollectionConverter CollectionConverter = new(); + + private readonly IResourceIdentifierObjectAdapter _resourceIdentifierObjectAdapter; + + public RelationshipDataAdapter(IResourceIdentifierObjectAdapter resourceIdentifierObjectAdapter) + { + ArgumentGuard.NotNull(resourceIdentifierObjectAdapter, nameof(resourceIdentifierObjectAdapter)); + + _resourceIdentifierObjectAdapter = resourceIdentifierObjectAdapter; + } + + /// + 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) + { + if (!data.IsAssigned) + { + return default; + } + + 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) + { + newValue = new ResourceIdentifierObject + { + Type = data.SingleValue.Type, + Id = data.SingleValue.Id, + Lid = data.SingleValue.Lid + }; + } + + return new SingleOrManyData(newValue); + } + + /// + public object Convert(SingleOrManyData data, RelationshipAttribute relationship, bool useToManyElementType, + RequestAdapterState state) + { + ArgumentGuard.NotNull(relationship, nameof(relationship)); + ArgumentGuard.NotNull(state, nameof(state)); + AssertHasData(data, state); + + using IDisposable _ = state.Position.PushElement("data"); + + var requirements = new ResourceIdentityRequirements + { + ResourceType = relationship.RightType, + IdConstraint = JsonElementConstraint.Required, + RelationshipName = relationship.PublicName + }; + + return relationship is HasOneAttribute + ? ConvertToOneRelationshipData(data, requirements, state) + : ConvertToManyRelationshipData(data, relationship, requirements, useToManyElementType, state); + } + + private IIdentifiable ConvertToOneRelationshipData(SingleOrManyData data, ResourceIdentityRequirements requirements, + RequestAdapterState state) + { + AssertHasSingleValue(data, true, state); + + return data.SingleValue != null ? _resourceIdentifierObjectAdapter.Convert(data.SingleValue, requirements, state) : null; + } + + private IEnumerable ConvertToManyRelationshipData(SingleOrManyData data, RelationshipAttribute relationship, + ResourceIdentityRequirements requirements, bool useToManyElementType, RequestAdapterState state) + { + AssertHasManyValue(data, state); + + int arrayIndex = 0; + var rightResources = new List(); + + foreach (ResourceIdentifierObject resourceIdentifierObject in data.ManyValue) + { + using IDisposable _ = state.Position.PushArrayIndex(arrayIndex); + + IIdentifiable rightResource = _resourceIdentifierObjectAdapter.Convert(resourceIdentifierObject, requirements, state); + rightResources.Add(rightResource); + + 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 new file mode 100644 index 0000000000..4c2d34b28d --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/RequestAdapterPosition.cs @@ -0,0 +1,76 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.Serialization.Request.Adapters +{ + /// + /// 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() + { + _disposable = new PopStackOnDispose(this); + } + + public IDisposable PushElement(string name) + { + ArgumentGuard.NotNullNorEmpty(name, nameof(name)); + + _stack.Push($"/{name}"); + return _disposable; + } + + public IDisposable PushArrayIndex(int index) + { + _stack.Push($"[{index}]"); + return _disposable; + } + + public string ToSourcePointer() + { + if (!_stack.Any()) + { + return null; + } + + var builder = new StringBuilder(); + var clone = new Stack(_stack); + + while (clone.Any()) + { + string element = clone.Pop(); + builder.Append(element); + } + + return builder.ToString(); + } + + public override string ToString() + { + return ToSourcePointer() ?? string.Empty; + } + + private sealed class PopStackOnDispose : IDisposable + { + private readonly RequestAdapterPosition _owner; + + public PopStackOnDispose(RequestAdapterPosition owner) + { + _owner = owner; + } + + public void Dispose() + { + _owner._stack.Pop(); + } + } + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/RequestAdapterState.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/RequestAdapterState.cs new file mode 100644 index 0000000000..2730bcbf9b --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/RequestAdapterState.cs @@ -0,0 +1,68 @@ +using System; +using JetBrains.Annotations; +using JsonApiDotNetCore.AtomicOperations; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.Serialization.Request.Adapters +{ + /// + /// Tracks state while adapting objects from into the shape that controller actions accept. + /// + [PublicAPI] + public sealed class RequestAdapterState : IDisposable + { + private readonly IDisposable _backupRequestState; + + public IJsonApiRequest InjectableRequest { get; } + public ITargetedFields InjectableTargetedFields { get; } + + public JsonApiRequest WritableRequest { get; set; } + public TargetedFields WritableTargetedFields { get; set; } + + 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)); + + InjectableRequest = request; + InjectableTargetedFields = targetedFields; + + if (request.Kind == EndpointKind.AtomicOperations) + { + _backupRequestState = new RevertRequestStateOnDispose(request, targetedFields); + } + } + + public void RefreshInjectables() + { + if (WritableRequest != null) + { + InjectableRequest.CopyFrom(WritableRequest); + } + + if (WritableTargetedFields != null) + { + 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(); + } + } + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceDataAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceDataAdapter.cs new file mode 100644 index 0000000000..f1747fffc8 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceDataAdapter.cs @@ -0,0 +1,49 @@ +using System; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Serialization.Request.Adapters +{ + /// + public class ResourceDataAdapter : BaseDataAdapter, IResourceDataAdapter + { + private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; + private readonly IResourceObjectAdapter _resourceObjectAdapter; + + public ResourceDataAdapter(IResourceDefinitionAccessor resourceDefinitionAccessor, IResourceObjectAdapter resourceObjectAdapter) + { + ArgumentGuard.NotNull(resourceDefinitionAccessor, nameof(resourceDefinitionAccessor)); + ArgumentGuard.NotNull(resourceObjectAdapter, nameof(resourceObjectAdapter)); + + _resourceDefinitionAccessor = resourceDefinitionAccessor; + _resourceObjectAdapter = resourceObjectAdapter; + } + + /// + public IIdentifiable Convert(SingleOrManyData data, ResourceIdentityRequirements requirements, RequestAdapterState state) + { + ArgumentGuard.NotNull(requirements, nameof(requirements)); + ArgumentGuard.NotNull(state, nameof(state)); + + AssertHasData(data, state); + + using IDisposable _ = state.Position.PushElement("data"); + AssertHasSingleValue(data, false, state); + + (IIdentifiable resource, ResourceType _) = ConvertResourceObject(data, requirements, state); + + // Ensure that IResourceDefinition extensibility point sees the current operation, in case it injects IJsonApiRequest. + state.RefreshInjectables(); + + _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); + } + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceDataInOperationsRequestAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceDataInOperationsRequestAdapter.cs new file mode 100644 index 0000000000..b0e59b6afd --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceDataInOperationsRequestAdapter.cs @@ -0,0 +1,28 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Serialization.Request.Adapters +{ + /// + public sealed class ResourceDataInOperationsRequestAdapter : ResourceDataAdapter, IResourceDataInOperationsRequestAdapter + { + 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. + + (IIdentifiable resource, ResourceType resourceType) = base.ConvertResourceObject(data, requirements, state); + + state.WritableRequest.PrimaryResourceType = resourceType; + state.WritableRequest.PrimaryId = resource.StringId; + + return (resource, resourceType); + } + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentifierObjectAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentifierObjectAdapter.cs new file mode 100644 index 0000000000..fc5cbfc3e4 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentifierObjectAdapter.cs @@ -0,0 +1,26 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Serialization.Request.Adapters +{ + /// + public sealed class ResourceIdentifierObjectAdapter : ResourceIdentityAdapter, IResourceIdentifierObjectAdapter + { + 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)); + + (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 new file mode 100644 index 0000000000..5bb2a52d53 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs @@ -0,0 +1,222 @@ +using System; +using System.Net; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Serialization.Request.Adapters +{ + /// + /// Base class for validating and converting objects that represent an identity. + /// + public abstract class ResourceIdentityAdapter + { + private readonly IResourceGraph _resourceGraph; + private readonly IResourceFactory _resourceFactory; + + protected ResourceIdentityAdapter(IResourceGraph resourceGraph, IResourceFactory resourceFactory) + { + ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); + ArgumentGuard.NotNull(resourceFactory, nameof(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)); + + ResourceType resourceType = ResolveType(identity, requirements, state); + IIdentifiable resource = CreateResource(identity, requirements, resourceType.ClrType, state); + + return (resource, resourceType); + } + + private ResourceType ResolveType(IResourceIdentity identity, ResourceIdentityRequirements requirements, RequestAdapterState state) + { + AssertHasType(identity, state); + + using IDisposable _ = state.Position.PushElement("type"); + ResourceType resourceType = _resourceGraph.TryGetResourceType(identity.Type); + + AssertIsKnownResourceType(resourceType, identity.Type, state); + AssertIsCompatibleResourceType(resourceType, requirements.ResourceType, requirements.RelationshipName, state); + + return resourceType; + } + + private static void AssertHasType(IResourceIdentity identity, RequestAdapterState state) + { + if (identity.Type == null) + { + throw new ModelConversionException(state.Position, "The 'type' element is required.", null); + } + } + + private static void AssertIsKnownResourceType(ResourceType resourceType, string typeName, RequestAdapterState state) + { + if (resourceType == null) + { + 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) + { + 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}'."; + + throw new ModelConversionException(state.Position, "Incompatible resource type found.", message, HttpStatusCode.Conflict); + } + } + + private IIdentifiable CreateResource(IResourceIdentity identity, ResourceIdentityRequirements requirements, Type resourceClrType, + RequestAdapterState state) + { + if (state.Request.Kind != EndpointKind.AtomicOperations) + { + AssertHasNoLid(identity, state); + } + + AssertNoIdWithLid(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); + + IIdentifiable resource = _resourceFactory.CreateInstance(resourceClrType); + AssignStringId(identity, resource, state); + resource.LocalId = identity.Lid; + return resource; + } + + private static void AssertHasNoLid(IResourceIdentity identity, RequestAdapterState state) + { + 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); + } + } + + private static void AssertNoIdWithLid(IResourceIdentity identity, RequestAdapterState state) + { + if (identity.Id != null && identity.Lid != 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; + + 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); + } + } + + private static void AssertHasNoId(IResourceIdentity identity, RequestAdapterState state) + { + 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); + } + } + + private static void AssertSameIdValue(IResourceIdentity identity, string expected, RequestAdapterState state) + { + if (expected != null && identity.Id != expected) + { + using IDisposable _ = state.Position.PushElement("id"); + + 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) + { + if (expected != null && identity.Lid != expected) + { + using IDisposable _ = state.Position.PushElement("lid"); + + 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) + { + if (identity.Id != null) + { + 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); + } + } + } + + protected static void AssertIsKnownRelationship(RelationshipAttribute relationship, string relationshipName, ResourceType resourceType, + RequestAdapterState state) + { + if (relationship == null) + { + throw new ModelConversionException(state.Position, "Unknown relationship found.", + $"Relationship '{relationshipName}' does not exist on resource type '{resourceType.PublicName}'."); + } + } + + protected internal static void AssertToManyInAddOrRemoveRelationship(RelationshipAttribute relationship, RequestAdapterState state) + { + bool requireToManyRelationship = state.Request.WriteOperation is WriteOperationKind.AddToRelationship or WriteOperationKind.RemoveFromRelationship; + + 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 new file mode 100644 index 0000000000..601212ec93 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityRequirements.cs @@ -0,0 +1,38 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Serialization.Request.Adapters +{ + /// + /// Defines requirements to validate an instance against. + /// + [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; } + + /// + /// 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 "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; } + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceObjectAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceObjectAdapter.cs new file mode 100644 index 0000000000..5c49a4e0e4 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceObjectAdapter.cs @@ -0,0 +1,159 @@ +using System; +using System.Collections.Generic; +using JetBrains.Annotations; +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 ResourceObjectAdapter : ResourceIdentityAdapter, IResourceObjectAdapter + { + private readonly IJsonApiOptions _options; + private readonly IRelationshipDataAdapter _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; + } + + /// + 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); + + ConvertAttributes(resourceObject.Attributes, resource, resourceType, state); + ConvertRelationships(resourceObject.Relationships, resource, resourceType, state); + + return (resource, resourceType); + } + + private void ConvertAttributes(IDictionary resourceObjectAttributes, IIdentifiable resource, ResourceType resourceType, + RequestAdapterState state) + { + using IDisposable _ = state.Position.PushElement("attributes"); + + foreach ((string attributeName, object attributeValue) in resourceObjectAttributes.EmptyIfNull()) + { + 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.TryGetAttributeByPublicName(attributeName); + + 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); + + attr!.SetValue(resource, attributeValue); + state.WritableTargetedFields.Attributes.Add(attr); + } + + [AssertionMethod] + private static void AssertIsKnownAttribute(AttrAttribute attr, string attributeName, ResourceType resourceType, RequestAdapterState state) + { + if (attr == null) + { + 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) + { + if (attributeValue is JsonInvalidAttributeInfo info) + { + if (info == JsonInvalidAttributeInfo.Id) + { + throw new ModelConversionException(state.Position, "Resource ID is read-only.", null); + } + + 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}'."); + } + } + + private static void AssertNoBlockedCreate(AttrAttribute attr, ResourceType resourceType, RequestAdapterState state) + { + 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."); + } + } + + private static void AssertNoBlockedChange(AttrAttribute attr, ResourceType resourceType, RequestAdapterState state) + { + 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."); + } + } + + private static void AssertNotReadOnly(AttrAttribute attr, ResourceType resourceType, RequestAdapterState state) + { + 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."); + } + } + + 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.Data, resource, resourceType, state); + } + } + + private void ConvertRelationship(string relationshipName, SingleOrManyData relationshipData, IIdentifiable resource, + ResourceType resourceType, RequestAdapterState state) + { + using IDisposable _ = state.Position.PushElement(relationshipName); + RelationshipAttribute relationship = resourceType.TryGetRelationshipByPublicName(relationshipName); + + if (relationship == null && _options.AllowUnknownFieldsInRequestBody) + { + return; + } + + AssertIsKnownRelationship(relationship, relationshipName, resourceType, state); + + object rightValue = _relationshipDataAdapter.Convert(relationshipData, relationship, true, state); + + 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 new file mode 100644 index 0000000000..aab4712844 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/IJsonApiReader.cs @@ -0,0 +1,19 @@ +using System.Threading.Tasks; +using JetBrains.Annotations; +using Microsoft.AspNetCore.Http; + +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 Core on `FromBody` + /// parameters. + /// + [PublicAPI] + public interface IJsonApiReader + { + /// + /// Reads an object from the request body. + /// + Task ReadAsync(HttpRequest httpRequest); + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Request/JsonApiReader.cs b/src/JsonApiDotNetCore/Serialization/Request/JsonApiReader.cs new file mode 100644 index 0000000000..96831014c3 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/JsonApiReader.cs @@ -0,0 +1,108 @@ +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; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCore.Serialization.Request.Adapters; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Logging; + +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 async Task ReadAsync(HttpRequest httpRequest) + { + ArgumentGuard.NotNull(httpRequest, nameof(httpRequest)); + + string requestBody = await ReceiveRequestBodyAsync(httpRequest); + + _traceWriter.LogMessage(() => $"Received {httpRequest.Method} request at '{httpRequest.GetEncodedUrl()}' with body: <<{requestBody}>>"); + + return GetModel(requestBody); + } + + private static async Task ReceiveRequestBodyAsync(HttpRequest httpRequest) + { + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Receive request body"); + + using var reader = new HttpRequestStreamReader(httpRequest.Body, Encoding.UTF8); + return await reader.ReadToEndAsync(); + } + + private object GetModel(string requestBody) + { + AssertHasRequestBody(requestBody); + + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Read request body"); + + Document document = DeserializeDocument(requestBody); + return ConvertDocumentToModel(document, requestBody); + } + + [AssertionMethod] + private static void AssertHasRequestBody(string requestBody) + { + if (string.IsNullOrEmpty(requestBody)) + { + throw new InvalidRequestBodyException(null, "Missing request body.", null, null, HttpStatusCode.BadRequest); + } + } + + private Document DeserializeDocument(string requestBody) + { + try + { + using IDisposable _ = + CodeTimingSessionManager.Current.Measure("JsonSerializer.Deserialize", MeasurementSettings.ExcludeJsonSerializationInPercentages); + + return JsonSerializer.Deserialize(requestBody, _options.SerializerReadOptions); + } + 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); + } + } + + private object ConvertDocumentToModel(Document document, string requestBody) + { + try + { + return _documentAdapter.Convert(document); + } + catch (ModelConversionException exception) + { + throw new InvalidRequestBodyException(_options.IncludeRequestBodyInErrors ? requestBody : null, exception.GenericMessage, + exception.SpecificMessage, exception.SourcePointer, exception.StatusCode, exception); + } + } + } +} diff --git a/src/JsonApiDotNetCore/Serialization/JsonInvalidAttributeInfo.cs b/src/JsonApiDotNetCore/Serialization/Request/JsonInvalidAttributeInfo.cs similarity index 95% rename from src/JsonApiDotNetCore/Serialization/JsonInvalidAttributeInfo.cs rename to src/JsonApiDotNetCore/Serialization/Request/JsonInvalidAttributeInfo.cs index 037eaf18af..5c448613a6 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonInvalidAttributeInfo.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/JsonInvalidAttributeInfo.cs @@ -1,7 +1,7 @@ using System; using System.Text.Json; -namespace JsonApiDotNetCore.Serialization +namespace JsonApiDotNetCore.Serialization.Request { /// /// A sentinel value that is temporarily stored in the attributes dictionary to postpone producing an error. diff --git a/src/JsonApiDotNetCore/Serialization/Request/ModelConversionException.cs b/src/JsonApiDotNetCore/Serialization/Request/ModelConversionException.cs new file mode 100644 index 0000000000..c9922e855b --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/ModelConversionException.cs @@ -0,0 +1,30 @@ +using System; +using System.Net; +using JetBrains.Annotations; +using JsonApiDotNetCore.Serialization.Request.Adapters; + +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 + { + 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)); + + GenericMessage = genericMessage; + SpecificMessage = specificMessage; + StatusCode = statusCode; + SourcePointer = position.ToSourcePointer(); + } + } +} diff --git a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs deleted file mode 100644 index 3b466a7087..0000000000 --- a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs +++ /dev/null @@ -1,524 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using System.Net.Http; -using Humanizer; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCore.Resources.Internal; -using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.AspNetCore.Http; - -namespace JsonApiDotNetCore.Serialization -{ - /// - /// Server deserializer implementation of the . - /// - [PublicAPI] - public class RequestDeserializer : BaseDeserializer, IJsonApiDeserializer - { - private readonly ITargetedFields _targetedFields; - private readonly IHttpContextAccessor _httpContextAccessor; - private readonly IJsonApiRequest _request; - private readonly IJsonApiOptions _options; - private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; - - public RequestDeserializer(IResourceGraph resourceGraph, IResourceFactory resourceFactory, ITargetedFields targetedFields, - IHttpContextAccessor httpContextAccessor, IJsonApiRequest request, IJsonApiOptions options, IResourceDefinitionAccessor resourceDefinitionAccessor) - : base(resourceGraph, resourceFactory) - { - ArgumentGuard.NotNull(targetedFields, nameof(targetedFields)); - ArgumentGuard.NotNull(httpContextAccessor, nameof(httpContextAccessor)); - ArgumentGuard.NotNull(request, nameof(request)); - ArgumentGuard.NotNull(options, nameof(options)); - ArgumentGuard.NotNull(resourceDefinitionAccessor, nameof(resourceDefinitionAccessor)); - - _targetedFields = targetedFields; - _httpContextAccessor = httpContextAccessor; - _request = request; - _options = options; - _resourceDefinitionAccessor = resourceDefinitionAccessor; - } - - /// - public object Deserialize(string body) - { - ArgumentGuard.NotNullNorEmpty(body, nameof(body)); - - if (_request.Kind == EndpointKind.Relationship) - { - _targetedFields.Relationships.Add(_request.Relationship); - } - - if (_request.Kind == EndpointKind.AtomicOperations) - { - return DeserializeOperationsDocument(body); - } - - object instance = DeserializeData(body, _options.SerializerReadOptions); - - if (instance is IIdentifiable resource && _request.Kind != EndpointKind.Relationship) - { - _resourceDefinitionAccessor.OnDeserialize(resource); - } - - AssertResourceIdIsNotTargeted(_targetedFields); - - return instance; - } - - private object DeserializeOperationsDocument(string body) - { - Document document = DeserializeDocument(body, _options.SerializerReadOptions); - - if ((document?.Operations).IsNullOrEmpty()) - { - throw new JsonApiSerializationException("No operations found.", null); - } - - if (document.Operations.Count > _options.MaximumOperationsPerRequest) - { - throw new JsonApiSerializationException("Request exceeds the maximum number of operations.", - $"The number of operations in this request ({document.Operations.Count}) is higher than {_options.MaximumOperationsPerRequest}."); - } - - var operations = new List(); - AtomicOperationIndex = 0; - - foreach (AtomicOperationObject operation in document.Operations) - { - OperationContainer container = DeserializeOperation(operation); - operations.Add(container); - - AtomicOperationIndex++; - } - - return operations; - } - - private OperationContainer DeserializeOperation(AtomicOperationObject operation) - { - _targetedFields.Attributes.Clear(); - _targetedFields.Relationships.Clear(); - - AssertHasNoHref(operation); - - WriteOperationKind writeOperation = GetWriteOperationKind(operation); - - switch (writeOperation) - { - case WriteOperationKind.CreateResource: - case WriteOperationKind.UpdateResource: - { - return ParseForCreateOrUpdateResourceOperation(operation, writeOperation); - } - case WriteOperationKind.DeleteResource: - { - return ParseForDeleteResourceOperation(operation, writeOperation); - } - } - - bool requireToManyRelationship = - writeOperation == WriteOperationKind.AddToRelationship || writeOperation == WriteOperationKind.RemoveFromRelationship; - - return ParseForRelationshipOperation(operation, writeOperation, requireToManyRelationship); - } - - [AssertionMethod] - private void AssertHasNoHref(AtomicOperationObject operation) - { - if (operation.Href != null) - { - throw new JsonApiSerializationException("Usage of the 'href' element is not supported.", null, atomicOperationIndex: AtomicOperationIndex); - } - } - - private WriteOperationKind GetWriteOperationKind(AtomicOperationObject operation) - { - switch (operation.Code) - { - case AtomicOperationCode.Add: - { - if (operation.Ref is { Relationship: null }) - { - throw new JsonApiSerializationException("The 'ref.relationship' element is required.", null, - atomicOperationIndex: AtomicOperationIndex); - } - - return operation.Ref == null ? WriteOperationKind.CreateResource : WriteOperationKind.AddToRelationship; - } - case AtomicOperationCode.Update: - { - return operation.Ref?.Relationship != null ? WriteOperationKind.SetRelationship : WriteOperationKind.UpdateResource; - } - case AtomicOperationCode.Remove: - { - if (operation.Ref == null) - { - throw new JsonApiSerializationException("The 'ref' element is required.", null, atomicOperationIndex: AtomicOperationIndex); - } - - return operation.Ref.Relationship != null ? WriteOperationKind.RemoveFromRelationship : WriteOperationKind.DeleteResource; - } - } - - throw new NotSupportedException($"Unknown operation code '{operation.Code}'."); - } - - private OperationContainer ParseForCreateOrUpdateResourceOperation(AtomicOperationObject operation, WriteOperationKind writeOperation) - { - ResourceObject resourceObject = GetRequiredSingleDataForResourceOperation(operation); - - AssertElementHasType(resourceObject, "data"); - AssertElementHasIdOrLid(resourceObject, "data", writeOperation != WriteOperationKind.CreateResource); - - ResourceContext primaryResourceContext = GetExistingResourceContext(resourceObject.Type); - - AssertCompatibleId(resourceObject, primaryResourceContext.IdentityType); - - if (operation.Ref != null) - { - // For resource update, 'ref' is optional. But when specified, it must match with 'data'. - - AssertElementHasType(operation.Ref, "ref"); - AssertElementHasIdOrLid(operation.Ref, "ref", true); - - ResourceContext resourceContextInRef = GetExistingResourceContext(operation.Ref.Type); - - if (!primaryResourceContext.Equals(resourceContextInRef)) - { - throw new JsonApiSerializationException("Resource type mismatch between 'ref.type' and 'data.type' element.", - $"Expected resource of type '{resourceContextInRef.PublicName}' in 'data.type', instead of '{primaryResourceContext.PublicName}'.", - atomicOperationIndex: AtomicOperationIndex); - } - - AssertSameIdentityInRefData(operation, resourceObject); - } - - var request = new JsonApiRequest - { - Kind = EndpointKind.AtomicOperations, - PrimaryResource = primaryResourceContext, - WriteOperation = writeOperation - }; - - _request.CopyFrom(request); - - IIdentifiable primaryResource = ParseResourceObject(operation.Data.SingleValue); - - _resourceDefinitionAccessor.OnDeserialize(primaryResource); - - request.PrimaryId = primaryResource.StringId; - _request.CopyFrom(request); - - var targetedFields = new TargetedFields - { - Attributes = _targetedFields.Attributes.ToHashSet(), - Relationships = _targetedFields.Relationships.ToHashSet() - }; - - AssertResourceIdIsNotTargeted(targetedFields); - - return new OperationContainer(writeOperation, primaryResource, targetedFields, request); - } - - private ResourceObject GetRequiredSingleDataForResourceOperation(AtomicOperationObject operation) - { - if (operation.Data.Value == null) - { - throw new JsonApiSerializationException("The 'data' element is required.", null, atomicOperationIndex: AtomicOperationIndex); - } - - if (operation.Data.SingleValue == null) - { - throw new JsonApiSerializationException("Expected single data element for create/update resource operation.", null, - atomicOperationIndex: AtomicOperationIndex); - } - - return operation.Data.SingleValue; - } - - [AssertionMethod] - private void AssertElementHasType(IResourceIdentity resourceIdentity, string elementPath) - { - if (resourceIdentity.Type == null) - { - throw new JsonApiSerializationException($"The '{elementPath}.type' element is required.", null, atomicOperationIndex: AtomicOperationIndex); - } - } - - private void AssertElementHasIdOrLid(IResourceIdentity resourceIdentity, string elementPath, bool isRequired) - { - bool hasNone = resourceIdentity.Id == null && resourceIdentity.Lid == null; - bool hasBoth = resourceIdentity.Id != null && resourceIdentity.Lid != null; - - if (isRequired ? hasNone || hasBoth : hasBoth) - { - throw new JsonApiSerializationException($"The '{elementPath}.id' or '{elementPath}.lid' element is required.", null, - atomicOperationIndex: AtomicOperationIndex); - } - } - - private void AssertCompatibleId(IResourceIdentity resourceIdentity, Type idType) - { - if (resourceIdentity.Id != null) - { - try - { - RuntimeTypeConverter.ConvertType(resourceIdentity.Id, idType); - } - catch (FormatException exception) - { - throw new JsonApiSerializationException(null, exception.Message, null, AtomicOperationIndex); - } - } - } - - private void AssertSameIdentityInRefData(AtomicOperationObject operation, IResourceIdentity resourceIdentity) - { - if (operation.Ref.Id != null && resourceIdentity.Id != null && resourceIdentity.Id != operation.Ref.Id) - { - throw new JsonApiSerializationException("Resource ID mismatch between 'ref.id' and 'data.id' element.", - $"Expected resource with ID '{operation.Ref.Id}' in 'data.id', instead of '{resourceIdentity.Id}'.", - atomicOperationIndex: AtomicOperationIndex); - } - - if (operation.Ref.Lid != null && resourceIdentity.Lid != null && resourceIdentity.Lid != operation.Ref.Lid) - { - throw new JsonApiSerializationException("Resource local ID mismatch between 'ref.lid' and 'data.lid' element.", - $"Expected resource with local ID '{operation.Ref.Lid}' in 'data.lid', instead of '{resourceIdentity.Lid}'.", - atomicOperationIndex: AtomicOperationIndex); - } - - if (operation.Ref.Id != null && resourceIdentity.Lid != null) - { - throw new JsonApiSerializationException("Resource identity mismatch between 'ref.id' and 'data.lid' element.", - $"Expected resource with ID '{operation.Ref.Id}' in 'data.id', instead of '{resourceIdentity.Lid}' in 'data.lid'.", - atomicOperationIndex: AtomicOperationIndex); - } - - if (operation.Ref.Lid != null && resourceIdentity.Id != null) - { - throw new JsonApiSerializationException("Resource identity mismatch between 'ref.lid' and 'data.id' element.", - $"Expected resource with local ID '{operation.Ref.Lid}' in 'data.lid', instead of '{resourceIdentity.Id}' in 'data.id'.", - atomicOperationIndex: AtomicOperationIndex); - } - } - - private OperationContainer ParseForDeleteResourceOperation(AtomicOperationObject operation, WriteOperationKind writeOperation) - { - AssertElementHasType(operation.Ref, "ref"); - AssertElementHasIdOrLid(operation.Ref, "ref", true); - - ResourceContext primaryResourceContext = GetExistingResourceContext(operation.Ref.Type); - - AssertCompatibleId(operation.Ref, primaryResourceContext.IdentityType); - - IIdentifiable primaryResource = ResourceFactory.CreateInstance(primaryResourceContext.ResourceType); - primaryResource.StringId = operation.Ref.Id; - primaryResource.LocalId = operation.Ref.Lid; - - var request = new JsonApiRequest - { - Kind = EndpointKind.AtomicOperations, - PrimaryId = primaryResource.StringId, - PrimaryResource = primaryResourceContext, - WriteOperation = writeOperation - }; - - return new OperationContainer(writeOperation, primaryResource, new TargetedFields(), request); - } - - private OperationContainer ParseForRelationshipOperation(AtomicOperationObject operation, WriteOperationKind writeOperation, bool requireToMany) - { - AssertElementHasType(operation.Ref, "ref"); - AssertElementHasIdOrLid(operation.Ref, "ref", true); - - ResourceContext primaryResourceContext = GetExistingResourceContext(operation.Ref.Type); - - AssertCompatibleId(operation.Ref, primaryResourceContext.IdentityType); - - IIdentifiable primaryResource = ResourceFactory.CreateInstance(primaryResourceContext.ResourceType); - primaryResource.StringId = operation.Ref.Id; - primaryResource.LocalId = operation.Ref.Lid; - - RelationshipAttribute relationship = GetExistingRelationship(operation.Ref, primaryResourceContext); - - if (requireToMany && relationship is HasOneAttribute) - { - throw new JsonApiSerializationException($"Only to-many relationships can be targeted in '{operation.Code.ToString().Camelize()}' operations.", - $"Relationship '{operation.Ref.Relationship}' must be a to-many relationship.", atomicOperationIndex: AtomicOperationIndex); - } - - ResourceContext secondaryResourceContext = ResourceGraph.GetResourceContext(relationship.RightType); - - var request = new JsonApiRequest - { - Kind = EndpointKind.AtomicOperations, - PrimaryId = primaryResource.StringId, - PrimaryResource = primaryResourceContext, - SecondaryResource = secondaryResourceContext, - Relationship = relationship, - IsCollection = relationship is HasManyAttribute, - WriteOperation = writeOperation - }; - - _request.CopyFrom(request); - - _targetedFields.Relationships.Add(relationship); - - ParseDataForRelationship(relationship, secondaryResourceContext, operation, primaryResource); - - var targetedFields = new TargetedFields - { - Attributes = _targetedFields.Attributes.ToHashSet(), - Relationships = _targetedFields.Relationships.ToHashSet() - }; - - return new OperationContainer(writeOperation, primaryResource, targetedFields, request); - } - - private RelationshipAttribute GetExistingRelationship(AtomicReference reference, ResourceContext resourceContext) - { - RelationshipAttribute relationship = resourceContext.TryGetRelationshipByPublicName(reference.Relationship); - - if (relationship == null) - { - throw new JsonApiSerializationException("The referenced relationship does not exist.", - $"Resource of type '{reference.Type}' does not contain a relationship named '{reference.Relationship}'.", - atomicOperationIndex: AtomicOperationIndex); - } - - return relationship; - } - - private void ParseDataForRelationship(RelationshipAttribute relationship, ResourceContext secondaryResourceContext, AtomicOperationObject operation, - IIdentifiable primaryResource) - { - if (relationship is HasOneAttribute) - { - if (operation.Data.ManyValue != null) - { - throw new JsonApiSerializationException("Expected single data element for to-one relationship.", - $"Expected single data element for '{relationship.PublicName}' relationship.", atomicOperationIndex: AtomicOperationIndex); - } - - if (operation.Data.SingleValue != null) - { - ValidateSingleDataForRelationship(operation.Data.SingleValue, secondaryResourceContext, "data"); - - IIdentifiable secondaryResource = ParseResourceObject(operation.Data.SingleValue); - relationship.SetValue(primaryResource, secondaryResource); - } - } - else if (relationship is HasManyAttribute) - { - if (operation.Data.ManyValue == null) - { - throw new JsonApiSerializationException("Expected data[] element for to-many relationship.", - $"Expected data[] element for '{relationship.PublicName}' relationship.", atomicOperationIndex: AtomicOperationIndex); - } - - var secondaryResources = new List(); - - foreach (ResourceObject resourceObject in operation.Data.ManyValue) - { - ValidateSingleDataForRelationship(resourceObject, secondaryResourceContext, "data[]"); - - IIdentifiable secondaryResource = ParseResourceObject(resourceObject); - secondaryResources.Add(secondaryResource); - } - - IEnumerable rightResources = CollectionConverter.CopyToTypedCollection(secondaryResources, relationship.Property.PropertyType); - relationship.SetValue(primaryResource, rightResources); - } - } - - private void ValidateSingleDataForRelationship(ResourceObject dataResourceObject, ResourceContext resourceContext, string elementPath) - { - AssertElementHasType(dataResourceObject, elementPath); - AssertElementHasIdOrLid(dataResourceObject, elementPath, true); - - ResourceContext resourceContextInData = GetExistingResourceContext(dataResourceObject.Type); - - AssertCompatibleType(resourceContextInData, resourceContext, elementPath); - AssertCompatibleId(dataResourceObject, resourceContextInData.IdentityType); - } - - private void AssertCompatibleType(ResourceContext resourceContextInData, ResourceContext resourceContextInRef, string elementPath) - { - if (!resourceContextInData.ResourceType.IsAssignableFrom(resourceContextInRef.ResourceType)) - { - throw new JsonApiSerializationException($"Resource type mismatch between 'ref.relationship' and '{elementPath}.type' element.", - $"Expected resource of type '{resourceContextInRef.PublicName}' in '{elementPath}.type', instead of '{resourceContextInData.PublicName}'.", - atomicOperationIndex: AtomicOperationIndex); - } - } - - private void AssertResourceIdIsNotTargeted(ITargetedFields targetedFields) - { - if (!_request.IsReadOnly && targetedFields.Attributes.Any(attribute => attribute.Property.Name == nameof(Identifiable.Id))) - { - throw new JsonApiSerializationException("Resource ID is read-only.", null, atomicOperationIndex: AtomicOperationIndex); - } - } - - /// - /// Additional processing required for server deserialization. Flags a processed attribute or relationship as updated using - /// . - /// - /// - /// The resource that was constructed from the document's body. - /// - /// - /// The metadata for the exposed field. - /// - /// - /// Relationship data for . Is null when is not a . - /// - protected override void AfterProcessField(IIdentifiable resource, ResourceFieldAttribute field, RelationshipObject data = null) - { - bool isCreatingResource = IsCreatingResource(); - bool isUpdatingResource = IsUpdatingResource(); - - if (field is AttrAttribute attr) - { - if (isCreatingResource && !attr.Capabilities.HasFlag(AttrCapabilities.AllowCreate)) - { - throw new JsonApiSerializationException("Setting the initial value of the requested attribute is not allowed.", - $"Setting the initial value of '{attr.PublicName}' is not allowed.", atomicOperationIndex: AtomicOperationIndex); - } - - if (isUpdatingResource && !attr.Capabilities.HasFlag(AttrCapabilities.AllowChange)) - { - throw new JsonApiSerializationException("Changing the value of the requested attribute is not allowed.", - $"Changing the value of '{attr.PublicName}' is not allowed.", atomicOperationIndex: AtomicOperationIndex); - } - - _targetedFields.Attributes.Add(attr); - } - else if (field is RelationshipAttribute relationship) - { - _targetedFields.Relationships.Add(relationship); - } - } - - private bool IsCreatingResource() - { - return _request.Kind == EndpointKind.AtomicOperations - ? _request.WriteOperation == WriteOperationKind.CreateResource - : _request.Kind == EndpointKind.Primary && _httpContextAccessor.HttpContext!.Request.Method == HttpMethod.Post.Method; - } - - private bool IsUpdatingResource() - { - return _request.Kind == EndpointKind.AtomicOperations - ? _request.WriteOperation == WriteOperationKind.UpdateResource - : _request.Kind == EndpointKind.Primary && _httpContextAccessor.HttpContext!.Request.Method == HttpMethod.Patch.Method; - } - } -} diff --git a/src/JsonApiDotNetCore/Serialization/ETagGenerator.cs b/src/JsonApiDotNetCore/Serialization/Response/ETagGenerator.cs similarity index 93% rename from src/JsonApiDotNetCore/Serialization/ETagGenerator.cs rename to src/JsonApiDotNetCore/Serialization/Response/ETagGenerator.cs index bc1a7f3e49..f2a78adb65 100644 --- a/src/JsonApiDotNetCore/Serialization/ETagGenerator.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/ETagGenerator.cs @@ -1,6 +1,6 @@ using Microsoft.Net.Http.Headers; -namespace JsonApiDotNetCore.Serialization +namespace JsonApiDotNetCore.Serialization.Response { /// internal sealed class ETagGenerator : IETagGenerator diff --git a/src/JsonApiDotNetCore/Serialization/EmptyResponseMeta.cs b/src/JsonApiDotNetCore/Serialization/Response/EmptyResponseMeta.cs similarity index 83% rename from src/JsonApiDotNetCore/Serialization/EmptyResponseMeta.cs rename to src/JsonApiDotNetCore/Serialization/Response/EmptyResponseMeta.cs index 592a752926..a06e4b76c3 100644 --- a/src/JsonApiDotNetCore/Serialization/EmptyResponseMeta.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/EmptyResponseMeta.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace JsonApiDotNetCore.Serialization +namespace JsonApiDotNetCore.Serialization.Response { /// public sealed class EmptyResponseMeta : IResponseMeta diff --git a/src/JsonApiDotNetCore/Serialization/FingerprintGenerator.cs b/src/JsonApiDotNetCore/Serialization/Response/FingerprintGenerator.cs similarity index 97% rename from src/JsonApiDotNetCore/Serialization/FingerprintGenerator.cs rename to src/JsonApiDotNetCore/Serialization/Response/FingerprintGenerator.cs index 3c1083dfbe..9835bf5de3 100644 --- a/src/JsonApiDotNetCore/Serialization/FingerprintGenerator.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/FingerprintGenerator.cs @@ -3,7 +3,7 @@ using System.Security.Cryptography; using System.Text; -namespace JsonApiDotNetCore.Serialization +namespace JsonApiDotNetCore.Serialization.Response { /// internal sealed class FingerprintGenerator : IFingerprintGenerator diff --git a/src/JsonApiDotNetCore/Serialization/IETagGenerator.cs b/src/JsonApiDotNetCore/Serialization/Response/IETagGenerator.cs similarity index 93% rename from src/JsonApiDotNetCore/Serialization/IETagGenerator.cs rename to src/JsonApiDotNetCore/Serialization/Response/IETagGenerator.cs index 5aa3abf759..5fc5070129 100644 --- a/src/JsonApiDotNetCore/Serialization/IETagGenerator.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/IETagGenerator.cs @@ -1,6 +1,6 @@ using Microsoft.Net.Http.Headers; -namespace JsonApiDotNetCore.Serialization +namespace JsonApiDotNetCore.Serialization.Response { /// /// Provides generation of an ETag HTTP response header. diff --git a/src/JsonApiDotNetCore/Serialization/IFingerprintGenerator.cs b/src/JsonApiDotNetCore/Serialization/Response/IFingerprintGenerator.cs similarity index 89% rename from src/JsonApiDotNetCore/Serialization/IFingerprintGenerator.cs rename to src/JsonApiDotNetCore/Serialization/Response/IFingerprintGenerator.cs index 51fafaf650..7b18e7db19 100644 --- a/src/JsonApiDotNetCore/Serialization/IFingerprintGenerator.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/IFingerprintGenerator.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Serialization +namespace JsonApiDotNetCore.Serialization.Response { /// /// Provides a method to generate a fingerprint for a collection of string values. diff --git a/src/JsonApiDotNetCore/Serialization/Response/IJsonApiWriter.cs b/src/JsonApiDotNetCore/Serialization/Response/IJsonApiWriter.cs new file mode 100644 index 0000000000..e8c0e6dab4 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Response/IJsonApiWriter.cs @@ -0,0 +1,18 @@ +using System.Threading.Tasks; +using JetBrains.Annotations; +using Microsoft.AspNetCore.Http; + +namespace JsonApiDotNetCore.Serialization.Response +{ + /// + /// Serializes ASP.NET models into the outgoing JSON:API response body. + /// + [PublicAPI] + public interface IJsonApiWriter + { + /// + /// Writes an object to the response body. + /// + Task WriteAsync(object model, HttpContext httpContext); + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Building/ILinkBuilder.cs b/src/JsonApiDotNetCore/Serialization/Response/ILinkBuilder.cs similarity index 83% rename from src/JsonApiDotNetCore/Serialization/Building/ILinkBuilder.cs rename to src/JsonApiDotNetCore/Serialization/Response/ILinkBuilder.cs index 86462aee54..ce3e027410 100644 --- a/src/JsonApiDotNetCore/Serialization/Building/ILinkBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/ILinkBuilder.cs @@ -1,8 +1,9 @@ +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Serialization.Building +namespace JsonApiDotNetCore.Serialization.Response { /// /// Builds resource object links and relationship object links. @@ -17,7 +18,7 @@ public interface ILinkBuilder /// /// Builds the links object for a returned resource (primary or included). /// - ResourceLinks GetResourceLinks(string resourceName, string id); + ResourceLinks GetResourceLinks(ResourceType resourceType, string id); /// /// Builds the links object for a relationship inside a returned resource. diff --git a/src/JsonApiDotNetCore/Serialization/Building/IMetaBuilder.cs b/src/JsonApiDotNetCore/Serialization/Response/IMetaBuilder.cs similarity index 92% rename from src/JsonApiDotNetCore/Serialization/Building/IMetaBuilder.cs rename to src/JsonApiDotNetCore/Serialization/Response/IMetaBuilder.cs index 1e668feca5..c94b0150da 100644 --- a/src/JsonApiDotNetCore/Serialization/Building/IMetaBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/IMetaBuilder.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Serialization.Building +namespace JsonApiDotNetCore.Serialization.Response { /// /// Builds the top-level meta object. diff --git a/src/JsonApiDotNetCore/Serialization/IResponseMeta.cs b/src/JsonApiDotNetCore/Serialization/Response/IResponseMeta.cs similarity index 92% rename from src/JsonApiDotNetCore/Serialization/IResponseMeta.cs rename to src/JsonApiDotNetCore/Serialization/Response/IResponseMeta.cs index 2561da2543..ca13ceaad2 100644 --- a/src/JsonApiDotNetCore/Serialization/IResponseMeta.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/IResponseMeta.cs @@ -2,7 +2,7 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Serialization +namespace JsonApiDotNetCore.Serialization.Response { /// /// Provides a method to obtain global JSON:API meta, which is added at top-level to a response . Use diff --git a/src/JsonApiDotNetCore/Serialization/Response/IResponseModelAdapter.cs b/src/JsonApiDotNetCore/Serialization/Response/IResponseModelAdapter.cs new file mode 100644 index 0000000000..960d541cd4 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Response/IResponseModelAdapter.cs @@ -0,0 +1,47 @@ +using JsonApiDotNetCore.Serialization.Objects; + +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 + { + /// + /// Validates and converts the specified . Supported model types: + /// + /// + /// + /// ]]> + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// ]]> + /// + /// + /// + /// + /// ]]> + /// + /// + /// + /// + /// + /// + /// + /// + /// + Document Convert(object model); + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Response/JsonApiWriter.cs b/src/JsonApiDotNetCore/Serialization/Response/JsonApiWriter.cs new file mode 100644 index 0000000000..57d33276a1 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Response/JsonApiWriter.cs @@ -0,0 +1,189 @@ +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; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Logging; +using Microsoft.Net.Http.Headers; + +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 async Task WriteAsync(object model, HttpContext httpContext) + { + ArgumentGuard.NotNull(httpContext, nameof(httpContext)); + + 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); + + if (httpContext.Request.Method == HttpMethod.Head.Method) + { + httpContext.Response.GetTypedHeaders().ContentLength = Encoding.UTF8.GetByteCount(responseBody); + return; + } + + _traceWriter.LogMessage(() => + $"Sending {httpContext.Response.StatusCode} response for {httpContext.Request.Method} request at '{httpContext.Request.GetEncodedUrl()}' with body: <<{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 string GetResponseBody(object model, HttpContext httpContext) + { + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Write response body"); + + try + { + if (model is ProblemDetails problemDetails) + { + throw new UnsuccessfulActionResultException(problemDetails); + } + + if (model == null && !IsSuccessStatusCode((HttpStatusCode)httpContext.Response.StatusCode)) + { + throw new UnsuccessfulActionResultException((HttpStatusCode)httpContext.Response.StatusCode); + } + + string responseBody = RenderModel(model); + + if (SetETagResponseHeader(httpContext.Request, httpContext.Response, responseBody)) + { + httpContext.Response.StatusCode = (int)HttpStatusCode.NotModified; + return null; + } + + 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 + { + IReadOnlyList errors = _exceptionHandler.HandleException(exception); + httpContext.Response.StatusCode = (int)ErrorObject.GetResponseStatusCode(errors); + + return RenderModel(errors); + } + } + + private static bool IsSuccessStatusCode(HttpStatusCode statusCode) + { + return new HttpResponseMessage(statusCode).IsSuccessStatusCode; + } + + private string RenderModel(object model) + { + Document document = _responseModelAdapter.Convert(model); + return SerializeDocument(document); + } + + private string SerializeDocument(Document document) + { + using IDisposable _ = + CodeTimingSessionManager.Current.Measure("JsonSerializer.Serialize", MeasurementSettings.ExcludeJsonSerializationInPercentages); + + 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; + + if (isReadOnly && response.StatusCode == (int)HttpStatusCode.OK) + { + string url = request.GetEncodedUrl(); + EntityTagHeaderValue responseETag = _eTagGenerator.Generate(url, responseContent); + + if (responseETag != null) + { + response.Headers.Add(HeaderNames.ETag, responseETag.ToString()); + + return RequestContainsMatchingETag(request.Headers, 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)) + { + foreach (EntityTagHeaderValue requestETag in requestETags) + { + if (responseETag.Equals(requestETag)) + { + return true; + } + } + } + + return false; + } + + private async Task SendResponseBodyAsync(HttpResponse httpResponse, string responseBody) + { + if (!string.IsNullOrEmpty(responseBody)) + { + httpResponse.ContentType = + _request.Kind == EndpointKind.AtomicOperations ? HeaderConstants.AtomicOperationsMediaType : HeaderConstants.MediaType; + + 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(); + } + } + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Building/LinkBuilder.cs b/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs similarity index 79% rename from src/JsonApiDotNetCore/Serialization/Building/LinkBuilder.cs rename to src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs index ad82acff5b..4871bd7fdb 100644 --- a/src/JsonApiDotNetCore/Serialization/Building/LinkBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs @@ -16,7 +16,7 @@ using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Routing; -namespace JsonApiDotNetCore.Serialization.Building +namespace JsonApiDotNetCore.Serialization.Response { [PublicAPI] public class LinkBuilder : ILinkBuilder @@ -31,25 +31,22 @@ public class LinkBuilder : ILinkBuilder private readonly IJsonApiOptions _options; private readonly IJsonApiRequest _request; private readonly IPaginationContext _paginationContext; - private readonly IResourceGraph _resourceGraph; private readonly IHttpContextAccessor _httpContextAccessor; private readonly LinkGenerator _linkGenerator; private readonly IControllerResourceMapping _controllerResourceMapping; - public LinkBuilder(IJsonApiOptions options, IJsonApiRequest request, IPaginationContext paginationContext, IResourceGraph resourceGraph, - IHttpContextAccessor httpContextAccessor, LinkGenerator linkGenerator, IControllerResourceMapping controllerResourceMapping) + 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(resourceGraph, nameof(resourceGraph)); ArgumentGuard.NotNull(linkGenerator, nameof(linkGenerator)); ArgumentGuard.NotNull(controllerResourceMapping, nameof(controllerResourceMapping)); _options = options; _request = request; _paginationContext = paginationContext; - _resourceGraph = resourceGraph; _httpContextAccessor = httpContextAccessor; _linkGenerator = linkGenerator; _controllerResourceMapping = controllerResourceMapping; @@ -64,36 +61,35 @@ private static string NoAsyncSuffix(string actionName) public TopLevelLinks GetTopLevelLinks() { var links = new TopLevelLinks(); + ResourceType resourceType = _request.SecondaryResourceType ?? _request.PrimaryResourceType; - ResourceContext requestContext = _request.SecondaryResource ?? _request.PrimaryResource; - - if (ShouldIncludeTopLevelLink(LinkTypes.Self, requestContext)) + if (ShouldIncludeTopLevelLink(LinkTypes.Self, resourceType)) { links.Self = GetLinkForTopLevelSelf(); } - if (_request.Kind == EndpointKind.Relationship && ShouldIncludeTopLevelLink(LinkTypes.Related, requestContext)) + if (_request.Kind == EndpointKind.Relationship && _request.Relationship != null && ShouldIncludeTopLevelLink(LinkTypes.Related, resourceType)) { links.Related = GetLinkForRelationshipRelated(_request.PrimaryId, _request.Relationship); } - if (_request.IsCollection && _paginationContext.PageSize != null && ShouldIncludeTopLevelLink(LinkTypes.Paging, requestContext)) + if (_request.IsCollection && _paginationContext.PageSize != null && ShouldIncludeTopLevelLink(LinkTypes.Paging, resourceType)) { - SetPaginationInTopLevelLinks(requestContext, links); + SetPaginationInTopLevelLinks(resourceType, 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 . + /// 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, ResourceContext resourceContext) + private bool ShouldIncludeTopLevelLink(LinkTypes linkType, ResourceType resourceType) { - if (resourceContext.TopLevelLinks != LinkTypes.NotConfigured) + if (resourceType != null && resourceType.TopLevelLinks != LinkTypes.NotConfigured) { - return resourceContext.TopLevelLinks.HasFlag(linkType); + return resourceType.TopLevelLinks.HasFlag(linkType); } return _options.TopLevelLinks.HasFlag(linkType); @@ -106,9 +102,9 @@ private string GetLinkForTopLevelSelf() : _httpContextAccessor.HttpContext!.Request.GetEncodedUrl(); } - private void SetPaginationInTopLevelLinks(ResourceContext requestContext, TopLevelLinks links) + private void SetPaginationInTopLevelLinks(ResourceType resourceType, TopLevelLinks links) { - string pageSizeValue = CalculatePageSizeValue(_paginationContext.PageSize, requestContext); + string pageSizeValue = CalculatePageSizeValue(_paginationContext.PageSize, resourceType); links.First = GetLinkForPagination(1, pageSizeValue); @@ -131,17 +127,17 @@ private void SetPaginationInTopLevelLinks(ResourceContext requestContext, TopLev } } - private string CalculatePageSizeValue(PageSize topPageSize, ResourceContext requestContext) + private string CalculatePageSizeValue(PageSize topPageSize, ResourceType resourceType) { string pageSizeParameterValue = _httpContextAccessor.HttpContext!.Request.Query[PageSizeParameterName]; PageSize newTopPageSize = Equals(topPageSize, _options.DefaultPageSize) ? null : topPageSize; - return ChangeTopPageSize(pageSizeParameterValue, newTopPageSize, requestContext); + return ChangeTopPageSize(pageSizeParameterValue, newTopPageSize, resourceType); } - private string ChangeTopPageSize(string pageSizeParameterValue, PageSize topPageSize, ResourceContext requestContext) + private string ChangeTopPageSize(string pageSizeParameterValue, PageSize topPageSize, ResourceType resourceType) { - IImmutableList elements = ParsePageSizeExpression(pageSizeParameterValue, requestContext); + IImmutableList elements = ParsePageSizeExpression(pageSizeParameterValue, resourceType); int elementInTopScopeIndex = elements.FindIndex(expression => expression.Scope == null); if (topPageSize != null) @@ -164,16 +160,15 @@ private string ChangeTopPageSize(string pageSizeParameterValue, PageSize topPage return parameterValue == string.Empty ? null : parameterValue; } - private IImmutableList ParsePageSizeExpression(string pageSizeParameterValue, - ResourceContext requestResource) + private IImmutableList ParsePageSizeExpression(string pageSizeParameterValue, ResourceType resourceType) { if (pageSizeParameterValue == null) { return ImmutableArray.Empty; } - var parser = new PaginationParser(_resourceGraph); - PaginationQueryStringValueExpression paginationExpression = parser.Parse(pageSizeParameterValue, requestResource); + var parser = new PaginationParser(); + PaginationQueryStringValueExpression paginationExpression = parser.Parse(pageSizeParameterValue, resourceType); return paginationExpression.Elements; } @@ -224,39 +219,38 @@ private static string DecodeSpecialCharacters(string uri) } /// - public ResourceLinks GetResourceLinks(string resourceName, string id) + public ResourceLinks GetResourceLinks(ResourceType resourceType, string id) { - ArgumentGuard.NotNullNorEmpty(resourceName, nameof(resourceName)); + ArgumentGuard.NotNull(resourceType, nameof(resourceType)); ArgumentGuard.NotNullNorEmpty(id, nameof(id)); var links = new ResourceLinks(); - ResourceContext resourceContext = _resourceGraph.GetResourceContext(resourceName); - if (_request.Kind != EndpointKind.Relationship && ShouldIncludeResourceLink(LinkTypes.Self, resourceContext)) + if (ShouldIncludeResourceLink(LinkTypes.Self, resourceType)) { - links.Self = GetLinkForResourceSelf(resourceContext, id); + links.Self = GetLinkForResourceSelf(resourceType, id); } return links.HasValue() ? links : null; } /// - /// 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 . + /// 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, ResourceContext resourceContext) + private bool ShouldIncludeResourceLink(LinkTypes linkType, ResourceType resourceType) { - if (resourceContext.ResourceLinks != LinkTypes.NotConfigured) + if (resourceType.ResourceLinks != LinkTypes.NotConfigured) { - return resourceContext.ResourceLinks.HasFlag(linkType); + return resourceType.ResourceLinks.HasFlag(linkType); } return _options.ResourceLinks.HasFlag(linkType); } - private string GetLinkForResourceSelf(ResourceContext resourceContext, string resourceId) + private string GetLinkForResourceSelf(ResourceType resourceType, string resourceId) { - string controllerName = _controllerResourceMapping.GetControllerNameForResourceType(resourceContext.ResourceType); + string controllerName = _controllerResourceMapping.TryGetControllerNameForResourceType(resourceType); IDictionary routeValues = GetRouteValues(resourceId, null); return RenderLinkForAction(controllerName, GetPrimaryControllerActionName, routeValues); @@ -269,14 +263,13 @@ public RelationshipLinks GetRelationshipLinks(RelationshipAttribute relationship ArgumentGuard.NotNull(leftResource, nameof(leftResource)); var links = new RelationshipLinks(); - ResourceContext leftResourceContext = _resourceGraph.GetResourceContext(leftResource.GetType()); - if (ShouldIncludeRelationshipLink(LinkTypes.Self, relationship, leftResourceContext)) + if (ShouldIncludeRelationshipLink(LinkTypes.Self, relationship)) { links.Self = GetLinkForRelationshipSelf(leftResource.StringId, relationship); } - if (ShouldIncludeRelationshipLink(LinkTypes.Related, relationship, leftResourceContext)) + if (ShouldIncludeRelationshipLink(LinkTypes.Related, relationship)) { links.Related = GetLinkForRelationshipRelated(leftResource.StringId, relationship); } @@ -286,7 +279,7 @@ public RelationshipLinks GetRelationshipLinks(RelationshipAttribute relationship private string GetLinkForRelationshipSelf(string leftId, RelationshipAttribute relationship) { - string controllerName = _controllerResourceMapping.GetControllerNameForResourceType(relationship.LeftType); + string controllerName = _controllerResourceMapping.TryGetControllerNameForResourceType(relationship.LeftType); IDictionary routeValues = GetRouteValues(leftId, relationship.PublicName); return RenderLinkForAction(controllerName, GetRelationshipControllerActionName, routeValues); @@ -294,7 +287,7 @@ private string GetLinkForRelationshipSelf(string leftId, RelationshipAttribute r private string GetLinkForRelationshipRelated(string leftId, RelationshipAttribute relationship) { - string controllerName = _controllerResourceMapping.GetControllerNameForResourceType(relationship.LeftType); + string controllerName = _controllerResourceMapping.TryGetControllerNameForResourceType(relationship.LeftType); IDictionary routeValues = GetRouteValues(leftId, relationship.PublicName); return RenderLinkForAction(controllerName, GetSecondaryControllerActionName, routeValues); @@ -325,16 +318,16 @@ protected virtual string RenderLinkForAction(string controllerName, string actio /// 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, ResourceContext leftResourceContext) + private bool ShouldIncludeRelationshipLink(LinkTypes linkType, RelationshipAttribute relationship) { if (relationship.Links != LinkTypes.NotConfigured) { return relationship.Links.HasFlag(linkType); } - if (leftResourceContext.RelationshipLinks != LinkTypes.NotConfigured) + if (relationship.LeftType.RelationshipLinks != LinkTypes.NotConfigured) { - return leftResourceContext.RelationshipLinks.HasFlag(linkType); + return relationship.LeftType.RelationshipLinks.HasFlag(linkType); } return _options.RelationshipLinks.HasFlag(linkType); diff --git a/src/JsonApiDotNetCore/Serialization/Building/MetaBuilder.cs b/src/JsonApiDotNetCore/Serialization/Response/MetaBuilder.cs similarity index 97% rename from src/JsonApiDotNetCore/Serialization/Building/MetaBuilder.cs rename to src/JsonApiDotNetCore/Serialization/Response/MetaBuilder.cs index dcddb2aa53..265154cca4 100644 --- a/src/JsonApiDotNetCore/Serialization/Building/MetaBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/MetaBuilder.cs @@ -4,7 +4,7 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries; -namespace JsonApiDotNetCore.Serialization.Building +namespace JsonApiDotNetCore.Serialization.Response { /// [PublicAPI] diff --git a/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs b/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs new file mode 100644 index 0000000000..5137f62716 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs @@ -0,0 +1,275 @@ +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 +{ + /// + /// 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(); + + // 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; + + private bool IsTreeRoot => RootType.Equals(Type); + + // 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 Type { 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 type, ResourceObject resourceObject) + { + ArgumentGuard.NotNull(resource, nameof(resource)); + ArgumentGuard.NotNull(type, nameof(type)); + ArgumentGuard.NotNull(resourceObject, nameof(resourceObject)); + + Resource = resource; + Type = type; + ResourceObject = resourceObject; + } + + public static ResourceObjectTreeNode CreateRoot() + { + return new(RootResource, RootType, new ResourceObject()); + } + + public void AttachDirectChild(ResourceObjectTreeNode treeNode) + { + ArgumentGuard.NotNull(treeNode, nameof(treeNode)); + + _directChildren ??= new List(); + _directChildren.Add(treeNode); + } + + public void EnsureHasRelationship(RelationshipAttribute relationship) + { + ArgumentGuard.NotNull(relationship, nameof(relationship)); + + _childrenByRelationship ??= new Dictionary>(); + + 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)); + + 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(); + + var visited = new HashSet(); + + VisitSubtree(this, visited); + + return visited; + } + + private static void VisitSubtree(ResourceObjectTreeNode treeNode, ISet visited) + { + if (visited.Contains(treeNode)) + { + return; + } + + if (!treeNode.IsTreeRoot) + { + visited.Add(treeNode); + } + + VisitDirectChildrenInSubtree(treeNode, visited); + VisitRelationshipChildrenInSubtree(treeNode, visited); + } + + private static void VisitDirectChildrenInSubtree(ResourceObjectTreeNode treeNode, ISet visited) + { + if (treeNode._directChildren != null) + { + foreach (ResourceObjectTreeNode child in treeNode._directChildren) + { + VisitSubtree(child, visited); + } + } + } + + private static void VisitRelationshipChildrenInSubtree(ResourceObjectTreeNode treeNode, ISet visited) + { + if (treeNode._childrenByRelationship != null) + { + foreach (RelationshipAttribute relationship in treeNode.Type.Relationships) + { + if (treeNode._childrenByRelationship.TryGetValue(relationship, out HashSet rightNodes)) + { + VisitRelationshipChildInSubtree(rightNodes, visited); + } + } + } + } + + private static void VisitRelationshipChildInSubtree(HashSet rightNodes, ISet visited) + { + foreach (ResourceObjectTreeNode rightNode in rightNodes) + { + VisitSubtree(rightNode, visited); + } + } + + 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(); + + 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(); + + foreach (ResourceObjectTreeNode child in GetDirectChildren()) + { + VisitRelationshipChildrenInSubtree(child, visited); + } + + return visited.Select(node => node.ResourceObject).ToArray(); + } + + private IList GetDirectChildren() + { + // 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; + } + + private void AssertIsTreeRoot() + { + if (!IsTreeRoot) + { + throw new InvalidOperationException("Internal error: this method should only be called from the root of the tree."); + } + } + + public bool Equals(ResourceObjectTreeNode other) + { + if (ReferenceEquals(null, other)) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + return ResourceObjectComparer.Instance.Equals(ResourceObject, other.ResourceObject); + } + + public override bool Equals(object other) + { + return Equals(other as ResourceObjectTreeNode); + } + + public override int GetHashCode() + { + return ResourceObject.GetHashCode(); + } + + public override string ToString() + { + var builder = new StringBuilder(); + builder.Append(IsTreeRoot ? Type.PublicName : $"{ResourceObject.Type}:{ResourceObject.Id}"); + + 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})"))}"); + } + + return builder.ToString(); + } + + private sealed class EmptyResource : IIdentifiable + { + public string StringId { get; set; } + public string LocalId { get; set; } + } + + private sealed class ResourceObjectComparer : IEqualityComparer + { + public static readonly ResourceObjectComparer Instance = new(); + + private ResourceObjectComparer() + { + } + + public bool Equals(ResourceObject x, ResourceObject y) + { + 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; + } + + 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 new file mode 100644 index 0000000000..10f25f2c20 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs @@ -0,0 +1,362 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Text.Json.Serialization; +using JetBrains.Annotations; +using JsonApiDotNetCore.AtomicOperations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Internal; +using JsonApiDotNetCore.QueryStrings; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Resources.Internal; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Serialization.Response +{ + /// + [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) + { + 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(); + + IncludeExpression include = _evaluatedIncludeCache.Get(); + IImmutableSet includeElements = include?.Elements ?? ImmutableHashSet.Empty; + + var rootNode = ResourceObjectTreeNode.CreateRoot(); + ResourceType resourceType = _request.SecondaryResourceType ?? _request.PrimaryResourceType; + + if (model is IEnumerable resources) + { + foreach (IIdentifiable resource in resources) + { + TraverseResource(resource, resourceType, _request.Kind, includeElements, rootNode, null); + } + + PopulateRelationshipsInTree(rootNode, _request.Kind); + + IEnumerable resourceObjects = rootNode.GetResponseData(); + document.Data = new SingleOrManyData(resourceObjects); + } + else if (model is IIdentifiable resource) + { + 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); + + return document; + } + + protected virtual AtomicResultObject ConvertOperation(OperationContainer operation, IImmutableSet includeElements) + { + ResourceObject resourceObject = null; + + if (operation != null) + { + _request.CopyFrom(operation.Request); + + ResourceType resourceType = operation.Request.SecondaryResourceType ?? operation.Request.PrimaryResourceType; + var rootNode = ResourceObjectTreeNode.CreateRoot(); + + TraverseResource(operation.Resource, resourceType, operation.Request.Kind, includeElements, rootNode, null); + PopulateRelationshipsInTree(rootNode, operation.Request.Kind); + + resourceObject = rootNode.GetResponseData().Single(); + + _sparseFieldSetCache.Reset(); + _resourceToTreeNodeCache.Clear(); + } + + return new AtomicResultObject + { + Data = resourceObject == null ? default : new SingleOrManyData(resourceObject) + }; + } + + private void TraverseResource(IIdentifiable resource, ResourceType type, EndpointKind kind, IImmutableSet includeElements, + ResourceObjectTreeNode parentTreeNode, RelationshipAttribute parentRelationship) + { + ResourceObjectTreeNode treeNode = GetOrCreateTreeNode(resource, type, kind); + + if (parentRelationship != null) + { + parentTreeNode.AttachRelationshipChild(parentRelationship, treeNode); + } + else + { + parentTreeNode.AttachDirectChild(treeNode); + } + + if (kind != EndpointKind.Relationship) + { + TraverseRelationships(resource, treeNode, includeElements, kind); + } + } + + private ResourceObjectTreeNode GetOrCreateTreeNode(IIdentifiable resource, ResourceType type, EndpointKind kind) + { + if (!_resourceToTreeNodeCache.TryGetValue(resource, out ResourceObjectTreeNode treeNode)) + { + ResourceObject resourceObject = ConvertResource(resource, type, kind); + treeNode = new ResourceObjectTreeNode(resource, type, resourceObject); + + _resourceToTreeNodeCache.Add(resource, treeNode); + } + + return treeNode; + } + + protected virtual ResourceObject ConvertResource(IIdentifiable resource, ResourceType type, EndpointKind kind) + { + bool isRelationship = kind == EndpointKind.Relationship; + + if (!isRelationship) + { + _resourceDefinitionAccessor.OnSerialize(resource); + } + + var resourceObject = new ResourceObject + { + Type = type.PublicName, + Id = resource.StringId + }; + + if (!isRelationship) + { + IImmutableSet fieldSet = _sparseFieldSetCache.GetSparseFieldSetForSerializer(type); + + resourceObject.Attributes = ConvertAttributes(resource, type, fieldSet); + resourceObject.Links = _linkBuilder.GetResourceLinks(type, resource.StringId); + resourceObject.Meta = _resourceDefinitionAccessor.GetMeta(type, resource); + } + + return resourceObject; + } + + protected virtual IDictionary ConvertAttributes(IIdentifiable resource, ResourceType resourceType, + IImmutableSet fieldSet) + { + var attrMap = new Dictionary(resourceType.Attributes.Count); + + foreach (AttrAttribute attr in resourceType.Attributes) + { + if (!fieldSet.Contains(attr) || attr.Property.Name == nameof(Identifiable.Id)) + { + continue; + } + + object value = attr.GetValue(resource); + + if (_options.SerializerOptions.DefaultIgnoreCondition == JsonIgnoreCondition.WhenWritingNull && value == null) + { + continue; + } + + if (_options.SerializerOptions.DefaultIgnoreCondition == JsonIgnoreCondition.WhenWritingDefault && + Equals(value, RuntimeTypeConverter.GetDefaultValue(attr.Property.PropertyType))) + { + continue; + } + + attrMap.Add(attr.PublicName, value); + } + + return attrMap.Any() ? attrMap : null; + } + + private void TraverseRelationships(IIdentifiable leftResource, ResourceObjectTreeNode leftTreeNode, + IImmutableSet includeElements, EndpointKind kind) + { + foreach (IncludeElementExpression includeElement in includeElements) + { + 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); + + foreach (IIdentifiable rightResource in rightResources) + { + TraverseResource(rightResource, relationship.RightType, kind, includeElement.Children, leftTreeNode, relationship); + } + } + + private void PopulateRelationshipsInTree(ResourceObjectTreeNode rootNode, EndpointKind kind) + { + if (kind != EndpointKind.Relationship) + { + foreach (ResourceObjectTreeNode treeNode in rootNode.GetUniqueNodes()) + { + PopulateRelationshipsInResourceObject(treeNode); + } + } + } + + private void PopulateRelationshipsInResourceObject(ResourceObjectTreeNode treeNode) + { + IImmutableSet fieldSet = _sparseFieldSetCache.GetSparseFieldSetForSerializer(treeNode.Type); + + foreach (RelationshipAttribute relationship in treeNode.Type.Relationships) + { + if (fieldSet.Contains(relationship)) + { + PopulateRelationshipInResourceObject(treeNode, relationship); + } + } + } + + private void PopulateRelationshipInResourceObject(ResourceObjectTreeNode treeNode, RelationshipAttribute relationship) + { + SingleOrManyData data = GetRelationshipData(treeNode, relationship); + RelationshipLinks links = _linkBuilder.GetRelationshipLinks(relationship, treeNode.Resource); + + if (links != null || data.IsAssigned) + { + var relationshipObject = new RelationshipObject + { + Links = links, + Data = data + }; + + treeNode.ResourceObject.Relationships ??= new Dictionary(); + treeNode.ResourceObject.Relationships.Add(relationship.PublicName, relationshipObject); + } + } + + private static SingleOrManyData GetRelationshipData(ResourceObjectTreeNode treeNode, RelationshipAttribute relationship) + { + ISet rightNodes = treeNode.GetRightNodesInRelationship(relationship); + + if (rightNodes != null) + { + IEnumerable resourceIdentifierObjects = rightNodes.Select(rightNode => new ResourceIdentifierObject + { + Type = rightNode.Type.PublicName, + Id = rightNode.ResourceObject.Id + }); + + return relationship is HasOneAttribute + ? new SingleOrManyData(resourceIdentifierObjects.SingleOrDefault()) + : new SingleOrManyData(resourceIdentifierObjects); + } + + return default; + } + + protected virtual JsonApiObject GetApiObject() + { + if (!_options.IncludeJsonApiVersion) + { + return null; + } + + var jsonApiObject = new JsonApiObject + { + Version = "1.1" + }; + + if (_request.Kind == EndpointKind.AtomicOperations) + { + jsonApiObject.Ext = new List + { + "https://jsonapi.org/ext/atomic" + }; + } + + return jsonApiObject; + } + + 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/Serialization/ResponseSerializer.cs b/src/JsonApiDotNetCore/Serialization/ResponseSerializer.cs deleted file mode 100644 index 348e1d7a2d..0000000000 --- a/src/JsonApiDotNetCore/Serialization/ResponseSerializer.cs +++ /dev/null @@ -1,179 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCore.Serialization.Building; -using JsonApiDotNetCore.Serialization.Objects; - -namespace JsonApiDotNetCore.Serialization -{ - /// - /// Server serializer implementation of for resources of a specific type. - /// - /// - /// Because in JsonApiDotNetCore every JSON:API request is associated with exactly one resource (the primary resource, see - /// ), the serializer can leverage this information using generics. See - /// for how this is instantiated. - /// - /// - /// Type of the resource associated with the scope of the request for which this serializer is used. - /// - [PublicAPI] - public class ResponseSerializer : BaseSerializer, IJsonApiSerializer - where TResource : class, IIdentifiable - { - private readonly IMetaBuilder _metaBuilder; - private readonly ILinkBuilder _linkBuilder; - private readonly IIncludedResourceObjectBuilder _includedBuilder; - private readonly IFieldsToSerialize _fieldsToSerialize; - private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; - private readonly IJsonApiOptions _options; - private readonly Type _primaryResourceType; - - /// - public string ContentType { get; } = HeaderConstants.MediaType; - - public ResponseSerializer(IMetaBuilder metaBuilder, ILinkBuilder linkBuilder, IIncludedResourceObjectBuilder includedBuilder, - IFieldsToSerialize fieldsToSerialize, IResourceObjectBuilder resourceObjectBuilder, IResourceDefinitionAccessor resourceDefinitionAccessor, - IJsonApiOptions options) - : base(resourceObjectBuilder) - { - ArgumentGuard.NotNull(metaBuilder, nameof(metaBuilder)); - ArgumentGuard.NotNull(linkBuilder, nameof(linkBuilder)); - ArgumentGuard.NotNull(includedBuilder, nameof(includedBuilder)); - ArgumentGuard.NotNull(fieldsToSerialize, nameof(fieldsToSerialize)); - ArgumentGuard.NotNull(resourceDefinitionAccessor, nameof(resourceDefinitionAccessor)); - ArgumentGuard.NotNull(options, nameof(options)); - - _metaBuilder = metaBuilder; - _linkBuilder = linkBuilder; - _includedBuilder = includedBuilder; - _fieldsToSerialize = fieldsToSerialize; - _resourceDefinitionAccessor = resourceDefinitionAccessor; - _options = options; - _primaryResourceType = typeof(TResource); - } - - /// - public string Serialize(object content) - { - if (content == null || content is IIdentifiable) - { - return SerializeSingle((IIdentifiable)content); - } - - if (content is IEnumerable collectionOfIdentifiable) - { - return SerializeMany(collectionOfIdentifiable.ToArray()); - } - - if (content is Document errorDocument) - { - return SerializeErrorDocument(errorDocument); - } - - throw new InvalidOperationException("Data being returned must be errors or resources."); - } - - private string SerializeErrorDocument(Document document) - { - SetApiVersion(document); - - return SerializeObject(document, _options.SerializerWriteOptions); - } - - /// - /// Converts a single resource into a serialized . - /// - /// - /// This method is internal instead of private for easier testability. - /// - internal string SerializeSingle(IIdentifiable resource) - { - if (resource != null && _fieldsToSerialize.ShouldSerialize) - { - _resourceDefinitionAccessor.OnSerialize(resource); - } - - IReadOnlyCollection attributes = _fieldsToSerialize.GetAttributes(_primaryResourceType); - IReadOnlyCollection relationships = _fieldsToSerialize.GetRelationships(_primaryResourceType); - - Document document = Build(resource, attributes, relationships); - ResourceObject resourceObject = document.Data.SingleValue; - - if (resourceObject != null) - { - resourceObject.Links = _linkBuilder.GetResourceLinks(resourceObject.Type, resourceObject.Id); - } - - AddTopLevelObjects(document); - - return SerializeObject(document, _options.SerializerWriteOptions); - } - - /// - /// Converts a collection of resources into a serialized . - /// - /// - /// This method is internal instead of private for easier testability. - /// - internal string SerializeMany(IReadOnlyCollection resources) - { - if (_fieldsToSerialize.ShouldSerialize) - { - foreach (IIdentifiable resource in resources) - { - _resourceDefinitionAccessor.OnSerialize(resource); - } - } - - IReadOnlyCollection attributes = _fieldsToSerialize.GetAttributes(_primaryResourceType); - IReadOnlyCollection relationships = _fieldsToSerialize.GetRelationships(_primaryResourceType); - - Document document = Build(resources, attributes, relationships); - - foreach (ResourceObject resourceObject in document.Data.ManyValue) - { - ResourceLinks links = _linkBuilder.GetResourceLinks(resourceObject.Type, resourceObject.Id); - - if (links == null) - { - break; - } - - resourceObject.Links = links; - } - - AddTopLevelObjects(document); - - return SerializeObject(document, _options.SerializerWriteOptions); - } - - /// - /// Adds top-level objects that are only added to a document in the case of server-side serialization. - /// - private void AddTopLevelObjects(Document document) - { - SetApiVersion(document); - - document.Links = _linkBuilder.GetTopLevelLinks(); - document.Meta = _metaBuilder.Build(); - document.Included = _includedBuilder.Build(); - } - - private void SetApiVersion(Document document) - { - if (_options.IncludeJsonApiVersion) - { - document.JsonApi = new JsonApiObject - { - Version = "1.1" - }; - } - } - } -} diff --git a/src/JsonApiDotNetCore/Serialization/ResponseSerializerFactory.cs b/src/JsonApiDotNetCore/Serialization/ResponseSerializerFactory.cs deleted file mode 100644 index 5ddc248a4d..0000000000 --- a/src/JsonApiDotNetCore/Serialization/ResponseSerializerFactory.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Middleware; -using Microsoft.Extensions.DependencyInjection; - -namespace JsonApiDotNetCore.Serialization -{ - /// - /// A factory class to abstract away the initialization of the serializer from the ASP.NET Core formatter pipeline. - /// - [PublicAPI] - public class ResponseSerializerFactory : IJsonApiSerializerFactory - { - private readonly IServiceProvider _provider; - private readonly IJsonApiRequest _request; - - public ResponseSerializerFactory(IJsonApiRequest request, IRequestScopedServiceProvider provider) - { - ArgumentGuard.NotNull(request, nameof(request)); - ArgumentGuard.NotNull(provider, nameof(provider)); - - _request = request; - _provider = provider; - } - - /// - /// Initializes the server serializer using the associated with the current request. - /// - public IJsonApiSerializer GetSerializer() - { - if (_request.Kind == EndpointKind.AtomicOperations) - { - return (IJsonApiSerializer)_provider.GetRequiredService(typeof(AtomicOperationsResponseSerializer)); - } - - Type targetType = GetDocumentType(); - - Type serializerType = typeof(ResponseSerializer<>).MakeGenericType(targetType); - object serializer = _provider.GetRequiredService(serializerType); - - return (IJsonApiSerializer)serializer; - } - - private Type GetDocumentType() - { - ResourceContext resourceContext = _request.SecondaryResource ?? _request.PrimaryResource; - return resourceContext.ResourceType; - } - } -} diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 9b659a4cdf..feb508cf56 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -66,7 +66,7 @@ public virtual async Task> GetAsync(CancellationT if (_options.IncludeTotalResourceCount) { - FilterExpression topFilter = _queryLayerComposer.GetTopFilterFromConstraints(_request.PrimaryResource); + FilterExpression topFilter = _queryLayerComposer.GetTopFilterFromConstraints(_request.PrimaryResourceType); _paginationContext.TotalResourceCount = await _repositoryAccessor.CountAsync(topFilter, cancellationToken); if (_paginationContext.TotalResourceCount == 0) @@ -75,7 +75,7 @@ public virtual async Task> GetAsync(CancellationT } } - QueryLayer queryLayer = _queryLayerComposer.ComposeFromConstraints(_request.PrimaryResource); + QueryLayer queryLayer = _queryLayerComposer.ComposeFromConstraints(_request.PrimaryResourceType); IReadOnlyCollection resources = await _repositoryAccessor.GetAsync(queryLayer, cancellationToken); if (queryLayer.Pagination?.PageSize != null && queryLayer.Pagination.PageSize.Value == resources.Count) @@ -112,8 +112,10 @@ public virtual async Task GetSecondaryAsync(TId id, string relationshipN AssertHasRelationship(_request.Relationship, relationshipName); - QueryLayer secondaryLayer = _queryLayerComposer.ComposeFromConstraints(_request.SecondaryResource); - QueryLayer primaryLayer = _queryLayerComposer.WrapLayerForSecondaryEndpoint(secondaryLayer, _request.PrimaryResource, id, _request.Relationship); + QueryLayer secondaryLayer = _queryLayerComposer.ComposeFromConstraints(_request.SecondaryResourceType); + + QueryLayer primaryLayer = + _queryLayerComposer.WrapLayerForSecondaryEndpoint(secondaryLayer, _request.PrimaryResourceType, id, _request.Relationship); IReadOnlyCollection primaryResources = await _repositoryAccessor.GetAsync(primaryLayer, cancellationToken); @@ -145,8 +147,10 @@ public virtual async Task GetRelationshipAsync(TId id, string relationsh AssertHasRelationship(_request.Relationship, relationshipName); - QueryLayer secondaryLayer = _queryLayerComposer.ComposeSecondaryLayerForRelationship(_request.SecondaryResource); - QueryLayer primaryLayer = _queryLayerComposer.WrapLayerForSecondaryEndpoint(secondaryLayer, _request.PrimaryResource, 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); @@ -190,7 +194,7 @@ public virtual async Task CreateAsync(TResource resource, Cancellatio if (existingResource != null) { - throw new ResourceAlreadyExistsException(resourceFromRequest.StringId, _request.PrimaryResource.PublicName); + throw new ResourceAlreadyExistsException(resourceFromRequest.StringId, _request.PrimaryResourceType.PublicName); } } @@ -236,8 +240,8 @@ protected async Task AssertResourcesToAssignInRelationshipsExistAsync(TResource private async IAsyncEnumerable GetMissingRightResourcesAsync(QueryLayer existingRightResourceIdsQueryLayer, RelationshipAttribute relationship, ICollection rightResourceIds, [EnumeratorCancellation] CancellationToken cancellationToken) { - IReadOnlyCollection existingResources = await _repositoryAccessor.GetAsync( - existingRightResourceIdsQueryLayer.ResourceContext.ResourceType, existingRightResourceIdsQueryLayer, cancellationToken); + IReadOnlyCollection existingResources = await _repositoryAccessor.GetAsync(existingRightResourceIdsQueryLayer.ResourceType, + existingRightResourceIdsQueryLayer, cancellationToken); string[] existingResourceIds = existingResources.Select(resource => resource.StringId).ToArray(); @@ -245,7 +249,7 @@ private async IAsyncEnumerable GetMissingRightRes { if (!existingResourceIds.Contains(rightResourceId.StringId)) { - yield return new MissingResourceInRelationship(relationship.PublicName, existingRightResourceIdsQueryLayer.ResourceContext.PublicName, + yield return new MissingResourceInRelationship(relationship.PublicName, existingRightResourceIdsQueryLayer.ResourceType.PublicName, rightResourceId.StringId); } } @@ -456,7 +460,7 @@ protected async Task GetPrimaryResourceByIdAsync(TId id, TopFieldSele private async Task TryGetPrimaryResourceByIdAsync(TId id, TopFieldSelection fieldSelection, CancellationToken cancellationToken) { - QueryLayer primaryLayer = _queryLayerComposer.ComposeForGetById(id, _request.PrimaryResource, fieldSelection); + QueryLayer primaryLayer = _queryLayerComposer.ComposeForGetById(id, _request.PrimaryResourceType, fieldSelection); IReadOnlyCollection primaryResources = await _repositoryAccessor.GetAsync(primaryLayer, cancellationToken); return primaryResources.SingleOrDefault(); @@ -464,7 +468,7 @@ private async Task TryGetPrimaryResourceByIdAsync(TId id, TopFieldSel protected async Task GetPrimaryResourceForUpdateAsync(TId id, CancellationToken cancellationToken) { - QueryLayer queryLayer = _queryLayerComposer.ComposeForUpdate(id, _request.PrimaryResource); + QueryLayer queryLayer = _queryLayerComposer.ComposeForUpdate(id, _request.PrimaryResourceType); var resource = await _repositoryAccessor.GetForUpdateAsync(queryLayer, cancellationToken); AssertPrimaryResourceExists(resource); @@ -476,7 +480,7 @@ private void AssertPrimaryResourceExists(TResource resource) { if (resource == null) { - throw new ResourceNotFoundException(_request.PrimaryId, _request.PrimaryResource.PublicName); + throw new ResourceNotFoundException(_request.PrimaryId, _request.PrimaryResourceType.PublicName); } } @@ -485,7 +489,7 @@ private void AssertHasRelationship(RelationshipAttribute relationship, string na { if (relationship == null) { - throw new RelationshipNotFoundException(name, _request.PrimaryResource.PublicName); + throw new RelationshipNotFoundException(name, _request.PrimaryResourceType.PublicName); } } } diff --git a/src/JsonApiDotNetCore/TypeExtensions.cs b/src/JsonApiDotNetCore/TypeExtensions.cs index 15713ac5fa..50514f3128 100644 --- a/src/JsonApiDotNetCore/TypeExtensions.cs +++ b/src/JsonApiDotNetCore/TypeExtensions.cs @@ -39,7 +39,7 @@ public static string GetFriendlyTypeName(this Type type) string genericArguments = type.GetGenericArguments().Select(GetFriendlyTypeName) .Aggregate((firstType, secondType) => $"{firstType}, {secondType}"); - return $"{type.Name[..type.Name.IndexOf("`", StringComparison.Ordinal)]}" + $"<{genericArguments}>"; + return $"{type.Name[..type.Name.IndexOf("`", StringComparison.Ordinal)]}<{genericArguments}>"; } return type.Name; diff --git a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs index a5a2f9fd77..1599ae57c2 100644 --- a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs +++ b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs @@ -56,11 +56,11 @@ public void Can_add_resources_from_assembly_to_graph() // Assert IResourceGraph resourceGraph = _resourceGraphBuilder.Build(); - ResourceContext personContext = resourceGraph.TryGetResourceContext(typeof(Person)); - personContext.Should().NotBeNull(); + ResourceType personType = resourceGraph.TryGetResourceType(typeof(Person)); + personType.Should().NotBeNull(); - ResourceContext todoItemContext = resourceGraph.TryGetResourceContext(typeof(TodoItem)); - todoItemContext.Should().NotBeNull(); + ResourceType todoItemType = resourceGraph.TryGetResourceType(typeof(TodoItem)); + todoItemType.Should().NotBeNull(); } [Fact] @@ -76,8 +76,8 @@ public void Can_add_resource_from_current_assembly_to_graph() // Assert IResourceGraph resourceGraph = _resourceGraphBuilder.Build(); - ResourceContext testContext = resourceGraph.TryGetResourceContext(typeof(TestResource)); - testContext.Should().NotBeNull(); + ResourceType testResourceType = resourceGraph.TryGetResourceType(typeof(TestResource)); + testResourceType.Should().NotBeNull(); } [Fact] diff --git a/test/DiscoveryTests/TestResourceRepository.cs b/test/DiscoveryTests/TestResourceRepository.cs index 096da8abd9..a42d1603f3 100644 --- a/test/DiscoveryTests/TestResourceRepository.cs +++ b/test/DiscoveryTests/TestResourceRepository.cs @@ -11,10 +11,10 @@ namespace DiscoveryTests [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] public sealed class TestResourceRepository : EntityFrameworkCoreRepository { - public TestResourceRepository(ITargetedFields targetedFields, IDbContextResolver contextResolver, IResourceGraph resourceGraph, + public TestResourceRepository(ITargetedFields targetedFields, IDbContextResolver dbContextResolver, IResourceGraph resourceGraph, IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory, IResourceDefinitionAccessor resourceDefinitionAccessor) - : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor) + : base(targetedFields, dbContextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionBroadcastDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionBroadcastDefinition.cs index 862612d978..f6b7148e3e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionBroadcastDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionBroadcastDefinition.cs @@ -43,7 +43,7 @@ public override FilterExpression OnApplyFilter(FilterExpression existingFilter) if (IsReturningCollectionOfTelevisionBroadcasts() && !HasFilterOnArchivedAt(existingFilter)) { - AttrAttribute archivedAtAttribute = ResourceContext.GetAttributeByPropertyName(nameof(TelevisionBroadcast.ArchivedAt)); + AttrAttribute archivedAtAttribute = ResourceType.GetAttributeByPropertyName(nameof(TelevisionBroadcast.ArchivedAt)); var archivedAtChain = new ResourceFieldChainExpression(archivedAtAttribute); FilterExpression isUnarchived = new ComparisonExpression(ComparisonOperator.Equals, archivedAtChain, new NullConstantExpression()); @@ -64,7 +64,7 @@ private bool IsRequestingCollectionOfTelevisionBroadcasts() { if (_request.IsCollection) { - if (ResourceContext.Equals(_request.PrimaryResource) || ResourceContext.Equals(_request.SecondaryResource)) + if (ResourceType.Equals(_request.PrimaryResourceType) || ResourceType.Equals(_request.SecondaryResourceType)) { return true; } @@ -90,7 +90,7 @@ private bool IsIncludingCollectionOfTelevisionBroadcasts() foreach (IncludeElementExpression includeElement in includeElements) { - if (includeElement.Relationship is HasManyAttribute && includeElement.Relationship.RightType == ResourceContext.ResourceType) + if (includeElement.Relationship is HasManyAttribute && includeElement.Relationship.RightType.Equals(ResourceType)) { return true; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/CreateMusicTrackOperationsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/CreateMusicTrackOperationsController.cs index 5806456a9c..c0b8319fac 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/CreateMusicTrackOperationsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/CreateMusicTrackOperationsController.cs @@ -38,7 +38,7 @@ private static void AssertOnlyCreatingMusicTracks(IEnumerable(); testContext.UseController(); testContext.UseController(); + + var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); + options.AllowUnknownFieldsInRequestBody = false; } [Fact] @@ -211,10 +216,57 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } + [Fact] + public async Task Cannot_create_resource_with_unknown_attribute() + { + // Arrange + string newName = _fakers.Playlist.Generate().Name; + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "playlists", + attributes = new + { + doesNotExist = "ignored", + name = newName + } + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(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.Pointer.Should().Be("/atomic:operations[0]/data/attributes/doesNotExist"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + } + [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; var requestBody = new @@ -261,10 +313,61 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } + [Fact] + public async Task Cannot_create_resource_with_unknown_relationship() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "lyrics", + relationships = new + { + doesNotExist = new + { + data = new + { + type = Unknown.ResourceType, + id = Unknown.StringId.Int32 + } + } + } + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(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.Pointer.Should().Be("/atomic:operations[0]/data/relationships/doesNotExist"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + } + [Fact] public async Task Can_create_resource_with_unknown_relationship() { // Arrange + var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); + options.AllowUnknownFieldsInRequestBody = true; + var requestBody = new { atomic__operations = new[] @@ -354,9 +457,10 @@ public async Task Cannot_create_resource_with_client_generated_ID() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.Forbidden); - error.Title.Should().Be("Specifying the resource ID in operations that create a resource is not allowed."); + error.Title.Should().Be("Failed to deserialize request body: The use of client-generated IDs is disabled."); error.Detail.Should().BeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/id"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -387,9 +491,10 @@ public async Task Cannot_create_resource_for_href_element() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Usage of the 'href' element is not supported."); + error.Title.Should().Be("Failed to deserialize request body: The 'href' element is not supported."); error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/href"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -423,9 +528,10 @@ public async Task Cannot_create_resource_for_ref_element() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref.relationship' element is required."); + error.Title.Should().Be("Failed to deserialize request body: The 'relationship' element is required."); error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -458,12 +564,49 @@ public async Task Cannot_create_resource_for_missing_data() error.Title.Should().Be("Failed to deserialize request body: The 'data' element is required."); error.Detail.Should().BeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] - public async Task Cannot_create_resource_for_missing_type() + public async Task Cannot_create_resource_for_null_data() + { + // Arrange + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "add", + data = (object)null + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Expected an object in 'data' element, instead of 'null'."); + error.Detail.Should().BeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task Cannot_create_resource_for_array_data() { // Arrange + string newArtistName = _fakers.Performer.Generate().ArtistName; + var requestBody = new { atomic__operations = new[] @@ -471,10 +614,15 @@ public async Task Cannot_create_resource_for_missing_type() new { op = "add", - data = new + data = new[] { - attributes = new + new { + type = "performers", + attributes = new + { + artistName = newArtistName + } } } } @@ -493,13 +641,14 @@ public async Task Cannot_create_resource_for_missing_type() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data.type' element is required."); + error.Title.Should().Be("Failed to deserialize request body: Expected an object in 'data' element, instead of an array."); error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] - public async Task Cannot_create_resource_for_unknown_type() + public async Task Cannot_create_resource_for_missing_type() { // Arrange var requestBody = new @@ -511,7 +660,9 @@ public async Task Cannot_create_resource_for_unknown_type() op = "add", data = new { - type = Unknown.ResourceType + attributes = new + { + } } } } @@ -529,17 +680,16 @@ public async Task Cannot_create_resource_for_unknown_type() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); - error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); + error.Detail.Should().BeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] - public async Task Cannot_create_resource_for_array() + public async Task Cannot_create_resource_for_unknown_type() { // Arrange - string newArtistName = _fakers.Performer.Generate().ArtistName; - var requestBody = new { atomic__operations = new[] @@ -547,16 +697,9 @@ public async Task Cannot_create_resource_for_array() new { op = "add", - data = new[] + data = new { - new - { - type = "performers", - attributes = new - { - artistName = newArtistName - } - } + type = Unknown.ResourceType } } } @@ -574,9 +717,10 @@ public async Task Cannot_create_resource_for_array() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected single data element for create/update resource operation."); - error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + 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.Pointer.Should().Be("/atomic:operations[0]/data/type"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -614,9 +758,10 @@ public async Task Cannot_create_resource_attribute_with_blocked_capability() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Setting the initial value of the requested attribute is not allowed."); - error.Detail.Should().Be("Setting the initial value of 'createdAt' is not allowed."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + 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.Pointer.Should().Be("/atomic:operations[0]/data/attributes/createdAt"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -658,8 +803,9 @@ public async Task Cannot_create_resource_with_readonly_attribute() 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' is read-only."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Detail.Should().Be("Attribute 'isArchived' on resource type 'playlists' is read-only."); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/isArchived"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -697,9 +843,10 @@ public async Task Cannot_create_resource_with_incompatible_attribute_value() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body."); + 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.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/bornAt"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs index eb3780a140..1574e20095 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs @@ -186,6 +186,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => 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.Pointer.Should().Be("/atomic:operations[0]"); + error.Meta.Should().NotContainKey("requestBody"); } [Fact] @@ -225,9 +226,10 @@ public async Task Cannot_create_resource_for_incompatible_ID() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body."); + 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.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/id"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -263,9 +265,10 @@ public async Task Cannot_create_resource_for_ID_and_local_ID() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data.id' or 'data.lid' element is required."); + error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs index d35107a8b8..c3952cf1c6 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs @@ -232,9 +232,10 @@ public async Task Cannot_create_for_missing_relationship_type() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Request body must include 'type' element."); - error.Detail.Should().Be("Expected 'type' element in 'performers' relationship."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); + error.Detail.Should().BeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data[0]"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -282,9 +283,10 @@ public async Task Cannot_create_for_unknown_relationship_type() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); + 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.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data[0]/type"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -331,9 +333,10 @@ public async Task Cannot_create_for_missing_relationship_ID() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Request body must include 'id' or 'lid' element."); - error.Detail.Should().Be("Expected 'id' or 'lid' element in 'performers' relationship."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); + error.Detail.Should().BeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data[0]"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -445,15 +448,16 @@ public async Task Cannot_create_on_relationship_type_mismatch() (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Relationship contains incompatible resource type."); - error.Detail.Should().Be("Relationship 'performers' contains incompatible resource type 'playlists'."); - error.Source.Pointer.Should().Be("/atomic:operations[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.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data[0]/type"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -533,7 +537,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Cannot_create_with_null_data_in_OneToMany_relationship() + public async Task Cannot_create_with_missing_data_in_OneToMany_relationship() { // Arrange var requestBody = new @@ -550,7 +554,6 @@ public async Task Cannot_create_with_null_data_in_OneToMany_relationship() { performers = new { - data = (object)null } } } @@ -570,9 +573,10 @@ public async Task Cannot_create_with_null_data_in_OneToMany_relationship() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected data[] element for to-many relationship."); - error.Detail.Should().Be("Expected data[] element for 'performers' relationship."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Title.Should().Be("Failed to deserialize request body: The 'data' element is required."); + error.Detail.Should().BeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -613,9 +617,56 @@ public async Task Cannot_create_with_null_data_in_ManyToMany_relationship() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected data[] element for to-many relationship."); - error.Detail.Should().Be("Expected data[] element for 'tracks' relationship."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Title.Should().Be("Failed to deserialize request body: Expected an array in 'data' element, instead of 'null'."); + error.Detail.Should().BeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/tracks/data"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task Cannot_create_with_object_data_in_ManyToMany_relationship() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "playlists", + relationships = new + { + tracks = new + { + data = new + { + } + } + } + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Expected an array in 'data' element, instead of an object."); + error.Detail.Should().BeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/tracks/data"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs index 9153bb404d..51c9dc27c9 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs @@ -256,6 +256,100 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } + [Fact] + public async Task Cannot_create_for_missing_data_in_relationship() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "musicTracks", + relationships = new + { + lyric = new + { + } + } + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(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.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task Cannot_create_for_array_data_in_relationship() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "musicTracks", + relationships = new + { + lyric = new + { + data = new[] + { + new + { + type = "lyrics", + id = Unknown.StringId.For() + } + } + } + } + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(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' in 'data' element, instead of an array."); + error.Detail.Should().BeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + } + [Fact] public async Task Cannot_create_for_missing_relationship_type() { @@ -297,9 +391,10 @@ public async Task Cannot_create_for_missing_relationship_type() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Request body must include 'type' element."); - error.Detail.Should().Be("Expected 'type' element in 'lyric' relationship."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); + error.Detail.Should().BeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -344,9 +439,10 @@ public async Task Cannot_create_for_unknown_relationship_type() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); + 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.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data/type"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -390,9 +486,10 @@ public async Task Cannot_create_for_missing_relationship_ID() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Request body must include 'id' or 'lid' element."); - error.Detail.Should().Be("Expected 'id' or 'lid' element in 'lyric' relationship."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); + error.Detail.Should().BeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -442,6 +539,7 @@ public async Task Cannot_create_with_unknown_relationship_ID() 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.Pointer.Should().Be("/atomic:operations[0]"); + error.Meta.Should().NotContainKey("requestBody"); } [Fact] @@ -480,15 +578,16 @@ public async Task Cannot_create_on_relationship_type_mismatch() (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Relationship contains incompatible resource type."); - error.Detail.Should().Be("Relationship 'lyric' contains incompatible resource type 'playlists'."); - error.Source.Pointer.Should().Be("/atomic:operations[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.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data/type"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -568,55 +667,5 @@ await _testContext.RunOnDatabaseAsync(async dbContext => trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); }); } - - [Fact] - public async Task Cannot_create_with_data_array_in_relationship() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - relationships = new - { - lyric = new - { - data = new[] - { - new - { - type = "lyrics", - id = Unknown.StringId.For() - } - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected single data element for to-one relationship."); - error.Detail.Should().Be("Expected single data element for 'lyric' relationship."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs index ff0f20a15b..5eef754388 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs @@ -356,9 +356,10 @@ public async Task Cannot_delete_resource_for_href_element() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Usage of the 'href' element is not supported."); + error.Title.Should().Be("Failed to deserialize request body: The 'href' element is not supported."); error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/href"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -391,6 +392,7 @@ public async Task Cannot_delete_resource_for_missing_ref_element() error.Title.Should().Be("Failed to deserialize request body: The 'ref' element is required."); error.Detail.Should().BeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -424,9 +426,10 @@ public async Task Cannot_delete_resource_for_missing_type() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref.type' element is required."); + error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -461,9 +464,10 @@ public async Task Cannot_delete_resource_for_unknown_type() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); + 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.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/type"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -497,9 +501,10 @@ public async Task Cannot_delete_resource_for_missing_ID() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref.id' or 'ref.lid' element is required."); + error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -539,6 +544,7 @@ public async Task Cannot_delete_resource_for_unknown_ID() 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.Pointer.Should().Be("/atomic:operations[0]"); + error.Meta.Should().NotContainKey("requestBody"); } [Fact] @@ -575,9 +581,10 @@ public async Task Cannot_delete_resource_for_incompatible_ID() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body."); + 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.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/id"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -613,9 +620,10 @@ public async Task Cannot_delete_resource_for_ID_and_local_ID() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref.id' or 'ref.lid' element is required."); + error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/LocalIds/AtomicLocalIdTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/LocalIds/AtomicLocalIdTests.cs index 036cb640af..a683635358 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/LocalIds/AtomicLocalIdTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/LocalIds/AtomicLocalIdTests.cs @@ -2098,7 +2098,7 @@ public async Task Cannot_consume_local_ID_of_different_type_in_same_operation() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Type mismatch in local ID usage."); + 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.Pointer.Should().Be("/atomic:operations[1]"); } @@ -2155,7 +2155,7 @@ public async Task Cannot_consume_local_ID_of_different_type_in_ref() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Type mismatch in local ID usage."); + 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.Pointer.Should().Be("/atomic:operations[2]"); } @@ -2215,7 +2215,7 @@ public async Task Cannot_consume_local_ID_of_different_type_in_data_element() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Type mismatch in local ID usage."); + 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.Pointer.Should().Be("/atomic:operations[2]"); } @@ -2289,7 +2289,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Type mismatch in local ID usage."); + 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.Pointer.Should().Be("/atomic:operations[2]"); } @@ -2362,7 +2362,7 @@ public async Task Cannot_consume_local_ID_of_different_type_in_relationship_data ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Type mismatch in local ID usage."); + 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.Pointer.Should().Be("/atomic:operations[2]"); } @@ -2432,7 +2432,7 @@ public async Task Cannot_consume_local_ID_of_different_type_in_relationship_data ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Type mismatch in local ID usage."); + 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.Pointer.Should().Be("/atomic:operations[2]"); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMeta.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMeta.cs index c6fede0077..a1f67e0bb2 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMeta.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMeta.cs @@ -1,5 +1,5 @@ using System.Collections.Generic; -using JsonApiDotNetCore.Serialization; +using JsonApiDotNetCore.Serialization.Response; namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Meta { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMetaTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMetaTests.cs index f775ea6c79..e1469b3454 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMetaTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMetaTests.cs @@ -5,8 +5,8 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Serialization; using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCore.Serialization.Response; using Microsoft.Extensions.DependencyInjection; using TestBuildingBlocks; using Xunit; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicLoggingTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicLoggingTests.cs new file mode 100644 index 0000000000..eb3bae022b --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicLoggingTests.cs @@ -0,0 +1,186 @@ +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; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Mixed +{ + public sealed class AtomicLoggingTests : IClassFixture, OperationsDbContext>> + { + private readonly IntegrationTestContext, OperationsDbContext> _testContext; + + public AtomicLoggingTests(IntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + + var loggerFactory = new FakeLoggerFactory(LogLevel.Information); + + testContext.ConfigureLogging(options => + { + options.ClearProviders(); + options.AddProvider(loggerFactory); + options.SetMinimumLevel(LogLevel.Information); + }); + + testContext.ConfigureServicesBeforeStartup(services => + { + services.AddSingleton(loggerFactory); + }); + + testContext.ConfigureServicesAfterStartup(services => + { + 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 requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "add", + data = new + { + type = "performers", + attributes = new + { + } + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.InternalServerError); + + responseDocument.Errors.Should().HaveCount(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.Pointer.Should().Be("/atomic:operations[0]"); + + loggerFactory.Logger.Messages.Should().NotBeEmpty(); + + 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(); + + var transactionFactory = (ThrowingOperationsTransactionFactory)_testContext.Factory.Services.GetRequiredService(); + transactionFactory.ThrowOnOperationStart = false; + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update" + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + + loggerFactory.Logger.Messages.Should().NotBeEmpty(); + + 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 ThrowingOperationsTransaction : IOperationsTransaction + { + private readonly ThrowingOperationsTransactionFactory _owner; + + public string TransactionId => "some"; + + public ThrowingOperationsTransaction(ThrowingOperationsTransactionFactory owner) + { + _owner = owner; + } + + public ValueTask DisposeAsync() + { + return ValueTask.CompletedTask; + } + + public Task BeforeProcessOperationAsync(CancellationToken cancellationToken) + { + return ThrowIfEnabled(); + } + + public Task AfterProcessOperationAsync(CancellationToken cancellationToken) + { + return ThrowIfEnabled(); + } + + public Task CommitAsync(CancellationToken cancellationToken) + { + return ThrowIfEnabled(); + } + + private Task ThrowIfEnabled() + { + if (_owner.ThrowOnOperationStart) + { + throw new Exception("Simulated failure."); + } + + return Task.CompletedTask; + } + } + } + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicRequestBodyTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicRequestBodyTests.cs index 8b2a56a092..0b61701084 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicRequestBodyTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicRequestBodyTests.cs @@ -1,11 +1,9 @@ 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; @@ -38,18 +36,10 @@ public async Task Cannot_process_for_missing_request_body() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Missing request body."); + error.Title.Should().Be("Failed to deserialize request body: Missing request body."); error.Detail.Should().BeNull(); error.Source.Should().BeNull(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List performersInDatabase = await dbContext.Performers.ToListAsync(); - performersInDatabase.Should().BeEmpty(); - - List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.Should().BeEmpty(); - }); + error.Meta.Should().NotContainKey("requestBody"); } [Fact] @@ -75,6 +65,36 @@ public async Task Cannot_process_for_broken_JSON_request_body() error.Source.Should().BeNull(); } + [Fact] + public async Task Cannot_process_for_missing_operations_array() + { + // Arrange + const string route = "/operations"; + + var requestBody = new + { + meta = new + { + key = "value" + } + }; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(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["requestBody"].ToString().Should().NotBeNullOrEmpty(); + } + [Fact] public async Task Cannot_process_empty_operations_array() { @@ -99,15 +119,7 @@ public async Task Cannot_process_empty_operations_array() error.Title.Should().Be("Failed to deserialize request body: No operations found."); error.Detail.Should().BeNull(); error.Source.Should().BeNull(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List performersInDatabase = await dbContext.Performers.ToListAsync(); - performersInDatabase.Should().BeEmpty(); - - List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.Should().BeEmpty(); - }); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -147,15 +159,6 @@ public async Task Cannot_process_for_unknown_operation_code() 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(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List performersInDatabase = await dbContext.Performers.ToListAsync(); - performersInDatabase.Should().BeEmpty(); - - List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.Should().BeEmpty(); - }); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicSerializationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicSerializationTests.cs index bc9b637617..f034fb9411 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicSerializationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicSerializationTests.cs @@ -4,7 +4,6 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Resources; using Microsoft.Extensions.DependencyInjection; using TestBuildingBlocks; using Xunit; @@ -13,8 +12,6 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Mixed { public sealed class AtomicSerializationTests : IClassFixture, OperationsDbContext>> { - private const string JsonDateTimeOffsetFormatSpecifier = "yyyy-MM-ddTHH:mm:ss.FFFFFFFK"; - private readonly IntegrationTestContext, OperationsDbContext> _testContext; private readonly OperationsFakers _fakers = new(); @@ -25,11 +22,11 @@ public AtomicSerializationTests(IntegrationTestContext(); // 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<>)); + services.AddResourceDefinition(); }); var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); @@ -39,32 +36,46 @@ public AtomicSerializationTests(IntegrationTestContext { - await dbContext.ClearTableAsync(); + dbContext.Performers.Add(existingPerformer); + await dbContext.SaveChangesAsync(); }); var requestBody = new { - atomic__operations = new[] + atomic__operations = new object[] { new { - op = "add", + op = "update", data = new { type = "performers", - id = newPerformer.StringId, + id = existingPerformer.StringId, attributes = new { - artistName = newPerformer.ArtistName, - bornAt = newPerformer.BornAt + } + } + }, + new + { + op = "add", + data = new + { + type = "textLanguages", + id = newLanguage.StringId, + attributes = new + { + isoCode = newLanguage.IsoCode } } } @@ -86,17 +97,30 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ""https://jsonapi.org/ext/atomic"" ] }, + ""links"": { + ""self"": ""http://localhost/operations"" + }, ""atomic:results"": [ + { + ""data"": null + }, { ""data"": { - ""type"": ""performers"", - ""id"": """ + newPerformer.StringId + @""", + ""type"": ""textLanguages"", + ""id"": """ + newLanguage.StringId + @""", ""attributes"": { - ""artistName"": """ + newPerformer.ArtistName + @""", - ""bornAt"": """ + newPerformer.BornAt.ToString(JsonDateTimeOffsetFormatSpecifier) + @""" + ""isoCode"": """ + newLanguage.IsoCode + @" (changed)"" + }, + ""relationships"": { + ""lyrics"": { + ""links"": { + ""self"": ""http://localhost/textLanguages/" + newLanguage.StringId + @"/relationships/lyrics"", + ""related"": ""http://localhost/textLanguages/" + newLanguage.StringId + @"/lyrics"" + } + } }, ""links"": { - ""self"": ""http://localhost/performers/" + newPerformer.StringId + @""" + ""self"": ""http://localhost/textLanguages/" + newLanguage.StringId + @""" } } } @@ -143,6 +167,9 @@ public async Task Includes_version_with_ext_on_error_in_operations_endpoint() ""https://jsonapi.org/ext/atomic"" ] }, + ""links"": { + ""self"": ""http://localhost/operations"" + }, ""errors"": [ { ""id"": """ + errorId + @""", diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/MaximumOperationsPerRequestTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/MaximumOperationsPerRequestTests.cs index d72a79f9b8..199f91f6ae 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/MaximumOperationsPerRequestTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/MaximumOperationsPerRequestTests.cs @@ -72,9 +72,10 @@ public async Task Cannot_process_more_operations_than_maximum() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Request exceeds the maximum number of operations."); - error.Detail.Should().Be("The number of operations in this request (3) is higher than 2."); - error.Source.Should().BeNull(); + 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.Pointer.Should().Be("/atomic:operations"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/LyricRepository.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/LyricRepository.cs index 60dfb891b0..fd9dd6afa3 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/LyricRepository.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/LyricRepository.cs @@ -15,10 +15,10 @@ public sealed class LyricRepository : EntityFrameworkCoreRepository public override string TransactionId => _extraDbContext.Database.CurrentTransaction.TransactionId.ToString(); - public LyricRepository(ExtraDbContext extraDbContext, ITargetedFields targetedFields, IDbContextResolver contextResolver, IResourceGraph resourceGraph, - IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory, - IResourceDefinitionAccessor resourceDefinitionAccessor) - : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor) + 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; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/MusicTrackRepository.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/MusicTrackRepository.cs index b379f6614b..b6268ee068 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/MusicTrackRepository.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/MusicTrackRepository.cs @@ -14,10 +14,10 @@ public sealed class MusicTrackRepository : EntityFrameworkCoreRepository null; - public MusicTrackRepository(ITargetedFields targetedFields, IDbContextResolver contextResolver, IResourceGraph resourceGraph, + public MusicTrackRepository(ITargetedFields targetedFields, IDbContextResolver dbContextResolver, IResourceGraph resourceGraph, IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory, IResourceDefinitionAccessor resourceDefinitionAccessor) - : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor) + : base(targetedFields, dbContextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs index ada44f47e7..c234c09182 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs @@ -64,14 +64,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Only to-many relationships can be targeted in 'add' operations."); - error.Detail.Should().Be("Relationship 'ownedBy' must be a to-many relationship."); + 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.Pointer.Should().Be("/atomic:operations[0]/ref/relationship"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -262,9 +264,10 @@ public async Task Cannot_add_for_href_element() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Usage of the 'href' element is not supported."); + error.Title.Should().Be("Failed to deserialize request body: The 'href' element is not supported."); error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/href"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -299,9 +302,10 @@ public async Task Cannot_add_for_missing_type_in_ref() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref.type' element is required."); + error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -337,9 +341,10 @@ public async Task Cannot_add_for_unknown_type_in_ref() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); + 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.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/type"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -374,9 +379,10 @@ public async Task Cannot_add_for_missing_ID_in_ref() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref.id' or 'ref.lid' element is required."); + error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -433,6 +439,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => 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.Pointer.Should().Be("/atomic:operations[0]"); + error.Meta.Should().NotContainKey("requestBody"); } [Fact] @@ -469,9 +476,10 @@ public async Task Cannot_add_for_ID_and_local_ID_in_ref() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref.id' or 'ref.lid' element is required."); + error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -506,9 +514,10 @@ public async Task Cannot_add_for_missing_relationship_in_ref() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref.relationship' element is required."); + error.Title.Should().Be("Failed to deserialize request body: The 'relationship' element is required."); error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -544,9 +553,57 @@ public async Task Cannot_add_for_unknown_relationship_in_ref() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The referenced relationship does not exist."); - error.Detail.Should().Be($"Resource of type 'performers' does not contain a relationship named '{Unknown.Relationship}'."); + 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.Pointer.Should().Be("/atomic:operations[0]/ref/relationship"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + } + + [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(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + @ref = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationship = "performers" + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(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.Pointer.Should().Be("/atomic:operations[0]"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -591,9 +648,60 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected data[] element for to-many relationship."); - error.Detail.Should().Be("Expected data[] element for 'performers' relationship."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Title.Should().Be("Failed to deserialize request body: Expected an array in 'data' element, instead of 'null'."); + error.Detail.Should().BeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + } + + [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(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + @ref = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationship = "performers" + }, + data = new + { + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Expected an array in 'data' element, instead of an object."); + error.Detail.Should().BeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -636,9 +744,10 @@ public async Task Cannot_add_for_missing_type_in_data() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data[].type' element is required."); + error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -682,9 +791,10 @@ public async Task Cannot_add_for_unknown_type_in_data() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); + 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.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]/type"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -727,9 +837,10 @@ public async Task Cannot_add_for_missing_ID_in_data() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data[].id' or 'data[].lid' element is required."); + error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -774,9 +885,10 @@ public async Task Cannot_add_for_ID_and_local_ID_in_data() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data[].id' or 'data[].lid' element is required."); + error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -893,15 +1005,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Resource type mismatch between 'ref.relationship' and 'data[].type' element."); - error.Detail.Should().Be("Expected resource of type 'performers' in 'data[].type', instead of 'playlists'."); - error.Source.Pointer.Should().Be("/atomic:operations[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.Pointer.Should().Be("/atomic:operations[0]/data[0]/type"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs index ffaff765c3..95840ff95d 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs @@ -65,14 +65,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Only to-many relationships can be targeted in 'remove' operations."); - error.Detail.Should().Be("Relationship 'ownedBy' must be a to-many relationship."); + 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.Pointer.Should().Be("/atomic:operations[0]/ref/relationship"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -262,9 +264,10 @@ public async Task Cannot_remove_for_href_element() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Usage of the 'href' element is not supported."); + error.Title.Should().Be("Failed to deserialize request body: The 'href' element is not supported."); error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/href"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -299,9 +302,10 @@ public async Task Cannot_remove_for_missing_type_in_ref() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref.type' element is required."); + error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -337,9 +341,10 @@ public async Task Cannot_remove_for_unknown_type_in_ref() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); + 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.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/type"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -374,9 +379,10 @@ public async Task Cannot_remove_for_missing_ID_in_ref() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref.id' or 'ref.lid' element is required."); + error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -433,6 +439,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => 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.Pointer.Should().Be("/atomic:operations[0]"); + error.Meta.Should().NotContainKey("requestBody"); } [Fact] @@ -469,9 +476,10 @@ public async Task Cannot_remove_for_ID_and_local_ID_in_ref() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref.id' or 'ref.lid' element is required."); + error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -507,9 +515,57 @@ public async Task Cannot_remove_for_unknown_relationship_in_ref() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The referenced relationship does not exist."); - error.Detail.Should().Be($"Resource of type 'performers' does not contain a relationship named '{Unknown.Relationship}'."); + 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.Pointer.Should().Be("/atomic:operations[0]/ref/relationship"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + } + + [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(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "remove", + @ref = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationship = "performers" + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(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.Pointer.Should().Be("/atomic:operations[0]"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -554,9 +610,60 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected data[] element for to-many relationship."); - error.Detail.Should().Be("Expected data[] element for 'performers' relationship."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Title.Should().Be("Failed to deserialize request body: Expected an array in 'data' element, instead of 'null'."); + error.Detail.Should().BeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task Cannot_remove_for_object_data() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "remove", + @ref = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationship = "performers" + }, + data = new + { + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Expected an array in 'data' element, instead of an object."); + error.Detail.Should().BeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -599,9 +706,10 @@ public async Task Cannot_remove_for_missing_type_in_data() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data[].type' element is required."); + error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -645,9 +753,10 @@ public async Task Cannot_remove_for_unknown_type_in_data() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); + 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.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]/type"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -690,9 +799,10 @@ public async Task Cannot_remove_for_missing_ID_in_data() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data[].id' or 'data[].lid' element is required."); + error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -737,9 +847,10 @@ public async Task Cannot_remove_for_ID_and_local_ID_in_data() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data[].id' or 'data[].lid' element is required."); + error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -856,15 +967,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Resource type mismatch between 'ref.relationship' and 'data[].type' element."); - error.Detail.Should().Be("Expected resource of type 'performers' in 'data[].type', instead of 'playlists'."); - error.Source.Pointer.Should().Be("/atomic:operations[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.Pointer.Should().Be("/atomic:operations[0]/data[0]/type"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs index c1426db8a9..c7b6972438 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs @@ -299,9 +299,10 @@ public async Task Cannot_replace_for_href_element() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Usage of the 'href' element is not supported."); + error.Title.Should().Be("Failed to deserialize request body: The 'href' element is not supported."); error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/href"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -336,9 +337,10 @@ public async Task Cannot_replace_for_missing_type_in_ref() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref.type' element is required."); + error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -374,9 +376,10 @@ public async Task Cannot_replace_for_unknown_type_in_ref() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); + 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.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/type"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -411,9 +414,10 @@ public async Task Cannot_replace_for_missing_ID_in_ref() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref.id' or 'ref.lid' element is required."); + error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -470,6 +474,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => 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.Pointer.Should().Be("/atomic:operations[0]"); + error.Meta.Should().NotContainKey("requestBody"); } [Fact] @@ -523,9 +528,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body."); + 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.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/id"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -562,9 +568,10 @@ public async Task Cannot_replace_for_ID_and_local_ID_in_ref() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref.id' or 'ref.lid' element is required."); + error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -600,9 +607,57 @@ public async Task Cannot_replace_for_unknown_relationship_in_ref() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The referenced relationship does not exist."); - error.Detail.Should().Be($"Resource of type 'performers' does not contain a relationship named '{Unknown.Relationship}'."); + 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.Pointer.Should().Be("/atomic:operations[0]/ref/relationship"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + } + + [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(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationship = "performers" + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(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.Pointer.Should().Be("/atomic:operations[0]"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -647,9 +702,60 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected data[] element for to-many relationship."); - error.Detail.Should().Be("Expected data[] element for 'performers' relationship."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Title.Should().Be("Failed to deserialize request body: Expected an array in 'data' element, instead of 'null'."); + error.Detail.Should().BeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + } + + [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(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationship = "performers" + }, + data = new + { + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Expected an array in 'data' element, instead of an object."); + error.Detail.Should().BeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -692,9 +798,10 @@ public async Task Cannot_replace_for_missing_type_in_data() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data[].type' element is required."); + error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -738,9 +845,10 @@ public async Task Cannot_replace_for_unknown_type_in_data() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); + 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.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]/type"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -783,9 +891,10 @@ public async Task Cannot_replace_for_missing_ID_in_data() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data[].id' or 'data[].lid' element is required."); + error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -830,9 +939,10 @@ public async Task Cannot_replace_for_ID_and_local_ID_in_data() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data[].id' or 'data[].lid' element is required."); + error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -955,9 +1065,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body."); + 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.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]/id"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -1003,15 +1114,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Resource type mismatch between 'ref.relationship' and 'data[].type' element."); - error.Detail.Should().Be("Expected resource of type 'performers' in 'data[].type', instead of 'playlists'."); - error.Source.Pointer.Should().Be("/atomic:operations[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.Pointer.Should().Be("/atomic:operations[0]/data[0]/type"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs index 85c153a7f1..30640ad32a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs @@ -546,9 +546,10 @@ public async Task Cannot_create_for_href_element() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Usage of the 'href' element is not supported."); + error.Title.Should().Be("Failed to deserialize request body: The 'href' element is not supported."); error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/href"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -583,9 +584,10 @@ public async Task Cannot_create_for_missing_type_in_ref() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref.type' element is required."); + error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -621,9 +623,10 @@ public async Task Cannot_create_for_unknown_type_in_ref() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); + 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.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/type"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -658,9 +661,10 @@ public async Task Cannot_create_for_missing_ID_in_ref() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref.id' or 'ref.lid' element is required."); + error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -714,6 +718,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => 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.Pointer.Should().Be("/atomic:operations[0]"); + error.Meta.Should().NotContainKey("requestBody"); } [Fact] @@ -762,9 +767,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body."); + 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.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/id"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -801,9 +807,10 @@ public async Task Cannot_create_for_ID_and_local_ID_in_ref() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref.id' or 'ref.lid' element is required."); + error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -839,13 +846,61 @@ public async Task Cannot_create_for_unknown_relationship_in_ref() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The referenced relationship does not exist."); - error.Detail.Should().Be($"Resource of type 'performers' does not contain a relationship named '{Unknown.Relationship}'."); + 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.Pointer.Should().Be("/atomic:operations[0]/ref/relationship"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + } + + [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(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationship = "lyric" + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(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.Pointer.Should().Be("/atomic:operations[0]"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] - public async Task Cannot_create_for_array_in_data() + public async Task Cannot_create_for_array_data() { // Arrange MusicTrack existingTrack = _fakers.MusicTrack.Generate(); @@ -893,9 +948,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected single data element for to-one relationship."); - error.Detail.Should().Be("Expected single data element for 'lyric' relationship."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Title.Should().Be("Failed to deserialize request body: Expected an object or 'null' in 'data' element, instead of an array."); + error.Detail.Should().BeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -935,9 +991,10 @@ public async Task Cannot_create_for_missing_type_in_data() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data.type' element is required."); + error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -978,9 +1035,10 @@ public async Task Cannot_create_for_unknown_type_in_data() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); + 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.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/type"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -1020,9 +1078,10 @@ public async Task Cannot_create_for_missing_ID_in_data() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data.id' or 'data.lid' element is required."); + error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -1064,9 +1123,10 @@ public async Task Cannot_create_for_ID_and_local_ID_in_data() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data.id' or 'data.lid' element is required."); + error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -1120,6 +1180,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => 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.Pointer.Should().Be("/atomic:operations[0]"); + error.Meta.Should().NotContainKey("requestBody"); } [Fact] @@ -1168,9 +1229,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body."); + 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.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/id"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -1213,15 +1275,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Resource type mismatch between 'ref.relationship' and 'data.type' element."); - error.Detail.Should().Be("Expected resource of type 'lyrics' in 'data.type', instead of 'playlists'."); - error.Source.Pointer.Should().Be("/atomic:operations[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.Pointer.Should().Be("/atomic:operations[0]/data/type"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs index 3ee9307112..3e8e8f9740 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs @@ -292,7 +292,59 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Cannot_replace_for_null_relationship_data() + 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(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationships = new + { + performers = new + { + } + } + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(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.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task Cannot_replace_for_null_data_in_relationship() { // Arrange MusicTrack existingTrack = _fakers.MusicTrack.Generate(); @@ -338,9 +390,65 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected data[] element for to-many relationship."); - error.Detail.Should().Be("Expected data[] element for 'performers' relationship."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Title.Should().Be("Failed to deserialize request body: Expected an array in 'data' element, instead of 'null'."); + error.Detail.Should().BeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + } + + [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(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationships = new + { + performers = new + { + data = new + { + } + } + } + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Expected an array in 'data' element, instead of an object."); + error.Detail.Should().BeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -388,9 +496,10 @@ public async Task Cannot_replace_for_missing_type_in_relationship_data() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Request body must include 'type' element."); - error.Detail.Should().Be("Expected 'type' element in 'tracks' relationship."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); + error.Detail.Should().BeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/tracks/data[0]"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -439,9 +548,10 @@ public async Task Cannot_replace_for_unknown_type_in_relationship_data() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); + 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.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data[0]/type"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -489,9 +599,10 @@ public async Task Cannot_replace_for_missing_ID_in_relationship_data() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Request body must include 'id' or 'lid' element."); - error.Detail.Should().Be("Expected 'id' or 'lid' element in 'performers' relationship."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); + error.Detail.Should().BeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data[0]"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -541,9 +652,10 @@ public async Task Cannot_replace_for_ID_and_local_ID_relationship_in_data() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Request body must include 'id' or 'lid' element."); - error.Detail.Should().Be("Expected 'id' or 'lid' element in 'performers' relationship."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); + error.Detail.Should().BeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data[0]"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -670,15 +782,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Relationship contains incompatible resource type."); - error.Detail.Should().Be("Relationship 'performers' contains incompatible resource type 'playlists'."); - error.Source.Pointer.Should().Be("/atomic:operations[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.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data[0]/type"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs index 1a6b73e239..f3892baa47 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs @@ -9,6 +9,7 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; using TestBuildingBlocks; using Xunit; @@ -32,6 +33,9 @@ public AtomicUpdateResourceTests(IntegrationTestContext(); }); + + var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); + options.AllowUnknownFieldsInRequestBody = false; } [Fact] @@ -157,10 +161,65 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } + [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(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "musicTracks", + id = existingTrack.StringId, + attributes = new + { + title = newTitle, + doesNotExist = "Ignored" + } + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(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.Pointer.Should().Be("/atomic:operations[0]/data/attributes/doesNotExist"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + } + [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; @@ -209,10 +268,70 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } + [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(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationships = new + { + doesNotExist = new + { + data = new + { + type = Unknown.ResourceType, + id = Unknown.StringId.Int32 + } + } + } + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(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.Pointer.Should().Be("/atomic:operations[0]/data/relationships/doesNotExist"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + } + [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(); await _testContext.RunOnDatabaseAsync(async dbContext => @@ -510,9 +629,10 @@ public async Task Cannot_update_resource_for_href_element() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Usage of the 'href' element is not supported."); + error.Title.Should().Be("Failed to deserialize request body: The 'href' element is not supported."); error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/href"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -614,9 +734,10 @@ public async Task Cannot_update_resource_for_missing_type_in_ref() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref.type' element is required."); + error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -661,9 +782,10 @@ public async Task Cannot_update_resource_for_missing_ID_in_ref() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref.id' or 'ref.lid' element is required."); + error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -710,9 +832,10 @@ public async Task Cannot_update_resource_for_ID_and_local_ID_in_ref() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref.id' or 'ref.lid' element is required."); + error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -745,10 +868,11 @@ public async Task Cannot_update_resource_for_missing_data() error.Title.Should().Be("Failed to deserialize request body: The 'data' element is required."); error.Detail.Should().BeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] - public async Task Cannot_update_resource_for_missing_type_in_data() + public async Task Cannot_update_resource_for_null_data() { // Arrange var requestBody = new @@ -758,14 +882,58 @@ public async Task Cannot_update_resource_for_missing_type_in_data() new { op = "update", - data = new + data = (object)null + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Expected an object in 'data' element, instead of 'null'."); + error.Detail.Should().BeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + } + + [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(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new[] { - id = Unknown.StringId.Int32, - attributes = new - { - }, - relationships = new + new { + type = "performers", + id = existingPerformer.StringId, + attributes = new + { + artistName = existingPerformer.ArtistName + } } } } @@ -784,13 +952,14 @@ public async Task Cannot_update_resource_for_missing_type_in_data() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data.type' element is required."); + error.Title.Should().Be("Failed to deserialize request body: Expected an object in 'data' element, instead of an array."); error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] - public async Task Cannot_update_resource_for_missing_ID_in_data() + public async Task Cannot_update_resource_for_missing_type_in_data() { // Arrange var requestBody = new @@ -802,7 +971,7 @@ public async Task Cannot_update_resource_for_missing_ID_in_data() op = "update", data = new { - type = "performers", + id = Unknown.StringId.Int32, attributes = new { }, @@ -826,13 +995,14 @@ public async Task Cannot_update_resource_for_missing_ID_in_data() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data.id' or 'data.lid' element is required."); + error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] - public async Task Cannot_update_resource_for_ID_and_local_ID_in_data() + public async Task Cannot_update_resource_for_missing_ID_in_data() { // Arrange var requestBody = new @@ -845,8 +1015,6 @@ public async Task Cannot_update_resource_for_ID_and_local_ID_in_data() data = new { type = "performers", - id = Unknown.StringId.For(), - lid = "local-1", attributes = new { }, @@ -870,23 +1038,16 @@ public async Task Cannot_update_resource_for_ID_and_local_ID_in_data() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data.id' or 'data.lid' element is required."); + error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] - public async Task Cannot_update_resource_for_array_in_data() + public async Task Cannot_update_resource_for_ID_and_local_ID_in_data() { // Arrange - Performer existingPerformer = _fakers.Performer.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Performers.Add(existingPerformer); - await dbContext.SaveChangesAsync(); - }); - var requestBody = new { atomic__operations = new[] @@ -894,16 +1055,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => new { op = "update", - data = new[] + data = new { - new + type = "performers", + id = Unknown.StringId.For(), + lid = "local-1", + attributes = new + { + }, + relationships = new { - type = "performers", - id = existingPerformer.StringId, - attributes = new - { - artistName = existingPerformer.ArtistName - } } } } @@ -922,9 +1083,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected single data element for create/update resource operation."); + error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -964,15 +1126,16 @@ public async Task Cannot_update_on_resource_type_mismatch_between_ref_and_data() (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Resource type mismatch between 'ref.type' and 'data.type' element."); - error.Detail.Should().Be("Expected resource of type 'performers' in 'data.type', instead of 'playlists'."); - error.Source.Pointer.Should().Be("/atomic:operations[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.Pointer.Should().Be("/atomic:operations[0]/data/type"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -1015,15 +1178,16 @@ public async Task Cannot_update_on_resource_ID_mismatch_between_ref_and_data() (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Resource ID mismatch between 'ref.id' and 'data.id' element."); - error.Detail.Should().Be($"Expected resource with ID '{performerId1}' in 'data.id', instead of '{performerId2}'."); - error.Source.Pointer.Should().Be("/atomic:operations[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.Pointer.Should().Be("/atomic:operations[0]/data/id"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -1063,15 +1227,16 @@ public async Task Cannot_update_on_resource_local_ID_mismatch_between_ref_and_da (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Resource local ID mismatch between 'ref.lid' and 'data.lid' element."); - error.Detail.Should().Be("Expected resource with local ID 'local-1' in 'data.lid', instead of 'local-2'."); - error.Source.Pointer.Should().Be("/atomic:operations[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.Pointer.Should().Be("/atomic:operations[0]/data/lid"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -1119,9 +1284,10 @@ public async Task Cannot_update_on_mixture_of_ID_and_local_ID_between_ref_and_da ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Resource identity mismatch between 'ref.id' and 'data.lid' element."); - error.Detail.Should().Be($"Expected resource with ID '{performerId}' in 'data.id', instead of 'local-1' in 'data.lid'."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Title.Should().Be("Failed to deserialize request body: The 'id' element is required."); + error.Detail.Should().BeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -1169,9 +1335,10 @@ public async Task Cannot_update_on_mixture_of_local_ID_and_ID_between_ref_and_da ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Resource identity mismatch between 'ref.lid' and 'data.id' element."); - error.Detail.Should().Be($"Expected resource with local ID 'local-1' in 'data.lid', instead of '{performerId}' in 'data.id'."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Title.Should().Be("Failed to deserialize request body: The 'lid' element is required."); + error.Detail.Should().BeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -1212,9 +1379,10 @@ public async Task Cannot_update_resource_for_unknown_type() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); + 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.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/type"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -1304,9 +1472,10 @@ public async Task Cannot_update_resource_for_incompatible_ID() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body."); + 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.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/id"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -1353,9 +1522,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Changing the value of the requested attribute is not allowed."); - error.Detail.Should().Be("Changing the value of 'createdAt' is not allowed."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + 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.Pointer.Should().Be("/atomic:operations[0]/data/attributes/createdAt"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -1403,8 +1573,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => 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' is read-only."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Detail.Should().Be("Attribute 'isArchived' on resource type 'playlists' is read-only."); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/isArchived"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -1451,9 +1622,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body."); - error.Detail.Should().Be("Resource ID is read-only."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Title.Should().Be("Failed to deserialize request body: Resource ID is read-only."); + error.Detail.Should().BeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/id"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -1500,9 +1672,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body."); + 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.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/bornAt"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateToOneRelationshipTests.cs index 9487a69551..1ec00d4b48 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateToOneRelationshipTests.cs @@ -564,7 +564,59 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Cannot_create_for_array_in_relationship_data() + 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(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationships = new + { + lyric = new + { + } + } + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(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.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task Cannot_create_for_array_data_in_relationship() { // Arrange MusicTrack existingTrack = _fakers.MusicTrack.Generate(); @@ -617,9 +669,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected single data element for to-one relationship."); - error.Detail.Should().Be("Expected single data element for 'lyric' relationship."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Title.Should().Be("Failed to deserialize request body: Expected an object or 'null' in 'data' element, instead of an array."); + error.Detail.Should().BeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -664,9 +717,10 @@ public async Task Cannot_create_for_missing_type_in_relationship_data() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Request body must include 'type' element."); - error.Detail.Should().Be("Expected 'type' element in 'track' relationship."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); + error.Detail.Should().BeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/track/data"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -712,9 +766,10 @@ public async Task Cannot_create_for_unknown_type_in_relationship_data() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); + 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.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data/type"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -759,9 +814,10 @@ public async Task Cannot_create_for_missing_ID_in_relationship_data() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Request body must include 'id' or 'lid' element."); - error.Detail.Should().Be("Expected 'id' or 'lid' element in 'lyric' relationship."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); + error.Detail.Should().BeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -808,9 +864,10 @@ public async Task Cannot_create_for_ID_and_local_ID_in_relationship_data() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Request body must include 'id' or 'lid' element."); - error.Detail.Should().Be("Expected 'id' or 'lid' element in 'lyric' relationship."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); + error.Detail.Should().BeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } [Fact] @@ -869,6 +926,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => 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.Pointer.Should().Be("/atomic:operations[0]"); + error.Meta.Should().NotContainKey("requestBody"); } [Fact] @@ -916,15 +974,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Relationship contains incompatible resource type."); - error.Detail.Should().Be("Relationship 'lyric' contains incompatible resource type 'playlists'."); - error.Source.Pointer.Should().Be("/atomic:operations[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.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data/type"); + error.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty(); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/Car.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/Car.cs index e50286f5a0..74ebc3a865 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/Car.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/Car.cs @@ -12,18 +12,22 @@ public sealed class Car : Identifiable [NotMapped] public override string Id { - get => $"{RegionId}:{LicensePlate}"; + 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) + if (elements.Length == 2 && int.TryParse(elements[0], out int regionId)) { - if (int.TryParse(elements[0], out int regionId)) - { - RegionId = regionId; - LicensePlate = elements[1]; - } + RegionId = regionId; + LicensePlate = elements[1]; } else { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarCompositeKeyAwareRepository.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarCompositeKeyAwareRepository.cs index f76e0baa66..035d18a1c9 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarCompositeKeyAwareRepository.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarCompositeKeyAwareRepository.cs @@ -16,10 +16,10 @@ public class CarCompositeKeyAwareRepository : EntityFrameworkCor { private readonly CarExpressionRewriter _writer; - public CarCompositeKeyAwareRepository(ITargetedFields targetedFields, IDbContextResolver contextResolver, IResourceGraph resourceGraph, + public CarCompositeKeyAwareRepository(ITargetedFields targetedFields, IDbContextResolver dbContextResolver, IResourceGraph resourceGraph, IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory, IResourceDefinitionAccessor resourceDefinitionAccessor) - : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor) + : base(targetedFields, dbContextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor) { _writer = new CarExpressionRewriter(resourceGraph); } @@ -57,10 +57,10 @@ private void RecursiveRewriteFilterInLayer(QueryLayer queryLayer) public class CarCompositeKeyAwareRepository : CarCompositeKeyAwareRepository, IResourceRepository where TResource : class, IIdentifiable { - public CarCompositeKeyAwareRepository(ITargetedFields targetedFields, IDbContextResolver contextResolver, IResourceGraph resourceGraph, + public CarCompositeKeyAwareRepository(ITargetedFields targetedFields, IDbContextResolver dbContextResolver, IResourceGraph resourceGraph, IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory, IResourceDefinitionAccessor resourceDefinitionAccessor) - : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor) + : base(targetedFields, dbContextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarExpressionRewriter.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarExpressionRewriter.cs index cce9320cce..c5ec910a98 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarExpressionRewriter.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarExpressionRewriter.cs @@ -25,10 +25,10 @@ internal sealed class CarExpressionRewriter : QueryExpressionRewriter public CarExpressionRewriter(IResourceGraph resourceGraph) { - ResourceContext carResourceContext = resourceGraph.GetResourceContext(); + ResourceType carType = resourceGraph.GetResourceType(); - _regionIdAttribute = carResourceContext.GetAttributeByPropertyName(nameof(Car.RegionId)); - _licensePlateAttribute = carResourceContext.GetAttributeByPropertyName(nameof(Car.LicensePlate)); + _regionIdAttribute = carType.GetAttributeByPropertyName(nameof(Car.RegionId)); + _licensePlateAttribute = carType.GetAttributeByPropertyName(nameof(Car.LicensePlate)); } public override QueryExpression VisitComparison(ComparisonExpression expression, object argument) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/ActionResultTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/ActionResultTests.cs index 51bb1c6f5c..5168931a82 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/ActionResultTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/ActionResultTests.cs @@ -100,7 +100,7 @@ public async Task Cannot_convert_ActionResult_with_string_parameter_to_error_col 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 errors or resources."); + error.Detail.Should().Be("Data being returned must be resources, operations, errors or null."); } [Fact] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/BuildingRepository.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/BuildingRepository.cs index 4c49bdc209..4c32d6d3a6 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/BuildingRepository.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/BuildingRepository.cs @@ -13,10 +13,10 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.EagerLoading [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] public sealed class BuildingRepository : EntityFrameworkCoreRepository { - public BuildingRepository(ITargetedFields targetedFields, IDbContextResolver contextResolver, IResourceGraph resourceGraph, + public BuildingRepository(ITargetedFields targetedFields, IDbContextResolver dbContextResolver, IResourceGraph resourceGraph, IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory, IResourceDefinitionAccessor resourceDefinitionAccessor) - : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor) + : base(targetedFields, dbContextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor) { } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/AlternateExceptionHandler.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/AlternateExceptionHandler.cs index d6af489f76..0ccbcf7947 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/AlternateExceptionHandler.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/AlternateExceptionHandler.cs @@ -24,7 +24,7 @@ protected override LogLevel GetLogLevel(Exception exception) return base.GetLogLevel(exception); } - protected override Document CreateErrorDocument(Exception exception) + protected override IReadOnlyList CreateErrorResponse(Exception exception) { if (exception is ConsumerArticleIsNoLongerAvailableException articleException) { @@ -34,7 +34,7 @@ protected override Document CreateErrorDocument(Exception exception) }; } - return base.CreateErrorDocument(exception); + return base.CreateErrorResponse(exception); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs index ff8a61471f..a7dcd3329a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs @@ -80,11 +80,44 @@ await _testContext.RunOnDatabaseAsync(async dbContext => error.Detail.Should().Be("Article with code 'X123' is no longer available."); ((JsonElement)error.Meta["support"]).GetString().Should().Be("Please contact us for info about similar articles at company@email.com."); + responseDocument.Meta.Should().BeNull(); + loggerFactory.Logger.Messages.Should().HaveCount(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(); + + const string requestBody = @"{ ""data"": { ""type"": """" } }"; + + const string route = "/consumerArticles"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(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."); + error.Meta["requestBody"].ToString().Should().Be(requestBody); + + IEnumerable stackTraceLines = ((JsonElement)error.Meta["stackTrace"]).EnumerateArray().Select(token => token.GetString()); + stackTraceLines.Should().NotBeEmpty(); + + loggerFactory.Logger.Messages.Should().BeEmpty(); + } + [Fact] public async Task Logs_and_produces_error_response_on_serialization_failure() { @@ -116,7 +149,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => error.Detail.Should().Be("Exception has been thrown by the target of an invocation."); IEnumerable stackTraceLines = ((JsonElement)error.Meta["stackTrace"]).EnumerateArray().Select(token => token.GetString()); - stackTraceLines.Should().ContainMatch("* System.InvalidOperationException: Article status could not be determined.*"); + stackTraceLines.Should().ContainMatch("*ThrowingArticle*"); + + responseDocument.Meta.Should().BeNull(); loggerFactory.Logger.Messages.Should().HaveCount(1); loggerFactory.Logger.Messages.Single().LogLevel.Should().Be(LogLevel.Error); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiable.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiable.cs index 99a89e6b8c..ab86709eba 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiable.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiable.cs @@ -8,12 +8,12 @@ public abstract class ObfuscatedIdentifiable : Identifiable protected override string GetStringId(int value) { - return Codec.Encode(value); + return value == default ? null : Codec.Encode(value); } protected override int GetTypedId(string value) { - return Codec.Decode(value); + return value == null ? default : Codec.Decode(value); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ResponseMetaTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ResponseMetaTests.cs index 0c698644da..ed5e3da83f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ResponseMetaTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ResponseMetaTests.cs @@ -3,7 +3,7 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Serialization; +using JsonApiDotNetCore.Serialization.Response; using Microsoft.Extensions.DependencyInjection; using TestBuildingBlocks; using Xunit; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportResponseMeta.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportResponseMeta.cs index dccf338f22..1f7eee1262 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportResponseMeta.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportResponseMeta.cs @@ -1,5 +1,5 @@ using System.Collections.Generic; -using JsonApiDotNetCore.Serialization; +using JsonApiDotNetCore.Serialization.Response; namespace JsonApiDotNetCoreTests.IntegrationTests.Meta { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/NonJsonApiController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/NonJsonApiController.cs index fa783d5095..3f108548ac 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/NonJsonApiController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/NonJsonApiController.cs @@ -28,21 +28,21 @@ public async Task PostAsync() return BadRequest("Please send your name."); } - string result = "Hello, " + name; + string result = $"Hello, {name}"; return Ok(result); } [HttpPut] public IActionResult Put([FromBody] string name) { - string result = "Hi, " + name; + string result = $"Hi, {name}"; return Ok(result); } [HttpPatch] public IActionResult Patch(string name) { - string result = "Good day, " + name; + string result = $"Good day, {name}"; return Ok(result); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDepthTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDepthTests.cs index 4d13fd69c1..807f104a15 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDepthTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDepthTests.cs @@ -415,7 +415,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Data.ManyValue.Should().HaveCount(1); responseDocument.Included.Should().HaveCount(2); + + responseDocument.Included[0].Type.Should().Be("webAccounts"); responseDocument.Included[0].Id.Should().Be(blog.Owner.StringId); + + responseDocument.Included[1].Type.Should().Be("blogPosts"); responseDocument.Included[1].Id.Should().Be(blog.Owner.Posts[1].StringId); } @@ -530,8 +534,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Data.ManyValue[0].Id.Should().Be(blogs[1].StringId); responseDocument.Included.Should().HaveCount(3); + + responseDocument.Included[0].Type.Should().Be("webAccounts"); responseDocument.Included[0].Id.Should().Be(blogs[1].Owner.StringId); + + responseDocument.Included[1].Type.Should().Be("blogPosts"); responseDocument.Included[1].Id.Should().Be(blogs[1].Owner.Posts[1].StringId); + + responseDocument.Included[2].Type.Should().Be("comments"); responseDocument.Included[2].Id.Should().Be(blogs[1].Owner.Posts[1].Comments.ElementAt(1).StringId); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterTests.cs index 8aa640c530..cc6a6cc372 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterTests.cs @@ -43,7 +43,7 @@ public async Task Cannot_filter_in_unknown_scope() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("The specified filter is invalid."); - error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' does not exist on resource 'webAccounts'."); + error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' does not exist on resource type 'webAccounts'."); error.Source.Parameter.Should().Be($"filter[{Unknown.Relationship}]"); } @@ -64,7 +64,7 @@ public async Task Cannot_filter_in_unknown_nested_scope() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("The specified filter is invalid."); - error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' in 'posts.{Unknown.Relationship}' does not exist on resource 'blogPosts'."); + error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' in 'posts.{Unknown.Relationship}' does not exist on resource type 'blogPosts'."); error.Source.Parameter.Should().Be($"filter[posts.{Unknown.Relationship}]"); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Includes/IncludeTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Includes/IncludeTests.cs index 7236e285eb..d6efa22782 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Includes/IncludeTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Includes/IncludeTests.cs @@ -447,37 +447,176 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Data.SingleValue.Should().NotBeNull(); responseDocument.Data.SingleValue.Id.Should().Be(blog.StringId); - responseDocument.Data.SingleValue.Attributes["title"].Should().Be(blog.Title); + responseDocument.Data.SingleValue.Relationships["posts"].Data.ManyValue[0].Type.Should().Be("blogPosts"); + responseDocument.Data.SingleValue.Relationships["posts"].Data.ManyValue[0].Id.Should().Be(blog.Posts[0].StringId); responseDocument.Included.Should().HaveCount(7); responseDocument.Included[0].Type.Should().Be("blogPosts"); responseDocument.Included[0].Id.Should().Be(blog.Posts[0].StringId); - responseDocument.Included[0].Attributes["caption"].Should().Be(blog.Posts[0].Caption); + responseDocument.Included[0].Relationships["author"].Data.SingleValue.Type.Should().Be("webAccounts"); + responseDocument.Included[0].Relationships["author"].Data.SingleValue.Id.Should().Be(blog.Posts[0].Author.StringId); + responseDocument.Included[0].Relationships["comments"].Data.ManyValue[0].Type.Should().Be("comments"); + responseDocument.Included[0].Relationships["comments"].Data.ManyValue[0].Id.Should().Be(blog.Posts[0].Comments.ElementAt(0).StringId); responseDocument.Included[1].Type.Should().Be("webAccounts"); responseDocument.Included[1].Id.Should().Be(blog.Posts[0].Author.StringId); - responseDocument.Included[1].Attributes["userName"].Should().Be(blog.Posts[0].Author.UserName); + responseDocument.Included[1].Relationships["preferences"].Data.SingleValue.Type.Should().Be("accountPreferences"); + responseDocument.Included[1].Relationships["preferences"].Data.SingleValue.Id.Should().Be(blog.Posts[0].Author.Preferences.StringId); + responseDocument.Included[1].Relationships["posts"].Data.Value.Should().BeNull(); responseDocument.Included[2].Type.Should().Be("accountPreferences"); responseDocument.Included[2].Id.Should().Be(blog.Posts[0].Author.Preferences.StringId); - responseDocument.Included[2].Attributes["useDarkTheme"].Should().Be(blog.Posts[0].Author.Preferences.UseDarkTheme); responseDocument.Included[3].Type.Should().Be("comments"); responseDocument.Included[3].Id.Should().Be(blog.Posts[0].Comments.ElementAt(0).StringId); - responseDocument.Included[3].Attributes["text"].Should().Be(blog.Posts[0].Comments.ElementAt(0).Text); + responseDocument.Included[3].Relationships["author"].Data.SingleValue.Type.Should().Be("webAccounts"); + responseDocument.Included[3].Relationships["author"].Data.SingleValue.Id.Should().Be(blog.Posts[0].Comments.ElementAt(0).Author.StringId); responseDocument.Included[4].Type.Should().Be("webAccounts"); responseDocument.Included[4].Id.Should().Be(blog.Posts[0].Comments.ElementAt(0).Author.StringId); - responseDocument.Included[4].Attributes["userName"].Should().Be(blog.Posts[0].Comments.ElementAt(0).Author.UserName); + responseDocument.Included[4].Relationships["posts"].Data.ManyValue[0].Type.Should().Be("blogPosts"); + responseDocument.Included[4].Relationships["posts"].Data.ManyValue[0].Id.Should().Be(blog.Posts[0].Comments.ElementAt(0).Author.Posts[0].StringId); + responseDocument.Included[4].Relationships["preferences"].Data.Value.Should().BeNull(); responseDocument.Included[5].Type.Should().Be("blogPosts"); responseDocument.Included[5].Id.Should().Be(blog.Posts[0].Comments.ElementAt(0).Author.Posts[0].StringId); - responseDocument.Included[5].Attributes["caption"].Should().Be(blog.Posts[0].Comments.ElementAt(0).Author.Posts[0].Caption); + responseDocument.Included[5].Relationships["author"].Data.Value.Should().BeNull(); + responseDocument.Included[5].Relationships["comments"].Data.Value.Should().BeNull(); responseDocument.Included[6].Type.Should().Be("comments"); responseDocument.Included[6].Id.Should().Be(blog.Posts[0].Comments.ElementAt(1).StringId); - responseDocument.Included[6].Attributes["text"].Should().Be(blog.Posts[0].Comments.ElementAt(1).Text); + responseDocument.Included[5].Relationships["author"].Data.Value.Should().BeNull(); + } + + [Fact] + public async Task Can_include_chain_of_relationships_with_reused_resources() + { + WebAccount author = _fakers.WebAccount.Generate(); + author.Preferences = _fakers.AccountPreferences.Generate(); + author.LoginAttempts = _fakers.LoginAttempt.Generate(1); + + WebAccount reviewer = _fakers.WebAccount.Generate(); + reviewer.Preferences = _fakers.AccountPreferences.Generate(); + reviewer.LoginAttempts = _fakers.LoginAttempt.Generate(1); + + BlogPost post1 = _fakers.BlogPost.Generate(); + post1.Author = author; + post1.Reviewer = reviewer; + + WebAccount person = _fakers.WebAccount.Generate(); + person.Preferences = _fakers.AccountPreferences.Generate(); + person.LoginAttempts = _fakers.LoginAttempt.Generate(1); + + BlogPost post2 = _fakers.BlogPost.Generate(); + post2.Author = person; + post2.Reviewer = person; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Posts.AddRange(post1, post2); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/blogPosts?include=reviewer.loginAttempts,author.preferences"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(2); + + responseDocument.Data.ManyValue[0].Type.Should().Be("blogPosts"); + responseDocument.Data.ManyValue[0].Id.Should().Be(post1.StringId); + responseDocument.Data.ManyValue[0].Relationships["author"].Data.SingleValue.Type.Should().Be("webAccounts"); + responseDocument.Data.ManyValue[0].Relationships["author"].Data.SingleValue.Id.Should().Be(author.StringId); + responseDocument.Data.ManyValue[0].Relationships["reviewer"].Data.SingleValue.Type.Should().Be("webAccounts"); + responseDocument.Data.ManyValue[0].Relationships["reviewer"].Data.SingleValue.Id.Should().Be(reviewer.StringId); + + responseDocument.Data.ManyValue[1].Type.Should().Be("blogPosts"); + responseDocument.Data.ManyValue[1].Id.Should().Be(post2.StringId); + responseDocument.Data.ManyValue[1].Relationships["author"].Data.SingleValue.Type.Should().Be("webAccounts"); + responseDocument.Data.ManyValue[1].Relationships["author"].Data.SingleValue.Id.Should().Be(person.StringId); + responseDocument.Data.ManyValue[1].Relationships["reviewer"].Data.SingleValue.Type.Should().Be("webAccounts"); + responseDocument.Data.ManyValue[1].Relationships["reviewer"].Data.SingleValue.Id.Should().Be(person.StringId); + + responseDocument.Included.Should().HaveCount(7); + + responseDocument.Included[0].Type.Should().Be("webAccounts"); + responseDocument.Included[0].Id.Should().Be(author.StringId); + responseDocument.Included[0].Relationships["preferences"].Data.SingleValue.Type.Should().Be("accountPreferences"); + responseDocument.Included[0].Relationships["preferences"].Data.SingleValue.Id.Should().Be(author.Preferences.StringId); + responseDocument.Included[0].Relationships["loginAttempts"].Data.Value.Should().BeNull(); + + responseDocument.Included[1].Type.Should().Be("accountPreferences"); + responseDocument.Included[1].Id.Should().Be(author.Preferences.StringId); + + responseDocument.Included[2].Type.Should().Be("webAccounts"); + responseDocument.Included[2].Id.Should().Be(reviewer.StringId); + responseDocument.Included[2].Relationships["preferences"].Data.Value.Should().BeNull(); + responseDocument.Included[2].Relationships["loginAttempts"].Data.ManyValue[0].Type.Should().Be("loginAttempts"); + responseDocument.Included[2].Relationships["loginAttempts"].Data.ManyValue[0].Id.Should().Be(reviewer.LoginAttempts[0].StringId); + + responseDocument.Included[3].Type.Should().Be("loginAttempts"); + responseDocument.Included[3].Id.Should().Be(reviewer.LoginAttempts[0].StringId); + + responseDocument.Included[4].Type.Should().Be("webAccounts"); + responseDocument.Included[4].Id.Should().Be(person.StringId); + responseDocument.Included[4].Relationships["preferences"].Data.SingleValue.Type.Should().Be("accountPreferences"); + responseDocument.Included[4].Relationships["preferences"].Data.SingleValue.Id.Should().Be(person.Preferences.StringId); + responseDocument.Included[4].Relationships["loginAttempts"].Data.ManyValue[0].Type.Should().Be("loginAttempts"); + responseDocument.Included[4].Relationships["loginAttempts"].Data.ManyValue[0].Id.Should().Be(person.LoginAttempts[0].StringId); + + responseDocument.Included[5].Type.Should().Be("accountPreferences"); + responseDocument.Included[5].Id.Should().Be(person.Preferences.StringId); + + responseDocument.Included[6].Type.Should().Be("loginAttempts"); + responseDocument.Included[6].Id.Should().Be(person.LoginAttempts[0].StringId); + } + + [Fact] + public async Task Can_include_chain_with_cyclic_dependency() + { + List posts = _fakers.BlogPost.Generate(1); + + Blog blog = _fakers.Blog.Generate(); + blog.Posts = posts; + blog.Posts[0].Author = _fakers.WebAccount.Generate(); + blog.Posts[0].Author.Posts = posts; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Blogs.Add(blog); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/blogs/{blog.StringId}?include=posts.author.posts.author.posts.author"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.Type.Should().Be("blogs"); + responseDocument.Data.SingleValue.Id.Should().Be(blog.StringId); + responseDocument.Data.SingleValue.Relationships["posts"].Data.ManyValue[0].Type.Should().Be("blogPosts"); + responseDocument.Data.SingleValue.Relationships["posts"].Data.ManyValue[0].Id.Should().Be(blog.Posts[0].StringId); + + responseDocument.Included.Should().HaveCount(2); + + responseDocument.Included[0].Type.Should().Be("blogPosts"); + responseDocument.Included[0].Id.Should().Be(blog.Posts[0].StringId); + responseDocument.Included[0].Relationships["author"].Data.SingleValue.Type.Should().Be("webAccounts"); + responseDocument.Included[0].Relationships["author"].Data.SingleValue.Id.Should().Be(blog.Posts[0].Author.StringId); + + responseDocument.Included[1].Type.Should().Be("webAccounts"); + responseDocument.Included[1].Id.Should().Be(blog.Posts[0].Author.StringId); + responseDocument.Included[1].Relationships["posts"].Data.ManyValue[0].Type.Should().Be("blogPosts"); + responseDocument.Included[1].Relationships["posts"].Data.ManyValue[0].Id.Should().Be(blog.Posts[0].StringId); } [Fact] @@ -564,7 +703,7 @@ public async Task Cannot_include_unknown_relationship() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("The specified include is invalid."); - error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' does not exist on resource 'webAccounts'."); + error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' does not exist on resource type 'webAccounts'."); error.Source.Parameter.Should().Be("include"); } @@ -585,7 +724,7 @@ public async Task Cannot_include_unknown_nested_relationship() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("The specified include is invalid."); - error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' in 'posts.{Unknown.Relationship}' does not exist on resource 'blogPosts'."); + error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' in 'posts.{Unknown.Relationship}' does not exist on resource type 'blogPosts'."); error.Source.Parameter.Should().Be("include"); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/LoginAttempt.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/LoginAttempt.cs new file mode 100644 index 0000000000..d86783f387 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/LoginAttempt.cs @@ -0,0 +1,17 @@ +using System; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class LoginAttempt : Identifiable + { + [Attr] + public DateTimeOffset TriedAt { get; set; } + + [Attr] + public bool IsSucceeded { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs index 8d87adfc7d..5c5eb11f6d 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs @@ -368,8 +368,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Data.ManyValue[0].Id.Should().Be(blogs[1].StringId); responseDocument.Included.Should().HaveCount(3); + + responseDocument.Included[0].Type.Should().Be("webAccounts"); responseDocument.Included[0].Id.Should().Be(blogs[1].Owner.StringId); + + responseDocument.Included[1].Type.Should().Be("blogPosts"); responseDocument.Included[1].Id.Should().Be(blogs[1].Owner.Posts[1].StringId); + + responseDocument.Included[2].Type.Should().Be("comments"); responseDocument.Included[2].Id.Should().Be(blogs[1].Owner.Posts[1].Comments.ElementAt(1).StringId); string linkPrefix = $"{HostPrefix}/blogs?include=owner.posts.comments"; @@ -399,7 +405,7 @@ public async Task Cannot_paginate_in_unknown_scope() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("The specified paging is invalid."); - error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' does not exist on resource 'webAccounts'."); + error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' does not exist on resource type 'webAccounts'."); error.Source.Parameter.Should().Be("page[number]"); } @@ -420,7 +426,7 @@ public async Task Cannot_paginate_in_unknown_nested_scope() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("The specified paging is invalid."); - error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' in 'posts.{Unknown.Relationship}' does not exist on resource 'blogPosts'."); + error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' in 'posts.{Unknown.Relationship}' does not exist on resource type 'blogPosts'."); error.Source.Parameter.Should().Be("page[size]"); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringDbContext.cs index b09dfbc278..af11134b1c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringDbContext.cs @@ -14,6 +14,7 @@ public sealed class QueryStringDbContext : DbContext public DbSet Comments { get; set; } public DbSet Accounts { get; set; } public DbSet AccountPreferences { get; set; } + public DbSet LoginAttempts { get; set; } public DbSet Calendars { get; set; } public DbSet Appointments { get; set; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringFakers.cs index 702ba9c7f5..54bf83fb01 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringFakers.cs @@ -43,6 +43,12 @@ internal sealed class QueryStringFakers : FakerContainer .RuleFor(webAccount => webAccount.DateOfBirth, faker => faker.Person.DateOfBirth) .RuleFor(webAccount => webAccount.EmailAddress, faker => faker.Internet.Email())); + private readonly Lazy> _lazyLoginAttemptFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(loginAttempt => loginAttempt.TriedAt, faker => faker.Date.PastOffset()) + .RuleFor(loginAttempt => loginAttempt.IsSucceeded, faker => faker.Random.Bool())); + private readonly Lazy> _lazyAccountPreferencesFaker = new(() => new Faker() .UseSeed(GetFakerSeed()) @@ -67,6 +73,7 @@ internal sealed class QueryStringFakers : FakerContainer public Faker