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