diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 1c7cf727cb..0a05541b2e 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,13 +3,13 @@ "isRoot": true, "tools": { "jetbrains.resharper.globaltools": { - "version": "2021.1.4", + "version": "2021.2.2", "commands": [ "jb" ] }, "regitlint": { - "version": "2.1.4", + "version": "6.0.6", "commands": [ "regitlint" ] diff --git a/Build.ps1 b/Build.ps1 index 0b5b8d190d..a4b930d26c 100644 --- a/Build.ps1 +++ b/Build.ps1 @@ -8,7 +8,7 @@ function CheckLastExitCode { function RunInspectCode { $outputPath = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), 'jetbrains-inspectcode-results.xml') - dotnet jb inspectcode JsonApiDotNetCore.sln --output="$outputPath" --profile=WarningSeverities.DotSettings --properties:Configuration=Release --severity=WARNING --verbosity=WARN -dsl=GlobalAll -dsl=SolutionPersonal -dsl=ProjectPersonal + dotnet jb inspectcode JsonApiDotNetCore.sln --no-build --output="$outputPath" --profile=WarningSeverities.DotSettings --properties:Configuration=Release --severity=WARNING --verbosity=WARN -dsl=GlobalAll -dsl=GlobalPerProduct -dsl=SolutionPersonal -dsl=ProjectPersonal CheckLastExitCode [xml]$xml = Get-Content "$outputPath" @@ -47,7 +47,7 @@ function RunCleanupCode { $mergeCommitHash = git rev-parse "HEAD" $targetCommitHash = git rev-parse "$env:APPVEYOR_REPO_BRANCH" - dotnet regitlint -s JsonApiDotNetCore.sln --print-command --jb --profile --jb --profile='\"JADNC Full Cleanup\"' --jb --properties:Configuration=Release --jb --verbosity=WARN -f commits -a $mergeCommitHash -b $targetCommitHash --fail-on-diff --print-diff + dotnet regitlint -s JsonApiDotNetCore.sln --print-command --disable-jb-path-hack --jb --profile='\"JADNC Full Cleanup\"' --jb --properties:Configuration=Release --jb --verbosity=WARN -f commits -a $mergeCommitHash -b $targetCommitHash --fail-on-diff --print-diff CheckLastExitCode } } @@ -73,10 +73,10 @@ function CreateNuGetPackage { $versionSuffix = $suffixSegments -join "-" } else { - # Get the version suffix from the auto-incrementing build number. Example: "123" => "pre-0123". + # Get the version suffix from the auto-incrementing build number. Example: "123" => "master-0123". if ($env:APPVEYOR_BUILD_NUMBER) { $revision = "{0:D4}" -f [convert]::ToInt32($env:APPVEYOR_BUILD_NUMBER, 10) - $versionSuffix = "pre-$revision" + $versionSuffix = "$($env:APPVEYOR_PULL_REQUEST_HEAD_REPO_BRANCH ?? $env:APPVEYOR_REPO_BRANCH)-$revision" } else { $versionSuffix = "pre-0001" diff --git a/CSharpGuidelinesAnalyzer.config b/CSharpGuidelinesAnalyzer.config index acd0856299..89b568e155 100644 --- a/CSharpGuidelinesAnalyzer.config +++ b/CSharpGuidelinesAnalyzer.config @@ -1,5 +1,5 @@ - + diff --git a/Directory.Build.props b/Directory.Build.props index b5da8620b7..43158313fe 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -5,13 +5,14 @@ 5.0.* 5.0.* 6.2.* - 4.2.0 + 5.0.0 $(MSBuildThisFileDirectory)CodingGuidelines.ruleset 9999 + enable - + @@ -26,9 +27,9 @@ 33.1.1 3.1.0 - 6.1.0 + 6.2.0 4.16.1 2.4.* - 16.11.0 + 17.0.0 diff --git a/README.md b/README.md index 39ea16b3be..e9670ff393 100644 --- a/README.md +++ b/README.md @@ -43,21 +43,23 @@ See [our documentation](https://www.jsonapi.net/) for detailed usage. ### Models ```c# -public class Article : Identifiable +#nullable enable + +public class Article : Identifiable { [Attr] - public string Name { get; set; } + public string Name { get; set; } = null!; } ``` ### Controllers ```c# -public class ArticlesController : JsonApiController
+public class ArticlesController : JsonApiController { - public ArticlesController(IJsonApiOptions options, ILoggerFactory loggerFactory, - IResourceService
resourceService,) - : base(options, loggerFactory, resourceService) + public ArticlesController(IJsonApiOptions options, IResourceGraph resourceGraph, + ILoggerFactory loggerFactory, IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } @@ -87,13 +89,16 @@ public class Startup The following chart should help you pick the best version, based on your environment. See also our [versioning policy](./VERSIONING_POLICY.md). -| .NET version | EF Core version | JsonApiDotNetCore version | -| ------------ | --------------- | ------------------------- | -| Core 2.x | 2.x | 3.x | -| Core 3.1 | 3.1 | 4.x | -| Core 3.1 | 5 | 4.x | -| 5 | 5 | 4.x or 5.x | -| 6 | 6 | 5.x | +| JsonApiDotNetCore | .NET | Entity Framework Core | Status | +| ----------------- | -------- | --------------------- | -------------------------- | +| 3.x | Core 2.x | 2.x | Released | +| 4.x | Core 3.1 | 3.1 | Released | +| | Core 3.1 | 5 | | +| | 5 | 5 | | +| | 6 | 5 | | +| v5.x (pending) | 5 | 5 | On AppVeyor, to-be-dropped | +| | 6 | 5 | On AppVeyor, to-be-dropped | +| | 6 | 6 | Requires build from master | ## Contributing diff --git a/ROADMAP.md b/ROADMAP.md index 1c15a33b2b..49f499e79c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -13,21 +13,22 @@ We've completed active development on v4.x, but we'll still fix important bugs o The need for breaking changes has blocked several efforts in the v4.x release, so now that we're starting work on v5, we're going to catch up. - [x] Remove Resource Hooks [#1025](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1025) -- [x] Update to .NET/EFCORE 5 [#1026](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1026) +- [x] Update to .NET 5 with EF Core 5 [#1026](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1026) - [x] Native many-to-many [#935](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/935) - [x] Refactorings [#1027](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1027) [#944](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/944) - [x] Tweak trace logging [#1033](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1033) - [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) -- [ ] Nullable reference types [#1029](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1029) +- [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) +- [x] Nullable reference types [#1029](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1029) +- [x] Improved paging links [#1010](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1010) +- [x] Configuration validation [#170](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/170) +- [ ] Support .NET 6 with EF Core 6 [#1109](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1109) Aside from the list above, we have interest in the following topics. It's too soon yet to decide whether they'll make it into v5.x or in a later major version. -- Improved paging links [#1010](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1010) - Auto-generated controllers [#732](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/732) [#365](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/365) -- Configuration validation [#170](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/170) - Optimistic concurrency [#1004](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1004) - Extract annotations into separate package [#730](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/730) - OpenAPI (Swagger) [#1046](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1046) diff --git a/appveyor.yml b/appveyor.yml index e36676e658..8ff835a527 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -1,6 +1,6 @@ image: - Ubuntu - - Visual Studio 2019 + - Visual Studio 2022 version: '{build}' @@ -33,7 +33,7 @@ for: - matrix: only: - - image: Visual Studio 2019 + - image: Visual Studio 2022 services: - postgresql13 # REF: https://github.com/docascode/docfx-seed/blob/master/appveyor.yml @@ -44,6 +44,9 @@ for: git checkout $env:APPVEYOR_REPO_BRANCH -q } choco install docfx -y + if ($lastexitcode -ne 0) { + throw "docfx install failed with exit code $lastexitcode." + } after_build: - pwsh: | CD ./docs diff --git a/benchmarks/BenchmarkResource.cs b/benchmarks/BenchmarkResource.cs deleted file mode 100644 index acc1511844..0000000000 --- a/benchmarks/BenchmarkResource.cs +++ /dev/null @@ -1,16 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace Benchmarks -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class BenchmarkResource : Identifiable - { - [Attr(PublicName = BenchmarkResourcePublicNames.NameAttr)] - public string Name { get; set; } - - [HasOne] - public SubResource Child { get; set; } - } -} diff --git a/benchmarks/BenchmarkResourcePublicNames.cs b/benchmarks/BenchmarkResourcePublicNames.cs deleted file mode 100644 index 84b63e7668..0000000000 --- a/benchmarks/BenchmarkResourcePublicNames.cs +++ /dev/null @@ -1,10 +0,0 @@ -#pragma warning disable AV1008 // Class should not be static - -namespace Benchmarks -{ - internal static class BenchmarkResourcePublicNames - { - public const string NameAttr = "full-name"; - public const string Type = "simple-types"; - } -} diff --git a/benchmarks/Benchmarks.csproj b/benchmarks/Benchmarks.csproj index 4b19516001..225c3a75d7 100644 --- a/benchmarks/Benchmarks.csproj +++ b/benchmarks/Benchmarks.csproj @@ -9,7 +9,7 @@ - + diff --git a/benchmarks/DependencyFactory.cs b/benchmarks/DependencyFactory.cs deleted file mode 100644 index 184ba5a082..0000000000 --- a/benchmarks/DependencyFactory.cs +++ /dev/null @@ -1,18 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Benchmarks -{ - internal sealed class DependencyFactory - { - public IResourceGraph CreateResourceGraph(IJsonApiOptions options) - { - var builder = new ResourceGraphBuilder(options, NullLoggerFactory.Instance); - - builder.Add(BenchmarkResourcePublicNames.Type); - builder.Add(); - - return builder.Build(); - } - } -} diff --git a/benchmarks/Deserialization/DeserializationBenchmarkBase.cs b/benchmarks/Deserialization/DeserializationBenchmarkBase.cs new file mode 100644 index 0000000000..b21d7c85e7 --- /dev/null +++ b/benchmarks/Deserialization/DeserializationBenchmarkBase.cs @@ -0,0 +1,124 @@ +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 IncomingResource : 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; } = null!; + + [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 IncomingResource Single1 { get; set; } = null!; + + [HasOne] + public IncomingResource Single2 { get; set; } = null!; + + [HasOne] + public IncomingResource Single3 { get; set; } = null!; + + [HasOne] + public IncomingResource Single4 { get; set; } = null!; + + [HasOne] + public IncomingResource Single5 { get; set; } = null!; + + [HasMany] + public ISet Multi1 { get; set; } = null!; + + [HasMany] + public ISet Multi2 { get; set; } = null!; + + [HasMany] + public ISet Multi3 { get; set; } = null!; + + [HasMany] + public ISet Multi4 { get; set; } = null!; + + [HasMany] + public ISet Multi5 { get; set; } = null!; + } + } +} diff --git a/benchmarks/Deserialization/OperationsDeserializationBenchmarks.cs b/benchmarks/Deserialization/OperationsDeserializationBenchmarks.cs new file mode 100644 index 0000000000..0181f4ccbc --- /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 = "incomingResources", + lid = "a-1", + attributes = new + { + attribute01 = true, + attribute02 = 'A', + attribute03 = 100UL, + attribute04 = 100.001m, + attribute05 = 200.002f, + attribute06 = "text", + attribute07 = DateTime.MaxValue, + attribute08 = DateTimeOffset.MaxValue, + attribute09 = TimeSpan.MaxValue, + attribute10 = DayOfWeek.Friday + }, + relationships = new + { + single1 = new + { + data = new + { + type = "incomingResources", + id = "101" + } + }, + single2 = new + { + data = new + { + type = "incomingResources", + id = "102" + } + }, + single3 = new + { + data = new + { + type = "incomingResources", + id = "103" + } + }, + single4 = new + { + data = new + { + type = "incomingResources", + id = "104" + } + }, + single5 = new + { + data = new + { + type = "incomingResources", + id = "105" + } + }, + multi1 = new + { + data = new[] + { + new + { + type = "incomingResources", + id = "201" + } + } + }, + multi2 = new + { + data = new[] + { + new + { + type = "incomingResources", + id = "202" + } + } + }, + multi3 = new + { + data = new[] + { + new + { + type = "incomingResources", + id = "203" + } + } + }, + multi4 = new + { + data = new[] + { + new + { + type = "incomingResources", + id = "204" + } + } + }, + multi5 = new + { + data = new[] + { + new + { + type = "incomingResources", + id = "205" + } + } + } + } + } + }, + new + { + op = "update", + data = new + { + type = "incomingResources", + id = "1", + attributes = new + { + attribute01 = true, + attribute02 = 'A', + attribute03 = 100UL, + attribute04 = 100.001m, + attribute05 = 200.002f, + attribute06 = "text", + attribute07 = DateTime.MaxValue, + attribute08 = DateTimeOffset.MaxValue, + attribute09 = TimeSpan.MaxValue, + attribute10 = DayOfWeek.Friday + }, + relationships = new + { + single1 = new + { + data = new + { + type = "incomingResources", + id = "101" + } + }, + single2 = new + { + data = new + { + type = "incomingResources", + id = "102" + } + }, + single3 = new + { + data = new + { + type = "incomingResources", + id = "103" + } + }, + single4 = new + { + data = new + { + type = "incomingResources", + id = "104" + } + }, + single5 = new + { + data = new + { + type = "incomingResources", + id = "105" + } + }, + multi1 = new + { + data = new[] + { + new + { + type = "incomingResources", + id = "201" + } + } + }, + multi2 = new + { + data = new[] + { + new + { + type = "incomingResources", + id = "202" + } + } + }, + multi3 = new + { + data = new[] + { + new + { + type = "incomingResources", + id = "203" + } + } + }, + multi4 = new + { + data = new[] + { + new + { + type = "incomingResources", + id = "204" + } + } + }, + multi5 = new + { + data = new[] + { + new + { + type = "incomingResources", + id = "205" + } + } + } + } + } + }, + new + { + op = "remove", + @ref = new + { + type = "incomingResources", + 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 JsonApiRequest + { + Kind = EndpointKind.AtomicOperations + }; + } + } +} diff --git a/benchmarks/Deserialization/ResourceDeserializationBenchmarks.cs b/benchmarks/Deserialization/ResourceDeserializationBenchmarks.cs new file mode 100644 index 0000000000..e154306819 --- /dev/null +++ b/benchmarks/Deserialization/ResourceDeserializationBenchmarks.cs @@ -0,0 +1,150 @@ +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 = "incomingResources", + attributes = new + { + attribute01 = true, + attribute02 = 'A', + attribute03 = 100UL, + attribute04 = 100.001m, + attribute05 = 200.002f, + attribute06 = "text", + attribute07 = DateTime.MaxValue, + attribute08 = DateTimeOffset.MaxValue, + attribute09 = TimeSpan.MaxValue, + attribute10 = DayOfWeek.Friday + }, + relationships = new + { + single1 = new + { + data = new + { + type = "incomingResources", + id = "101" + } + }, + single2 = new + { + data = new + { + type = "incomingResources", + id = "102" + } + }, + single3 = new + { + data = new + { + type = "incomingResources", + id = "103" + } + }, + single4 = new + { + data = new + { + type = "incomingResources", + id = "104" + } + }, + single5 = new + { + data = new + { + type = "incomingResources", + id = "105" + } + }, + multi1 = new + { + data = new[] + { + new + { + type = "incomingResources", + id = "201" + } + } + }, + multi2 = new + { + data = new[] + { + new + { + type = "incomingResources", + id = "202" + } + } + }, + multi3 = new + { + data = new[] + { + new + { + type = "incomingResources", + id = "203" + } + } + }, + multi4 = new + { + data = new[] + { + new + { + type = "incomingResources", + id = "204" + } + } + }, + multi5 = new + { + data = new[] + { + new + { + type = "incomingResources", + id = "205" + } + } + } + } + } + }); + + [Benchmark] + public object? DeserializeResourceRequest() + { + var document = JsonSerializer.Deserialize(RequestBody, SerializerReadOptions)!; + return DocumentAdapter.Convert(document); + } + + protected override JsonApiRequest CreateJsonApiRequest(IResourceGraph resourceGraph) + { + return new JsonApiRequest + { + Kind = EndpointKind.Primary, + PrimaryResourceType = resourceGraph.GetResourceType(), + WriteOperation = WriteOperationKind.CreateResource + }; + } + } +} diff --git a/benchmarks/LinkBuilder/LinkBuilderGetNamespaceFromPathBenchmarks.cs b/benchmarks/LinkBuilder/LinkBuilderGetNamespaceFromPathBenchmarks.cs deleted file mode 100644 index c7110bf73e..0000000000 --- a/benchmarks/LinkBuilder/LinkBuilderGetNamespaceFromPathBenchmarks.cs +++ /dev/null @@ -1,78 +0,0 @@ -using System; -using System.Text; -using BenchmarkDotNet.Attributes; - -namespace Benchmarks.LinkBuilder -{ - // ReSharper disable once ClassCanBeSealed.Global - [MarkdownExporter] - [SimpleJob(3, 10, 20)] - [MemoryDiagnoser] - public class LinkBuilderGetNamespaceFromPathBenchmarks - { - private const string RequestPath = "/api/some-really-long-namespace-path/resources/current/articles/?some"; - private const string ResourceName = "articles"; - private const char PathDelimiter = '/'; - - [Benchmark] - public void UsingStringSplit() - { - GetNamespaceFromPathUsingStringSplit(RequestPath, ResourceName); - } - - [Benchmark] - public void UsingReadOnlySpan() - { - GetNamespaceFromPathUsingReadOnlySpan(RequestPath, ResourceName); - } - - private static void GetNamespaceFromPathUsingStringSplit(string path, string resourceName) - { - var namespaceBuilder = new StringBuilder(path.Length); - string[] segments = path.Split('/'); - - for (int index = 1; index < segments.Length; index++) - { - if (segments[index] == resourceName) - { - break; - } - - namespaceBuilder.Append(PathDelimiter); - namespaceBuilder.Append(segments[index]); - } - - _ = namespaceBuilder.ToString(); - } - - private static void GetNamespaceFromPathUsingReadOnlySpan(string path, string resourceName) - { - ReadOnlySpan resourceNameSpan = resourceName.AsSpan(); - ReadOnlySpan pathSpan = path.AsSpan(); - - for (int index = 0; index < pathSpan.Length; index++) - { - if (pathSpan[index].Equals(PathDelimiter)) - { - if (pathSpan.Length > index + resourceNameSpan.Length) - { - ReadOnlySpan possiblePathSegment = pathSpan.Slice(index + 1, resourceNameSpan.Length); - - if (resourceNameSpan.SequenceEqual(possiblePathSegment)) - { - int lastCharacterIndex = index + 1 + resourceNameSpan.Length; - - bool isAtEnd = lastCharacterIndex == pathSpan.Length; - bool hasDelimiterAfterSegment = pathSpan.Length >= lastCharacterIndex + 1 && pathSpan[lastCharacterIndex].Equals(PathDelimiter); - - if (isAtEnd || hasDelimiterAfterSegment) - { - _ = pathSpan[..index].ToString(); - } - } - } - } - } - } - } -} diff --git a/benchmarks/Program.cs b/benchmarks/Program.cs index 0d745a795d..45406133dd 100644 --- a/benchmarks/Program.cs +++ b/benchmarks/Program.cs @@ -1,6 +1,6 @@ using BenchmarkDotNet.Running; -using Benchmarks.LinkBuilder; -using Benchmarks.Query; +using Benchmarks.Deserialization; +using Benchmarks.QueryString; using Benchmarks.Serialization; namespace Benchmarks @@ -11,10 +11,11 @@ private static void Main(string[] args) { var switcher = new BenchmarkSwitcher(new[] { - typeof(JsonApiDeserializerBenchmarks), - typeof(JsonApiSerializerBenchmarks), - typeof(QueryParserBenchmarks), - typeof(LinkBuilderGetNamespaceFromPathBenchmarks) + typeof(ResourceDeserializationBenchmarks), + typeof(OperationsDeserializationBenchmarks), + typeof(ResourceSerializationBenchmarks), + typeof(OperationsSerializationBenchmarks), + typeof(QueryStringParserBenchmarks) }); switcher.Run(args); diff --git a/benchmarks/Query/QueryParserBenchmarks.cs b/benchmarks/QueryString/QueryStringParserBenchmarks.cs similarity index 53% rename from benchmarks/Query/QueryParserBenchmarks.cs rename to benchmarks/QueryString/QueryStringParserBenchmarks.cs index 8f1ec950da..42d34f8ce4 100644 --- a/benchmarks/Query/QueryParserBenchmarks.cs +++ b/benchmarks/QueryString/QueryStringParserBenchmarks.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.ComponentModel.Design; using BenchmarkDotNet.Attributes; using JsonApiDotNetCore; @@ -12,51 +11,33 @@ using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.Logging.Abstractions; -namespace Benchmarks.Query +namespace Benchmarks.QueryString { // ReSharper disable once ClassCanBeSealed.Global [MarkdownExporter] [SimpleJob(3, 10, 20)] [MemoryDiagnoser] - public class QueryParserBenchmarks + public class QueryStringParserBenchmarks { - private readonly DependencyFactory _dependencyFactory = new(); private readonly FakeRequestQueryStringAccessor _queryStringAccessor = new(); - private readonly QueryStringReader _queryStringReaderForSort; - private readonly QueryStringReader _queryStringReaderForAll; + private readonly QueryStringReader _queryStringReader; - public QueryParserBenchmarks() + public QueryStringParserBenchmarks() { IJsonApiOptions options = new JsonApiOptions { EnableLegacyFilterNotation = true }; - IResourceGraph resourceGraph = _dependencyFactory.CreateResourceGraph(options); + IResourceGraph resourceGraph = + new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add("alt-resource-name").Build(); var request = new JsonApiRequest { - PrimaryResource = resourceGraph.GetResourceContext(typeof(BenchmarkResource)), + PrimaryResourceType = resourceGraph.GetResourceType(typeof(QueryableResource)), IsCollection = true }; - _queryStringReaderForSort = CreateQueryParameterDiscoveryForSort(resourceGraph, request, options, _queryStringAccessor); - _queryStringReaderForAll = CreateQueryParameterDiscoveryForAll(resourceGraph, request, options, _queryStringAccessor); - } - - private static QueryStringReader CreateQueryParameterDiscoveryForSort(IResourceGraph resourceGraph, JsonApiRequest request, IJsonApiOptions options, - FakeRequestQueryStringAccessor queryStringAccessor) - { - var sortReader = new SortQueryStringParameterReader(request, resourceGraph); - - IEnumerable readers = sortReader.AsEnumerable(); - - return new QueryStringReader(options, queryStringAccessor, readers, NullLoggerFactory.Instance); - } - - private static QueryStringReader CreateQueryParameterDiscoveryForAll(IResourceGraph resourceGraph, JsonApiRequest request, IJsonApiOptions options, - FakeRequestQueryStringAccessor queryStringAccessor) - { var resourceFactory = new ResourceFactory(new ServiceContainer()); var includeReader = new IncludeQueryStringParameterReader(request, resourceGraph, options); @@ -68,25 +49,25 @@ private static QueryStringReader CreateQueryParameterDiscoveryForAll(IResourceGr IQueryStringParameterReader[] readers = ArrayFactory.Create(includeReader, filterReader, sortReader, sparseFieldSetReader, paginationReader); - return new QueryStringReader(options, queryStringAccessor, readers, NullLoggerFactory.Instance); + _queryStringReader = new QueryStringReader(options, _queryStringAccessor, readers, NullLoggerFactory.Instance); } [Benchmark] public void AscendingSort() { - string queryString = $"?sort={BenchmarkResourcePublicNames.NameAttr}"; + const string queryString = "?sort=alt-attr-name"; _queryStringAccessor.SetQueryString(queryString); - _queryStringReaderForSort.ReadAll(null); + _queryStringReader.ReadAll(null); } [Benchmark] public void DescendingSort() { - string queryString = $"?sort=-{BenchmarkResourcePublicNames.NameAttr}"; + const string queryString = "?sort=-alt-attr-name"; _queryStringAccessor.SetQueryString(queryString); - _queryStringReaderForSort.ReadAll(null); + _queryStringReader.ReadAll(null); } [Benchmark] @@ -94,13 +75,11 @@ public void ComplexQuery() { Run(100, () => { - const string resourceName = BenchmarkResourcePublicNames.Type; - const string attrName = BenchmarkResourcePublicNames.NameAttr; - - string queryString = $"?filter[{attrName}]=abc,eq:abc&sort=-{attrName}&include=child&page[size]=1&fields[{resourceName}]={attrName}"; + const string queryString = + "?filter[alt-attr-name]=abc,eq:abc&sort=-alt-attr-name&include=child&page[size]=1&fields[alt-resource-name]=alt-attr-name"; _queryStringAccessor.SetQueryString(queryString); - _queryStringReaderForAll.ReadAll(null); + _queryStringReader.ReadAll(null); }); } @@ -114,7 +93,7 @@ private void Run(int iterations, Action action) private sealed class FakeRequestQueryStringAccessor : IRequestQueryStringAccessor { - public IQueryCollection Query { get; private set; } + public IQueryCollection Query { get; private set; } = new QueryCollection(); public void SetQueryString(string queryString) { diff --git a/benchmarks/QueryString/QueryableResource.cs b/benchmarks/QueryString/QueryableResource.cs new file mode 100644 index 0000000000..bcf0a5075a --- /dev/null +++ b/benchmarks/QueryString/QueryableResource.cs @@ -0,0 +1,16 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace Benchmarks.QueryString +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class QueryableResource : Identifiable + { + [Attr(PublicName = "alt-attr-name")] + public string? Name { get; set; } + + [HasOne] + public QueryableResource? Child { get; set; } + } +} 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..fef0d67a12 --- /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 OutgoingResource + { + Id = 1, + Attribute01 = true, + Attribute02 = 'A', + Attribute03 = 100UL, + Attribute04 = 100.001m, + Attribute05 = 100.002f, + Attribute06 = "text1", + Attribute07 = new DateTime(2001, 1, 1), + Attribute08 = new DateTimeOffset(2001, 1, 1, 0, 0, 0, TimeSpan.FromHours(1)), + Attribute09 = new TimeSpan(1, 0, 0), + Attribute10 = DayOfWeek.Sunday + }; + + var resource2 = new OutgoingResource + { + Id = 2, + Attribute01 = false, + Attribute02 = 'B', + Attribute03 = 200UL, + Attribute04 = 200.001m, + Attribute05 = 200.002f, + Attribute06 = "text2", + Attribute07 = new DateTime(2002, 2, 2), + Attribute08 = new DateTimeOffset(2002, 2, 2, 0, 0, 0, TimeSpan.FromHours(2)), + Attribute09 = new TimeSpan(2, 0, 0), + Attribute10 = DayOfWeek.Monday + }; + + var resource3 = new OutgoingResource + { + Id = 3, + Attribute01 = true, + Attribute02 = 'C', + Attribute03 = 300UL, + Attribute04 = 300.001m, + Attribute05 = 300.002f, + Attribute06 = "text3", + Attribute07 = new DateTime(2003, 3, 3), + Attribute08 = new DateTimeOffset(2003, 3, 3, 0, 0, 0, TimeSpan.FromHours(3)), + Attribute09 = new TimeSpan(3, 0, 0), + Attribute10 = DayOfWeek.Tuesday + }; + + var resource4 = new OutgoingResource + { + Id = 4, + Attribute01 = false, + Attribute02 = 'D', + Attribute03 = 400UL, + Attribute04 = 400.001m, + Attribute05 = 400.002f, + Attribute06 = "text4", + Attribute07 = new DateTime(2004, 4, 4), + Attribute08 = new DateTimeOffset(2004, 4, 4, 0, 0, 0, TimeSpan.FromHours(4)), + Attribute09 = new TimeSpan(4, 0, 0), + Attribute10 = DayOfWeek.Wednesday + }; + + var resource5 = new OutgoingResource + { + Id = 5, + Attribute01 = true, + Attribute02 = 'E', + Attribute03 = 500UL, + Attribute04 = 500.001m, + Attribute05 = 500.002f, + Attribute06 = "text5", + Attribute07 = new DateTime(2005, 5, 5), + Attribute08 = new DateTimeOffset(2005, 5, 5, 0, 0, 0, TimeSpan.FromHours(5)), + Attribute09 = new TimeSpan(5, 0, 0), + Attribute10 = DayOfWeek.Thursday + }; + + 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 JsonApiRequest + { + 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..3435265262 --- /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 OutgoingResource ResponseResource = CreateResponseResource(); + + private static OutgoingResource CreateResponseResource() + { + var resource1 = new OutgoingResource + { + Id = 1, + Attribute01 = true, + Attribute02 = 'A', + Attribute03 = 100UL, + Attribute04 = 100.001m, + Attribute05 = 100.002f, + Attribute06 = "text1", + Attribute07 = new DateTime(2001, 1, 1), + Attribute08 = new DateTimeOffset(2001, 1, 1, 0, 0, 0, TimeSpan.FromHours(1)), + Attribute09 = new TimeSpan(1, 0, 0), + Attribute10 = DayOfWeek.Sunday + }; + + var resource2 = new OutgoingResource + { + Id = 2, + Attribute01 = false, + Attribute02 = 'B', + Attribute03 = 200UL, + Attribute04 = 200.001m, + Attribute05 = 200.002f, + Attribute06 = "text2", + Attribute07 = new DateTime(2002, 2, 2), + Attribute08 = new DateTimeOffset(2002, 2, 2, 0, 0, 0, TimeSpan.FromHours(2)), + Attribute09 = new TimeSpan(2, 0, 0), + Attribute10 = DayOfWeek.Monday + }; + + var resource3 = new OutgoingResource + { + Id = 3, + Attribute01 = true, + Attribute02 = 'C', + Attribute03 = 300UL, + Attribute04 = 300.001m, + Attribute05 = 300.002f, + Attribute06 = "text3", + Attribute07 = new DateTime(2003, 3, 3), + Attribute08 = new DateTimeOffset(2003, 3, 3, 0, 0, 0, TimeSpan.FromHours(3)), + Attribute09 = new TimeSpan(3, 0, 0), + Attribute10 = DayOfWeek.Tuesday + }; + + var resource4 = new OutgoingResource + { + Id = 4, + Attribute01 = false, + Attribute02 = 'D', + Attribute03 = 400UL, + Attribute04 = 400.001m, + Attribute05 = 400.002f, + Attribute06 = "text4", + Attribute07 = new DateTime(2004, 4, 4), + Attribute08 = new DateTimeOffset(2004, 4, 4, 0, 0, 0, TimeSpan.FromHours(4)), + Attribute09 = new TimeSpan(4, 0, 0), + Attribute10 = DayOfWeek.Wednesday + }; + + var resource5 = new OutgoingResource + { + Id = 5, + Attribute01 = true, + Attribute02 = 'E', + Attribute03 = 500UL, + Attribute04 = 500.001m, + Attribute05 = 500.002f, + Attribute06 = "text5", + Attribute07 = new DateTime(2005, 5, 5), + Attribute08 = new DateTimeOffset(2005, 5, 5, 0, 0, 0, TimeSpan.FromHours(5)), + Attribute09 = new TimeSpan(5, 0, 0), + Attribute10 = DayOfWeek.Thursday + }; + + resource1.Single2 = resource2; + resource2.Single3 = resource3; + resource3.Multi4 = resource4.AsHashSet(); + resource4.Multi5 = resource5.AsHashSet(); + + return resource1; + } + + [Benchmark] + public string SerializeResourceResponse() + { + Document responseDocument = ResponseModelAdapter.Convert(ResponseResource); + return JsonSerializer.Serialize(responseDocument, SerializerWriteOptions); + } + + protected override JsonApiRequest CreateJsonApiRequest(IResourceGraph resourceGraph) + { + return new JsonApiRequest + { + Kind = EndpointKind.Primary, + PrimaryResourceType = resourceGraph.GetResourceType() + }; + } + + protected override IEvaluatedIncludeCache CreateEvaluatedIncludeCache(IResourceGraph resourceGraph) + { + ResourceType resourceAType = resourceGraph.GetResourceType(); + + RelationshipAttribute single2 = resourceAType.GetRelationshipByPropertyName(nameof(OutgoingResource.Single2)); + RelationshipAttribute single3 = resourceAType.GetRelationshipByPropertyName(nameof(OutgoingResource.Single3)); + RelationshipAttribute multi4 = resourceAType.GetRelationshipByPropertyName(nameof(OutgoingResource.Multi4)); + RelationshipAttribute multi5 = resourceAType.GetRelationshipByPropertyName(nameof(OutgoingResource.Multi5)); + + ImmutableArray chain = ImmutableArray.Create(single2, single3, multi4, multi5); + 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..84d28c22ab --- /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 OutgoingResource : 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; } = null!; + + [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 OutgoingResource Single1 { get; set; } = null!; + + [HasOne] + public OutgoingResource Single2 { get; set; } = null!; + + [HasOne] + public OutgoingResource Single3 { get; set; } = null!; + + [HasOne] + public OutgoingResource Single4 { get; set; } = null!; + + [HasOne] + public OutgoingResource Single5 { get; set; } = null!; + + [HasMany] + public ISet Multi1 { get; set; } = null!; + + [HasMany] + public ISet Multi2 { get; set; } = null!; + + [HasMany] + public ISet Multi3 { get; set; } = null!; + + [HasMany] + public ISet Multi4 { get; set; } = null!; + + [HasMany] + public ISet Multi5 { get; set; } = null!; + } + + 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 TopLevelLinks + { + Self = "TopLevel:Self" + }; + } + + public ResourceLinks GetResourceLinks(ResourceType resourceType, IIdentifiable resource) + { + return new ResourceLinks + { + Self = "Resource:Self" + }; + } + + public RelationshipLinks GetRelationshipLinks(RelationshipAttribute relationship, IIdentifiable leftResource) + { + return new RelationshipLinks + { + 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/cleanupcode.ps1 b/cleanupcode.ps1 index 605ebff705..6db01a863a 100644 --- a/cleanupcode.ps1 +++ b/cleanupcode.ps1 @@ -8,10 +8,10 @@ if ($LASTEXITCODE -ne 0) { throw "Tool restore failed with exit code $LASTEXITCODE" } -dotnet build -c Release +dotnet restore if ($LASTEXITCODE -ne 0) { - throw "Build failed with exit code $LASTEXITCODE" + throw "Package restore failed with exit code $LASTEXITCODE" } -dotnet regitlint -s JsonApiDotNetCore.sln --print-command --jb --profile --jb --profile='\"JADNC Full Cleanup\"' --jb --properties:Configuration=Release --jb --verbosity=WARN +dotnet regitlint -s JsonApiDotNetCore.sln --print-command --disable-jb-path-hack --jb --profile='\"JADNC Full Cleanup\"' --jb --properties:Configuration=Release --jb --verbosity=WARN diff --git a/docs/api/index.md b/docs/api/index.md index c8e4a69a3d..7eb109b9af 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -6,4 +6,4 @@ This section documents the package API and is generated from the XML source comm - [`JsonApiOptions`](JsonApiDotNetCore.Configuration.JsonApiOptions.yml) - [`IResourceGraph`](JsonApiDotNetCore.Configuration.IResourceGraph.yml) -- [`JsonApiResourceDefinition`](JsonApiDotNetCore.Resources.JsonApiResourceDefinition-1.yml) +- [`JsonApiResourceDefinition`](JsonApiDotNetCore.Resources.JsonApiResourceDefinition-2.yml) diff --git a/docs/getting-started/step-by-step.md b/docs/getting-started/step-by-step.md index 9273de6eb1..21daf04171 100644 --- a/docs/getting-started/step-by-step.md +++ b/docs/getting-started/step-by-step.md @@ -35,43 +35,45 @@ Install-Package JsonApiDotNetCore ### Define Models Define your domain models such that they implement `IIdentifiable`. -The easiest way to do this is to inherit from `Identifiable` +The easiest way to do this is to inherit from `Identifiable`. ```c# -public class Person : Identifiable +#nullable enable + +public class Person : Identifiable { [Attr] - public string Name { get; set; } + public string Name { get; set; } = null!; } ``` ### Define DbContext -Nothing special here, just an ordinary `DbContext` +Nothing special here, just an ordinary `DbContext`. ``` public class AppDbContext : DbContext { + public DbSet People => Set(); + public AppDbContext(DbContextOptions options) : base(options) { } - - public DbSet People { get; set; } } ``` ### Define Controllers -You need to create controllers that inherit from `JsonApiController` or `JsonApiController` -where `TResource` is the model that inherits from `Identifiable` +You need to create controllers that inherit from `JsonApiController` +where `TResource` is the model that inherits from `Identifiable`. ```c# -public class PeopleController : JsonApiController +public class PeopleController : JsonApiController { - public PeopleController(IJsonApiOptions options, ILoggerFactory loggerFactory, - IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public PeopleController(IJsonApiOptions options, IResourceGraph resourceGraph, + ILoggerFactory loggerFactory, IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } @@ -114,18 +116,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/home/index.html b/docs/home/index.html index 661819f3f6..7f01a30e32 100644 --- a/docs/home/index.html +++ b/docs/home/index.html @@ -142,31 +142,35 @@

Example usage

Resource

-public class Article : Identifiable
+#nullable enable
+
+public class Article : Identifiable<long>
 {
     [Attr]
-    [Required, MaxLength(30)]
-    public string Title { get; set; }
+    [MaxLength(30)]
+    public string Title { get; set; } = null!;
 
     [Attr(Capabilities = AttrCapabilities.AllowFilter)]
-    public string Summary { get; set; }
+    public string? Summary { get; set; }
 
     [Attr(PublicName = "websiteUrl")]
-    public string Url { get; set; }
+    public string? Url { get; set; }
+
+    [Attr]
+    [Required]
+    public int? WordCount { get; set; }
 
     [Attr(Capabilities = AttrCapabilities.AllowView)]
     public DateTimeOffset LastModifiedAt { get; set; }
 
     [HasOne]
-    public Person Author { get; set; }
+    public Person Author { get; set; } = null!;
 
-    [HasMany]
-    public ICollection<Revision> Revisions { get; set; }  
+    [HasOne]
+    public Person? Reviewer { get; set; }
 
-    [HasManyThrough(nameof(ArticleTags))]
-    [NotMapped]
-    public ICollection<Tag> Tags { get; set; }
-    public ICollection<ArticleTag> ArticleTags { get; set; }
+    [HasMany]
+    public ICollection<Tag> Tags { get; set; } = new HashSet<Tag>();
 }
                      
@@ -179,7 +183,7 @@

Resource

Request

 
-GET /articles?filter=contains(summary,'web')&sort=-lastModifiedAt&fields=title,summary&include=author HTTP/1.1
+GET /articles?filter=contains(summary,'web')&sort=-lastModifiedAt&fields[articles]=title,summary&include=author HTTP/1.1
 
                      
@@ -197,9 +201,9 @@

Response

"totalResources": 1 }, "links": { - "self": "/articles?filter=contains(summary,'web')&sort=-lastModifiedAt&fields=title,summary&include=author", - "first": "/articles?filter=contains(summary,'web')&sort=-lastModifiedAt&fields=title,summary&include=author", - "last": "/articles?filter=contains(summary,'web')&sort=-lastModifiedAt&fields=title,summary&include=author" + "self": "/articles?filter=contains(summary,'web')&sort=-lastModifiedAt&fields%5Barticles%5D=title,summary&include=author", + "first": "/articles?filter=contains(summary,'web')&sort=-lastModifiedAt&fields%5Barticles%5D=title,summary&include=author", + "last": "/articles?filter=contains(summary,'web')&sort=-lastModifiedAt&fields%5Barticles%5D=title,summary&include=author" }, "data": [ { diff --git a/docs/internals/queries.md b/docs/internals/queries.md index b5e5c2cf19..46005f489c 100644 --- a/docs/internals/queries.md +++ b/docs/internals/queries.md @@ -5,7 +5,7 @@ _since v4.0_ The query pipeline roughly looks like this: ``` -HTTP --[ASP.NET Core]--> QueryString --[JADNC:QueryStringParameterReader]--> QueryExpression[] --[JADNC:ResourceService]--> QueryLayer --[JADNC:Repository]--> IQueryable --[EF Core]--> SQL +HTTP --[ASP.NET]--> QueryString --[JADNC:QueryStringParameterReader]--> QueryExpression[] --[JADNC:ResourceService]--> QueryLayer --[JADNC:Repository]--> IQueryable --[Entity Framework Core]--> SQL ``` Processing a request involves the following steps: @@ -22,7 +22,7 @@ Processing a request involves the following steps: - `JsonApiResourceService` contains no more usage of `IQueryable`. - `EntityFrameworkCoreRepository` delegates to `QueryableBuilder` to transform the `QueryLayer` tree into `IQueryable` expression trees. `QueryBuilder` depends on `QueryClauseBuilder` implementations that visit the tree nodes, transforming them to `System.Linq.Expression` equivalents. - The `IQueryable` expression trees are executed by EF Core, which produces SQL statements out of them. + The `IQueryable` expression trees are executed by Entity Framework Core, which produces SQL statements out of them. - `JsonApiWriter` transforms resource objects into json response. # Example diff --git a/docs/usage/errors.md b/docs/usage/errors.md index 96722739b4..3278526e6c 100644 --- a/docs/usage/errors.md +++ b/docs/usage/errors.md @@ -10,7 +10,7 @@ From a controller method: return Conflict(new Error(HttpStatusCode.Conflict) { Title = "Target resource was modified by another user.", - Detail = $"User {userName} changed the {resourceField} field on the {resourceName} resource." + Detail = $"User {userName} changed the {resourceField} field on {resourceName} resource." }); ``` @@ -20,7 +20,7 @@ From other code: throw new JsonApiException(new Error(HttpStatusCode.Conflict) { Title = "Target resource was modified by another user.", - Detail = $"User {userName} changed the {resourceField} field on the {resourceName} resource." + Detail = $"User {userName} changed the {resourceField} field on {resourceName} resource." }); ``` @@ -69,18 +69,22 @@ public class CustomExceptionHandler : ExceptionHandler return base.GetLogMessage(exception); } - protected override ErrorDocument CreateErrorDocument(Exception exception) + protected override IReadOnlyList CreateErrorResponse(Exception exception) { if (exception is ProductOutOfStockException productOutOfStock) { - return new ErrorDocument(new Error(HttpStatusCode.Conflict) + return new[] { - Title = "Product is temporarily available.", - Detail = $"Product {productOutOfStock.ProductId} cannot be ordered at the moment." - }); + new Error(HttpStatusCode.Conflict) + { + Title = "Product is temporarily available.", + Detail = $"Product {productOutOfStock.ProductId} " + + "cannot be ordered at the moment." + } + }; } - return base.CreateErrorDocument(exception); + return base.CreateErrorResponse(exception); } } diff --git a/docs/usage/extensibility/controllers.md b/docs/usage/extensibility/controllers.md index c117642cbc..1993f77841 100644 --- a/docs/usage/extensibility/controllers.md +++ b/docs/usage/extensibility/controllers.md @@ -1,112 +1,61 @@ # Controllers -You need to create controllers that inherit from `JsonApiController` - -```c# -public class ArticlesController : JsonApiController
-{ - public ArticlesController(IJsonApiOptions options, ILoggerFactory loggerFactory, - IResourceService
resourceService) - : base(options, loggerFactory, resourceService) - { - } -} -``` - -## Non-Integer Type Keys - -If your model is using a type other than `int` for the primary key, you must explicitly declare it in the controller/service/repository definitions. +You need to create controllers that inherit from `JsonApiController` ```c# public class ArticlesController : JsonApiController -//---------------------------------------------------------- ^^^^ { - public ArticlesController(IJsonApiOptions options, ILoggerFactory loggerFactory, - IResourceService resourceService) - //----------------------- ^^^^ - : base(options, loggerFactory, resourceService) + public ArticlesController(IJsonApiOptions options, IResourceGraph resourceGraph, + ILoggerFactory loggerFactory, IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } ``` +If you want to setup routes yourself, you can instead inherit from `BaseJsonApiController` and override its methods with your own `[HttpGet]`, `[HttpHead]`, `[HttpPost]`, `[HttpPatch]` and `[HttpDelete]` attributes added on them. Don't forget to add `[FromBody]` on parameters where needed. + ## Resource Access Control -It is often desirable to limit what methods are exposed on your controller. The first way you can do this, is to simply inherit from `BaseJsonApiController` and explicitly declare what methods are available. +It is often desirable to limit which routes are exposed on your controller. -In this example, if a client attempts to do anything other than GET a resource, an HTTP 404 Not Found response will be returned since no other methods are exposed. +To provide read-only access, inherit from `JsonApiQueryController` instead, which blocks all POST, PATCH and DELETE requests. +Likewise, to provide write-only access, inherit from `JsonApiCommandController`, which blocks all GET and HEAD requests. -This approach is ok, but introduces some boilerplate that can easily be avoided. +You can even make your own mix of allowed routes by calling the alternate constructor of `JsonApiController` and injecting the set of service implementations available. +In some cases, resources may be an aggregation of entities or a view on top of the underlying entities. In these cases, there may not be a writable `IResourceService` implementation, so simply inject the implementation that is available. ```c# -public class ArticlesController : BaseJsonApiController
+public class ReportsController : JsonApiController { - public ArticlesController(IJsonApiOptions options, ILoggerFactory loggerFactory, - IResourceService
resourceService) - : base(options, loggerFactory, resourceService) - { - } - - [HttpGet] - public override async Task GetAsync(CancellationToken cancellationToken) + public ReportsController(IJsonApiOptions options, IResourceGraph resourceGraph, + ILoggerFactory loggerFactory, IGetAllService getAllService) + : base(options, resourceGraph, loggerFactory, getAll: getAllService) { - return await base.GetAsync(cancellationToken); - } - - [HttpGet("{id}")] - public override async Task GetAsync(int id, - CancellationToken cancellationToken) - { - return await base.GetAsync(id, cancellationToken); } } ``` -## Using ActionFilterAttributes - -The next option is to use the ActionFilter attributes that ship with the library. The available attributes are: - -- `NoHttpPost`: disallow POST requests -- `NoHttpPatch`: disallow PATCH requests -- `NoHttpDelete`: disallow DELETE requests -- `HttpReadOnly`: all of the above +For more information about resource service injection, see [Replacing injected services](~/usage/extensibility/layer-overview.md#replacing-injected-services) and [Resource Services](~/usage/extensibility/services.md). -Not only does this reduce boilerplate, but it also provides a more meaningful HTTP response code. -An attempt to use one of the blacklisted methods will result in a HTTP 405 Method Not Allowed response. +When a route is blocked, an HTTP 403 Forbidden response is returned. -```c# -[HttpReadOnly] -public class ArticlesController : BaseJsonApiController
-{ - public ArticlesController(IJsonApiOptions options, ILoggerFactory loggerFactory, - IResourceService
resourceService) - : base(options, loggerFactory, resourceService) - { - } -} +```http +DELETE http://localhost:14140/people/1 HTTP/1.1 ``` -## Implicit Access By Service Injection - -Finally, you can control the allowed methods by supplying only the available service implementations. In some cases, resources may be an aggregation of entities or a view on top of the underlying entities. In these cases, there may not be a writable `IResourceService` implementation, so simply inject the implementation that is available. - -As with the ActionFilter attributes, if a service implementation is not available to service a request, HTTP 405 Method Not Allowed will be returned. - -For more information about resource service injection, see [Replacing injected services](~/usage/extensibility/layer-overview.md#replacing-injected-services) and [Resource Services](~/usage/extensibility/services.md). - -```c# -public class ReportsController : BaseJsonApiController +```json { - public ReportsController(IJsonApiOptions options, ILoggerFactory loggerFactory, - IGetAllService getAllService) - : base(options, loggerFactory, getAllService) - { - } - - [HttpGet] - public override async Task GetAsync(CancellationToken cancellationToken) + "links": { + "self": "/api/v1/people" + }, + "errors": [ { - return await base.GetAsync(cancellationToken); + "id": "dde7f219-2274-4473-97ef-baac3e7c1487", + "status": "403", + "title": "The requested endpoint is not accessible.", + "detail": "Endpoint '/people/1' is not accessible for DELETE requests." } + ] } ``` diff --git a/docs/usage/extensibility/repositories.md b/docs/usage/extensibility/repositories.md index 623c959510..7d76f2389a 100644 --- a/docs/usage/extensibility/repositories.md +++ b/docs/usage/extensibility/repositories.md @@ -8,9 +8,9 @@ The repository should then be registered in Startup.cs. ```c# public void ConfigureServices(IServiceCollection services) { - services.AddScoped, ArticleRepository>(); - services.AddScoped, ArticleRepository>(); - services.AddScoped, ArticleRepository>(); + services.AddScoped, ArticleRepository>(); + services.AddScoped, ArticleRepository>(); + services.AddScoped, ArticleRepository>(); } ``` @@ -34,18 +34,18 @@ A sample implementation that performs authorization might look like this. All of the methods in EntityFrameworkCoreRepository will use the `GetAll()` method to get the `DbSet`, so this is a good method to apply filters such as user or tenant authorization. ```c# -public class ArticleRepository : EntityFrameworkCoreRepository
+public class ArticleRepository : EntityFrameworkCoreRepository { private readonly IAuthenticationService _authenticationService; public ArticleRepository(IAuthenticationService authenticationService, - ITargetedFields targetedFields, IDbContextResolver contextResolver, - IResourceGraph resourceGraph, IGenericServiceFactory genericServiceFactory, - IResourceFactory resourceFactory, + ITargetedFields targetedFields, IDbContextResolver dbContextResolver, + IResourceGraph resourceGraph, IResourceFactory resourceFactory, IEnumerable constraintProviders, - ILoggerFactory loggerFactory) - : base(targetedFields, contextResolver, resourceGraph, genericServiceFactory, - resourceFactory, constraintProviders, loggerFactory) + ILoggerFactory loggerFactory, + IResourceDefinitionAccessor resourceDefinitionAccessor) + : base(targetedFields, dbContextResolver, resourceGraph, resourceFactory, + constraintProviders, loggerFactory, resourceDefinitionAccessor) { _authenticationService = authenticationService; } @@ -64,18 +64,17 @@ If you need to use multiple Entity Framework Core DbContexts, first create a rep This example shows a single `DbContextARepository` for all entities that are members of `DbContextA`. ```c# -public class DbContextARepository : EntityFrameworkCoreRepository - where TResource : class, IIdentifiable +public class DbContextARepository : EntityFrameworkCoreRepository + where TResource : class, IIdentifiable { public DbContextARepository(ITargetedFields targetedFields, - DbContextResolver contextResolver, + DbContextResolver dbContextResolver, // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - IResourceGraph resourceGraph, IGenericServiceFactory genericServiceFactory, - IResourceFactory resourceFactory, + IResourceGraph resourceGraph, IResourceFactory resourceFactory, IEnumerable constraintProviders, - ILoggerFactory loggerFactory) - : base(targetedFields, contextResolver, resourceGraph, genericServiceFactory, - resourceFactory, constraintProviders, loggerFactory) + ILoggerFactory loggerFactory, IResourceDefinitionAccessor resourceDefinitionAccessor) + : base(targetedFields, dbContextResolver, resourceGraph, resourceFactory, + constraintProviders, loggerFactory, resourceDefinitionAccessor) { } } diff --git a/docs/usage/extensibility/resource-definitions.md b/docs/usage/extensibility/resource-definitions.md index 5f0ca406be..4c9eeeb8a6 100644 --- a/docs/usage/extensibility/resource-definitions.md +++ b/docs/usage/extensibility/resource-definitions.md @@ -23,7 +23,7 @@ public class Startup resource definition on the container yourself: ```c# -services.AddScoped, ProductResource>(); +services.AddScoped, ProductDefinition>(); ``` ## Customizing queries @@ -31,7 +31,7 @@ services.AddScoped, ProductResource>(); _since v4.0_ For various reasons (see examples below) you may need to change parts of the query, depending on resource type. -`JsonApiResourceDefinition` (which is an empty implementation of `IResourceDefinition`) provides overridable methods that pass you the result of query string parameter parsing. +`JsonApiResourceDefinition` (which is an empty implementation of `IResourceDefinition`) provides overridable methods that pass you the result of query string parameter parsing. The value returned by you determines what will be used to execute the query. An intermediate format (`QueryExpression` and derived types) is used, which enables us to separate JSON:API implementation @@ -45,7 +45,7 @@ For example, you may accept some sensitive data that should only be exposed to a **Note:** to exclude attributes unconditionally, use `[Attr(Capabilities = ~AttrCapabilities.AllowView)]` on a resource class property. ```c# -public class UserDefinition : JsonApiResourceDefinition +public class UserDefinition : JsonApiResourceDefinition { public UserDefinition(IResourceGraph resourceGraph) : base(resourceGraph) @@ -104,7 +104,7 @@ Content-Type: application/vnd.api+json You can define the default sort order if no `sort` query string parameter is provided. ```c# -public class AccountDefinition : JsonApiResourceDefinition +public class AccountDefinition : JsonApiResourceDefinition { public AccountDefinition(IResourceGraph resourceGraph) : base(resourceGraph) @@ -132,7 +132,7 @@ public class AccountDefinition : JsonApiResourceDefinition You may want to enforce pagination on large database tables. ```c# -public class AccessLogDefinition : JsonApiResourceDefinition +public class AccessLogDefinition : JsonApiResourceDefinition { public AccessLogDefinition(IResourceGraph resourceGraph) : base(resourceGraph) @@ -163,7 +163,7 @@ public class AccessLogDefinition : JsonApiResourceDefinition The next example filters out `Account` resources that are suspended. ```c# -public class AccountDefinition : JsonApiResourceDefinition +public class AccountDefinition : JsonApiResourceDefinition { public AccountDefinition(IResourceGraph resourceGraph) : base(resourceGraph) @@ -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), @@ -195,7 +192,7 @@ public class AccountDefinition : JsonApiResourceDefinition In the example below, an error is returned when a user tries to include the manager of an employee. ```c# -public class EmployeeDefinition : JsonApiResourceDefinition +public class EmployeeDefinition : JsonApiResourceDefinition { public EmployeeDefinition(IResourceGraph resourceGraph) : base(resourceGraph) @@ -226,11 +223,11 @@ _since v3_ You can define additional query string parameters with the LINQ expression that should be used. If the key is present in a query string, the supplied LINQ expression will be added to the database query. -Note this directly influences the Entity Framework Core `IQueryable`. As opposed to using `OnApplyFilter`, this enables the full range of EF Core operators. +Note this directly influences the Entity Framework Core `IQueryable`. As opposed to using `OnApplyFilter`, this enables the full range of Entity Framework Core operators. But it only works on primary resource endpoints (for example: /articles, but not on /blogs/1/articles or /blogs?include=articles). ```c# -public class ItemDefinition : JsonApiResourceDefinition +public class ItemDefinition : JsonApiResourceDefinition { public ItemDefinition(IResourceGraph resourceGraph) : base(resourceGraph) diff --git a/docs/usage/extensibility/services.md b/docs/usage/extensibility/services.md index 2c157ae432..77d772435e 100644 --- a/docs/usage/extensibility/services.md +++ b/docs/usage/extensibility/services.md @@ -5,13 +5,13 @@ This allows you to customize it however you want. This is also a good place to i ## Supplementing Default Behavior -If you don't need to alter the underlying mechanisms, you can inherit from `JsonApiResourceService` and override the existing methods. +If you don't need to alter the underlying mechanisms, you can inherit from `JsonApiResourceService` and override the existing methods. In simple cases, you can also just wrap the base implementation with your custom logic. A simple example would be to send notifications when a resource gets created. ```c# -public class TodoItemService : JsonApiResourceService +public class TodoItemService : JsonApiResourceService { private readonly INotificationService _notificationService; @@ -19,7 +19,8 @@ public class TodoItemService : JsonApiResourceService IQueryLayerComposer queryLayerComposer, IPaginationContext paginationContext, IJsonApiOptions options, ILoggerFactory loggerFactory, IJsonApiRequest request, IResourceChangeTracker resourceChangeTracker, - IResourceDefinitionAccessor resourceDefinitionAccessor) + IResourceDefinitionAccessor resourceDefinitionAccessor, + INotificationService notificationService) : base(repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, resourceChangeTracker, resourceDefinitionAccessor) { @@ -43,21 +44,21 @@ public class TodoItemService : JsonApiResourceService ## Not Using Entity Framework Core? As previously discussed, this library uses Entity Framework Core by default. -If you'd like to use another ORM that does not provide what JsonApiResourceService depends upon, you can use a custom `IResourceService` implementation. +If you'd like to use another ORM that does not provide what JsonApiResourceService depends upon, you can use a custom `IResourceService` implementation. ```c# // Startup.cs public void ConfigureServices(IServiceCollection services) { // add the service override for Product - services.AddScoped, ProductService>(); + services.AddScoped, ProductService>(); // add your own Data Access Object services.AddScoped(); } // ProductService.cs -public class ProductService : IResourceService +public class ProductService : IResourceService { private readonly IProductDao _dao; @@ -121,7 +122,7 @@ IResourceService In order to take advantage of these interfaces you first need to register the service for each implemented interface. ```c# -public class ArticleService : ICreateService
, IDeleteService
+public class ArticleService : ICreateService, IDeleteService { // ... } @@ -130,8 +131,8 @@ public class Startup { public void ConfigureServices(IServiceCollection services) { - services.AddScoped, ArticleService>(); - services.AddScoped, ArticleService>(); + services.AddScoped, ArticleService>(); + services.AddScoped, ArticleService>(); } } ``` @@ -151,29 +152,16 @@ public class Startup } ``` -Then in the controller, you should inherit from the base controller and pass the services into the named, optional base parameters: +Then in the controller, you should inherit from the JSON:API controller and pass the services into the named, optional base parameters: ```c# -public class ArticlesController : BaseJsonApiController
+public class ArticlesController : JsonApiController { - public ArticlesController(IJsonApiOptions options, ILoggerFactory loggerFactory, - ICreateService create, IDeleteService delete) - : base(options, loggerFactory, create: create, delete: delete) + public ArticlesController(IJsonApiOptions options, IResourceGraph resourceGraph, + ILoggerFactory loggerFactory, ICreateService create, + IDeleteService delete) + : base(options, resourceGraph, loggerFactory, create: create, delete: delete) { } - - [HttpPost] - public override async Task PostAsync([FromBody] Article resource, - CancellationToken cancellationToken) - { - return await base.PostAsync(resource, cancellationToken); - } - - [HttpDelete("{id}")] - public override async TaskDeleteAsync(int id, - CancellationToken cancellationToken) - { - return await base.DeleteAsync(id, cancellationToken); - } } ``` diff --git a/docs/usage/meta.md b/docs/usage/meta.md index 6f052103e4..29c074b8b6 100644 --- a/docs/usage/meta.md +++ b/docs/usage/meta.md @@ -8,14 +8,16 @@ Global metadata can be added to the root of the response document by registering This is useful if you need access to other registered services to build the meta object. ```c# +#nullable enable + // In Startup.ConfigureServices services.AddSingleton(); public sealed class CopyrightResponseMeta : IResponseMeta { - public IReadOnlyDictionary GetMeta() + public IReadOnlyDictionary GetMeta() { - return new Dictionary + return new Dictionary { ["copyright"] = "Copyright (C) 2002 Umbrella Corporation.", ["authors"] = new[] { "Alice", "Red Queen" } @@ -39,24 +41,26 @@ public sealed class CopyrightResponseMeta : IResponseMeta ## Resource Meta -Resource-specific metadata can be added by implementing `IResourceDefinition.GetMeta` (or overriding it on `JsonApiResourceDefinition`): +Resource-specific metadata can be added by implementing `IResourceDefinition.GetMeta` (or overriding it on `JsonApiResourceDefinition`): ```c# -public class PersonDefinition : JsonApiResourceDefinition +#nullable enable + +public class PersonDefinition : JsonApiResourceDefinition { public PersonDefinition(IResourceGraph resourceGraph) : base(resourceGraph) { } - public override IReadOnlyDictionary GetMeta(Person person) + public override IReadOnlyDictionary? GetMeta(Person person) { if (person.IsEmployee) { - return new Dictionary + return new Dictionary { ["notice"] = "Check our intranet at http://www.example.com/employees/" + - person.StringId + " for personal details." + $"{person.StringId} for personal details." }; } diff --git a/docs/usage/options.md b/docs/usage/options.md index e2e099e31e..2f350b8bf9 100644 --- a/docs/usage/options.md +++ b/docs/usage/options.md @@ -39,6 +39,9 @@ options.MaximumPageNumber = new PageNumber(50); options.IncludeTotalResourceCount = true; ``` +To retrieve the total number of resources on secondary and relationship endpoints, the reverse of the relationship must to be available. For example, in `GET /customers/1/orders`, both the relationships `[HasMany] Customer.Orders` and `[HasOne] Order.Customer` must be defined. +If `IncludeTotalResourceCount` is set to `false` (or the inverse relationship is unavailable on a non-primary endpoint), best-effort paging links are returned instead. This means no `last` link and the `next` link only occurs when the current page is full. + ## Relative Links All links are absolute by default. However, you can configure relative links. @@ -100,20 +103,31 @@ options.SerializerOptions.DictionaryKeyPolicy = null; Because we copy resource properties into an intermediate object before serialization, JSON annotations such as `[JsonPropertyName]` and `[JsonIgnore]` on `[Attr]` properties are ignored. -## Enable ModelState Validation +## ModelState Validation + +[ASP.NET ModelState validation](https://docs.microsoft.com/en-us/aspnet/core/mvc/models/validation) can be used to validate incoming request bodies when creating and updating resources. Since v5.0, this is enabled by default. +When `ValidateModelState` is set to `false`, no model validation is performed. -If you would like to use ASP.NET Core ModelState validation into your controllers when creating / updating resources, set `ValidateModelState` to `true`. By default, no model validation is performed. +How nullability affects ModelState validation is described [here](~/usage/resources/nullability.md). ```c# options.ValidateModelState = true; ``` ```c# -public class Person : Identifiable +#nullable enable + +public class Person : Identifiable { [Attr] - [Required] [MinLength(3)] - public string FirstName { get; set; } + public string FirstName { get; set; } = null!; + + [Attr] + [Required] + public int? Age { get; set; } + + [HasOne] + public LoginAccount Account { get; set; } = null!; } ``` diff --git a/docs/usage/resource-graph.md b/docs/usage/resource-graph.md index 36e424d6e0..beb20d2d92 100644 --- a/docs/usage/resource-graph.md +++ b/docs/usage/resource-graph.md @@ -65,7 +65,7 @@ public void ConfigureServices(IServiceCollection services) { services.AddJsonApi(resources: builder => { - builder.Add(); + builder.Add(); }); } ``` @@ -78,14 +78,14 @@ The public resource name is exposed through the `type` member in the JSON:API pa ```c# services.AddJsonApi(resources: builder => { - builder.Add(publicName: "people"); + builder.Add(publicName: "people"); }); ``` 2. The model is decorated with a `ResourceAttribute` ```c# [Resource("myResources")] -public class MyModel : Identifiable +public class MyModel : Identifiable { } ``` @@ -93,7 +93,7 @@ public class MyModel : Identifiable 3. The configured naming convention (by default this is camel-case). ```c# // this will be registered as "myModels" -public class MyModel : Identifiable +public class MyModel : Identifiable { } ``` diff --git a/docs/usage/resources/attributes.md b/docs/usage/resources/attributes.md index 6a42bae7e0..669dba0892 100644 --- a/docs/usage/resources/attributes.md +++ b/docs/usage/resources/attributes.md @@ -3,10 +3,15 @@ If you want an attribute on your model to be publicly available, add the `AttrAttribute`. ```c# -public class Person : Identifiable +#nullable enable + +public class Person : Identifiable { [Attr] - public string FirstName { get; set; } + public string? FirstName { get; set; } + + [Attr] + public string LastName { get; set; } = null!; } ``` @@ -18,10 +23,11 @@ There are two ways the exposed attribute name is determined: 2. Individually using the attribute's constructor. ```c# -public class Person : Identifiable +#nullable enable +public class Person : Identifiable { [Attr(PublicName = "first-name")] - public string FirstName { get; set; } + public string? FirstName { get; set; } } ``` @@ -42,10 +48,12 @@ This can be overridden per attribute. Attributes can be marked to allow returning their value in responses. When not allowed and requested using `?fields[]=`, it results in an HTTP 400 response. ```c# -public class User : Identifiable +#nullable enable + +public class User : Identifiable { [Attr(Capabilities = ~AttrCapabilities.AllowView)] - public string Password { get; set; } + public string Password { get; set; } = null!; } ``` @@ -54,10 +62,12 @@ public class User : Identifiable Attributes can be marked as creatable, which will allow `POST` requests to assign a value to them. When sent but not allowed, an HTTP 422 response is returned. ```c# -public class Person : Identifiable +#nullable enable + +public class Person : Identifiable { [Attr(Capabilities = AttrCapabilities.AllowCreate)] - public string CreatorName { get; set; } + public string? CreatorName { get; set; } } ``` @@ -66,10 +76,12 @@ public class Person : Identifiable Attributes can be marked as changeable, which will allow `PATCH` requests to update them. When sent but not allowed, an HTTP 422 response is returned. ```c# -public class Person : Identifiable +#nullable enable + +public class Person : Identifiable { [Attr(Capabilities = AttrCapabilities.AllowChange)] - public string FirstName { get; set; } + public string? FirstName { get; set; }; } ``` @@ -78,10 +90,12 @@ public class Person : Identifiable Attributes can be marked to allow filtering and/or sorting. When not allowed, it results in an HTTP 400 response. ```c# -public class Person : Identifiable +#nullable enable + +public class Person : Identifiable { [Attr(Capabilities = AttrCapabilities.AllowSort | AttrCapabilities.AllowFilter)] - public string FirstName { get; set; } + public string? FirstName { get; set; } } ``` @@ -93,17 +107,19 @@ so you should use their APIs to specify serialization format. You can also use [global options](~/usage/options.md#customize-serializer-options) to control the `JsonSerializer` behavior. ```c# -public class Foo : Identifiable +#nullable enable + +public class Foo : Identifiable { [Attr] - public Bar Bar { get; set; } + public Bar? Bar { get; set; } } public class Bar { [JsonPropertyName("compound-member")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string CompoundMember { get; set; } + public string? CompoundMember { get; set; } } ``` @@ -113,12 +129,15 @@ The first member is the concrete type that you will directly interact with in yo and retrieval. ```c# -public class Foo : Identifiable +#nullable enable + +public class Foo : Identifiable { - [Attr, NotMapped] - public Bar Bar { get; set; } + [Attr] + [NotMapped] + public Bar? Bar { get; set; } - public string BarJson + public string? BarJson { get { diff --git a/docs/usage/resources/index.md b/docs/usage/resources/index.md index 29f510e543..552b3886fa 100644 --- a/docs/usage/resources/index.md +++ b/docs/usage/resources/index.md @@ -8,25 +8,12 @@ public class Person : Identifiable } ``` -You can use the non-generic `Identifiable` if your primary key is an integer. +**Note:** Earlier versions of JsonApiDotNetCore allowed a short-hand notation when `TId` is of type `int`. This was removed in v5. -```c# -public class Person : Identifiable -{ -} - -// is the same as: - -public class Person : Identifiable -{ -} -``` - -If you need to attach annotations or attributes on the `Id` property, -you can override the virtual property. +If you need to attach annotations or attributes on the `Id` property, you can override the virtual property. ```c# -public class Person : Identifiable +public class Person : Identifiable { [Key] [Column("PersonID")] diff --git a/docs/usage/resources/nullability.md b/docs/usage/resources/nullability.md new file mode 100644 index 0000000000..24b15572fc --- /dev/null +++ b/docs/usage/resources/nullability.md @@ -0,0 +1,89 @@ +# Nullability in resources + +Properties on a resource class can be declared as nullable or non-nullable. This affects both ASP.NET ModelState validation and the way Entity Framework Core generates database columns. + +ModelState validation is enabled by default since v5.0. In earlier versions, it can be enabled in [options](~/usage/options.md#modelstate-validation). + +# Value types + +When ModelState validation is enabled, non-nullable value types will **not** trigger a validation error when omitted in the request body. +To make JsonApiDotNetCore return an error when such a property is missing on resource creation, declare it as nullable and annotate it with `[Required]`. + +Example: + +```c# +public sealed class User : Identifiable +{ + [Attr] + [Required] + public bool? IsAdministrator { get; set; } +} +``` + +This makes Entity Framework Core generate non-nullable columns. And model errors are returned when nullable fields are omitted. + +# Reference types + +When the [nullable reference types](https://docs.microsoft.com/en-us/dotnet/csharp/nullable-references) (NRT) compiler feature is enabled, it affects both ASP.NET ModelState validation and Entity Framework Core. + +## NRT turned off + +When NRT is turned off, use `[Required]` on required attributes and relationships. This makes Entity Framework Core generate non-nullable columns. And model errors are returned when required fields are omitted. + +Example: + +```c# +#nullable disable + +public sealed class Label : Identifiable +{ + [Attr] + [Required] + public string Name { get; set; } + + [Attr] + public string RgbColor { get; set; } + + [HasOne] + [Required] + public Person Creator { get; set; } + + [HasOne] + public Label Parent { get; set; } + + [HasMany] + public ISet TodoItems { get; set; } +} +``` + +## NRT turned on + +When NRT is turned on, use nullability annotations (?) on attributes and relationships. This makes Entity Framework Core generate non-nullable columns. And model errors are returned when non-nullable fields are omitted. + +The [Entity Framework Core guide on NRT](https://docs.microsoft.com/en-us/ef/core/miscellaneous/nullable-reference-types) recommends to use constructor binding to initialize non-nullable properties, but JsonApiDotNetCore does not support that. For required navigation properties, it suggests to use a non-nullable property with a nullable backing field. JsonApiDotNetCore does not support that either. In both cases, just use the null-forgiving operator (!). + +When ModelState validation is turned on, to-many relationships must be assigned an empty collection. Otherwise an error is returned when they don't occur in the request body. + +Example: + +```c# +#nullable enable + +public sealed class Label : Identifiable +{ + [Attr] + public string Name { get; set; } = null!; + + [Attr] + public string? RgbColor { get; set; } + + [HasOne] + public Person Creator { get; set; } = null!; + + [HasOne] + public Label? Parent { get; set; } + + [HasMany] + public ISet TodoItems { get; set; } = new HashSet(); +} +``` diff --git a/docs/usage/resources/relationships.md b/docs/usage/resources/relationships.md index 2495419a6a..8776041e98 100644 --- a/docs/usage/resources/relationships.md +++ b/docs/usage/resources/relationships.md @@ -11,24 +11,106 @@ The left side of a relationship is where the relationship is declared, the right This exposes a to-one relationship. ```c# -public class TodoItem : Identifiable +#nullable enable + +public class TodoItem : Identifiable { [HasOne] - public Person Owner { get; set; } + public Person? Owner { get; set; } } ``` The left side of this relationship is of type `TodoItem` (public name: "todoItems") and the right side is of type `Person` (public name: "persons"). +### Required one-to-one relationships in Entity Framework Core + +By default, Entity Framework Core generates an identifying foreign key for a required 1-to-1 relationship. +This means no foreign key column is generated, instead the primary keys point to each other directly. + +The next example defines that each car requires an engine, while an engine is optionally linked to a car. + +```c# +#nullable enable + +public sealed class Car : Identifiable +{ + [HasOne] + public Engine Engine { get; set; } = null!; +} + +public sealed class Engine : Identifiable +{ + [HasOne] + public Car? Car { get; set; } +} + +public sealed class AppDbContext : DbContext +{ + protected override void OnModelCreating(ModelBuilder builder) + { + builder.Entity() + .HasOne(car => car.Engine) + .WithOne(engine => engine.Car) + .HasForeignKey() + .IsRequired(); + } +} +``` + +Which results in Entity Framework Core generating the next database objects: +```sql +CREATE TABLE "Engine" ( + "Id" integer GENERATED BY DEFAULT AS IDENTITY, + CONSTRAINT "PK_Engine" PRIMARY KEY ("Id") +); +CREATE TABLE "Cars" ( + "Id" integer NOT NULL, + CONSTRAINT "PK_Cars" PRIMARY KEY ("Id"), + CONSTRAINT "FK_Cars_Engine_Id" FOREIGN KEY ("Id") REFERENCES "Engine" ("Id") + ON DELETE CASCADE +); +``` + +That mechanism does not make sense for JSON:API, because patching a relationship would result in also +changing the identity of a resource. Naming the foreign key explicitly fixes the problem by forcing to +create a foreign key column. + +```c# +protected override void OnModelCreating(ModelBuilder builder) +{ + builder.Entity() + .HasOne(car => car.Engine) + .WithOne(engine => engine.Car) + .HasForeignKey("EngineId") // Explicit foreign key name added + .IsRequired(); +} +``` + +Which generates the correct database objects: +```sql +CREATE TABLE "Engine" ( + "Id" integer GENERATED BY DEFAULT AS IDENTITY, + CONSTRAINT "PK_Engine" PRIMARY KEY ("Id") +); +CREATE TABLE "Cars" ( + "Id" integer GENERATED BY DEFAULT AS IDENTITY, + "EngineId" integer NOT NULL, + CONSTRAINT "PK_Cars" PRIMARY KEY ("Id"), + CONSTRAINT "FK_Cars_Engine_EngineId" FOREIGN KEY ("EngineId") REFERENCES "Engine" ("Id") + ON DELETE CASCADE +); +CREATE UNIQUE INDEX "IX_Cars_EngineId" ON "Cars" ("EngineId"); +``` + ## HasMany This exposes a to-many relationship. ```c# -public class Person : Identifiable +public class Person : Identifiable { [HasMany] - public ICollection TodoItems { get; set; } + public ICollection TodoItems { get; set; } = new HashSet(); } ``` @@ -44,7 +126,9 @@ which would expose the relationship to the client the same way as any other `Has However, under the covers it would use the join type and Entity Framework Core's APIs to get and set the relationship. ```c# -public class Article : Identifiable +#nullable disable + +public class Article : Identifiable { // tells Entity Framework Core to ignore this property [NotMapped] @@ -68,10 +152,11 @@ There are two ways the exposed relationship name is determined: 2. Individually using the attribute's constructor. ```c# -public class TodoItem : Identifiable +#nullable enable +public class TodoItem : Identifiable { [HasOne(PublicName = "item-owner")] - public Person Owner { get; set; } + public Person Owner { get; set; } = null!; } ``` @@ -80,10 +165,12 @@ public class TodoItem : Identifiable Relationships can be marked to disallow including them using the `?include=` query string parameter. When not allowed, it results in an HTTP 400 response. ```c# -public class TodoItem : Identifiable +#nullable enable + +public class TodoItem : Identifiable { [HasOne(CanInclude: false)] - public Person Owner { get; set; } + public Person? Owner { get; set; } } ``` @@ -95,25 +182,24 @@ Your resource may expose a calculated property, whose value depends on a related So for the calculated property to be evaluated correctly, the related entity must always be retrieved. You can achieve that using `EagerLoad`, for example: ```c# -public class ShippingAddress : Identifiable +#nullable enable + +public class ShippingAddress : Identifiable { [Attr] - public string Street { get; set; } + public string Street { get; set; } = null!; [Attr] - public string CountryName - { - get { return Country.DisplayName; } - } + public string? CountryName => Country?.DisplayName; // not exposed as resource, but adds .Include("Country") to the query [EagerLoad] - public Country Country { get; set; } + public Country? Country { get; set; } } public class Country { - public string IsoCode { get; set; } - public string DisplayName { get; set; } + public string IsoCode { get; set; } = null!; + public string DisplayName { get; set; } = null!; } ``` diff --git a/docs/usage/routing.md b/docs/usage/routing.md index 0a10831d9b..c68914a04a 100644 --- a/docs/usage/routing.md +++ b/docs/usage/routing.md @@ -23,15 +23,15 @@ Which results in URLs like: https://yourdomain.com/api/v1/people The library will configure routes for all controllers in your project. By default, routes are camel-cased. This is based on the [recommendations](https://jsonapi.org/recommendations/) outlined in the JSON:API spec. ```c# -public class OrderLine : Identifiable +public class OrderLine : Identifiable { } -public class OrderLineController : JsonApiController +public class OrderLineController : JsonApiController { - public OrderLineController(IJsonApiOptions options, ILoggerFactory loggerFactory, - IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public OrderLineController(IJsonApiOptions options, IResourceGraph resourceGraph, + ILoggerFactory loggerFactory, IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } @@ -45,7 +45,7 @@ The exposed name of the resource ([which can be customized](~/usage/resource-gra ### Non-JSON:API controllers -If a controller does not inherit from `JsonApiController`, the [configured naming convention](~/usage/options.md#customize-serializer-options) is applied to the name of the controller. +If a controller does not inherit from `JsonApiController`, the [configured naming convention](~/usage/options.md#customize-serializer-options) is applied to the name of the controller. ```c# public class OrderLineController : ControllerBase @@ -63,11 +63,11 @@ It is possible to bypass the default routing convention for a controller. ```c# [Route("v1/custom/route/lines-in-order"), DisableRoutingConvention] -public class OrderLineController : JsonApiController +public class OrderLineController : JsonApiController { - public OrderLineController(IJsonApiOptions options, ILoggerFactory loggerFactory, - IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public OrderLineController(IJsonApiOptions options, IResourceGraph resourceGraph, + ILoggerFactory loggerFactory, IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/docs/usage/toc.md b/docs/usage/toc.md index 10fee6bc72..fabef61b68 100644 --- a/docs/usage/toc.md +++ b/docs/usage/toc.md @@ -1,6 +1,7 @@ # [Resources](resources/index.md) ## [Attributes](resources/attributes.md) ## [Relationships](resources/relationships.md) +## [Nullability](resources/nullability.md) # Reading data ## [Filtering](reading/filtering.md) diff --git a/docs/usage/writing/bulk-batch-operations.md b/docs/usage/writing/bulk-batch-operations.md index 549ff68025..21fe04b636 100644 --- a/docs/usage/writing/bulk-batch-operations.md +++ b/docs/usage/writing/bulk-batch-operations.md @@ -17,10 +17,10 @@ To enable operations, add a controller to your project that inherits from `JsonA ```c# public sealed class OperationsController : JsonApiOperationsController { - public OperationsController(IJsonApiOptions options, ILoggerFactory loggerFactory, - IOperationsProcessor processor, IJsonApiRequest request, - ITargetedFields targetedFields) - : base(options, loggerFactory, processor, request, targetedFields) + public OperationsController(IJsonApiOptions options, IResourceGraph resourceGraph, + ILoggerFactory loggerFactory, IOperationsProcessor processor, + IJsonApiRequest request, ITargetedFields targetedFields) + : base(options, resourceGraph, loggerFactory, processor, request, targetedFields) { } } diff --git a/inspectcode.ps1 b/inspectcode.ps1 index ab4b9c95dd..16dccfd373 100644 --- a/inspectcode.ps1 +++ b/inspectcode.ps1 @@ -8,15 +8,9 @@ if ($LASTEXITCODE -ne 0) { throw "Tool restore failed with exit code $LASTEXITCODE" } -dotnet build -c Release - -if ($LASTEXITCODE -ne 0) { - throw "Build failed with exit code $LASTEXITCODE" -} - $outputPath = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), 'jetbrains-inspectcode-results.xml') $resultPath = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), 'jetbrains-inspectcode-results.html') -dotnet jb inspectcode JsonApiDotNetCore.sln --output="$outputPath" --profile=WarningSeverities.DotSettings --properties:Configuration=Release --severity=WARNING --verbosity=WARN -dsl=GlobalAll -dsl=SolutionPersonal -dsl=ProjectPersonal +dotnet jb inspectcode JsonApiDotNetCore.sln --build --output="$outputPath" --profile=WarningSeverities.DotSettings --properties:Configuration=Release --severity=WARNING --verbosity=WARN -dsl=GlobalAll -dsl=GlobalPerProduct -dsl=SolutionPersonal -dsl=ProjectPersonal if ($LASTEXITCODE -ne 0) { throw "Code inspection failed with exit code $LASTEXITCODE" diff --git a/src/Examples/GettingStarted/Controllers/BooksController.cs b/src/Examples/GettingStarted/Controllers/BooksController.cs index 17e1c1417d..3f049429cd 100644 --- a/src/Examples/GettingStarted/Controllers/BooksController.cs +++ b/src/Examples/GettingStarted/Controllers/BooksController.cs @@ -6,10 +6,10 @@ namespace GettingStarted.Controllers { - public sealed class BooksController : JsonApiController + public sealed class BooksController : JsonApiController { - public BooksController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public BooksController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/src/Examples/GettingStarted/Controllers/PeopleController.cs b/src/Examples/GettingStarted/Controllers/PeopleController.cs index c7600be15a..e7a5537f14 100644 --- a/src/Examples/GettingStarted/Controllers/PeopleController.cs +++ b/src/Examples/GettingStarted/Controllers/PeopleController.cs @@ -6,10 +6,11 @@ namespace GettingStarted.Controllers { - public sealed class PeopleController : JsonApiController + public sealed class PeopleController : JsonApiController { - public PeopleController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public PeopleController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/src/Examples/GettingStarted/Data/SampleDbContext.cs b/src/Examples/GettingStarted/Data/SampleDbContext.cs index b54011ff14..c5460db810 100644 --- a/src/Examples/GettingStarted/Data/SampleDbContext.cs +++ b/src/Examples/GettingStarted/Data/SampleDbContext.cs @@ -7,16 +7,11 @@ namespace GettingStarted.Data [UsedImplicitly(ImplicitUseTargetFlags.Members)] public class SampleDbContext : DbContext { - public DbSet Books { get; set; } + public DbSet Books => Set(); public SampleDbContext(DbContextOptions options) : base(options) { } - - protected override void OnModelCreating(ModelBuilder builder) - { - builder.Entity(); - } } } diff --git a/src/Examples/GettingStarted/Models/Book.cs b/src/Examples/GettingStarted/Models/Book.cs index 9f15d3e3c9..0957461cd7 100644 --- a/src/Examples/GettingStarted/Models/Book.cs +++ b/src/Examples/GettingStarted/Models/Book.cs @@ -5,15 +5,15 @@ namespace GettingStarted.Models { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Book : Identifiable + public sealed class Book : Identifiable { [Attr] - public string Title { get; set; } + public string Title { get; set; } = null!; [Attr] public int PublishYear { get; set; } [HasOne] - public Person Author { get; set; } + public Person Author { get; set; } = null!; } } diff --git a/src/Examples/GettingStarted/Models/Person.cs b/src/Examples/GettingStarted/Models/Person.cs index 495a4fe27b..f9b8e55fff 100644 --- a/src/Examples/GettingStarted/Models/Person.cs +++ b/src/Examples/GettingStarted/Models/Person.cs @@ -6,12 +6,12 @@ namespace GettingStarted.Models { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Person : Identifiable + public sealed class Person : Identifiable { [Attr] - public string Name { get; set; } + public string Name { get; set; } = null!; [HasMany] - public ICollection Books { get; set; } + public ICollection Books { get; set; } = new List(); } } 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/Controllers/OperationsController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/OperationsController.cs index 4851336a9a..3d29f72af1 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/OperationsController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/OperationsController.cs @@ -9,9 +9,9 @@ namespace JsonApiDotNetCoreExample.Controllers { public sealed class OperationsController : JsonApiOperationsController { - public OperationsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IOperationsProcessor processor, IJsonApiRequest request, - ITargetedFields targetedFields) - : base(options, loggerFactory, processor, request, targetedFields) + public OperationsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IOperationsProcessor processor, + IJsonApiRequest request, ITargetedFields targetedFields) + : base(options, resourceGraph, loggerFactory, processor, request, targetedFields) { } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/PeopleController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/PeopleController.cs index 430790bc6e..0ebafd1767 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/PeopleController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/PeopleController.cs @@ -6,10 +6,11 @@ namespace JsonApiDotNetCoreExample.Controllers { - public sealed class PeopleController : JsonApiController + public sealed class PeopleController : JsonApiController { - public PeopleController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public PeopleController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/TagsController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/TagsController.cs index a9536f009b..b08af4e399 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/TagsController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/TagsController.cs @@ -6,10 +6,10 @@ namespace JsonApiDotNetCoreExample.Controllers { - public sealed class TagsController : JsonApiController + public sealed class TagsController : JsonApiController { - public TagsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public TagsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsController.cs index a28a7033d6..c862853302 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsController.cs @@ -6,10 +6,11 @@ namespace JsonApiDotNetCoreExample.Controllers { - public sealed class TodoItemsController : JsonApiController + public sealed class TodoItemsController : JsonApiController { - public TodoItemsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public TodoItemsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs index cc59628fc6..f9f6752990 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs @@ -9,7 +9,7 @@ namespace JsonApiDotNetCoreExample.Data [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class AppDbContext : DbContext { - public DbSet TodoItems { get; set; } + public DbSet TodoItems => Set(); public AppDbContext(DbContextOptions options) : base(options) @@ -21,16 +21,12 @@ protected override void OnModelCreating(ModelBuilder builder) // When deleting a person, un-assign him/her from existing todo items. builder.Entity() .HasMany(person => person.AssignedTodoItems) - .WithOne(todoItem => todoItem.Assignee) - .IsRequired(false) - .OnDelete(DeleteBehavior.SetNull); + .WithOne(todoItem => todoItem.Assignee!); // When deleting a person, the todo items he/she owns are deleted too. builder.Entity() .HasOne(todoItem => todoItem.Owner) - .WithMany() - .IsRequired() - .OnDelete(DeleteBehavior.Cascade); + .WithMany(); } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Definitions/TodoItemDefinition.cs b/src/Examples/JsonApiDotNetCoreExample/Definitions/TodoItemDefinition.cs index 927bed7f57..306315d05f 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Definitions/TodoItemDefinition.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Definitions/TodoItemDefinition.cs @@ -12,7 +12,7 @@ namespace JsonApiDotNetCoreExample.Definitions { [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class TodoItemDefinition : JsonApiResourceDefinition + public sealed class TodoItemDefinition : JsonApiResourceDefinition { private readonly ISystemClock _systemClock; @@ -22,7 +22,7 @@ public TodoItemDefinition(IResourceGraph resourceGraph, ISystemClock systemClock _systemClock = systemClock; } - public override SortExpression OnApplySort(SortExpression existingSort) + public override SortExpression OnApplySort(SortExpression? existingSort) { return existingSort ?? GetDefaultSortOrder(); } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs index 0f30ab3bf6..44be2df864 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs @@ -6,15 +6,15 @@ namespace JsonApiDotNetCoreExample.Models { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Person : Identifiable + public sealed class Person : Identifiable { [Attr] - public string FirstName { get; set; } + public string? FirstName { get; set; } [Attr] - public string LastName { get; set; } + public string LastName { get; set; } = null!; [HasMany] - public ISet AssignedTodoItems { get; set; } + public ISet AssignedTodoItems { get; set; } = new HashSet(); } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs index e0f5d0894c..713eafe605 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs @@ -7,14 +7,13 @@ namespace JsonApiDotNetCoreExample.Models { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Tag : Identifiable + public sealed class Tag : Identifiable { - [Required] - [MinLength(1)] [Attr] - public string Name { get; set; } + [MinLength(1)] + public string Name { get; set; } = null!; [HasMany] - public ISet TodoItems { get; set; } + public ISet TodoItems { get; set; } = new HashSet(); } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs index dbc0c59a04..5c4d5c6ea1 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; @@ -7,13 +8,14 @@ namespace JsonApiDotNetCoreExample.Models { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class TodoItem : Identifiable + public sealed class TodoItem : Identifiable { [Attr] - public string Description { get; set; } + public string Description { get; set; } = null!; [Attr] - public TodoItemPriority Priority { get; set; } + [Required] + public TodoItemPriority? Priority { get; set; } [Attr(Capabilities = AttrCapabilities.AllowFilter | AttrCapabilities.AllowSort | AttrCapabilities.AllowView)] public DateTimeOffset CreatedAt { get; set; } @@ -22,12 +24,12 @@ public sealed class TodoItem : Identifiable public DateTimeOffset? LastModifiedAt { get; set; } [HasOne] - public Person Owner { get; set; } + public Person Owner { get; set; } = null!; [HasOne] - public Person Assignee { get; set; } + public Person? Assignee { get; set; } [HasMany] - public ISet Tags { get; set; } + public ISet Tags { get; set; } = new HashSet(); } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Startup.cs b/src/Examples/JsonApiDotNetCoreExample/Startup.cs index e90f81a0f5..67c6a223b5 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Startup.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Startup.cs @@ -52,12 +52,12 @@ public void ConfigureServices(IServiceCollection services) { options.Namespace = "api/v1"; options.UseRelativeLinks = true; - options.ValidateModelState = true; options.IncludeTotalResourceCount = true; options.SerializerOptions.WriteIndented = true; options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); #if DEBUG options.IncludeExceptionStackTraceInErrors = true; + options.IncludeRequestBodyInErrors = true; #endif }, discovery => discovery.AddCurrentAssembly(), mvcBuilder: mvcBuilder); } @@ -67,17 +67,13 @@ public void ConfigureServices(IServiceCollection services) } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. - public void Configure(IApplicationBuilder app, IWebHostEnvironment environment, ILoggerFactory loggerFactory) + public void Configure(IApplicationBuilder app, IWebHostEnvironment environment, ILoggerFactory loggerFactory, AppDbContext dbContext) { ILogger logger = loggerFactory.CreateLogger(); using (CodeTimingSessionManager.Current.Measure("Initialize other (startup)")) { - using (IServiceScope scope = app.ApplicationServices.CreateScope()) - { - var appDbContext = scope.ServiceProvider.GetRequiredService(); - appDbContext.Database.EnsureCreated(); - } + dbContext.Database.EnsureCreated(); app.UseRouting(); diff --git a/src/Examples/MultiDbContextExample/Controllers/ResourceAsController.cs b/src/Examples/MultiDbContextExample/Controllers/ResourceAsController.cs index 4e976acdc0..5fd3c662a4 100644 --- a/src/Examples/MultiDbContextExample/Controllers/ResourceAsController.cs +++ b/src/Examples/MultiDbContextExample/Controllers/ResourceAsController.cs @@ -6,10 +6,11 @@ namespace MultiDbContextExample.Controllers { - public sealed class ResourceAsController : JsonApiController + public sealed class ResourceAsController : JsonApiController { - public ResourceAsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public ResourceAsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/src/Examples/MultiDbContextExample/Controllers/ResourceBsController.cs b/src/Examples/MultiDbContextExample/Controllers/ResourceBsController.cs index bd61b7aa2e..33b89aa9ec 100644 --- a/src/Examples/MultiDbContextExample/Controllers/ResourceBsController.cs +++ b/src/Examples/MultiDbContextExample/Controllers/ResourceBsController.cs @@ -6,10 +6,11 @@ namespace MultiDbContextExample.Controllers { - public sealed class ResourceBsController : JsonApiController + public sealed class ResourceBsController : JsonApiController { - public ResourceBsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public ResourceBsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/src/Examples/MultiDbContextExample/Data/DbContextA.cs b/src/Examples/MultiDbContextExample/Data/DbContextA.cs index cb6000e051..23b2f4a37c 100644 --- a/src/Examples/MultiDbContextExample/Data/DbContextA.cs +++ b/src/Examples/MultiDbContextExample/Data/DbContextA.cs @@ -7,7 +7,7 @@ namespace MultiDbContextExample.Data [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class DbContextA : DbContext { - public DbSet ResourceAs { get; set; } + public DbSet ResourceAs => Set(); public DbContextA(DbContextOptions options) : base(options) diff --git a/src/Examples/MultiDbContextExample/Data/DbContextB.cs b/src/Examples/MultiDbContextExample/Data/DbContextB.cs index b3e4e6e47f..bf9c575fa9 100644 --- a/src/Examples/MultiDbContextExample/Data/DbContextB.cs +++ b/src/Examples/MultiDbContextExample/Data/DbContextB.cs @@ -7,7 +7,7 @@ namespace MultiDbContextExample.Data [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class DbContextB : DbContext { - public DbSet ResourceBs { get; set; } + public DbSet ResourceBs => Set(); public DbContextB(DbContextOptions options) : base(options) diff --git a/src/Examples/MultiDbContextExample/Models/ResourceA.cs b/src/Examples/MultiDbContextExample/Models/ResourceA.cs index 85cbf2b89a..1c754be6ed 100644 --- a/src/Examples/MultiDbContextExample/Models/ResourceA.cs +++ b/src/Examples/MultiDbContextExample/Models/ResourceA.cs @@ -5,9 +5,9 @@ namespace MultiDbContextExample.Models { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class ResourceA : Identifiable + public sealed class ResourceA : Identifiable { [Attr] - public string NameA { get; set; } + public string? NameA { get; set; } } } diff --git a/src/Examples/MultiDbContextExample/Models/ResourceB.cs b/src/Examples/MultiDbContextExample/Models/ResourceB.cs index dd1739ee49..70941a1f4d 100644 --- a/src/Examples/MultiDbContextExample/Models/ResourceB.cs +++ b/src/Examples/MultiDbContextExample/Models/ResourceB.cs @@ -5,9 +5,9 @@ namespace MultiDbContextExample.Models { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class ResourceB : Identifiable + public sealed class ResourceB : Identifiable { [Attr] - public string NameB { get; set; } + public string? NameB { get; set; } } } diff --git a/src/Examples/MultiDbContextExample/Properties/launchSettings.json b/src/Examples/MultiDbContextExample/Properties/launchSettings.json index 6d7e1b5cbd..e328cc07be 100644 --- a/src/Examples/MultiDbContextExample/Properties/launchSettings.json +++ b/src/Examples/MultiDbContextExample/Properties/launchSettings.json @@ -1,4 +1,4 @@ -{ +{ "$schema": "http://json.schemastore.org/launchsettings.json", "iisSettings": { "windowsAuthentication": false, @@ -12,7 +12,7 @@ "IIS Express": { "commandName": "IISExpress", "launchBrowser": false, - "launchUrl": "/resourceBs", + "launchUrl": "resourceBs", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } @@ -20,7 +20,7 @@ "Kestrel": { "commandName": "Project", "launchBrowser": false, - "launchUrl": "/resourceBs", + "launchUrl": "resourceBs", "applicationUrl": "https://localhost:44350;http://localhost:14150", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" diff --git a/src/Examples/MultiDbContextExample/Repositories/DbContextARepository.cs b/src/Examples/MultiDbContextExample/Repositories/DbContextARepository.cs index 5b07948005..820c78f241 100644 --- a/src/Examples/MultiDbContextExample/Repositories/DbContextARepository.cs +++ b/src/Examples/MultiDbContextExample/Repositories/DbContextARepository.cs @@ -10,13 +10,13 @@ namespace MultiDbContextExample.Repositories { [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class DbContextARepository : EntityFrameworkCoreRepository + 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..98156a7295 100644 --- a/src/Examples/MultiDbContextExample/Repositories/DbContextBRepository.cs +++ b/src/Examples/MultiDbContextExample/Repositories/DbContextBRepository.cs @@ -10,13 +10,13 @@ namespace MultiDbContextExample.Repositories { [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class DbContextBRepository : EntityFrameworkCoreRepository + 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/Controllers/WorkItemsController.cs b/src/Examples/NoEntityFrameworkExample/Controllers/WorkItemsController.cs index 63ab620b93..055fa60ed8 100644 --- a/src/Examples/NoEntityFrameworkExample/Controllers/WorkItemsController.cs +++ b/src/Examples/NoEntityFrameworkExample/Controllers/WorkItemsController.cs @@ -6,10 +6,11 @@ namespace NoEntityFrameworkExample.Controllers { - public sealed class WorkItemsController : JsonApiController + public sealed class WorkItemsController : JsonApiController { - public WorkItemsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public WorkItemsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/src/Examples/NoEntityFrameworkExample/Data/AppDbContext.cs b/src/Examples/NoEntityFrameworkExample/Data/AppDbContext.cs index 336951eec3..bfe2115f7d 100644 --- a/src/Examples/NoEntityFrameworkExample/Data/AppDbContext.cs +++ b/src/Examples/NoEntityFrameworkExample/Data/AppDbContext.cs @@ -7,7 +7,7 @@ namespace NoEntityFrameworkExample.Data [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class AppDbContext : DbContext { - public DbSet WorkItems { get; set; } + public DbSet WorkItems => Set(); public AppDbContext(DbContextOptions options) : base(options) diff --git a/src/Examples/NoEntityFrameworkExample/Models/WorkItem.cs b/src/Examples/NoEntityFrameworkExample/Models/WorkItem.cs index 20d381a2ba..083894fd04 100644 --- a/src/Examples/NoEntityFrameworkExample/Models/WorkItem.cs +++ b/src/Examples/NoEntityFrameworkExample/Models/WorkItem.cs @@ -6,13 +6,13 @@ namespace NoEntityFrameworkExample.Models { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class WorkItem : Identifiable + public sealed class WorkItem : Identifiable { [Attr] public bool IsBlocked { get; set; } [Attr] - public string Title { get; set; } + public string Title { get; set; } = null!; [Attr] public long DurationInHours { get; set; } diff --git a/src/Examples/NoEntityFrameworkExample/Properties/launchSettings.json b/src/Examples/NoEntityFrameworkExample/Properties/launchSettings.json index 32bc82dfc2..d28c050bd8 100644 --- a/src/Examples/NoEntityFrameworkExample/Properties/launchSettings.json +++ b/src/Examples/NoEntityFrameworkExample/Properties/launchSettings.json @@ -12,7 +12,7 @@ "IIS Express": { "commandName": "IISExpress", "launchBrowser": false, - "launchUrl": "/api/reports", + "launchUrl": "api/v1/workItems", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } @@ -20,7 +20,7 @@ "Kestrel": { "commandName": "Project", "launchBrowser": false, - "launchUrl": "/api/reports", + "launchUrl": "api/v1/workItems", "applicationUrl": "https://localhost:44349;http://localhost:14149", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" diff --git a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs b/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs index df227d12d9..5fbd062b11 100644 --- a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs +++ b/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs @@ -15,7 +15,7 @@ namespace NoEntityFrameworkExample.Services { [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class WorkItemService : IResourceService + public sealed class WorkItemService : IResourceService { private readonly string _connectionString; @@ -46,17 +46,17 @@ public async Task GetAsync(int id, CancellationToken cancellationToken return workItems.Single(); } - public Task GetSecondaryAsync(int id, string relationshipName, CancellationToken cancellationToken) + public Task GetSecondaryAsync(int id, string relationshipName, CancellationToken cancellationToken) { throw new NotImplementedException(); } - public Task GetRelationshipAsync(int id, string relationshipName, CancellationToken cancellationToken) + public Task GetRelationshipAsync(int id, string relationshipName, CancellationToken cancellationToken) { throw new NotImplementedException(); } - public async Task CreateAsync(WorkItem resource, CancellationToken cancellationToken) + public async Task CreateAsync(WorkItem resource, CancellationToken cancellationToken) { const string commandText = @"insert into ""WorkItems"" (""Title"", ""IsBlocked"", ""DurationInHours"", ""ProjectId"") values " + @"(@title, @isBlocked, @durationInHours, @projectId) returning ""Id"", ""Title"", ""IsBlocked"", ""DurationInHours"", ""ProjectId"""; @@ -78,12 +78,12 @@ public Task AddToToManyRelationshipAsync(int leftId, string relationshipName, IS throw new NotImplementedException(); } - public Task UpdateAsync(int id, WorkItem resource, CancellationToken cancellationToken) + public Task UpdateAsync(int id, WorkItem resource, CancellationToken cancellationToken) { throw new NotImplementedException(); } - public Task SetRelationshipAsync(int leftId, string relationshipName, object rightValue, CancellationToken cancellationToken) + public Task SetRelationshipAsync(int leftId, string relationshipName, object? rightValue, CancellationToken cancellationToken) { throw new NotImplementedException(); } diff --git a/src/Examples/NoEntityFrameworkExample/Startup.cs b/src/Examples/NoEntityFrameworkExample/Startup.cs index c51985f5f2..dc86192832 100644 --- a/src/Examples/NoEntityFrameworkExample/Startup.cs +++ b/src/Examples/NoEntityFrameworkExample/Startup.cs @@ -1,7 +1,6 @@ using System; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Services; using Microsoft.AspNetCore.Builder; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; @@ -25,18 +24,18 @@ public Startup(IConfiguration configuration) // This method gets called by the runtime. Use this method to add services to the container. public void ConfigureServices(IServiceCollection services) { - services.AddJsonApi(options => options.Namespace = "api/v1", resources: builder => builder.Add("workItems")); + services.AddJsonApi(options => options.Namespace = "api/v1", resources: builder => builder.Add("workItems")); - services.AddScoped, WorkItemService>(); + services.AddResourceService(); services.AddDbContext(options => options.UseNpgsql(_connectionString)); } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. [UsedImplicitly] - public void Configure(IApplicationBuilder app, AppDbContext context) + public void Configure(IApplicationBuilder app, AppDbContext dbContext) { - context.Database.EnsureCreated(); + dbContext.Database.EnsureCreated(); app.UseRouting(); app.UseJsonApi(); diff --git a/src/Examples/ReportsExample/Controllers/ReportsController.cs b/src/Examples/ReportsExample/Controllers/ReportsController.cs index bafd4cebae..8c177e7db0 100644 --- a/src/Examples/ReportsExample/Controllers/ReportsController.cs +++ b/src/Examples/ReportsExample/Controllers/ReportsController.cs @@ -10,10 +10,10 @@ namespace ReportsExample.Controllers { [Route("api/[controller]")] - public class ReportsController : BaseJsonApiController + public class ReportsController : BaseJsonApiController { - public ReportsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IGetAllService getAllService) - : base(options, loggerFactory, getAllService) + public ReportsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IGetAllService getAllService) + : base(options, resourceGraph, loggerFactory, getAllService) { } diff --git a/src/Examples/ReportsExample/Models/Report.cs b/src/Examples/ReportsExample/Models/Report.cs index 6635687a1d..65f6972d16 100644 --- a/src/Examples/ReportsExample/Models/Report.cs +++ b/src/Examples/ReportsExample/Models/Report.cs @@ -5,12 +5,12 @@ namespace ReportsExample.Models { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Report : Identifiable + public sealed class Report : Identifiable { [Attr] - public string Title { get; set; } + public string Title { get; set; } = null!; [Attr] - public ReportStatistics Statistics { get; set; } + public ReportStatistics Statistics { get; set; } = null!; } } diff --git a/src/Examples/ReportsExample/Models/ReportStatistics.cs b/src/Examples/ReportsExample/Models/ReportStatistics.cs index 53c2c2d2ee..7c520eded8 100644 --- a/src/Examples/ReportsExample/Models/ReportStatistics.cs +++ b/src/Examples/ReportsExample/Models/ReportStatistics.cs @@ -5,7 +5,7 @@ namespace ReportsExample.Models [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class ReportStatistics { - public string ProgressIndication { get; set; } + public string ProgressIndication { get; set; } = null!; public int HoursSpent { get; set; } } } diff --git a/src/Examples/ReportsExample/Properties/launchSettings.json b/src/Examples/ReportsExample/Properties/launchSettings.json index ee2eba1f80..7add074ef2 100644 --- a/src/Examples/ReportsExample/Properties/launchSettings.json +++ b/src/Examples/ReportsExample/Properties/launchSettings.json @@ -11,16 +11,16 @@ "profiles": { "IIS Express": { "commandName": "IISExpress", - "launchBrowser": false, - "launchUrl": "/api/reports", + "launchBrowser": true, + "launchUrl": "api/reports", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } }, "Kestrel": { "commandName": "Project", - "launchBrowser": false, - "launchUrl": "/api/reports", + "launchBrowser": true, + "launchUrl": "api/reports", "applicationUrl": "https://localhost:44348;http://localhost:14148", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" diff --git a/src/Examples/ReportsExample/Services/ReportService.cs b/src/Examples/ReportsExample/Services/ReportService.cs index b0655d38e1..19f18bfed3 100644 --- a/src/Examples/ReportsExample/Services/ReportService.cs +++ b/src/Examples/ReportsExample/Services/ReportService.cs @@ -9,7 +9,7 @@ namespace ReportsExample.Services { [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public class ReportService : IGetAllService + public class ReportService : IGetAllService { private readonly ILogger _logger; @@ -33,6 +33,7 @@ private IReadOnlyCollection GetReports() { new() { + Id = 1, Title = "Status Report", Statistics = new ReportStatistics { diff --git a/src/JsonApiDotNetCore.OpenApi.Client/ArgumentGuard.cs b/src/JsonApiDotNetCore.OpenApi.Client/ArgumentGuard.cs index e0531017af..cef5458920 100644 --- a/src/JsonApiDotNetCore.OpenApi.Client/ArgumentGuard.cs +++ b/src/JsonApiDotNetCore.OpenApi.Client/ArgumentGuard.cs @@ -1,5 +1,6 @@ using System; using JetBrains.Annotations; +using SysNotNull = System.Diagnostics.CodeAnalysis.NotNullAttribute; #pragma warning disable AV1008 // Class should not be static @@ -8,8 +9,7 @@ namespace JsonApiDotNetCore.OpenApi.Client internal static class ArgumentGuard { [AssertionMethod] - [ContractAnnotation("value: null => halt")] - public static void NotNull([CanBeNull] [NoEnumeration] T value, [NotNull] [InvokerParameterName] string name) + public static void NotNull([NoEnumeration] [SysNotNull] T? value, [InvokerParameterName] string name) where T : class { if (value is null) diff --git a/src/JsonApiDotNetCore.OpenApi.Client/IJsonApiClient.cs b/src/JsonApiDotNetCore.OpenApi.Client/IJsonApiClient.cs index 1a78e31242..672e7d125e 100644 --- a/src/JsonApiDotNetCore.OpenApi.Client/IJsonApiClient.cs +++ b/src/JsonApiDotNetCore.OpenApi.Client/IJsonApiClient.cs @@ -32,7 +32,7 @@ public interface IJsonApiClient /// using statement, so the registrations are cleaned up after executing the request. /// IDisposable RegisterAttributesForRequestDocument(TRequestDocument requestDocument, - params Expression>[] alwaysIncludedAttributeSelectors) + params Expression>[] alwaysIncludedAttributeSelectors) where TRequestDocument : class; } } diff --git a/src/JsonApiDotNetCore.OpenApi.Client/JsonApiClient.cs b/src/JsonApiDotNetCore.OpenApi.Client/JsonApiClient.cs index 79be3be736..7f9904af8c 100644 --- a/src/JsonApiDotNetCore.OpenApi.Client/JsonApiClient.cs +++ b/src/JsonApiDotNetCore.OpenApi.Client/JsonApiClient.cs @@ -27,14 +27,14 @@ protected void SetSerializerSettingsForJsonApi(JsonSerializerSettings settings) /// public IDisposable RegisterAttributesForRequestDocument(TRequestDocument requestDocument, - params Expression>[] alwaysIncludedAttributeSelectors) + params Expression>[] alwaysIncludedAttributeSelectors) where TRequestDocument : class { ArgumentGuard.NotNull(requestDocument, nameof(requestDocument)); var attributeNames = new HashSet(); - foreach (Expression> selector in alwaysIncludedAttributeSelectors) + foreach (Expression> selector in alwaysIncludedAttributeSelectors) { if (RemoveConvert(selector.Body) is MemberExpression selectorBody) { diff --git a/src/JsonApiDotNetCore.OpenApi/ActionDescriptorExtensions.cs b/src/JsonApiDotNetCore.OpenApi/ActionDescriptorExtensions.cs index e0d4ae4f35..43e9592119 100644 --- a/src/JsonApiDotNetCore.OpenApi/ActionDescriptorExtensions.cs +++ b/src/JsonApiDotNetCore.OpenApi/ActionDescriptorExtensions.cs @@ -16,23 +16,24 @@ public static MethodInfo GetActionMethod(this ActionDescriptor descriptor) return ((ControllerActionDescriptor)descriptor).MethodInfo; } - public static TFilterMetaData GetFilterMetadata(this ActionDescriptor descriptor) + public static TFilterMetaData? GetFilterMetadata(this ActionDescriptor descriptor) where TFilterMetaData : IFilterMetadata { ArgumentGuard.NotNull(descriptor, nameof(descriptor)); - IFilterMetadata filterMetadata = descriptor.FilterDescriptors.Select(filterDescriptor => filterDescriptor.Filter) + IFilterMetadata? filterMetadata = descriptor.FilterDescriptors.Select(filterDescriptor => filterDescriptor.Filter) .FirstOrDefault(filter => filter is TFilterMetaData); - return (TFilterMetaData)filterMetadata; + return (TFilterMetaData?)filterMetadata; } - public static ControllerParameterDescriptor GetBodyParameterDescriptor(this ActionDescriptor descriptor) + public static ControllerParameterDescriptor? GetBodyParameterDescriptor(this ActionDescriptor descriptor) { ArgumentGuard.NotNull(descriptor, nameof(descriptor)); - return (ControllerParameterDescriptor)descriptor.Parameters.FirstOrDefault(parameterDescriptor => - // ReSharper disable once ConstantConditionalAccessQualifier Motivation: see https://github.com/dotnet/aspnetcore/issues/32538 + return (ControllerParameterDescriptor?)descriptor.Parameters.FirstOrDefault(parameterDescriptor => + // ReSharper disable once ConstantConditionalAccessQualifier + // Justification: see https://github.com/dotnet/aspnetcore/issues/32538 parameterDescriptor.BindingInfo?.BindingSource == BindingSource.Body); } } diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiActionDescriptorCollectionProvider.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiActionDescriptorCollectionProvider.cs index 3821ad5264..16efa2485a 100644 --- a/src/JsonApiDotNetCore.OpenApi/JsonApiActionDescriptorCollectionProvider.cs +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiActionDescriptorCollectionProvider.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; -using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.OpenApi.JsonApiMetadata; using Microsoft.AspNetCore.Mvc; @@ -26,15 +25,14 @@ internal sealed class JsonApiActionDescriptorCollectionProvider : IActionDescrip public ActionDescriptorCollection ActionDescriptors => GetActionDescriptors(); - public JsonApiActionDescriptorCollectionProvider(IResourceGraph resourceGraph, IControllerResourceMapping controllerResourceMapping, + public JsonApiActionDescriptorCollectionProvider(IControllerResourceMapping controllerResourceMapping, IActionDescriptorCollectionProvider defaultProvider) { - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); ArgumentGuard.NotNull(controllerResourceMapping, nameof(controllerResourceMapping)); ArgumentGuard.NotNull(defaultProvider, nameof(defaultProvider)); _defaultProvider = defaultProvider; - _jsonApiEndpointMetadataProvider = new JsonApiEndpointMetadataProvider(resourceGraph, controllerResourceMapping); + _jsonApiEndpointMetadataProvider = new JsonApiEndpointMetadataProvider(controllerResourceMapping); } private ActionDescriptorCollection GetActionDescriptors() @@ -67,29 +65,28 @@ private static bool IsVisibleJsonApiEndpoint(ActionDescriptor descriptor) return descriptor is ControllerActionDescriptor controllerAction && controllerAction.Properties.ContainsKey(typeof(ApiDescriptionActionData)); } - private static IList AddJsonApiMetadataToAction(ActionDescriptor endpoint, IJsonApiEndpointMetadata jsonApiEndpointMetadata) + private static IEnumerable AddJsonApiMetadataToAction(ActionDescriptor endpoint, IJsonApiEndpointMetadata? jsonApiEndpointMetadata) { switch (jsonApiEndpointMetadata) { case PrimaryResponseMetadata primaryMetadata: { - UpdateProducesResponseTypeAttribute(endpoint, primaryMetadata.Type); + UpdateProducesResponseTypeAttribute(endpoint, primaryMetadata.DocumentType); return Array.Empty(); } case PrimaryRequestMetadata primaryMetadata: { - UpdateBodyParameterDescriptor(endpoint, primaryMetadata.Type); + UpdateBodyParameterDescriptor(endpoint, primaryMetadata.DocumentType); return Array.Empty(); } - case ExpansibleEndpointMetadata expansibleMetadata - when expansibleMetadata is RelationshipResponseMetadata || expansibleMetadata is SecondaryResponseMetadata: + case NonPrimaryEndpointMetadata nonPrimaryEndpointMetadata and (RelationshipResponseMetadata or SecondaryResponseMetadata): { - return Expand(endpoint, expansibleMetadata, - (expandedEndpoint, relationshipType, _) => UpdateProducesResponseTypeAttribute(expandedEndpoint, relationshipType)); + return Expand(endpoint, nonPrimaryEndpointMetadata, + (expandedEndpoint, documentType, _) => UpdateProducesResponseTypeAttribute(expandedEndpoint, documentType)); } - case ExpansibleEndpointMetadata expansibleMetadata when expansibleMetadata is RelationshipRequestMetadata: + case NonPrimaryEndpointMetadata nonPrimaryEndpointMetadata and RelationshipRequestMetadata: { - return Expand(endpoint, expansibleMetadata, UpdateBodyParameterDescriptor); + return Expand(endpoint, nonPrimaryEndpointMetadata, UpdateBodyParameterDescriptor); } default: { @@ -98,34 +95,48 @@ private static IList AddJsonApiMetadataToAction(ActionDescript } } - private static void UpdateProducesResponseTypeAttribute(ActionDescriptor endpoint, Type responseTypeToSet) + private static void UpdateProducesResponseTypeAttribute(ActionDescriptor endpoint, Type responseDocumentType) { - if (ProducesJsonApiResponseBody(endpoint)) + if (ProducesJsonApiResponseDocument(endpoint)) { var producesResponse = endpoint.GetFilterMetadata(); - producesResponse.Type = responseTypeToSet; + + if (producesResponse != null) + { + producesResponse.Type = responseDocumentType; + return; + } } + + throw new UnreachableCodeException(); } - private static bool ProducesJsonApiResponseBody(ActionDescriptor endpoint) + private static bool ProducesJsonApiResponseDocument(ActionDescriptor endpoint) { var produces = endpoint.GetFilterMetadata(); return produces != null && produces.ContentTypes.Any(contentType => contentType == HeaderConstants.MediaType); } - private static IList Expand(ActionDescriptor genericEndpoint, ExpansibleEndpointMetadata metadata, + private static IEnumerable Expand(ActionDescriptor genericEndpoint, NonPrimaryEndpointMetadata metadata, Action expansionCallback) { var expansion = new List(); - foreach ((string relationshipName, Type relationshipType) in metadata.ExpansionElements) + foreach ((string relationshipName, Type documentType) in metadata.DocumentTypesByRelationshipName) { + if (genericEndpoint.AttributeRouteInfo == null) + { + throw new NotSupportedException("Only attribute routing is supported for JsonApiDotNetCore endpoints."); + } + ActionDescriptor expandedEndpoint = Clone(genericEndpoint); + RemovePathParameter(expandedEndpoint.Parameters, JsonApiPathParameter.RelationshipName); - ExpandTemplate(expandedEndpoint.AttributeRouteInfo, relationshipName); - expansionCallback(expandedEndpoint, relationshipType, relationshipName); + ExpandTemplate(expandedEndpoint.AttributeRouteInfo!, relationshipName); + + expansionCallback(expandedEndpoint, documentType, relationshipName); expansion.Add(expandedEndpoint); } @@ -133,11 +144,18 @@ private static IList Expand(ActionDescriptor genericEndpoint, return expansion; } - private static void UpdateBodyParameterDescriptor(ActionDescriptor endpoint, Type bodyType, string parameterName = null) + private static void UpdateBodyParameterDescriptor(ActionDescriptor endpoint, Type documentType, string? parameterName = null) { - ControllerParameterDescriptor requestBodyDescriptor = endpoint.GetBodyParameterDescriptor(); - requestBodyDescriptor.ParameterType = bodyType; - ParameterInfo replacementParameterInfo = requestBodyDescriptor.ParameterInfo.WithParameterType(bodyType); + ControllerParameterDescriptor? requestBodyDescriptor = endpoint.GetBodyParameterDescriptor(); + + if (requestBodyDescriptor == null) + { + // ASP.NET model binding picks up on [FromBody] in base classes, so even when it is left out in an override, this should not be reachable. + throw new UnreachableCodeException(); + } + + requestBodyDescriptor.ParameterType = documentType; + ParameterInfo replacementParameterInfo = requestBodyDescriptor.ParameterInfo.WithParameterType(documentType); if (parameterName != null) { @@ -151,7 +169,7 @@ private static ActionDescriptor Clone(ActionDescriptor descriptor) { var clonedDescriptor = (ActionDescriptor)descriptor.MemberwiseClone(); - clonedDescriptor.AttributeRouteInfo = (AttributeRouteInfo)descriptor.AttributeRouteInfo.MemberwiseClone(); + clonedDescriptor.AttributeRouteInfo = (AttributeRouteInfo)descriptor.AttributeRouteInfo!.MemberwiseClone(); clonedDescriptor.FilterDescriptors = new List(); diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/EndpointResolver.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/EndpointResolver.cs index fae0d1fbc6..08368a797c 100644 --- a/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/EndpointResolver.cs +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/EndpointResolver.cs @@ -18,7 +18,7 @@ internal sealed class EndpointResolver return null; } - HttpMethodAttribute method = controllerAction.GetCustomAttributes(true).OfType().FirstOrDefault(); + HttpMethodAttribute? method = controllerAction.GetCustomAttributes(true).OfType().FirstOrDefault(); return ResolveJsonApiEndpoint(method); } @@ -33,7 +33,7 @@ private static bool IsOperationsController(MethodInfo controllerAction) return typeof(BaseJsonApiOperationsController).IsAssignableFrom(controllerAction.ReflectedType); } - private static JsonApiEndpoint? ResolveJsonApiEndpoint(HttpMethodAttribute httpMethod) + private static JsonApiEndpoint? ResolveJsonApiEndpoint(HttpMethodAttribute? httpMethod) { return httpMethod switch { diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/ExpansibleEndpointMetadata.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/ExpansibleEndpointMetadata.cs deleted file mode 100644 index 279abddf9c..0000000000 --- a/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/ExpansibleEndpointMetadata.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace JsonApiDotNetCore.OpenApi.JsonApiMetadata -{ - internal abstract class ExpansibleEndpointMetadata - { - public abstract IDictionary ExpansionElements { get; } - } -} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/JsonApiEndpointMetadataContainer.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/JsonApiEndpointMetadataContainer.cs index 76a22595bd..fad7c32158 100644 --- a/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/JsonApiEndpointMetadataContainer.cs +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/JsonApiEndpointMetadataContainer.cs @@ -5,8 +5,14 @@ namespace JsonApiDotNetCore.OpenApi.JsonApiMetadata /// internal sealed class JsonApiEndpointMetadataContainer { - public IJsonApiRequestMetadata RequestMetadata { get; init; } + public IJsonApiRequestMetadata? RequestMetadata { get; } - public IJsonApiResponseMetadata ResponseMetadata { get; init; } + public IJsonApiResponseMetadata? ResponseMetadata { get; } + + public JsonApiEndpointMetadataContainer(IJsonApiRequestMetadata? requestMetadata, IJsonApiResponseMetadata? responseMetadata) + { + RequestMetadata = requestMetadata; + ResponseMetadata = responseMetadata; + } } } diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/JsonApiEndpointMetadataProvider.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/JsonApiEndpointMetadataProvider.cs index 6a2769d829..0c31c43852 100644 --- a/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/JsonApiEndpointMetadataProvider.cs +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/JsonApiEndpointMetadataProvider.cs @@ -4,8 +4,8 @@ using System.Reflection; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.OpenApi.JsonApiObjects; using JsonApiDotNetCore.OpenApi.JsonApiObjects.Documents; -using JsonApiDotNetCore.OpenApi.JsonApiObjects.RelationshipData; using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCore.OpenApi.JsonApiMetadata @@ -16,16 +16,13 @@ namespace JsonApiDotNetCore.OpenApi.JsonApiMetadata /// internal sealed class JsonApiEndpointMetadataProvider { - private readonly IResourceGraph _resourceGraph; private readonly IControllerResourceMapping _controllerResourceMapping; private readonly EndpointResolver _endpointResolver = new(); - public JsonApiEndpointMetadataProvider(IResourceGraph resourceGraph, IControllerResourceMapping controllerResourceMapping) + public JsonApiEndpointMetadataProvider(IControllerResourceMapping controllerResourceMapping) { - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); ArgumentGuard.NotNull(controllerResourceMapping, nameof(controllerResourceMapping)); - _resourceGraph = resourceGraph; _controllerResourceMapping = controllerResourceMapping; } @@ -40,32 +37,35 @@ public JsonApiEndpointMetadataContainer Get(MethodInfo controllerAction) throw new NotSupportedException($"Unable to provide metadata for non-JsonApiDotNetCore endpoint '{controllerAction.ReflectedType!.FullName}'."); } - Type primaryResourceType = _controllerResourceMapping.GetResourceTypeForController(controllerAction.ReflectedType); + ResourceType? primaryResourceType = _controllerResourceMapping.GetResourceTypeForController(controllerAction.ReflectedType); - return new JsonApiEndpointMetadataContainer + if (primaryResourceType == null) { - RequestMetadata = GetRequestMetadata(endpoint.Value, primaryResourceType), - ResponseMetadata = GetResponseMetadata(endpoint.Value, primaryResourceType) - }; + throw new UnreachableCodeException(); + } + + IJsonApiRequestMetadata? requestMetadata = GetRequestMetadata(endpoint.Value, primaryResourceType); + IJsonApiResponseMetadata? responseMetadata = GetResponseMetadata(endpoint.Value, primaryResourceType); + return new JsonApiEndpointMetadataContainer(requestMetadata, responseMetadata); } - private IJsonApiRequestMetadata GetRequestMetadata(JsonApiEndpoint endpoint, Type primaryResourceType) + private IJsonApiRequestMetadata? GetRequestMetadata(JsonApiEndpoint endpoint, ResourceType primaryResourceType) { switch (endpoint) { case JsonApiEndpoint.Post: { - return GetPostRequestMetadata(primaryResourceType); + return GetPostRequestMetadata(primaryResourceType.ClrType); } case JsonApiEndpoint.Patch: { - return GetPatchRequestMetadata(primaryResourceType); + return GetPatchRequestMetadata(primaryResourceType.ClrType); } case JsonApiEndpoint.PostRelationship: case JsonApiEndpoint.PatchRelationship: case JsonApiEndpoint.DeleteRelationship: { - return GetRelationshipRequestMetadata(primaryResourceType, endpoint != JsonApiEndpoint.PatchRelationship); + return GetRelationshipRequestMetadata(primaryResourceType.Relationships, endpoint != JsonApiEndpoint.PatchRelationship); } default: { @@ -74,43 +74,32 @@ private IJsonApiRequestMetadata GetRequestMetadata(JsonApiEndpoint endpoint, Typ } } - private static PrimaryRequestMetadata GetPostRequestMetadata(Type primaryResourceType) + private static PrimaryRequestMetadata GetPostRequestMetadata(Type resourceClrType) { - return new() - { - Type = typeof(ResourcePostRequestDocument<>).MakeGenericType(primaryResourceType) - }; + Type documentType = typeof(ResourcePostRequestDocument<>).MakeGenericType(resourceClrType); + + return new PrimaryRequestMetadata(documentType); } - private static PrimaryRequestMetadata GetPatchRequestMetadata(Type primaryResourceType) + private static PrimaryRequestMetadata GetPatchRequestMetadata(Type resourceClrType) { - return new() - { - Type = typeof(ResourcePatchRequestDocument<>).MakeGenericType(primaryResourceType) - }; + Type documentType = typeof(ResourcePatchRequestDocument<>).MakeGenericType(resourceClrType); + + return new PrimaryRequestMetadata(documentType); } - private RelationshipRequestMetadata GetRelationshipRequestMetadata(Type primaryResourceType, bool ignoreHasOneRelationships) + private static RelationshipRequestMetadata GetRelationshipRequestMetadata(IEnumerable relationships, + bool ignoreHasOneRelationships) { - IEnumerable relationships = _resourceGraph.GetResourceContext(primaryResourceType).Relationships; + IEnumerable relationshipsOfEndpoint = ignoreHasOneRelationships ? relationships.OfType() : relationships; - if (ignoreHasOneRelationships) - { - relationships = relationships.OfType(); - } + IDictionary requestDocumentTypesByRelationshipName = relationshipsOfEndpoint.ToDictionary(relationship => relationship.PublicName, + NonPrimaryDocumentTypeFactory.Instance.GetForRelationshipRequest); - IDictionary resourceTypesByRelationshipName = relationships.ToDictionary(relationship => relationship.PublicName, - relationship => relationship is HasManyAttribute - ? typeof(ToManyRelationshipRequestData<>).MakeGenericType(relationship.RightType) - : typeof(ToOneRelationshipRequestData<>).MakeGenericType(relationship.RightType)); - - return new RelationshipRequestMetadata - { - RequestBodyTypeByRelationshipName = resourceTypesByRelationshipName - }; + return new RelationshipRequestMetadata(requestDocumentTypesByRelationshipName); } - private IJsonApiResponseMetadata GetResponseMetadata(JsonApiEndpoint endpoint, Type primaryResourceType) + private IJsonApiResponseMetadata? GetResponseMetadata(JsonApiEndpoint endpoint, ResourceType primaryResourceType) { switch (endpoint) { @@ -119,15 +108,15 @@ private IJsonApiResponseMetadata GetResponseMetadata(JsonApiEndpoint endpoint, T case JsonApiEndpoint.Post: case JsonApiEndpoint.Patch: { - return GetPrimaryResponseMetadata(primaryResourceType, endpoint == JsonApiEndpoint.GetCollection); + return GetPrimaryResponseMetadata(primaryResourceType.ClrType, endpoint == JsonApiEndpoint.GetCollection); } case JsonApiEndpoint.GetSecondary: { - return GetSecondaryResponseMetadata(primaryResourceType); + return GetSecondaryResponseMetadata(primaryResourceType.Relationships); } case JsonApiEndpoint.GetRelationship: { - return GetRelationshipResponseMetadata(primaryResourceType); + return GetRelationshipResponseMetadata(primaryResourceType.Relationships); } default: { @@ -136,52 +125,28 @@ private IJsonApiResponseMetadata GetResponseMetadata(JsonApiEndpoint endpoint, T } } - private static PrimaryResponseMetadata GetPrimaryResponseMetadata(Type primaryResourceType, bool endpointReturnsCollection) + private static PrimaryResponseMetadata GetPrimaryResponseMetadata(Type resourceClrType, bool endpointReturnsCollection) { - Type documentType = endpointReturnsCollection ? typeof(ResourceCollectionResponseDocument<>) : typeof(PrimaryResourceResponseDocument<>); + Type documentOpenType = endpointReturnsCollection ? typeof(ResourceCollectionResponseDocument<>) : typeof(PrimaryResourceResponseDocument<>); + Type documentType = documentOpenType.MakeGenericType(resourceClrType); - return new PrimaryResponseMetadata - { - Type = documentType.MakeGenericType(primaryResourceType) - }; + return new PrimaryResponseMetadata(documentType); } - private SecondaryResponseMetadata GetSecondaryResponseMetadata(Type primaryResourceType) + private static SecondaryResponseMetadata GetSecondaryResponseMetadata(IEnumerable relationships) { - IDictionary responseTypesByRelationshipName = GetMetadataByRelationshipName(primaryResourceType, relationship => - { - Type documentType = relationship is HasManyAttribute - ? typeof(ResourceCollectionResponseDocument<>) - : typeof(SecondaryResourceResponseDocument<>); - - return documentType.MakeGenericType(relationship.RightType); - }); + IDictionary responseDocumentTypesByRelationshipName = relationships.ToDictionary(relationship => relationship.PublicName, + NonPrimaryDocumentTypeFactory.Instance.GetForSecondaryResponse); - return new SecondaryResponseMetadata - { - ResponseTypesByRelationshipName = responseTypesByRelationshipName - }; + return new SecondaryResponseMetadata(responseDocumentTypesByRelationshipName); } - private IDictionary GetMetadataByRelationshipName(Type primaryResourceType, - Func extractRelationshipMetadataCallback) + private static RelationshipResponseMetadata GetRelationshipResponseMetadata(IEnumerable relationships) { - IReadOnlyCollection relationships = _resourceGraph.GetResourceContext(primaryResourceType).Relationships; + IDictionary responseDocumentTypesByRelationshipName = relationships.ToDictionary(relationship => relationship.PublicName, + NonPrimaryDocumentTypeFactory.Instance.GetForRelationshipResponse); - return relationships.ToDictionary(relationship => relationship.PublicName, extractRelationshipMetadataCallback); - } - - private RelationshipResponseMetadata GetRelationshipResponseMetadata(Type primaryResourceType) - { - IDictionary responseTypesByRelationshipName = GetMetadataByRelationshipName(primaryResourceType, - relationship => relationship is HasManyAttribute - ? typeof(ResourceIdentifierCollectionResponseDocument<>).MakeGenericType(relationship.RightType) - : typeof(ResourceIdentifierResponseDocument<>).MakeGenericType(relationship.RightType)); - - return new RelationshipResponseMetadata - { - ResponseTypesByRelationshipName = responseTypesByRelationshipName - }; + return new RelationshipResponseMetadata(responseDocumentTypesByRelationshipName); } } } diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/NonPrimaryEndpointMetadata.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/NonPrimaryEndpointMetadata.cs new file mode 100644 index 0000000000..b0bd5b275c --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/NonPrimaryEndpointMetadata.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; + +namespace JsonApiDotNetCore.OpenApi.JsonApiMetadata +{ + internal abstract class NonPrimaryEndpointMetadata + { + public IDictionary DocumentTypesByRelationshipName { get; } + + protected NonPrimaryEndpointMetadata(IDictionary documentTypesByRelationshipName) + { + ArgumentGuard.NotNull(documentTypesByRelationshipName, nameof(documentTypesByRelationshipName)); + + DocumentTypesByRelationshipName = documentTypesByRelationshipName; + } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/PrimaryRequestMetadata.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/PrimaryRequestMetadata.cs index c217aefcb3..876e04b913 100644 --- a/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/PrimaryRequestMetadata.cs +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/PrimaryRequestMetadata.cs @@ -4,6 +4,13 @@ namespace JsonApiDotNetCore.OpenApi.JsonApiMetadata { internal sealed class PrimaryRequestMetadata : IJsonApiRequestMetadata { - public Type Type { get; init; } + public Type DocumentType { get; } + + public PrimaryRequestMetadata(Type documentType) + { + ArgumentGuard.NotNull(documentType, nameof(documentType)); + + DocumentType = documentType; + } } } diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/PrimaryResponseMetadata.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/PrimaryResponseMetadata.cs index 13647ec857..81c7127c6f 100644 --- a/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/PrimaryResponseMetadata.cs +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/PrimaryResponseMetadata.cs @@ -4,6 +4,13 @@ namespace JsonApiDotNetCore.OpenApi.JsonApiMetadata { internal sealed class PrimaryResponseMetadata : IJsonApiResponseMetadata { - public Type Type { get; init; } + public Type DocumentType { get; } + + public PrimaryResponseMetadata(Type documentType) + { + ArgumentGuard.NotNull(documentType, nameof(documentType)); + + DocumentType = documentType; + } } } diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/RelationshipRequestMetadata.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/RelationshipRequestMetadata.cs index 9156803a3b..7b39b848e0 100644 --- a/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/RelationshipRequestMetadata.cs +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/RelationshipRequestMetadata.cs @@ -3,10 +3,11 @@ namespace JsonApiDotNetCore.OpenApi.JsonApiMetadata { - internal sealed class RelationshipRequestMetadata : ExpansibleEndpointMetadata, IJsonApiRequestMetadata + internal sealed class RelationshipRequestMetadata : NonPrimaryEndpointMetadata, IJsonApiRequestMetadata { - public IDictionary RequestBodyTypeByRelationshipName { get; init; } - - public override IDictionary ExpansionElements => RequestBodyTypeByRelationshipName; + public RelationshipRequestMetadata(IDictionary documentTypesByRelationshipName) + : base(documentTypesByRelationshipName) + { + } } } diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/RelationshipResponseMetadata.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/RelationshipResponseMetadata.cs index 28b9cd2df1..0acca50617 100644 --- a/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/RelationshipResponseMetadata.cs +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/RelationshipResponseMetadata.cs @@ -3,10 +3,11 @@ namespace JsonApiDotNetCore.OpenApi.JsonApiMetadata { - internal sealed class RelationshipResponseMetadata : ExpansibleEndpointMetadata, IJsonApiResponseMetadata + internal sealed class RelationshipResponseMetadata : NonPrimaryEndpointMetadata, IJsonApiResponseMetadata { - public IDictionary ResponseTypesByRelationshipName { get; init; } - - public override IDictionary ExpansionElements => ResponseTypesByRelationshipName; + public RelationshipResponseMetadata(IDictionary documentTypesByRelationshipName) + : base(documentTypesByRelationshipName) + { + } } } diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/SecondaryResponseMetadata.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/SecondaryResponseMetadata.cs index 45e5f4e0ab..c72661e594 100644 --- a/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/SecondaryResponseMetadata.cs +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiMetadata/SecondaryResponseMetadata.cs @@ -3,10 +3,11 @@ namespace JsonApiDotNetCore.OpenApi.JsonApiMetadata { - internal sealed class SecondaryResponseMetadata : ExpansibleEndpointMetadata, IJsonApiResponseMetadata + internal sealed class SecondaryResponseMetadata : NonPrimaryEndpointMetadata, IJsonApiResponseMetadata { - public IDictionary ResponseTypesByRelationshipName { get; init; } - - public override IDictionary ExpansionElements => ResponseTypesByRelationshipName; + public SecondaryResponseMetadata(IDictionary documentTypesByRelationshipName) + : base(documentTypesByRelationshipName) + { + } } } diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjectPropertyName.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjectPropertyName.cs index c9ff79f9d6..342999f8a4 100644 --- a/src/JsonApiDotNetCore.OpenApi/JsonApiObjectPropertyName.cs +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjectPropertyName.cs @@ -11,9 +11,5 @@ internal static class JsonApiObjectPropertyName public const string RelationshipsObject = "relationships"; public const string MetaObject = "meta"; public const string LinksObject = "links"; - public const string JsonapiObject = "jsonapi"; - public const string JsonapiObjectVersion = "version"; - public const string JsonapiObjectExt = "ext"; - public const string JsonapiObjectProfile = "profile"; } } diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Documents/NullableResourceIdentifierResponseDocument.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Documents/NullableResourceIdentifierResponseDocument.cs new file mode 100644 index 0000000000..7f2673bf97 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Documents/NullableResourceIdentifierResponseDocument.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; +using JsonApiDotNetCore.OpenApi.JsonApiObjects.Links; +using JsonApiDotNetCore.OpenApi.JsonApiObjects.ResourceObjects; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.OpenApi.JsonApiObjects.Documents +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + internal sealed class NullableResourceIdentifierResponseDocument : NullableSingleData> + where TResource : IIdentifiable + { + public IDictionary Meta { get; set; } = null!; + + public JsonapiObject Jsonapi { get; set; } = null!; + + [Required] + public LinksInResourceIdentifierDocument Links { get; set; } = null!; + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Documents/NullableSecondaryResourceResponseDocument.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Documents/NullableSecondaryResourceResponseDocument.cs new file mode 100644 index 0000000000..4f86562323 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Documents/NullableSecondaryResourceResponseDocument.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; +using JsonApiDotNetCore.OpenApi.JsonApiObjects.Links; +using JsonApiDotNetCore.OpenApi.JsonApiObjects.ResourceObjects; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.OpenApi.JsonApiObjects.Documents +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + internal sealed class NullableSecondaryResourceResponseDocument : NullableSingleData> + where TResource : IIdentifiable + { + public IDictionary Meta { get; set; } = null!; + + public JsonapiObject Jsonapi { get; set; } = null!; + + [Required] + public LinksInResourceDocument Links { get; set; } = null!; + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Documents/PrimaryResourceResponseDocument.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Documents/PrimaryResourceResponseDocument.cs index 931f787808..d7c2ee83e3 100644 --- a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Documents/PrimaryResourceResponseDocument.cs +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Documents/PrimaryResourceResponseDocument.cs @@ -7,15 +7,17 @@ namespace JsonApiDotNetCore.OpenApi.JsonApiObjects.Documents { + // Types in the current namespace are never touched by ASP.NET ModelState validation, therefore using a non-nullable reference type for a property does not + // imply this property is required. Instead, we use [Required] explicitly, because this is how Swashbuckle is instructed to mark properties as required. [UsedImplicitly(ImplicitUseTargetFlags.Members)] internal sealed class PrimaryResourceResponseDocument : SingleData> where TResource : IIdentifiable { - public IDictionary Meta { get; set; } + public IDictionary Meta { get; set; } = null!; - public JsonapiObject Jsonapi { get; set; } + public JsonapiObject Jsonapi { get; set; } = null!; [Required] - public LinksInResourceDocument Links { get; set; } + public LinksInResourceDocument Links { get; set; } = null!; } } diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Documents/ResourceCollectionResponseDocument.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Documents/ResourceCollectionResponseDocument.cs index a6a2377bdc..4b90acec0e 100644 --- a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Documents/ResourceCollectionResponseDocument.cs +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Documents/ResourceCollectionResponseDocument.cs @@ -11,11 +11,11 @@ namespace JsonApiDotNetCore.OpenApi.JsonApiObjects.Documents internal sealed class ResourceCollectionResponseDocument : ManyData> where TResource : IIdentifiable { - public IDictionary Meta { get; set; } + public IDictionary Meta { get; set; } = null!; - public JsonapiObject Jsonapi { get; set; } + public JsonapiObject Jsonapi { get; set; } = null!; [Required] - public LinksInResourceCollectionDocument Links { get; set; } + public LinksInResourceCollectionDocument Links { get; set; } = null!; } } diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Documents/ResourceIdentifierCollectionResponseDocument.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Documents/ResourceIdentifierCollectionResponseDocument.cs index 85e9b9e7f0..669609a3d5 100644 --- a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Documents/ResourceIdentifierCollectionResponseDocument.cs +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Documents/ResourceIdentifierCollectionResponseDocument.cs @@ -11,11 +11,11 @@ namespace JsonApiDotNetCore.OpenApi.JsonApiObjects.Documents internal sealed class ResourceIdentifierCollectionResponseDocument : ManyData> where TResource : IIdentifiable { - public IDictionary Meta { get; set; } + public IDictionary Meta { get; set; } = null!; - public JsonapiObject Jsonapi { get; set; } + public JsonapiObject Jsonapi { get; set; } = null!; [Required] - public LinksInResourceIdentifierCollectionDocument Links { get; set; } + public LinksInResourceIdentifierCollectionDocument Links { get; set; } = null!; } } diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Documents/ResourceIdentifierResponseDocument.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Documents/ResourceIdentifierResponseDocument.cs index b1b4299191..bc6d6d0de1 100644 --- a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Documents/ResourceIdentifierResponseDocument.cs +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Documents/ResourceIdentifierResponseDocument.cs @@ -11,11 +11,11 @@ namespace JsonApiDotNetCore.OpenApi.JsonApiObjects.Documents internal sealed class ResourceIdentifierResponseDocument : SingleData> where TResource : IIdentifiable { - public IDictionary Meta { get; set; } + public IDictionary Meta { get; set; } = null!; - public JsonapiObject Jsonapi { get; set; } + public JsonapiObject Jsonapi { get; set; } = null!; [Required] - public LinksInResourceIdentifierDocument Links { get; set; } + public LinksInResourceIdentifierDocument Links { get; set; } = null!; } } diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Documents/SecondaryResourceResponseDocument.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Documents/SecondaryResourceResponseDocument.cs index 876e290565..6a2fbae85b 100644 --- a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Documents/SecondaryResourceResponseDocument.cs +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Documents/SecondaryResourceResponseDocument.cs @@ -11,11 +11,11 @@ namespace JsonApiDotNetCore.OpenApi.JsonApiObjects.Documents internal sealed class SecondaryResourceResponseDocument : SingleData> where TResource : IIdentifiable { - public IDictionary Meta { get; set; } + public IDictionary Meta { get; set; } = null!; - public JsonapiObject Jsonapi { get; set; } + public JsonapiObject Jsonapi { get; set; } = null!; [Required] - public LinksInResourceDocument Links { get; set; } + public LinksInResourceDocument Links { get; set; } = null!; } } diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/JsonapiObject.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/JsonapiObject.cs index 6f7d7bd4fa..2b4d8a43e5 100644 --- a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/JsonapiObject.cs +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/JsonapiObject.cs @@ -6,12 +6,12 @@ namespace JsonApiDotNetCore.OpenApi.JsonApiObjects [UsedImplicitly(ImplicitUseTargetFlags.Members)] internal sealed class JsonapiObject { - public string Version { get; set; } + public string Version { get; set; } = null!; - public ICollection Ext { get; set; } + public ICollection Ext { get; set; } = null!; - public ICollection Profile { get; set; } + public ICollection Profile { get; set; } = null!; - public IDictionary Meta { get; set; } + public IDictionary Meta { get; set; } = null!; } } diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Links/LinksInRelationshipObject.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Links/LinksInRelationshipObject.cs index 8b1ca67162..37ad37342b 100644 --- a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Links/LinksInRelationshipObject.cs +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Links/LinksInRelationshipObject.cs @@ -7,9 +7,9 @@ namespace JsonApiDotNetCore.OpenApi.JsonApiObjects.Links internal sealed class LinksInRelationshipObject { [Required] - public string Self { get; set; } + public string Self { get; set; } = null!; [Required] - public string Related { get; set; } + public string Related { get; set; } = null!; } } diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Links/LinksInResourceCollectionDocument.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Links/LinksInResourceCollectionDocument.cs index 84d5e37aa0..7dacca5b49 100644 --- a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Links/LinksInResourceCollectionDocument.cs +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Links/LinksInResourceCollectionDocument.cs @@ -7,17 +7,17 @@ namespace JsonApiDotNetCore.OpenApi.JsonApiObjects.Links internal sealed class LinksInResourceCollectionDocument { [Required] - public string Self { get; set; } + public string Self { get; set; } = null!; - public string Describedby { get; set; } + public string Describedby { get; set; } = null!; [Required] - public string First { get; set; } + public string First { get; set; } = null!; - public string Last { get; set; } + public string Last { get; set; } = null!; - public string Prev { get; set; } + public string Prev { get; set; } = null!; - public string Next { get; set; } + public string Next { get; set; } = null!; } } diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Links/LinksInResourceDocument.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Links/LinksInResourceDocument.cs index f2686c12b3..2eea7e1bc6 100644 --- a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Links/LinksInResourceDocument.cs +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Links/LinksInResourceDocument.cs @@ -7,8 +7,8 @@ namespace JsonApiDotNetCore.OpenApi.JsonApiObjects.Links internal sealed class LinksInResourceDocument { [Required] - public string Self { get; set; } + public string Self { get; set; } = null!; - public string Describedby { get; set; } + public string Describedby { get; set; } = null!; } } diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Links/LinksInResourceIdentifierCollectionDocument.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Links/LinksInResourceIdentifierCollectionDocument.cs index 8596f60156..f8dc978a2a 100644 --- a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Links/LinksInResourceIdentifierCollectionDocument.cs +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Links/LinksInResourceIdentifierCollectionDocument.cs @@ -7,20 +7,20 @@ namespace JsonApiDotNetCore.OpenApi.JsonApiObjects.Links internal sealed class LinksInResourceIdentifierCollectionDocument { [Required] - public string Self { get; set; } + public string Self { get; set; } = null!; - public string Describedby { get; set; } + public string Describedby { get; set; } = null!; [Required] - public string Related { get; set; } + public string Related { get; set; } = null!; [Required] - public string First { get; set; } + public string First { get; set; } = null!; - public string Last { get; set; } + public string Last { get; set; } = null!; - public string Prev { get; set; } + public string Prev { get; set; } = null!; - public string Next { get; set; } + public string Next { get; set; } = null!; } } diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Links/LinksInResourceIdentifierDocument.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Links/LinksInResourceIdentifierDocument.cs index 88d568f648..550cac6b76 100644 --- a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Links/LinksInResourceIdentifierDocument.cs +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Links/LinksInResourceIdentifierDocument.cs @@ -7,11 +7,11 @@ namespace JsonApiDotNetCore.OpenApi.JsonApiObjects.Links internal sealed class LinksInResourceIdentifierDocument { [Required] - public string Self { get; set; } + public string Self { get; set; } = null!; - public string Describedby { get; set; } + public string Describedby { get; set; } = null!; [Required] - public string Related { get; set; } + public string Related { get; set; } = null!; } } diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Links/LinksInResourceObject.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Links/LinksInResourceObject.cs index 10313617cf..1ee227cfcc 100644 --- a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Links/LinksInResourceObject.cs +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/Links/LinksInResourceObject.cs @@ -7,6 +7,6 @@ namespace JsonApiDotNetCore.OpenApi.JsonApiObjects.Links internal sealed class LinksInResourceObject { [Required] - public string Self { get; set; } + public string Self { get; set; } = null!; } } diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/ManyData.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/ManyData.cs index b6253f5142..071d1d0bcb 100644 --- a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/ManyData.cs +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/ManyData.cs @@ -10,6 +10,6 @@ internal abstract class ManyData where TData : ResourceIdentifierObject { [Required] - public ICollection Data { get; set; } + public ICollection Data { get; set; } = null!; } } diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/NonPrimaryDocumentTypeFactory.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/NonPrimaryDocumentTypeFactory.cs new file mode 100644 index 0000000000..54a3d82bf3 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/NonPrimaryDocumentTypeFactory.cs @@ -0,0 +1,79 @@ +using System; +using JsonApiDotNetCore.OpenApi.JsonApiObjects.Documents; +using JsonApiDotNetCore.OpenApi.JsonApiObjects.RelationshipData; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.OpenApi.JsonApiObjects +{ + internal sealed class NonPrimaryDocumentTypeFactory + { + private static readonly DocumentOpenTypes SecondaryResponseDocumentOpenTypes = new(typeof(ResourceCollectionResponseDocument<>), + typeof(NullableSecondaryResourceResponseDocument<>), typeof(SecondaryResourceResponseDocument<>)); + + private static readonly DocumentOpenTypes RelationshipRequestDocumentOpenTypes = new(typeof(ToManyRelationshipRequestData<>), + typeof(NullableToOneRelationshipRequestData<>), typeof(ToOneRelationshipRequestData<>)); + + private static readonly DocumentOpenTypes RelationshipResponseDocumentOpenTypes = new(typeof(ResourceIdentifierCollectionResponseDocument<>), + typeof(NullableResourceIdentifierResponseDocument<>), typeof(ResourceIdentifierResponseDocument<>)); + + public static NonPrimaryDocumentTypeFactory Instance { get; } = new(); + + private NonPrimaryDocumentTypeFactory() + { + } + + public Type GetForSecondaryResponse(RelationshipAttribute relationship) + { + ArgumentGuard.NotNull(relationship, nameof(relationship)); + + return Get(relationship, SecondaryResponseDocumentOpenTypes); + } + + public Type GetForRelationshipRequest(RelationshipAttribute relationship) + { + ArgumentGuard.NotNull(relationship, nameof(relationship)); + + return Get(relationship, RelationshipRequestDocumentOpenTypes); + } + + public Type GetForRelationshipResponse(RelationshipAttribute relationship) + { + ArgumentGuard.NotNull(relationship, nameof(relationship)); + + return Get(relationship, RelationshipResponseDocumentOpenTypes); + } + + private static Type Get(RelationshipAttribute relationship, DocumentOpenTypes types) + { + // @formatter:nested_ternary_style expanded + + Type documentOpenType = relationship is HasManyAttribute + ? types.ManyDataOpenType + : relationship.IsNullable() + ? types.NullableSingleDataOpenType + : types.SingleDataOpenType; + + // @formatter:nested_ternary_style restore + + return documentOpenType.MakeGenericType(relationship.RightType.ClrType); + } + + private sealed class DocumentOpenTypes + { + public Type ManyDataOpenType { get; } + public Type NullableSingleDataOpenType { get; } + public Type SingleDataOpenType { get; } + + public DocumentOpenTypes(Type manyDataOpenType, Type nullableSingleDataOpenType, Type singleDataOpenType) + { + ArgumentGuard.NotNull(manyDataOpenType, nameof(manyDataOpenType)); + ArgumentGuard.NotNull(nullableSingleDataOpenType, nameof(nullableSingleDataOpenType)); + ArgumentGuard.NotNull(singleDataOpenType, nameof(singleDataOpenType)); + + ManyDataOpenType = manyDataOpenType; + NullableSingleDataOpenType = nullableSingleDataOpenType; + SingleDataOpenType = singleDataOpenType; + } + } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/NullableSingleData.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/NullableSingleData.cs new file mode 100644 index 0000000000..685ca9eb72 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/NullableSingleData.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; +using JsonApiDotNetCore.OpenApi.JsonApiObjects.ResourceObjects; + +namespace JsonApiDotNetCore.OpenApi.JsonApiObjects +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + internal abstract class NullableSingleData + where TData : ResourceIdentifierObject + { + [Required] + public TData? Data { get; set; } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/RelationshipData/NullableToOneRelationshipRequestData.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/RelationshipData/NullableToOneRelationshipRequestData.cs new file mode 100644 index 0000000000..a6163ab358 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/RelationshipData/NullableToOneRelationshipRequestData.cs @@ -0,0 +1,12 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.OpenApi.JsonApiObjects.ResourceObjects; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.OpenApi.JsonApiObjects.RelationshipData +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + internal sealed class NullableToOneRelationshipRequestData : NullableSingleData> + where TResource : IIdentifiable + { + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/RelationshipData/NullableToOneRelationshipResponseData.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/RelationshipData/NullableToOneRelationshipResponseData.cs new file mode 100644 index 0000000000..7e2c8714ba --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/RelationshipData/NullableToOneRelationshipResponseData.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; +using JsonApiDotNetCore.OpenApi.JsonApiObjects.Links; +using JsonApiDotNetCore.OpenApi.JsonApiObjects.ResourceObjects; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.OpenApi.JsonApiObjects.RelationshipData +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + internal sealed class NullableToOneRelationshipResponseData : NullableSingleData> + where TResource : IIdentifiable + { + [Required] + public LinksInRelationshipObject Links { get; set; } = null!; + + public IDictionary Meta { get; set; } = null!; + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/RelationshipData/ToManyRelationshipResponseData.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/RelationshipData/ToManyRelationshipResponseData.cs index a6f10e9e9a..eb5eb2d1ac 100644 --- a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/RelationshipData/ToManyRelationshipResponseData.cs +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/RelationshipData/ToManyRelationshipResponseData.cs @@ -12,8 +12,8 @@ internal sealed class ToManyRelationshipResponseData : ManyData Meta { get; set; } + public IDictionary Meta { get; set; } = null!; } } diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/RelationshipData/ToOneRelationshipResponseData.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/RelationshipData/ToOneRelationshipResponseData.cs index 5edc6b3450..02798adee8 100644 --- a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/RelationshipData/ToOneRelationshipResponseData.cs +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/RelationshipData/ToOneRelationshipResponseData.cs @@ -12,8 +12,8 @@ internal sealed class ToOneRelationshipResponseData : SingleData Meta { get; set; } + public IDictionary Meta { get; set; } = null!; } } diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/RelationshipDataTypeFactory.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/RelationshipDataTypeFactory.cs new file mode 100644 index 0000000000..dde79ca61a --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/RelationshipDataTypeFactory.cs @@ -0,0 +1,39 @@ +using System; +using JsonApiDotNetCore.OpenApi.JsonApiObjects.RelationshipData; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.OpenApi.JsonApiObjects +{ + internal sealed class RelationshipDataTypeFactory + { + public static RelationshipDataTypeFactory Instance { get; } = new(); + + private RelationshipDataTypeFactory() + { + } + + public Type GetForRequest(RelationshipAttribute relationship) + { + ArgumentGuard.NotNull(relationship, nameof(relationship)); + + return NonPrimaryDocumentTypeFactory.Instance.GetForRelationshipRequest(relationship); + } + + public Type GetForResponse(RelationshipAttribute relationship) + { + ArgumentGuard.NotNull(relationship, nameof(relationship)); + + // @formatter:nested_ternary_style expanded + + Type relationshipDataOpenType = relationship is HasManyAttribute + ? typeof(ToManyRelationshipResponseData<>) + : relationship.IsNullable() + ? typeof(NullableToOneRelationshipResponseData<>) + : typeof(ToOneRelationshipResponseData<>); + + // @formatter:nested_ternary_style restore + + return relationshipDataOpenType.MakeGenericType(relationship.RightType.ClrType); + } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/ResourceObjects/ResourceIdentifierObject.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/ResourceObjects/ResourceIdentifierObject.cs index e2f6d08136..ad7fb8bb6c 100644 --- a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/ResourceObjects/ResourceIdentifierObject.cs +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/ResourceObjects/ResourceIdentifierObject.cs @@ -14,9 +14,9 @@ internal class ResourceIdentifierObject : ResourceIdentifierObject internal class ResourceIdentifierObject { [Required] - public string Type { get; set; } + public string Type { get; set; } = null!; [Required] - public string Id { get; set; } + public string Id { get; set; } = null!; } } diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/ResourceObjects/ResourceObject.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/ResourceObjects/ResourceObject.cs index 80366a8277..38218eddc6 100644 --- a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/ResourceObjects/ResourceObject.cs +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/ResourceObjects/ResourceObject.cs @@ -8,8 +8,8 @@ namespace JsonApiDotNetCore.OpenApi.JsonApiObjects.ResourceObjects internal abstract class ResourceObject : ResourceIdentifierObject where TResource : IIdentifiable { - public IDictionary Attributes { get; set; } + public IDictionary Attributes { get; set; } = null!; - public IDictionary Relationships { get; set; } + public IDictionary Relationships { get; set; } = null!; } } diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/ResourceObjects/ResourceResponseObject.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/ResourceObjects/ResourceResponseObject.cs index 44fcfdcfe0..62cb0378e5 100644 --- a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/ResourceObjects/ResourceResponseObject.cs +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/ResourceObjects/ResourceResponseObject.cs @@ -11,8 +11,8 @@ internal sealed class ResourceResponseObject : ResourceObject Meta { get; set; } + public IDictionary Meta { get; set; } = null!; } } diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/SingleData.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/SingleData.cs index 616f357014..0e447e256a 100644 --- a/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/SingleData.cs +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiObjects/SingleData.cs @@ -9,6 +9,6 @@ internal abstract class SingleData where TData : ResourceIdentifierObject { [Required] - public TData Data { get; set; } + public TData Data { get; set; } = null!; } } diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiOperationIdSelector.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiOperationIdSelector.cs index 8e45c93397..410a63a4ba 100644 --- a/src/JsonApiDotNetCore.OpenApi/JsonApiOperationIdSelector.cs +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiOperationIdSelector.cs @@ -28,20 +28,22 @@ internal sealed class JsonApiOperationIdSelector [typeof(ResourcePatchRequestDocument<>)] = ResourceOperationIdTemplate, [typeof(void)] = ResourceOperationIdTemplate, [typeof(SecondaryResourceResponseDocument<>)] = SecondaryOperationIdTemplate, + [typeof(NullableSecondaryResourceResponseDocument<>)] = SecondaryOperationIdTemplate, [typeof(ResourceIdentifierCollectionResponseDocument<>)] = RelationshipOperationIdTemplate, [typeof(ResourceIdentifierResponseDocument<>)] = RelationshipOperationIdTemplate, + [typeof(NullableResourceIdentifierResponseDocument<>)] = RelationshipOperationIdTemplate, [typeof(ToOneRelationshipRequestData<>)] = RelationshipOperationIdTemplate, + [typeof(NullableToOneRelationshipRequestData<>)] = RelationshipOperationIdTemplate, [typeof(ToManyRelationshipRequestData<>)] = RelationshipOperationIdTemplate }; private readonly IControllerResourceMapping _controllerResourceMapping; - private readonly JsonNamingPolicy _namingPolicy; + private readonly JsonNamingPolicy? _namingPolicy; private readonly ResourceNameFormatter _formatter; - public JsonApiOperationIdSelector(IControllerResourceMapping controllerResourceMapping, JsonNamingPolicy namingPolicy) + public JsonApiOperationIdSelector(IControllerResourceMapping controllerResourceMapping, JsonNamingPolicy? namingPolicy) { ArgumentGuard.NotNull(controllerResourceMapping, nameof(controllerResourceMapping)); - ArgumentGuard.NotNull(namingPolicy, nameof(namingPolicy)); _controllerResourceMapping = controllerResourceMapping; _namingPolicy = namingPolicy; @@ -52,33 +54,50 @@ public string GetOperationId(ApiDescription endpoint) { ArgumentGuard.NotNull(endpoint, nameof(endpoint)); - Type primaryResourceType = _controllerResourceMapping.GetResourceTypeForController(endpoint.ActionDescriptor.GetActionMethod().ReflectedType); + ResourceType? primaryResourceType = + _controllerResourceMapping.GetResourceTypeForController(endpoint.ActionDescriptor.GetActionMethod().ReflectedType); - string template = GetTemplate(primaryResourceType, endpoint); + if (primaryResourceType == null) + { + throw new UnreachableCodeException(); + } + + string template = GetTemplate(primaryResourceType.ClrType, endpoint); - return ApplyTemplate(template, primaryResourceType, endpoint); + return ApplyTemplate(template, primaryResourceType.ClrType, endpoint); } - private static string GetTemplate(Type primaryResourceType, ApiDescription endpoint) + private static string GetTemplate(Type resourceClrType, ApiDescription endpoint) { - Type requestDocumentType = GetDocumentType(primaryResourceType, endpoint); + Type requestDocumentType = GetDocumentType(resourceClrType, endpoint); + + if (!DocumentOpenTypeToOperationIdTemplateMap.TryGetValue(requestDocumentType, out string? template)) + { + throw new UnreachableCodeException(); + } - return DocumentOpenTypeToOperationIdTemplateMap[requestDocumentType]; + return template; } - private static Type GetDocumentType(Type primaryResourceType, ApiDescription endpoint) + private static Type GetDocumentType(Type primaryResourceClrType, ApiDescription endpoint) { - ControllerParameterDescriptor requestBodyDescriptor = endpoint.ActionDescriptor.GetBodyParameterDescriptor(); var producesResponseTypeAttribute = endpoint.ActionDescriptor.GetFilterMetadata(); + if (producesResponseTypeAttribute == null) + { + throw new UnreachableCodeException(); + } + + ControllerParameterDescriptor? requestBodyDescriptor = endpoint.ActionDescriptor.GetBodyParameterDescriptor(); + Type documentType = requestBodyDescriptor?.ParameterType.GetGenericTypeDefinition() ?? - TryGetGenericTypeDefinition(producesResponseTypeAttribute.Type) ?? producesResponseTypeAttribute.Type; + GetGenericTypeDefinition(producesResponseTypeAttribute.Type) ?? producesResponseTypeAttribute.Type; if (documentType == typeof(ResourceCollectionResponseDocument<>)) { Type documentResourceType = producesResponseTypeAttribute.Type.GetGenericArguments()[0]; - if (documentResourceType != primaryResourceType) + if (documentResourceType != primaryResourceClrType) { documentType = typeof(SecondaryResourceResponseDocument<>); } @@ -87,15 +106,15 @@ private static Type GetDocumentType(Type primaryResourceType, ApiDescription end return documentType; } - private static Type TryGetGenericTypeDefinition(Type type) + private static Type? GetGenericTypeDefinition(Type type) { return type.IsConstructedGenericType ? type.GetGenericTypeDefinition() : null; } - private string ApplyTemplate(string operationIdTemplate, Type primaryResourceType, ApiDescription endpoint) + private string ApplyTemplate(string operationIdTemplate, Type resourceClrType, ApiDescription endpoint) { string method = endpoint.HttpMethod!.ToLowerInvariant(); - string primaryResourceName = _formatter.FormatResourceName(primaryResourceType).Singularize(); + string primaryResourceName = _formatter.FormatResourceName(resourceClrType).Singularize(); string relationshipName = operationIdTemplate.Contains("[RelationshipName]") ? endpoint.RelativePath.Split("/").Last() : string.Empty; // @formatter:wrap_chained_method_calls chop_always @@ -109,7 +128,7 @@ private string ApplyTemplate(string operationIdTemplate, Type primaryResourceTyp // @formatter:keep_existing_linebreaks restore // @formatter:wrap_chained_method_calls restore - return _namingPolicy.ConvertName(pascalCaseId); + return _namingPolicy != null ? _namingPolicy.ConvertName(pascalCaseId) : pascalCaseId; } } } diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiRequestFormatMetadataProvider.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiRequestFormatMetadataProvider.cs index 1fef9854f0..e3d2a0a17a 100644 --- a/src/JsonApiDotNetCore.OpenApi/JsonApiRequestFormatMetadataProvider.cs +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiRequestFormatMetadataProvider.cs @@ -17,6 +17,7 @@ internal sealed class JsonApiRequestFormatMetadataProvider : IInputFormatter, IA { typeof(ToManyRelationshipRequestData<>), typeof(ToOneRelationshipRequestData<>), + typeof(NullableToOneRelationshipRequestData<>), typeof(ResourcePostRequestDocument<>), typeof(ResourcePatchRequestDocument<>) }; diff --git a/src/JsonApiDotNetCore.OpenApi/JsonApiSchemaIdSelector.cs b/src/JsonApiDotNetCore.OpenApi/JsonApiSchemaIdSelector.cs index e87497c938..d8ad776cf8 100644 --- a/src/JsonApiDotNetCore.OpenApi/JsonApiSchemaIdSelector.cs +++ b/src/JsonApiDotNetCore.OpenApi/JsonApiSchemaIdSelector.cs @@ -18,13 +18,17 @@ internal sealed class JsonApiSchemaIdSelector [typeof(ResourcePostRequestObject<>)] = "###-data-in-post-request", [typeof(ResourcePatchRequestObject<>)] = "###-data-in-patch-request", [typeof(ToOneRelationshipRequestData<>)] = "to-one-###-request-data", + [typeof(NullableToOneRelationshipRequestData<>)] = "nullable-to-one-###-request-data", [typeof(ToManyRelationshipRequestData<>)] = "to-many-###-request-data", [typeof(PrimaryResourceResponseDocument<>)] = "###-primary-response-document", [typeof(SecondaryResourceResponseDocument<>)] = "###-secondary-response-document", + [typeof(NullableSecondaryResourceResponseDocument<>)] = "nullable-###-secondary-response-document", [typeof(ResourceCollectionResponseDocument<>)] = "###-collection-response-document", [typeof(ResourceIdentifierResponseDocument<>)] = "###-identifier-response-document", + [typeof(NullableResourceIdentifierResponseDocument<>)] = "nullable-###-identifier-response-document", [typeof(ResourceIdentifierCollectionResponseDocument<>)] = "###-identifier-collection-response-document", [typeof(ToOneRelationshipResponseData<>)] = "to-one-###-response-data", + [typeof(NullableToOneRelationshipResponseData<>)] = "nullable-to-one-###-response-data", [typeof(ToManyRelationshipResponseData<>)] = "to-many-###-response-data", [typeof(ResourceResponseObject<>)] = "###-data-in-response", [typeof(ResourceIdentifierObject<>)] = "###-identifier" @@ -46,17 +50,17 @@ public string GetSchemaId(Type type) { ArgumentGuard.NotNull(type, nameof(type)); - ResourceContext resourceContext = _resourceGraph.TryGetResourceContext(type); + ResourceType? resourceType = _resourceGraph.FindResourceType(type); - if (resourceContext != null) + if (resourceType != null) { - return resourceContext.PublicName.Singularize(); + return resourceType.PublicName.Singularize(); } if (type.IsConstructedGenericType && OpenTypeToSchemaTemplateMap.ContainsKey(type.GetGenericTypeDefinition())) { - Type resourceType = type.GetGenericArguments().First(); - string resourceName = _formatter.FormatResourceName(resourceType).Singularize(); + Type resourceClrType = type.GetGenericArguments().First(); + string resourceName = _formatter.FormatResourceName(resourceClrType).Singularize(); string template = OpenTypeToSchemaTemplateMap[type.GetGenericTypeDefinition()]; return template.Replace("###", resourceName); diff --git a/src/JsonApiDotNetCore.OpenApi/MemberInfoExtensions.cs b/src/JsonApiDotNetCore.OpenApi/MemberInfoExtensions.cs new file mode 100644 index 0000000000..27342725a9 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/MemberInfoExtensions.cs @@ -0,0 +1,38 @@ +using System; +using System.Reflection; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace JsonApiDotNetCore.OpenApi +{ + internal static class MemberInfoExtensions + { + public static TypeCategory GetTypeCategory(this MemberInfo source) + { + ArgumentGuard.NotNull(source, nameof(source)); + + Type memberType; + + if (source.MemberType.HasFlag(MemberTypes.Field)) + { + memberType = ((FieldInfo)source).FieldType; + } + else if (source.MemberType.HasFlag(MemberTypes.Property)) + { + memberType = ((PropertyInfo)source).PropertyType; + } + else + { + throw new NotSupportedException($"Member type '{source.MemberType}' must be a property or field."); + } + + if (memberType.IsValueType) + { + return Nullable.GetUnderlyingType(memberType) != null ? TypeCategory.NullableValueType : TypeCategory.ValueType; + } + + // Once we switch to .NET 6, we should rely instead on the built-in reflection APIs for nullability information. + // See https://devblogs.microsoft.com/dotnet/announcing-net-6-preview-7/#libraries-reflection-apis-for-nullability-information. + return source.IsNonNullableReferenceType() ? TypeCategory.NonNullableReferenceType : TypeCategory.NullableReferenceType; + } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/ObjectExtensions.cs b/src/JsonApiDotNetCore.OpenApi/ObjectExtensions.cs index 573067520b..b881574127 100644 --- a/src/JsonApiDotNetCore.OpenApi/ObjectExtensions.cs +++ b/src/JsonApiDotNetCore.OpenApi/ObjectExtensions.cs @@ -6,14 +6,15 @@ namespace JsonApiDotNetCore.OpenApi { internal static class ObjectExtensions { - private static readonly Lazy MemberwiseCloneMethod = new(() => - typeof(object).GetMethod(nameof(MemberwiseClone), BindingFlags.Instance | BindingFlags.NonPublic), LazyThreadSafetyMode.ExecutionAndPublication); + private static readonly Lazy MemberwiseCloneMethod = + new(() => typeof(object).GetMethod(nameof(MemberwiseClone), BindingFlags.Instance | BindingFlags.NonPublic)!, + LazyThreadSafetyMode.ExecutionAndPublication); public static object MemberwiseClone(this object source) { ArgumentGuard.NotNull(source, nameof(source)); - return MemberwiseCloneMethod.Value.Invoke(source, null); + return MemberwiseCloneMethod.Value.Invoke(source, null)!; } } } diff --git a/src/JsonApiDotNetCore.OpenApi/OpenApiEndpointConvention.cs b/src/JsonApiDotNetCore.OpenApi/OpenApiEndpointConvention.cs index 5f0f36d56a..c4c5dfb7e9 100644 --- a/src/JsonApiDotNetCore.OpenApi/OpenApiEndpointConvention.cs +++ b/src/JsonApiDotNetCore.OpenApi/OpenApiEndpointConvention.cs @@ -16,16 +16,13 @@ namespace JsonApiDotNetCore.OpenApi /// internal sealed class OpenApiEndpointConvention : IActionModelConvention { - private readonly IResourceGraph _resourceGraph; private readonly IControllerResourceMapping _controllerResourceMapping; private readonly EndpointResolver _endpointResolver = new(); - public OpenApiEndpointConvention(IResourceGraph resourceGraph, IControllerResourceMapping controllerResourceMapping) + public OpenApiEndpointConvention(IControllerResourceMapping controllerResourceMapping) { - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); ArgumentGuard.NotNull(controllerResourceMapping, nameof(controllerResourceMapping)); - _resourceGraph = resourceGraph; _controllerResourceMapping = controllerResourceMapping; } @@ -69,11 +66,14 @@ private bool ShouldSuppressEndpoint(JsonApiEndpoint endpoint, Type controllerTyp private IReadOnlyCollection GetRelationshipsOfPrimaryResource(Type controllerType) { - Type primaryResourceOfEndpointType = _controllerResourceMapping.GetResourceTypeForController(controllerType); + ResourceType? primaryResourceType = _controllerResourceMapping.GetResourceTypeForController(controllerType); - ResourceContext primaryResourceContext = _resourceGraph.GetResourceContext(primaryResourceOfEndpointType); + if (primaryResourceType == null) + { + throw new UnreachableCodeException(); + } - return primaryResourceContext.Relationships; + return primaryResourceType.Relationships; } private static bool IsSecondaryOrRelationshipEndpoint(JsonApiEndpoint endpoint) diff --git a/src/JsonApiDotNetCore.OpenApi/ParameterInfoExtensions.cs b/src/JsonApiDotNetCore.OpenApi/ParameterInfoExtensions.cs index 9d46706586..1a42d377b8 100644 --- a/src/JsonApiDotNetCore.OpenApi/ParameterInfoExtensions.cs +++ b/src/JsonApiDotNetCore.OpenApi/ParameterInfoExtensions.cs @@ -6,11 +6,13 @@ namespace JsonApiDotNetCore.OpenApi { internal static class ParameterInfoExtensions { - private static readonly Lazy NameField = new(() => - typeof(ParameterInfo).GetField("NameImpl", BindingFlags.Instance | BindingFlags.NonPublic), LazyThreadSafetyMode.ExecutionAndPublication); + private static readonly Lazy NameField = + new(() => typeof(ParameterInfo).GetField("NameImpl", BindingFlags.Instance | BindingFlags.NonPublic)!, + LazyThreadSafetyMode.ExecutionAndPublication); - private static readonly Lazy ParameterTypeField = new(() => - typeof(ParameterInfo).GetField("ClassImpl", BindingFlags.Instance | BindingFlags.NonPublic), LazyThreadSafetyMode.ExecutionAndPublication); + private static readonly Lazy ParameterTypeField = + new(() => typeof(ParameterInfo).GetField("ClassImpl", BindingFlags.Instance | BindingFlags.NonPublic)!, + LazyThreadSafetyMode.ExecutionAndPublication); public static ParameterInfo WithName(this ParameterInfo source, string name) { diff --git a/src/JsonApiDotNetCore.OpenApi/ResourceFieldAttributeExtensions.cs b/src/JsonApiDotNetCore.OpenApi/ResourceFieldAttributeExtensions.cs new file mode 100644 index 0000000000..d59b3a891e --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/ResourceFieldAttributeExtensions.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations; +using JsonApiDotNetCore.Resources.Annotations; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace JsonApiDotNetCore.OpenApi +{ + internal static class ResourceFieldAttributeExtensions + { + public static bool IsNullable(this ResourceFieldAttribute source) + { + TypeCategory fieldTypeCategory = source.Property.GetTypeCategory(); + bool hasRequiredAttribute = source.Property.HasAttribute(); + + return fieldTypeCategory switch + { + TypeCategory.NonNullableReferenceType or TypeCategory.ValueType => false, + TypeCategory.NullableReferenceType or TypeCategory.NullableValueType => !hasRequiredAttribute, + _ => throw new UnreachableCodeException() + }; + } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi/ServiceCollectionExtensions.cs b/src/JsonApiDotNetCore.OpenApi/ServiceCollectionExtensions.cs index 855bfee84e..8f2a781fbd 100644 --- a/src/JsonApiDotNetCore.OpenApi/ServiceCollectionExtensions.cs +++ b/src/JsonApiDotNetCore.OpenApi/ServiceCollectionExtensions.cs @@ -18,7 +18,7 @@ public static class ServiceCollectionExtensions /// /// Adds the OpenAPI integration to JsonApiDotNetCore by configuring Swashbuckle. /// - public static void AddOpenApi(this IServiceCollection services, IMvcCoreBuilder mvcBuilder, Action setupSwaggerGenAction = null) + public static void AddOpenApi(this IServiceCollection services, IMvcCoreBuilder mvcBuilder, Action? setupSwaggerGenAction = null) { ArgumentGuard.NotNull(services, nameof(services)); ArgumentGuard.NotNull(mvcBuilder, nameof(mvcBuilder)); @@ -38,13 +38,12 @@ private static void AddCustomApiExplorer(IServiceCollection services, IMvcCoreBu { services.AddSingleton(provider => { - var resourceGraph = provider.GetRequiredService(); var controllerResourceMapping = provider.GetRequiredService(); var actionDescriptorCollectionProvider = provider.GetRequiredService(); var apiDescriptionProviders = provider.GetRequiredService>(); JsonApiActionDescriptorCollectionProvider descriptorCollectionProviderWrapper = - new(resourceGraph, controllerResourceMapping, actionDescriptorCollectionProvider); + new(controllerResourceMapping, actionDescriptorCollectionProvider); return new ApiDescriptionGroupCollectionProvider(descriptorCollectionProviderWrapper, apiDescriptionProviders); }); @@ -54,19 +53,21 @@ private static void AddCustomApiExplorer(IServiceCollection services, IMvcCoreBu mvcBuilder.AddMvcOptions(options => options.InputFormatters.Add(new JsonApiRequestFormatMetadataProvider())); } - private static void AddSwaggerGenerator(IServiceScope scope, IServiceCollection services, Action setupSwaggerGenAction) + private static void AddSwaggerGenerator(IServiceScope scope, IServiceCollection services, Action? setupSwaggerGenAction) { var controllerResourceMapping = scope.ServiceProvider.GetRequiredService(); var resourceGraph = scope.ServiceProvider.GetRequiredService(); var jsonApiOptions = scope.ServiceProvider.GetRequiredService(); - JsonNamingPolicy namingPolicy = jsonApiOptions.SerializerOptions.PropertyNamingPolicy; + JsonNamingPolicy? namingPolicy = jsonApiOptions.SerializerOptions.PropertyNamingPolicy; + ResourceNameFormatter resourceNameFormatter = new(namingPolicy); AddSchemaGenerator(services); services.AddSwaggerGen(swaggerGenOptions => { - SetOperationInfo(swaggerGenOptions, controllerResourceMapping, resourceGraph, namingPolicy); - SetSchemaIdSelector(swaggerGenOptions, resourceGraph, namingPolicy); + swaggerGenOptions.SupportNonNullableReferenceTypes(); + SetOperationInfo(swaggerGenOptions, controllerResourceMapping, namingPolicy); + SetSchemaIdSelector(swaggerGenOptions, resourceGraph, resourceNameFormatter); swaggerGenOptions.DocumentFilter(); setupSwaggerGenAction?.Invoke(swaggerGenOptions); @@ -80,30 +81,32 @@ private static void AddSchemaGenerator(IServiceCollection services) } private static void SetOperationInfo(SwaggerGenOptions swaggerGenOptions, IControllerResourceMapping controllerResourceMapping, - IResourceGraph resourceGraph, JsonNamingPolicy namingPolicy) + JsonNamingPolicy? namingPolicy) { - swaggerGenOptions.TagActionsBy(description => GetOperationTags(description, controllerResourceMapping, resourceGraph)); + swaggerGenOptions.TagActionsBy(description => GetOperationTags(description, controllerResourceMapping)); JsonApiOperationIdSelector jsonApiOperationIdSelector = new(controllerResourceMapping, namingPolicy); swaggerGenOptions.CustomOperationIds(jsonApiOperationIdSelector.GetOperationId); } - private static IList GetOperationTags(ApiDescription description, IControllerResourceMapping controllerResourceMapping, - IResourceGraph resourceGraph) + private static IList GetOperationTags(ApiDescription description, IControllerResourceMapping controllerResourceMapping) { MethodInfo actionMethod = description.ActionDescriptor.GetActionMethod(); - Type resourceType = controllerResourceMapping.GetResourceTypeForController(actionMethod.ReflectedType); - ResourceContext resourceContext = resourceGraph.GetResourceContext(resourceType); + ResourceType? resourceType = controllerResourceMapping.GetResourceTypeForController(actionMethod.ReflectedType); + + if (resourceType == null) + { + throw new NotSupportedException("Only JsonApiDotNetCore endpoints are supported."); + } return new[] { - resourceContext.PublicName + resourceType.PublicName }; } - private static void SetSchemaIdSelector(SwaggerGenOptions swaggerGenOptions, IResourceGraph resourceGraph, JsonNamingPolicy namingPolicy) + private static void SetSchemaIdSelector(SwaggerGenOptions swaggerGenOptions, IResourceGraph resourceGraph, ResourceNameFormatter resourceNameFormatter) { - ResourceNameFormatter resourceNameFormatter = new(namingPolicy); JsonApiSchemaIdSelector jsonApiObjectSchemaSelector = new(resourceNameFormatter, resourceGraph); swaggerGenOptions.CustomSchemaIds(type => jsonApiObjectSchemaSelector.GetSchemaId(type)); @@ -126,10 +129,9 @@ private static void AddSwashbuckleCliCompatibility(IServiceScope scope, IMvcCore private static void AddOpenApiEndpointConvention(IServiceScope scope, IMvcCoreBuilder mvcBuilder) { - var resourceGraph = scope.ServiceProvider.GetRequiredService(); var controllerResourceMapping = scope.ServiceProvider.GetRequiredService(); - mvcBuilder.AddMvcOptions(options => options.Conventions.Add(new OpenApiEndpointConvention(resourceGraph, controllerResourceMapping))); + mvcBuilder.AddMvcOptions(options => options.Conventions.Add(new OpenApiEndpointConvention(controllerResourceMapping))); } } } diff --git a/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/CachingSwaggerGenerator.cs b/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/CachingSwaggerGenerator.cs index 0a5d1180b3..07128b71e6 100644 --- a/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/CachingSwaggerGenerator.cs +++ b/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/CachingSwaggerGenerator.cs @@ -20,7 +20,7 @@ public CachingSwaggerGenerator(SwaggerGenerator defaultSwaggerGenerator) _defaultSwaggerGenerator = defaultSwaggerGenerator; } - public OpenApiDocument GetSwagger(string documentName, string host = null, string basePath = null) + public OpenApiDocument GetSwagger(string documentName, string? host = null, string? basePath = null) { ArgumentGuard.NotNullNorEmpty(documentName, nameof(documentName)); diff --git a/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/JsonApiDataContractResolver.cs b/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/JsonApiDataContractResolver.cs index c5723fa0ec..a158f6e9e2 100644 --- a/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/JsonApiDataContractResolver.cs +++ b/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/JsonApiDataContractResolver.cs @@ -38,11 +38,11 @@ public DataContract GetDataContractForType(Type type) DataContract dataContract = _dataContractResolver.GetDataContractForType(type); - IList replacementProperties = null; + IList? replacementProperties = null; if (type.IsAssignableTo(typeof(IIdentifiable))) { - replacementProperties = GetDataPropertiesThatExistInResourceContext(type, dataContract); + replacementProperties = GetDataPropertiesThatExistInResourceClrType(type, dataContract); } if (replacementProperties != null) @@ -59,20 +59,20 @@ private static DataContract ReplacePropertiesInDataContract(DataContract dataCon dataContract.ObjectTypeNameProperty, dataContract.ObjectTypeNameValue); } - private IList GetDataPropertiesThatExistInResourceContext(Type resourceType, DataContract dataContract) + private IList GetDataPropertiesThatExistInResourceClrType(Type resourceClrType, DataContract dataContract) { - ResourceContext resourceContext = _resourceGraph.GetResourceContext(resourceType); + ResourceType resourceType = _resourceGraph.GetResourceType(resourceClrType); var dataProperties = new List(); foreach (DataProperty property in dataContract.ObjectProperties) { - if (property.MemberInfo.Name == nameof(Identifiable.Id)) + if (property.MemberInfo.Name == nameof(Identifiable.Id)) { // Schemas of JsonApiDotNetCore resources will obtain an Id property through inheritance of a resource identifier type. continue; } - ResourceFieldAttribute matchingField = resourceContext.Fields.SingleOrDefault(field => + ResourceFieldAttribute? matchingField = resourceType.Fields.SingleOrDefault(field => IsPropertyCompatibleWithMember(field.Property, property.MemberInfo)); if (matchingField != null) @@ -90,7 +90,8 @@ private IList GetDataPropertiesThatExistInResourceContext(Type res private static DataProperty ChangeDataPropertyName(DataProperty property, string name) { - return new(name, property.MemberType, property.IsRequired, property.IsNullable, property.IsReadOnly, property.IsWriteOnly, property.MemberInfo); + return new DataProperty(name, property.MemberType, property.IsRequired, property.IsNullable, property.IsReadOnly, property.IsWriteOnly, + property.MemberInfo); } private static bool IsPropertyCompatibleWithMember(PropertyInfo property, MemberInfo member) diff --git a/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/JsonApiObjectNullabilityProcessor.cs b/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/JsonApiObjectNullabilityProcessor.cs deleted file mode 100644 index 29d1d71bd6..0000000000 --- a/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/JsonApiObjectNullabilityProcessor.cs +++ /dev/null @@ -1,131 +0,0 @@ -using Microsoft.OpenApi.Models; - -namespace JsonApiDotNetCore.OpenApi.SwaggerComponents -{ - /// - /// Removes unwanted nullability of entries in schemas of JSON:API documents. - /// - /// - /// Initially these entries are marked nullable by Swashbuckle because nullable reference types are not enabled. This post-processing step can be removed - /// entirely once we enable nullable reference types. - /// - internal sealed class JsonApiObjectNullabilityProcessor - { - private readonly ISchemaRepositoryAccessor _schemaRepositoryAccessor; - - public JsonApiObjectNullabilityProcessor(ISchemaRepositoryAccessor schemaRepositoryAccessor) - { - ArgumentGuard.NotNull(schemaRepositoryAccessor, nameof(schemaRepositoryAccessor)); - - _schemaRepositoryAccessor = schemaRepositoryAccessor; - } - - public void ClearDocumentProperties(OpenApiSchema referenceSchemaForDocument) - { - ArgumentGuard.NotNull(referenceSchemaForDocument, nameof(referenceSchemaForDocument)); - - OpenApiSchema fullSchemaForDocument = _schemaRepositoryAccessor.Current.Schemas[referenceSchemaForDocument.Reference.Id]; - - ClearMetaObjectNullability(fullSchemaForDocument); - ClearJsonapiObjectNullability(fullSchemaForDocument); - ClearLinksObjectNullability(fullSchemaForDocument); - - OpenApiSchema fullSchemaForResourceObject = TryGetFullSchemaForResourceObject(fullSchemaForDocument); - - if (fullSchemaForResourceObject != null) - { - ClearResourceObjectNullability(fullSchemaForResourceObject); - } - } - - private static void ClearMetaObjectNullability(OpenApiSchema fullSchema) - { - if (fullSchema.Properties.ContainsKey(JsonApiObjectPropertyName.MetaObject)) - { - fullSchema.Properties[JsonApiObjectPropertyName.MetaObject].Nullable = false; - } - } - - private void ClearJsonapiObjectNullability(OpenApiSchema fullSchema) - { - if (fullSchema.Properties.ContainsKey(JsonApiObjectPropertyName.JsonapiObject)) - { - OpenApiSchema fullSchemaForJsonapiObject = - _schemaRepositoryAccessor.Current.Schemas[fullSchema.Properties[JsonApiObjectPropertyName.JsonapiObject].Reference.Id]; - - fullSchemaForJsonapiObject.Properties[JsonApiObjectPropertyName.JsonapiObjectVersion].Nullable = false; - fullSchemaForJsonapiObject.Properties[JsonApiObjectPropertyName.JsonapiObjectExt].Nullable = false; - fullSchemaForJsonapiObject.Properties[JsonApiObjectPropertyName.JsonapiObjectProfile].Nullable = false; - ClearMetaObjectNullability(fullSchemaForJsonapiObject); - } - } - - private void ClearLinksObjectNullability(OpenApiSchema fullSchema) - { - if (fullSchema.Properties.ContainsKey(JsonApiObjectPropertyName.LinksObject)) - { - OpenApiSchema fullSchemaForLinksObject = - _schemaRepositoryAccessor.Current.Schemas[fullSchema.Properties[JsonApiObjectPropertyName.LinksObject].Reference.Id]; - - foreach (OpenApiSchema schemaForEntryInLinksObject in fullSchemaForLinksObject.Properties.Values) - { - schemaForEntryInLinksObject.Nullable = false; - } - } - } - - private OpenApiSchema TryGetFullSchemaForResourceObject(OpenApiSchema fullSchemaForDocument) - { - OpenApiSchema schemaForDataObject = fullSchemaForDocument.Properties[JsonApiObjectPropertyName.Data]; - OpenApiReference dataSchemaReference = schemaForDataObject.Type == "array" ? schemaForDataObject.Items.Reference : schemaForDataObject.Reference; - - if (dataSchemaReference == null) - { - return null; - } - - return _schemaRepositoryAccessor.Current.Schemas[dataSchemaReference.Id]; - } - - private void ClearResourceObjectNullability(OpenApiSchema fullSchemaForValueOfData) - { - ClearMetaObjectNullability(fullSchemaForValueOfData); - ClearLinksObjectNullability(fullSchemaForValueOfData); - ClearAttributesObjectNullability(fullSchemaForValueOfData); - ClearRelationshipsObjectNullability(fullSchemaForValueOfData); - } - - private void ClearAttributesObjectNullability(OpenApiSchema fullSchemaForResourceObject) - { - if (fullSchemaForResourceObject.Properties.ContainsKey(JsonApiObjectPropertyName.AttributesObject)) - { - OpenApiSchema fullSchemaForAttributesObject = _schemaRepositoryAccessor.Current.Schemas[ - fullSchemaForResourceObject.Properties[JsonApiObjectPropertyName.AttributesObject].Reference.Id]; - - fullSchemaForAttributesObject.Nullable = false; - } - } - - private void ClearRelationshipsObjectNullability(OpenApiSchema fullSchemaForResourceObject) - { - if (fullSchemaForResourceObject.Properties.ContainsKey(JsonApiObjectPropertyName.RelationshipsObject)) - { - OpenApiSchema fullSchemaForRelationshipsObject = _schemaRepositoryAccessor.Current.Schemas[ - fullSchemaForResourceObject.Properties[JsonApiObjectPropertyName.RelationshipsObject].Reference.Id]; - - fullSchemaForRelationshipsObject.Nullable = false; - ClearRelationshipsDataNullability(fullSchemaForRelationshipsObject); - } - } - - private void ClearRelationshipsDataNullability(OpenApiSchema fullSchemaForRelationshipsObject) - { - foreach (OpenApiSchema relationshipObjectData in fullSchemaForRelationshipsObject.Properties.Values) - { - OpenApiSchema fullSchemaForRelationshipsObjectData = _schemaRepositoryAccessor.Current.Schemas[relationshipObjectData.Reference.Id]; - ClearLinksObjectNullability(fullSchemaForRelationshipsObjectData); - ClearMetaObjectNullability(fullSchemaForRelationshipsObjectData); - } - } - } -} diff --git a/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/JsonApiSchemaGenerator.cs b/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/JsonApiSchemaGenerator.cs index 05e17fabce..3e7c98b9f9 100644 --- a/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/JsonApiSchemaGenerator.cs +++ b/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/JsonApiSchemaGenerator.cs @@ -12,32 +12,25 @@ namespace JsonApiDotNetCore.OpenApi.SwaggerComponents { internal sealed class JsonApiSchemaGenerator : ISchemaGenerator { - private static readonly Type[] JsonApiResourceDocumentOpenTypes = + private static readonly Type[] JsonApiDocumentOpenTypes = { typeof(ResourceCollectionResponseDocument<>), typeof(PrimaryResourceResponseDocument<>), typeof(SecondaryResourceResponseDocument<>), + typeof(NullableSecondaryResourceResponseDocument<>), typeof(ResourcePostRequestDocument<>), - typeof(ResourcePatchRequestDocument<>) - }; - - private static readonly Type[] SingleNonPrimaryDataDocumentOpenTypes = - { - typeof(ToOneRelationshipRequestData<>), - typeof(ResourceIdentifierResponseDocument<>), - typeof(SecondaryResourceResponseDocument<>) - }; - - private static readonly Type[] JsonApiResourceIdentifierDocumentOpenTypes = - { + typeof(ResourcePatchRequestDocument<>), typeof(ResourceIdentifierCollectionResponseDocument<>), - typeof(ResourceIdentifierResponseDocument<>) + typeof(ResourceIdentifierResponseDocument<>), + typeof(NullableResourceIdentifierResponseDocument<>), + typeof(ToManyRelationshipRequestData<>), + typeof(ToOneRelationshipRequestData<>), + typeof(NullableToOneRelationshipRequestData<>) }; private readonly ISchemaGenerator _defaultSchemaGenerator; private readonly ResourceObjectSchemaGenerator _resourceObjectSchemaGenerator; private readonly NullableReferenceSchemaGenerator _nullableReferenceSchemaGenerator; - private readonly JsonApiObjectNullabilityProcessor _jsonApiObjectNullabilityProcessor; private readonly SchemaRepositoryAccessor _schemaRepositoryAccessor = new(); public JsonApiSchemaGenerator(SchemaGenerator defaultSchemaGenerator, IResourceGraph resourceGraph, IJsonApiOptions options) @@ -48,11 +41,10 @@ public JsonApiSchemaGenerator(SchemaGenerator defaultSchemaGenerator, IResourceG _defaultSchemaGenerator = defaultSchemaGenerator; _nullableReferenceSchemaGenerator = new NullableReferenceSchemaGenerator(_schemaRepositoryAccessor); - _jsonApiObjectNullabilityProcessor = new JsonApiObjectNullabilityProcessor(_schemaRepositoryAccessor); _resourceObjectSchemaGenerator = new ResourceObjectSchemaGenerator(defaultSchemaGenerator, resourceGraph, options, _schemaRepositoryAccessor); } - public OpenApiSchema GenerateSchema(Type type, SchemaRepository schemaRepository, MemberInfo memberInfo = null, ParameterInfo parameterInfo = null) + public OpenApiSchema GenerateSchema(Type type, SchemaRepository schemaRepository, MemberInfo? memberInfo = null, ParameterInfo? parameterInfo = null) { ArgumentGuard.NotNull(type, nameof(type)); ArgumentGuard.NotNull(schemaRepository, nameof(schemaRepository)); @@ -64,66 +56,62 @@ public OpenApiSchema GenerateSchema(Type type, SchemaRepository schemaRepository return jsonApiDocumentSchema; } - OpenApiSchema schema = IsJsonApiResourceDocument(type) - ? GenerateResourceJsonApiDocumentSchema(type) - : _defaultSchemaGenerator.GenerateSchema(type, schemaRepository, memberInfo, parameterInfo); - - if (IsSingleNonPrimaryDataDocument(type)) - { - SetDataObjectSchemaToNullable(schema); - } - if (IsJsonApiDocument(type)) { - RemoveNotApplicableNullability(schema); - } + OpenApiSchema schema = GenerateJsonApiDocumentSchema(type); - return schema; - } + if (IsDataPropertyNullable(type)) + { + SetDataObjectSchemaToNullable(schema); + } + } - private static bool IsJsonApiResourceDocument(Type type) - { - return type.IsConstructedGenericType && JsonApiResourceDocumentOpenTypes.Contains(type.GetGenericTypeDefinition()); + return _defaultSchemaGenerator.GenerateSchema(type, schemaRepository, memberInfo, parameterInfo); } private static bool IsJsonApiDocument(Type type) { - return IsJsonApiResourceDocument(type) || IsJsonApiResourceIdentifierDocument(type); + return type.IsConstructedGenericType && JsonApiDocumentOpenTypes.Contains(type.GetGenericTypeDefinition()); } - private static bool IsJsonApiResourceIdentifierDocument(Type type) + private OpenApiSchema GenerateJsonApiDocumentSchema(Type documentType) { - return type.IsConstructedGenericType && JsonApiResourceIdentifierDocumentOpenTypes.Contains(type.GetGenericTypeDefinition()); - } - - private OpenApiSchema GenerateResourceJsonApiDocumentSchema(Type type) - { - Type resourceObjectType = type.BaseType!.GenericTypeArguments[0]; + Type resourceObjectType = documentType.BaseType!.GenericTypeArguments[0]; if (!_schemaRepositoryAccessor.Current.TryLookupByType(resourceObjectType, out OpenApiSchema referenceSchemaForResourceObject)) { referenceSchemaForResourceObject = _resourceObjectSchemaGenerator.GenerateSchema(resourceObjectType); } - OpenApiSchema referenceSchemaForDocument = _defaultSchemaGenerator.GenerateSchema(type, _schemaRepositoryAccessor.Current); + OpenApiSchema referenceSchemaForDocument = _defaultSchemaGenerator.GenerateSchema(documentType, _schemaRepositoryAccessor.Current); OpenApiSchema fullSchemaForDocument = _schemaRepositoryAccessor.Current.Schemas[referenceSchemaForDocument.Reference.Id]; - OpenApiSchema referenceSchemaForDataObject = - IsSingleDataDocument(type) ? referenceSchemaForResourceObject : CreateArrayTypeDataSchema(referenceSchemaForResourceObject); + OpenApiSchema referenceSchemaForDataObject = IsManyDataDocument(documentType) + ? CreateArrayTypeDataSchema(referenceSchemaForResourceObject) + : referenceSchemaForResourceObject; fullSchemaForDocument.Properties[JsonApiObjectPropertyName.Data] = referenceSchemaForDataObject; return referenceSchemaForDocument; } - private static bool IsSingleDataDocument(Type type) + private static bool IsManyDataDocument(Type documentType) { - return type.BaseType?.IsConstructedGenericType == true && type.BaseType.GetGenericTypeDefinition() == typeof(SingleData<>); + return documentType.BaseType!.GetGenericTypeDefinition() == typeof(ManyData<>); } - private static bool IsSingleNonPrimaryDataDocument(Type type) + private static bool IsDataPropertyNullable(Type type) { - return type.IsConstructedGenericType && SingleNonPrimaryDataDocumentOpenTypes.Contains(type.GetGenericTypeDefinition()); + PropertyInfo? dataProperty = type.GetProperty(nameof(JsonApiObjectPropertyName.Data)); + + if (dataProperty == null) + { + throw new UnreachableCodeException(); + } + + TypeCategory typeCategory = dataProperty.GetTypeCategory(); + + return typeCategory == TypeCategory.NullableReferenceType; } private void SetDataObjectSchemaToNullable(OpenApiSchema referenceSchemaForDocument) @@ -135,16 +123,11 @@ private void SetDataObjectSchemaToNullable(OpenApiSchema referenceSchemaForDocum private static OpenApiSchema CreateArrayTypeDataSchema(OpenApiSchema referenceSchemaForResourceObject) { - return new() + return new OpenApiSchema { Items = referenceSchemaForResourceObject, Type = "array" }; } - - private void RemoveNotApplicableNullability(OpenApiSchema schema) - { - _jsonApiObjectNullabilityProcessor.ClearDocumentProperties(schema); - } } } diff --git a/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/NullableReferenceSchemaGenerator.cs b/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/NullableReferenceSchemaGenerator.cs index 3449b2abfc..f14e585bb8 100644 --- a/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/NullableReferenceSchemaGenerator.cs +++ b/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/NullableReferenceSchemaGenerator.cs @@ -9,7 +9,7 @@ internal sealed class NullableReferenceSchemaGenerator private static readonly NullableReferenceSchemaStrategy NullableReferenceStrategy = Enum.Parse(NullableReferenceSchemaStrategy.Implicit.ToString()); - private static OpenApiSchema _referenceSchemaForNullValue; + private static OpenApiSchema? _referenceSchemaForNullValue; private readonly ISchemaRepositoryAccessor _schemaRepositoryAccessor; public NullableReferenceSchemaGenerator(ISchemaRepositoryAccessor schemaRepositoryAccessor) @@ -43,7 +43,7 @@ private OpenApiSchema GetNullableReferenceSchema() // This approach is supported in OAS starting from v3.1. See https://github.com/OAI/OpenAPI-Specification/issues/1368#issuecomment-580103688 private static OpenApiSchema GetNullableReferenceSchemaUsingExplicitNullType() { - return new() + return new OpenApiSchema { Type = "null" }; diff --git a/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/ResourceFieldObjectSchemaBuilder.cs b/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/ResourceFieldObjectSchemaBuilder.cs index ab08ae3948..bb45460968 100644 --- a/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/ResourceFieldObjectSchemaBuilder.cs +++ b/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/ResourceFieldObjectSchemaBuilder.cs @@ -3,6 +3,7 @@ using System.ComponentModel.DataAnnotations; using System.Linq; using System.Reflection; +using JsonApiDotNetCore.OpenApi.JsonApiObjects; using JsonApiDotNetCore.OpenApi.JsonApiObjects.RelationshipData; using JsonApiDotNetCore.OpenApi.JsonApiObjects.ResourceObjects; using JsonApiDotNetCore.Resources.Annotations; @@ -15,6 +16,13 @@ internal sealed class ResourceFieldObjectSchemaBuilder { private static readonly SchemaRepository ResourceSchemaRepository = new(); + private static readonly Type[] RelationshipResponseDataOpenTypes = + { + typeof(ToOneRelationshipResponseData<>), + typeof(ToManyRelationshipResponseData<>), + typeof(NullableToOneRelationshipResponseData<>) + }; + private readonly ResourceTypeInfo _resourceTypeInfo; private readonly ISchemaRepositoryAccessor _schemaRepositoryAccessor; private readonly SchemaGenerator _defaultSchemaGenerator; @@ -44,16 +52,16 @@ public ResourceFieldObjectSchemaBuilder(ResourceTypeInfo resourceTypeInfo, ISche private IDictionary GetFieldSchemas() { - if (!ResourceSchemaRepository.TryLookupByType(_resourceTypeInfo.ResourceType, out OpenApiSchema referenceSchemaForResource)) + if (!ResourceSchemaRepository.TryLookupByType(_resourceTypeInfo.ResourceType.ClrType, out OpenApiSchema referenceSchemaForResource)) { - referenceSchemaForResource = _defaultSchemaGenerator.GenerateSchema(_resourceTypeInfo.ResourceType, ResourceSchemaRepository); + referenceSchemaForResource = _defaultSchemaGenerator.GenerateSchema(_resourceTypeInfo.ResourceType.ClrType, ResourceSchemaRepository); } OpenApiSchema fullSchemaForResource = ResourceSchemaRepository.Schemas[referenceSchemaForResource.Reference.Id]; return fullSchemaForResource.Properties; } - public OpenApiSchema BuildAttributesObject(OpenApiSchema fullSchemaForResourceObject) + public OpenApiSchema? BuildAttributesObject(OpenApiSchema fullSchemaForResourceObject) { ArgumentGuard.NotNull(fullSchemaForResourceObject, nameof(fullSchemaForResourceObject)); @@ -61,14 +69,14 @@ public OpenApiSchema BuildAttributesObject(OpenApiSchema fullSchemaForResourceOb SetMembersOfAttributesObject(fullSchemaForAttributesObject); - fullSchemaForAttributesObject.AdditionalPropertiesAllowed = false; - - if (fullSchemaForAttributesObject.Properties.Any()) + if (!fullSchemaForAttributesObject.Properties.Any()) { - return GetReferenceSchemaForFieldObject(fullSchemaForAttributesObject, JsonApiObjectPropertyName.AttributesObject); + return null; } - return null; + fullSchemaForAttributesObject.AdditionalPropertiesAllowed = false; + + return GetReferenceSchemaForFieldObject(fullSchemaForAttributesObject, JsonApiObjectPropertyName.AttributesObject); } private void SetMembersOfAttributesObject(OpenApiSchema fullSchemaForAttributesObject) @@ -77,13 +85,15 @@ private void SetMembersOfAttributesObject(OpenApiSchema fullSchemaForAttributesO foreach ((string fieldName, OpenApiSchema resourceFieldSchema) in _schemasForResourceFields) { - var matchingAttribute = _resourceTypeInfo.TryGetResourceFieldByName(fieldName); + AttrAttribute? matchingAttribute = _resourceTypeInfo.ResourceType.FindAttributeByPublicName(fieldName); if (matchingAttribute != null && matchingAttribute.Capabilities.HasFlag(requiredCapability)) { AddAttributeSchemaToResourceObject(matchingAttribute, fullSchemaForAttributesObject, resourceFieldSchema); - if (IsAttributeRequired(_resourceTypeInfo.ResourceObjectOpenType, matchingAttribute)) + resourceFieldSchema.Nullable = matchingAttribute.IsNullable(); + + if (IsFieldRequired(matchingAttribute)) { fullSchemaForAttributesObject.Required.Add(matchingAttribute.PublicName); } @@ -115,9 +125,23 @@ private void ExposeSchema(OpenApiReference openApiReference, Type typeRepresente _schemaRepositoryAccessor.Current.RegisterType(typeRepresentedBySchema, openApiReference.Id); } - private static bool IsAttributeRequired(Type resourceObjectOpenType, AttrAttribute matchingAttribute) + private bool IsFieldRequired(ResourceFieldAttribute field) { - return resourceObjectOpenType == typeof(ResourcePostRequestObject<>) && matchingAttribute.Property.GetCustomAttribute() != null; + if (field is HasManyAttribute || _resourceTypeInfo.ResourceObjectOpenType != typeof(ResourcePostRequestObject<>)) + { + return false; + } + + TypeCategory fieldTypeCategory = field.Property.GetTypeCategory(); + bool hasRequiredAttribute = field.Property.HasAttribute(); + + return fieldTypeCategory switch + { + TypeCategory.NonNullableReferenceType => true, + TypeCategory.ValueType => hasRequiredAttribute, + TypeCategory.NullableReferenceType or TypeCategory.NullableValueType => hasRequiredAttribute, + _ => throw new UnreachableCodeException() + }; } private OpenApiSchema GetReferenceSchemaForFieldObject(OpenApiSchema fullSchema, string fieldObjectName) @@ -129,7 +153,7 @@ private OpenApiSchema GetReferenceSchemaForFieldObject(OpenApiSchema fullSchema, return _schemaRepositoryAccessor.Current.AddDefinition(fieldObjectSchemaId, fullSchema); } - public OpenApiSchema BuildRelationshipsObject(OpenApiSchema fullSchemaForResourceObject) + public OpenApiSchema? BuildRelationshipsObject(OpenApiSchema fullSchemaForResourceObject) { ArgumentGuard.NotNull(fullSchemaForResourceObject, nameof(fullSchemaForResourceObject)); @@ -137,21 +161,21 @@ public OpenApiSchema BuildRelationshipsObject(OpenApiSchema fullSchemaForResourc SetMembersOfRelationshipsObject(fullSchemaForRelationshipsObject); - fullSchemaForRelationshipsObject.AdditionalPropertiesAllowed = false; - - if (fullSchemaForRelationshipsObject.Properties.Any()) + if (!fullSchemaForRelationshipsObject.Properties.Any()) { - return GetReferenceSchemaForFieldObject(fullSchemaForRelationshipsObject, JsonApiObjectPropertyName.RelationshipsObject); + return null; } - return null; + fullSchemaForRelationshipsObject.AdditionalPropertiesAllowed = false; + + return GetReferenceSchemaForFieldObject(fullSchemaForRelationshipsObject, JsonApiObjectPropertyName.RelationshipsObject); } private void SetMembersOfRelationshipsObject(OpenApiSchema fullSchemaForRelationshipsObject) { foreach (string fieldName in _schemasForResourceFields.Keys) { - var matchingRelationship = _resourceTypeInfo.TryGetResourceFieldByName(fieldName); + RelationshipAttribute? matchingRelationship = _resourceTypeInfo.ResourceType.FindRelationshipByPublicName(fieldName); if (matchingRelationship != null) { @@ -163,7 +187,7 @@ private void SetMembersOfRelationshipsObject(OpenApiSchema fullSchemaForRelation private void EnsureResourceIdentifierObjectSchemaExists(RelationshipAttribute relationship) { - Type resourceIdentifierObjectType = typeof(ResourceIdentifierObject<>).MakeGenericType(relationship.RightType); + Type resourceIdentifierObjectType = typeof(ResourceIdentifierObject<>).MakeGenericType(relationship.RightType.ClrType); if (!ResourceIdentifierObjectSchemaExists(resourceIdentifierObjectType)) { @@ -188,56 +212,71 @@ private void GenerateResourceIdentifierObjectSchema(Type resourceIdentifierObjec fullSchemaForResourceIdentifierObject.Properties[JsonApiObjectPropertyName.Type] = _resourceTypeSchemaGenerator.Get(resourceType); } - private void AddRelationshipDataSchemaToResourceObject(RelationshipAttribute relationship, OpenApiSchema relationshipObjectSchema) + private void AddRelationshipDataSchemaToResourceObject(RelationshipAttribute relationship, OpenApiSchema fullSchemaForRelationshipObject) { Type relationshipDataType = GetRelationshipDataType(relationship, _resourceTypeInfo.ResourceObjectOpenType); - OpenApiSchema referenceSchemaForRelationshipData = TryGetReferenceSchemaForRelationshipData(relationshipDataType) ?? - CreateRelationshipDataObjectSchema(relationship, relationshipDataType); + OpenApiSchema relationshipDataSchema = GetReferenceSchemaForRelationshipData(relationshipDataType) ?? + CreateRelationshipDataObjectSchema(relationshipDataType); - relationshipObjectSchema.Properties.Add(relationship.PublicName, referenceSchemaForRelationshipData); - } + fullSchemaForRelationshipObject.Properties.Add(relationship.PublicName, relationshipDataSchema); - private static Type GetRelationshipDataType(RelationshipAttribute relationship, Type resourceObjectType) - { - if (resourceObjectType.GetGenericTypeDefinition().IsAssignableTo(typeof(ResourceResponseObject<>))) + if (IsFieldRequired(relationship)) { - return relationship is HasOneAttribute - ? typeof(ToOneRelationshipResponseData<>).MakeGenericType(relationship.RightType) - : typeof(ToManyRelationshipResponseData<>).MakeGenericType(relationship.RightType); + fullSchemaForRelationshipObject.Required.Add(relationship.PublicName); } + } - return relationship is HasOneAttribute - ? typeof(ToOneRelationshipRequestData<>).MakeGenericType(relationship.RightType) - : typeof(ToManyRelationshipRequestData<>).MakeGenericType(relationship.RightType); + private static Type GetRelationshipDataType(RelationshipAttribute relationship, Type resourceObjectType) + { + return resourceObjectType.GetGenericTypeDefinition().IsAssignableTo(typeof(ResourceResponseObject<>)) + ? RelationshipDataTypeFactory.Instance.GetForResponse(relationship) + : RelationshipDataTypeFactory.Instance.GetForRequest(relationship); } - private OpenApiSchema TryGetReferenceSchemaForRelationshipData(Type relationshipDataType) + private OpenApiSchema? GetReferenceSchemaForRelationshipData(Type relationshipDataType) { - _schemaRepositoryAccessor.Current.TryLookupByType(relationshipDataType, out OpenApiSchema referenceSchemaForRelationshipData); + _schemaRepositoryAccessor.Current.TryLookupByType(relationshipDataType, out OpenApiSchema? referenceSchemaForRelationshipData); return referenceSchemaForRelationshipData; } - private OpenApiSchema CreateRelationshipDataObjectSchema(RelationshipAttribute relationship, Type relationshipDataType) + private OpenApiSchema CreateRelationshipDataObjectSchema(Type relationshipDataType) { OpenApiSchema referenceSchema = _defaultSchemaGenerator.GenerateSchema(relationshipDataType, _schemaRepositoryAccessor.Current); OpenApiSchema fullSchema = _schemaRepositoryAccessor.Current.Schemas[referenceSchema.Reference.Id]; + if (IsDataPropertyNullable(relationshipDataType)) + { + fullSchema.Properties[JsonApiObjectPropertyName.Data] = + _nullableReferenceSchemaGenerator.GenerateSchema(fullSchema.Properties[JsonApiObjectPropertyName.Data]); + } + Type relationshipDataOpenType = relationshipDataType.GetGenericTypeDefinition(); - if (relationshipDataOpenType == typeof(ToOneRelationshipResponseData<>) || relationshipDataOpenType == typeof(ToManyRelationshipResponseData<>)) + if (IsRelationshipDataPropertyInResponse(relationshipDataOpenType)) { fullSchema.Required.Remove(JsonApiObjectPropertyName.Data); } - if (relationship is HasOneAttribute) + return referenceSchema; + } + + private static bool IsRelationshipDataPropertyInResponse(Type relationshipDataOpenType) + { + return RelationshipResponseDataOpenTypes.Contains(relationshipDataOpenType); + } + + private static bool IsDataPropertyNullable(Type type) + { + PropertyInfo? dataProperty = type.GetProperty(nameof(JsonApiObjectPropertyName.Data)); + + if (dataProperty == null) { - fullSchema.Properties[JsonApiObjectPropertyName.Data] = - _nullableReferenceSchemaGenerator.GenerateSchema(fullSchema.Properties[JsonApiObjectPropertyName.Data]); + throw new UnreachableCodeException(); } - return referenceSchema; + return dataProperty.GetTypeCategory() == TypeCategory.NullableReferenceType; } } } diff --git a/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/ResourceObjectSchemaGenerator.cs b/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/ResourceObjectSchemaGenerator.cs index baf380972d..e374b203b7 100644 --- a/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/ResourceObjectSchemaGenerator.cs +++ b/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/ResourceObjectSchemaGenerator.cs @@ -40,7 +40,7 @@ private static Func CreateFi IResourceGraph resourceGraph, IJsonApiOptions options, ISchemaRepositoryAccessor schemaRepositoryAccessor, ResourceTypeSchemaGenerator resourceTypeSchemaGenerator) { - JsonNamingPolicy namingPolicy = options.SerializerOptions.PropertyNamingPolicy; + JsonNamingPolicy? namingPolicy = options.SerializerOptions.PropertyNamingPolicy; ResourceNameFormatter resourceNameFormatter = new(namingPolicy); var jsonApiSchemaIdSelector = new JsonApiSchemaIdSelector(resourceNameFormatter, resourceGraph); @@ -59,7 +59,7 @@ public OpenApiSchema GenerateSchema(Type resourceObjectType) RemoveResourceIdIfPostResourceObject(resourceTypeInfo.ResourceObjectOpenType, fullSchemaForResourceObject); - SetResourceType(fullSchemaForResourceObject, resourceTypeInfo.ResourceType); + SetResourceType(fullSchemaForResourceObject, resourceTypeInfo.ResourceType.ClrType); SetResourceAttributes(fullSchemaForResourceObject, fieldObjectBuilder); @@ -106,7 +106,7 @@ private void SetResourceType(OpenApiSchema fullSchemaForResourceObject, Type res private static void SetResourceAttributes(OpenApiSchema fullSchemaForResourceObject, ResourceFieldObjectSchemaBuilder builder) { - OpenApiSchema fullSchemaForAttributesObject = builder.BuildAttributesObject(fullSchemaForResourceObject); + OpenApiSchema? fullSchemaForAttributesObject = builder.BuildAttributesObject(fullSchemaForResourceObject); if (fullSchemaForAttributesObject != null) { @@ -120,7 +120,7 @@ private static void SetResourceAttributes(OpenApiSchema fullSchemaForResourceObj private static void SetResourceRelationships(OpenApiSchema fullSchemaForResourceObject, ResourceFieldObjectSchemaBuilder builder) { - OpenApiSchema fullSchemaForRelationshipsObject = builder.BuildRelationshipsObject(fullSchemaForResourceObject); + OpenApiSchema? fullSchemaForRelationshipsObject = builder.BuildRelationshipsObject(fullSchemaForResourceObject); if (fullSchemaForRelationshipsObject != null) { diff --git a/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/ResourceTypeInfo.cs b/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/ResourceTypeInfo.cs index 67d98dbb85..ff7cc34d5e 100644 --- a/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/ResourceTypeInfo.cs +++ b/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/ResourceTypeInfo.cs @@ -1,22 +1,16 @@ using System; -using System.Linq; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCore.OpenApi.SwaggerComponents { internal sealed class ResourceTypeInfo { - private readonly ResourceContext _resourceContext; - public Type ResourceObjectType { get; } public Type ResourceObjectOpenType { get; } - public Type ResourceType { get; } + public ResourceType ResourceType { get; } - private ResourceTypeInfo(Type resourceObjectType, Type resourceObjectOpenType, Type resourceType, ResourceContext resourceContext) + private ResourceTypeInfo(Type resourceObjectType, Type resourceObjectOpenType, ResourceType resourceType) { - _resourceContext = resourceContext; - ResourceObjectType = resourceObjectType; ResourceObjectOpenType = resourceObjectOpenType; ResourceType = resourceType; @@ -28,18 +22,10 @@ public static ResourceTypeInfo Create(Type resourceObjectType, IResourceGraph re ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); Type resourceObjectOpenType = resourceObjectType.GetGenericTypeDefinition(); - Type resourceType = resourceObjectType.GenericTypeArguments[0]; - ResourceContext resourceContext = resourceGraph.GetResourceContext(resourceType); - - return new ResourceTypeInfo(resourceObjectType, resourceObjectOpenType, resourceType, resourceContext); - } - - public TResourceFieldAttribute TryGetResourceFieldByName(string publicName) - where TResourceFieldAttribute : ResourceFieldAttribute - { - ArgumentGuard.NotNullNorEmpty(publicName, nameof(publicName)); + Type resourceClrType = resourceObjectType.GenericTypeArguments[0]; + ResourceType resourceType = resourceGraph.GetResourceType(resourceClrType); - return (TResourceFieldAttribute)_resourceContext.Fields.FirstOrDefault(field => field is TResourceFieldAttribute && field.PublicName == publicName); + return new ResourceTypeInfo(resourceObjectType, resourceObjectOpenType, resourceType); } } } diff --git a/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/ResourceTypeSchemaGenerator.cs b/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/ResourceTypeSchemaGenerator.cs index 2c1b43916c..c3272f35a1 100644 --- a/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/ResourceTypeSchemaGenerator.cs +++ b/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/ResourceTypeSchemaGenerator.cs @@ -10,7 +10,7 @@ internal sealed class ResourceTypeSchemaGenerator { private readonly ISchemaRepositoryAccessor _schemaRepositoryAccessor; private readonly IResourceGraph _resourceGraph; - private readonly Dictionary _resourceTypeSchemaCache = new(); + private readonly Dictionary _resourceClrTypeSchemaCache = new(); public ResourceTypeSchemaGenerator(ISchemaRepositoryAccessor schemaRepositoryAccessor, IResourceGraph resourceGraph) { @@ -21,23 +21,23 @@ public ResourceTypeSchemaGenerator(ISchemaRepositoryAccessor schemaRepositoryAcc _resourceGraph = resourceGraph; } - public OpenApiSchema Get(Type resourceType) + public OpenApiSchema Get(Type resourceClrType) { - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + ArgumentGuard.NotNull(resourceClrType, nameof(resourceClrType)); - if (_resourceTypeSchemaCache.TryGetValue(resourceType, out OpenApiSchema referenceSchema)) + if (_resourceClrTypeSchemaCache.TryGetValue(resourceClrType, out OpenApiSchema? referenceSchema)) { return referenceSchema; } - ResourceContext resourceContext = _resourceGraph.GetResourceContext(resourceType); + ResourceType resourceType = _resourceGraph.GetResourceType(resourceClrType); var fullSchema = new OpenApiSchema { Type = "string", Enum = new List { - new OpenApiString(resourceContext.PublicName) + new OpenApiString(resourceType.PublicName) } }; @@ -45,13 +45,13 @@ public OpenApiSchema Get(Type resourceType) { Reference = new OpenApiReference { - Id = $"{resourceContext.PublicName}-resource-type", + Id = $"{resourceType.PublicName}-resource-type", Type = ReferenceType.Schema } }; _schemaRepositoryAccessor.Current.AddDefinition(referenceSchema.Reference.Id, fullSchema); - _resourceTypeSchemaCache.Add(resourceContext.ResourceType, referenceSchema); + _resourceClrTypeSchemaCache.Add(resourceType.ClrType, referenceSchema); return referenceSchema; } diff --git a/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/SchemaRepositoryAccessor.cs b/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/SchemaRepositoryAccessor.cs index e0a909f68a..3dcb43d5a1 100644 --- a/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/SchemaRepositoryAccessor.cs +++ b/src/JsonApiDotNetCore.OpenApi/SwaggerComponents/SchemaRepositoryAccessor.cs @@ -5,7 +5,7 @@ namespace JsonApiDotNetCore.OpenApi.SwaggerComponents { internal sealed class SchemaRepositoryAccessor : ISchemaRepositoryAccessor { - private SchemaRepository _schemaRepository; + private SchemaRepository? _schemaRepository; public SchemaRepository Current { diff --git a/src/JsonApiDotNetCore.OpenApi/TypeCategory.cs b/src/JsonApiDotNetCore.OpenApi/TypeCategory.cs new file mode 100644 index 0000000000..bb8104d6fb --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi/TypeCategory.cs @@ -0,0 +1,10 @@ +namespace JsonApiDotNetCore.OpenApi +{ + internal enum TypeCategory + { + NonNullableReferenceType, + NullableReferenceType, + ValueType, + NullableValueType + } +} diff --git a/src/JsonApiDotNetCore/ArgumentGuard.cs b/src/JsonApiDotNetCore/ArgumentGuard.cs index c9f9e2d6a7..1877078df9 100644 --- a/src/JsonApiDotNetCore/ArgumentGuard.cs +++ b/src/JsonApiDotNetCore/ArgumentGuard.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using JetBrains.Annotations; +using SysNotNull = System.Diagnostics.CodeAnalysis.NotNullAttribute; #pragma warning disable AV1008 // Class should not be static @@ -10,8 +11,7 @@ namespace JsonApiDotNetCore internal static class ArgumentGuard { [AssertionMethod] - [ContractAnnotation("value: null => halt")] - public static void NotNull([CanBeNull] [NoEnumeration] T value, [NotNull] [InvokerParameterName] string name) + public static void NotNull([NoEnumeration] [SysNotNull] T? value, [InvokerParameterName] string name) where T : class { if (value is null) @@ -21,9 +21,7 @@ public static void NotNull([CanBeNull] [NoEnumeration] T value, [NotNull] [In } [AssertionMethod] - [ContractAnnotation("value: null => halt")] - public static void NotNullNorEmpty([CanBeNull] IEnumerable value, [NotNull] [InvokerParameterName] string name, - [CanBeNull] string collectionName = null) + public static void NotNullNorEmpty([SysNotNull] IEnumerable? value, [InvokerParameterName] string name, string? collectionName = null) { NotNull(value, name); @@ -34,8 +32,7 @@ public static void NotNullNorEmpty([CanBeNull] IEnumerable value, [NotNull } [AssertionMethod] - [ContractAnnotation("value: null => halt")] - public static void NotNullNorEmpty([CanBeNull] string value, [NotNull] [InvokerParameterName] string name) + public static void NotNullNorEmpty([SysNotNull] string? value, [InvokerParameterName] string name) { NotNull(value, name); diff --git a/src/JsonApiDotNetCore/AtomicOperations/ILocalIdTracker.cs b/src/JsonApiDotNetCore/AtomicOperations/ILocalIdTracker.cs index eb61e41371..1f6eda010d 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/ILocalIdTracker.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/ILocalIdTracker.cs @@ -1,3 +1,5 @@ +using JsonApiDotNetCore.Configuration; + namespace JsonApiDotNetCore.AtomicOperations { /// @@ -13,16 +15,16 @@ public interface ILocalIdTracker /// /// Declares a local ID without assigning a server-generated value. /// - void Declare(string localId, string resourceType); + void Declare(string localId, ResourceType resourceType); /// /// Assigns a server-generated ID value to a previously declared local ID. /// - void Assign(string localId, string resourceType, string stringId); + void Assign(string localId, ResourceType resourceType, string stringId); /// /// Gets the server-assigned ID for the specified local ID. /// - string GetValue(string localId, string resourceType); + string GetValue(string localId, ResourceType resourceType); } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/IOperationProcessorAccessor.cs b/src/JsonApiDotNetCore/AtomicOperations/IOperationProcessorAccessor.cs index 693bd6098b..d35d6e6154 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/IOperationProcessorAccessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/IOperationProcessorAccessor.cs @@ -13,6 +13,6 @@ public interface IOperationProcessorAccessor /// /// Invokes on a processor compatible with the operation kind. /// - Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken); + Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken); } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/IOperationsProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/IOperationsProcessor.cs index 839d0d6cb0..f6c736ee9d 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/IOperationsProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/IOperationsProcessor.cs @@ -13,6 +13,6 @@ public interface IOperationsProcessor /// /// Processes the list of specified operations. /// - Task> ProcessAsync(IList operations, CancellationToken cancellationToken); + Task> ProcessAsync(IList operations, CancellationToken cancellationToken); } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/LocalIdTracker.cs b/src/JsonApiDotNetCore/AtomicOperations/LocalIdTracker.cs index 744a03a9e8..9b24ff4e18 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/LocalIdTracker.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/LocalIdTracker.cs @@ -1,8 +1,7 @@ using System; using System.Collections.Generic; -using System.Net; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Errors; -using JsonApiDotNetCore.Serialization.Objects; namespace JsonApiDotNetCore.AtomicOperations { @@ -18,10 +17,10 @@ public void Reset() } /// - public void Declare(string localId, string resourceType) + public void Declare(string localId, ResourceType resourceType) { ArgumentGuard.NotNullNorEmpty(localId, nameof(localId)); - ArgumentGuard.NotNullNorEmpty(resourceType, nameof(resourceType)); + ArgumentGuard.NotNull(resourceType, nameof(resourceType)); AssertIsNotDeclared(localId); @@ -32,19 +31,15 @@ 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); } } /// - public void Assign(string localId, string resourceType, string stringId) + public void Assign(string localId, ResourceType resourceType, string stringId) { ArgumentGuard.NotNullNorEmpty(localId, nameof(localId)); - ArgumentGuard.NotNullNorEmpty(resourceType, nameof(resourceType)); + ArgumentGuard.NotNull(resourceType, nameof(resourceType)); ArgumentGuard.NotNullNorEmpty(stringId, nameof(stringId)); AssertIsDeclared(localId); @@ -62,10 +57,10 @@ public void Assign(string localId, string resourceType, string stringId) } /// - public string GetValue(string localId, string resourceType) + public string GetValue(string localId, ResourceType resourceType) { ArgumentGuard.NotNullNorEmpty(localId, nameof(localId)); - ArgumentGuard.NotNullNorEmpty(resourceType, nameof(resourceType)); + ArgumentGuard.NotNull(resourceType, nameof(resourceType)); AssertIsDeclared(localId); @@ -75,11 +70,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,32 +80,24 @@ 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); } } - private static void AssertSameResourceType(string currentType, string declaredType, string localId) + private static void AssertSameResourceType(ResourceType currentType, ResourceType declaredType, string localId) { - if (declaredType != currentType) + if (!declaredType.Equals(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.PublicName, currentType.PublicName); } } private sealed class LocalIdState { - public string ResourceType { get; } - public string ServerId { get; set; } + public ResourceType ResourceType { get; } + public string? ServerId { get; set; } - public LocalIdState(string resourceType) + public LocalIdState(ResourceType resourceType) { ResourceType = resourceType; } diff --git a/src/JsonApiDotNetCore/AtomicOperations/LocalIdValidator.cs b/src/JsonApiDotNetCore/AtomicOperations/LocalIdValidator.cs index d880ab7b42..670280e59b 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); } } - 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, "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); } } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/OperationProcessorAccessor.cs b/src/JsonApiDotNetCore/AtomicOperations/OperationProcessorAccessor.cs index a71fa906cd..67596ee697 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/OperationProcessorAccessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/OperationProcessorAccessor.cs @@ -14,20 +14,17 @@ 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; } /// - public Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) + public Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) { ArgumentGuard.NotNull(operation, nameof(operation)); @@ -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!.Value); + 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..ceca03ccdf 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,33 +43,38 @@ public OperationsProcessor(IOperationProcessorAccessor operationProcessorAccesso _resourceGraph = resourceGraph; _request = request; _targetedFields = targetedFields; + _sparseFieldSetCache = sparseFieldSetCache; _localIdValidator = new LocalIdValidator(_localIdTracker, _resourceGraph); } /// - public virtual async Task> ProcessAsync(IList operations, CancellationToken cancellationToken) + public virtual async Task> ProcessAsync(IList operations, CancellationToken cancellationToken) { ArgumentGuard.NotNull(operations, nameof(operations)); _localIdValidator.Validate(operations); _localIdTracker.Reset(); - var results = new List(); + var results = new List(); await using IOperationsTransaction transaction = await _operationsTransactionFactory.BeginTransactionAsync(cancellationToken); try { + using IDisposable _ = new RevertRequestStateOnDispose(_request, _targetedFields); + foreach (OperationContainer operation in operations) { operation.SetTransactionId(transaction.TransactionId); await transaction.BeforeProcessOperationAsync(cancellationToken); - OperationContainer result = await ProcessOperationAsync(operation, cancellationToken); + OperationContainer? result = await ProcessOperationAsync(operation, cancellationToken); results.Add(result); await transaction.AfterProcessOperationAsync(cancellationToken); + + _sparseFieldSetCache.Reset(); } await transaction.CommitAsync(cancellationToken); @@ -89,29 +97,19 @@ public virtual async Task> ProcessAsync(IList ProcessOperationAsync(OperationContainer operation, CancellationToken cancellationToken) + protected virtual async Task ProcessOperationAsync(OperationContainer operation, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); 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); } } @@ -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); } } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/AddToRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/AddToRelationshipProcessor.cs index 775e896ebc..1b6025cf40 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/AddToRelationshipProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/AddToRelationshipProcessor.cs @@ -22,14 +22,14 @@ public AddToRelationshipProcessor(IAddToRelationshipService serv } /// - public virtual async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) + public virtual async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) { ArgumentGuard.NotNull(operation, nameof(operation)); var leftId = (TId)operation.Resource.GetTypedId(); ISet rightResourceIds = operation.GetSecondaryResources(); - await _service.AddToToManyRelationshipAsync(leftId, operation.Request.Relationship.PublicName, rightResourceIds, cancellationToken); + await _service.AddToToManyRelationshipAsync(leftId, operation.Request.Relationship!.PublicName, rightResourceIds, cancellationToken); return null; } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateProcessor.cs index 2e113561ab..06d9ae485a 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateProcessor.cs @@ -1,7 +1,6 @@ using System.Threading; using System.Threading.Tasks; using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Services; @@ -14,32 +13,27 @@ 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; } /// - public virtual async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) + public virtual async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) { ArgumentGuard.NotNull(operation, nameof(operation)); - TResource newResource = await _service.CreateAsync((TResource)operation.Resource, cancellationToken); + TResource? newResource = await _service.CreateAsync((TResource)operation.Resource, cancellationToken); if (operation.Resource.LocalId != null) { - string serverId = newResource != null ? newResource.StringId : operation.Resource.StringId; - ResourceContext resourceContext = _resourceGraph.GetResourceContext(); - - _localIdTracker.Assign(operation.Resource.LocalId, resourceContext.PublicName, serverId); + string serverId = newResource != null ? newResource.StringId! : operation.Resource.StringId!; + _localIdTracker.Assign(operation.Resource.LocalId, operation.Request.PrimaryResourceType!, serverId); } return newResource == null ? null : operation.WithResource(newResource); diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/DeleteProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/DeleteProcessor.cs index 929ffe73a9..29750b395b 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/DeleteProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/DeleteProcessor.cs @@ -21,7 +21,7 @@ public DeleteProcessor(IDeleteService service) } /// - public virtual async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) + public virtual async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) { ArgumentGuard.NotNull(operation, nameof(operation)); diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/IOperationProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/IOperationProcessor.cs index 6b51694260..559bd4cbf4 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/IOperationProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/IOperationProcessor.cs @@ -12,6 +12,6 @@ public interface IOperationProcessor /// /// Processes the specified operation. /// - Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken); + Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken); } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveFromRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveFromRelationshipProcessor.cs index 74197f417f..a186967cf0 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveFromRelationshipProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveFromRelationshipProcessor.cs @@ -22,14 +22,14 @@ public RemoveFromRelationshipProcessor(IRemoveFromRelationshipService - public virtual async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) + public virtual async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) { ArgumentGuard.NotNull(operation, nameof(operation)); var leftId = (TId)operation.Resource.GetTypedId(); ISet rightResourceIds = operation.GetSecondaryResources(); - await _service.RemoveFromToManyRelationshipAsync(leftId, operation.Request.Relationship.PublicName, rightResourceIds, cancellationToken); + await _service.RemoveFromToManyRelationshipAsync(leftId, operation.Request.Relationship!.PublicName, rightResourceIds, cancellationToken); return null; } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs index 92bd69942e..bec2a47854 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs @@ -25,22 +25,22 @@ public SetRelationshipProcessor(ISetRelationshipService service) } /// - public virtual async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) + public virtual async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) { ArgumentGuard.NotNull(operation, nameof(operation)); var leftId = (TId)operation.Resource.GetTypedId(); - object rightValue = GetRelationshipRightValue(operation); + object? rightValue = GetRelationshipRightValue(operation); - await _service.SetRelationshipAsync(leftId, operation.Request.Relationship.PublicName, rightValue, cancellationToken); + await _service.SetRelationshipAsync(leftId, operation.Request.Relationship!.PublicName, rightValue, cancellationToken); return null; } - private object GetRelationshipRightValue(OperationContainer operation) + private object? GetRelationshipRightValue(OperationContainer operation) { - RelationshipAttribute relationship = operation.Request.Relationship; - object rightValue = relationship.GetValue(operation.Resource); + RelationshipAttribute relationship = operation.Request.Relationship!; + object? rightValue = relationship.GetValue(operation.Resource); if (relationship is HasManyAttribute) { diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateProcessor.cs index 151d91adfe..f88bf086df 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateProcessor.cs @@ -21,12 +21,12 @@ public UpdateProcessor(IUpdateService service) } /// - public virtual async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) + public virtual async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) { ArgumentGuard.NotNull(operation, nameof(operation)); var resource = (TResource)operation.Resource; - TResource updated = await _service.UpdateAsync(resource.Id, resource, cancellationToken); + TResource? updated = await _service.UpdateAsync(resource.Id, resource, cancellationToken); return updated == null ? null : operation.WithResource(updated); } diff --git a/src/JsonApiDotNetCore/AtomicOperations/RevertRequestStateOnDispose.cs b/src/JsonApiDotNetCore/AtomicOperations/RevertRequestStateOnDispose.cs new file mode 100644 index 0000000000..b2a1a8daa5 --- /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/CollectionConverter.cs b/src/JsonApiDotNetCore/CollectionConverter.cs index 1f403b4ccd..1ab1768cb9 100644 --- a/src/JsonApiDotNetCore/CollectionConverter.cs +++ b/src/JsonApiDotNetCore/CollectionConverter.cs @@ -33,11 +33,11 @@ public IEnumerable CopyToTypedCollection(IEnumerable source, Type collectionType ArgumentGuard.NotNull(collectionType, nameof(collectionType)); Type concreteCollectionType = ToConcreteCollectionType(collectionType); - dynamic concreteCollectionInstance = Activator.CreateInstance(concreteCollectionType); + dynamic concreteCollectionInstance = Activator.CreateInstance(concreteCollectionType)!; foreach (object item in source) { - concreteCollectionInstance!.Add((dynamic)item); + concreteCollectionInstance.Add((dynamic)item); } return concreteCollectionInstance; @@ -69,7 +69,7 @@ private Type ToConcreteCollectionType(Type collectionType) /// /// Returns a collection that contains zero, one or multiple resources, depending on the specified value. /// - public ICollection ExtractResources(object value) + public ICollection ExtractResources(object? value) { if (value is ICollection resourceCollection) { @@ -92,13 +92,13 @@ public ICollection ExtractResources(object value) /// /// Returns the element type if the specified type is a generic collection, for example: IList{string} -> string or IList -> null. /// - public Type TryGetCollectionElementType(Type type) + public Type? FindCollectionElementType(Type? type) { if (type != null) { if (type.IsGenericType && type.GenericTypeArguments.Length == 1) { - if (type.IsOrImplementsInterface(typeof(IEnumerable))) + if (type.IsOrImplementsInterface()) { return type.GenericTypeArguments[0]; } @@ -114,6 +114,8 @@ public Type TryGetCollectionElementType(Type type) /// public bool TypeCanContainHashSet(Type collectionType) { + ArgumentGuard.NotNull(collectionType, nameof(collectionType)); + if (collectionType.IsGenericType) { Type openCollectionType = collectionType.GetGenericTypeDefinition(); diff --git a/src/JsonApiDotNetCore/CollectionExtensions.cs b/src/JsonApiDotNetCore/CollectionExtensions.cs index ca69755c1c..ea3f0dd3a7 100644 --- a/src/JsonApiDotNetCore/CollectionExtensions.cs +++ b/src/JsonApiDotNetCore/CollectionExtensions.cs @@ -1,15 +1,15 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Contracts; using System.Linq; -using JetBrains.Annotations; namespace JsonApiDotNetCore { internal static class CollectionExtensions { [Pure] - [ContractAnnotation("source: null => true")] - public static bool IsNullOrEmpty(this IEnumerable source) + public static bool IsNullOrEmpty([NotNullWhen(false)] this IEnumerable? source) { if (source == null) { @@ -35,10 +35,10 @@ public static int FindIndex(this IReadOnlyList source, Predicate match) return -1; } - public static bool DictionaryEqual(this IReadOnlyDictionary first, IReadOnlyDictionary second, - IEqualityComparer valueComparer = null) + public static bool DictionaryEqual(this IReadOnlyDictionary? first, IReadOnlyDictionary? second, + IEqualityComparer? valueComparer = null) { - if (first == second) + if (ReferenceEquals(first, second)) { return true; } @@ -57,7 +57,7 @@ public static bool DictionaryEqual(this IReadOnlyDictionary(this IReadOnlyDictionary EmptyIfNull(this IEnumerable? source) + { + return source ?? Enumerable.Empty(); + } + + public static IEnumerable WhereNotNull(this IEnumerable source) + { +#pragma warning disable AV1250 // Evaluate LINQ query before returning it + return source.Where(element => element is not null)!; +#pragma warning restore AV1250 // Evaluate LINQ query before returning it + } + public static void AddRange(this ICollection source, IEnumerable itemsToAdd) { ArgumentGuard.NotNull(source, nameof(source)); diff --git a/src/JsonApiDotNetCore/Configuration/IInverseNavigationResolver.cs b/src/JsonApiDotNetCore/Configuration/IInverseNavigationResolver.cs index 4a7aac4ec2..aa7c77d6e0 100644 --- a/src/JsonApiDotNetCore/Configuration/IInverseNavigationResolver.cs +++ b/src/JsonApiDotNetCore/Configuration/IInverseNavigationResolver.cs @@ -5,7 +5,7 @@ namespace JsonApiDotNetCore.Configuration { /// /// Responsible for populating . This service is instantiated in the configure phase of the - /// application. When using a data access layer different from EF Core, you will need to implement and register this service, or set + /// application. When using a data access layer different from Entity Framework Core, you will need to implement and register this service, or set /// explicitly. /// [PublicAPI] diff --git a/src/JsonApiDotNetCore/Configuration/IJsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/IJsonApiApplicationBuilder.cs index 53b84e36d1..b3f33a1b38 100644 --- a/src/JsonApiDotNetCore/Configuration/IJsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/IJsonApiApplicationBuilder.cs @@ -5,6 +5,6 @@ namespace JsonApiDotNetCore.Configuration { internal interface IJsonApiApplicationBuilder { - public Action ConfigureMvcOptions { set; } + public Action? ConfigureMvcOptions { set; } } } diff --git a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs index 4b5d36c421..15f46e7e79 100644 --- a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs @@ -17,7 +17,7 @@ public interface IJsonApiOptions /// /// options.Namespace = "api/v1"; /// - string Namespace { get; } + string? Namespace { get; } /// /// Specifies the default query string capabilities that can be used on exposed JSON:API attributes. Defaults to . @@ -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. /// @@ -85,20 +90,20 @@ public interface IJsonApiOptions /// /// The page size (10 by default) that is used when not specified in query string. Set to null to not use paging by default. /// - PageSize DefaultPageSize { get; } + PageSize? DefaultPageSize { get; } /// /// The maximum page size that can be used, or null for unconstrained (default). /// - PageSize MaximumPageSize { get; } + PageSize? MaximumPageSize { get; } /// /// The maximum page number that can be used, or null for unconstrained (default). /// - PageNumber MaximumPageNumber { get; } + PageNumber? MaximumPageNumber { get; } /// - /// Whether or not to enable ASP.NET Core model state validation. False by default. + /// Whether or not to enable ASP.NET ModelState validation. True by default. /// bool ValidateModelState { get; } @@ -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..89684e5d86 100644 --- a/src/JsonApiDotNetCore/Configuration/IResourceGraph.cs +++ b/src/JsonApiDotNetCore/Configuration/IResourceGraph.cs @@ -17,69 +17,74 @@ 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? FindResourceType(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? FindResourceType(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) + IReadOnlyCollection GetFields(Expression> selector) where TResource : class, IIdentifiable; /// /// Gets the attributes for 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) + IReadOnlyCollection GetAttributes(Expression> selector) where TResource : class, IIdentifiable; /// /// Gets the relationships for 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) + IReadOnlyCollection GetRelationships(Expression> selector) where TResource : class, IIdentifiable; } } diff --git a/src/JsonApiDotNetCore/Configuration/InverseNavigationResolver.cs b/src/JsonApiDotNetCore/Configuration/InverseNavigationResolver.cs index abe95d00bf..58d0919984 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); } } } @@ -64,7 +64,7 @@ private void ResolveRelationships(IReadOnlyCollection rel { foreach (RelationshipAttribute relationship in relationships) { - if (navigationMap.TryGetValue(relationship.Property.Name, out INavigationBase navigation)) + if (navigationMap.TryGetValue(relationship.Property.Name, out INavigationBase? navigation)) { relationship.InverseNavigationProperty = navigation.Inverse?.PropertyInfo; } diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs index 9200149b25..8bc921af78 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; @@ -39,7 +38,7 @@ internal sealed class JsonApiApplicationBuilder : IJsonApiApplicationBuilder, ID private readonly ServiceDiscoveryFacade _serviceDiscoveryFacade; private readonly ServiceProvider _intermediateProvider; - public Action ConfigureMvcOptions { get; set; } + public Action? ConfigureMvcOptions { get; set; } public JsonApiApplicationBuilder(IServiceCollection services, IMvcCoreBuilder mvcBuilder) { @@ -59,7 +58,7 @@ public JsonApiApplicationBuilder(IServiceCollection services, IMvcCoreBuilder mv /// /// Executes the action provided by the user to configure . /// - public void ConfigureJsonApiOptions(Action configureOptions) + public void ConfigureJsonApiOptions(Action? configureOptions) { configureOptions?.Invoke(_options); } @@ -67,7 +66,7 @@ public void ConfigureJsonApiOptions(Action configureOptions) /// /// Executes the action provided by the user to configure . /// - public void ConfigureAutoDiscovery(Action configureAutoDiscovery) + public void ConfigureAutoDiscovery(Action? configureAutoDiscovery) { configureAutoDiscovery?.Invoke(_serviceDiscoveryFacade); } @@ -75,14 +74,16 @@ public void ConfigureAutoDiscovery(Action configureAutoD /// /// Configures and builds the resource graph with resources from the provided sources and adds it to the DI container. /// - public void AddResourceGraph(ICollection dbContextTypes, Action configureResourceGraph) + public void ConfigureResourceGraph(ICollection dbContextTypes, Action? configureResourceGraph) { + ArgumentGuard.NotNull(dbContextTypes, nameof(dbContextTypes)); + _serviceDiscoveryFacade.DiscoverResources(); foreach (Type dbContextType in dbContextTypes) { var dbContext = (DbContext)_intermediateProvider.GetRequiredService(dbContextType); - AddResourcesFromDbContext(dbContext, _resourceGraphBuilder); + _resourceGraphBuilder.Add(dbContext); } configureResourceGraph?.Invoke(_resourceGraphBuilder); @@ -95,7 +96,7 @@ public void AddResourceGraph(ICollection dbContextTypes, Action - /// Configures built-in ASP.NET Core MVC components. Most of this configuration can be adjusted for the developers' need. + /// Configures built-in ASP.NET MVC components. Most of this configuration can be adjusted for the developers' need. /// public void ConfigureMvc() { @@ -128,14 +129,16 @@ public void DiscoverInjectables() /// public void ConfigureServiceContainer(ICollection dbContextTypes) { + ArgumentGuard.NotNull(dbContextTypes, nameof(dbContextTypes)); + if (dbContextTypes.Any()) { _services.AddScoped(typeof(DbContextResolver<>)); 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 +159,7 @@ public void ConfigureServiceContainer(ICollection dbContextTypes) _services.AddScoped(typeof(IResourceChangeTracker<>), typeof(ResourceChangeTracker<>)); _services.AddScoped(); _services.AddScoped(); + _services.AddScoped(); _services.AddScoped(); _services.AddScoped(); } @@ -173,18 +177,15 @@ 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() { - RegisterImplementationForOpenInterfaces(ServiceDiscoveryFacade.ResourceDefinitionInterfaces, typeof(JsonApiResourceDefinition<>), - typeof(JsonApiResourceDefinition<,>)); + RegisterImplementationForOpenInterfaces(ServiceDiscoveryFacade.ResourceDefinitionInterfaces, typeof(JsonApiResourceDefinition<,>)); _services.AddScoped(); _services.AddScoped(); @@ -192,24 +193,20 @@ private void AddResourceLayer() private void AddRepositoryLayer() { - RegisterImplementationForOpenInterfaces(ServiceDiscoveryFacade.RepositoryInterfaces, typeof(EntityFrameworkCoreRepository<>), - typeof(EntityFrameworkCoreRepository<,>)); + RegisterImplementationForOpenInterfaces(ServiceDiscoveryFacade.RepositoryInterfaces, typeof(EntityFrameworkCoreRepository<,>)); _services.AddScoped(); } private void AddServiceLayer() { - RegisterImplementationForOpenInterfaces(ServiceDiscoveryFacade.ServiceInterfaces, typeof(JsonApiResourceService<>), - typeof(JsonApiResourceService<,>)); + RegisterImplementationForOpenInterfaces(ServiceDiscoveryFacade.ServiceInterfaces, typeof(JsonApiResourceService<,>)); } - private void RegisterImplementationForOpenInterfaces(HashSet openGenericInterfaces, Type intImplementation, Type implementation) + private void RegisterImplementationForOpenInterfaces(HashSet openGenericInterfaces, Type implementationType) { foreach (Type openGenericInterface in openGenericInterfaces) { - Type implementationType = openGenericInterface.GetGenericArguments().Length == 1 ? intImplementation : implementation; - _services.TryAddScoped(openGenericInterface, implementationType); } } @@ -250,18 +247,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 +280,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..39a5e197f2 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs @@ -27,7 +27,7 @@ public sealed class JsonApiOptions : IJsonApiOptions internal bool DisableChildrenPagination { get; set; } /// - public string Namespace { get; set; } + public string? Namespace { get; set; } /// public AttrCapabilities DefaultAttrCapabilities { get; set; } = AttrCapabilities.All; @@ -38,6 +38,9 @@ public sealed class JsonApiOptions : IJsonApiOptions /// public bool IncludeExceptionStackTraceInErrors { get; set; } + /// + public bool IncludeRequestBodyInErrors { get; set; } + /// public bool UseRelativeLinks { get; set; } @@ -54,16 +57,16 @@ public sealed class JsonApiOptions : IJsonApiOptions public bool IncludeTotalResourceCount { get; set; } /// - public PageSize DefaultPageSize { get; set; } = new(10); + public PageSize? DefaultPageSize { get; set; } = new(10); /// - public PageSize MaximumPageSize { get; set; } + public PageSize? MaximumPageSize { get; set; } /// - public PageNumber MaximumPageNumber { get; set; } + public PageNumber? MaximumPageNumber { get; set; } /// - public bool ValidateModelState { get; set; } + public bool ValidateModelState { get; set; } = true; /// public bool AllowClientGeneratedIds { 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..beb978b137 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiValidationFilter.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiValidationFilter.cs @@ -9,23 +9,25 @@ namespace JsonApiDotNetCore.Configuration { /// - /// Validation filter that blocks ASP.NET Core ModelState validation on data according to the JSON:API spec. + /// Validation filter that blocks ASP.NET ModelState validation on data according to the JSON:API spec. /// 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,30 +41,41 @@ 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 (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); + return key == nameof(Identifiable.Id) || key.EndsWith($".{nameof(Identifiable.Id)}", StringComparison.Ordinal); } 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) { - return targetedFields.Attributes.Any(attribute => attribute.Property.Name == entry.Key); + return targetedFields.Attributes.Any(attribute => attribute.Property.Name == entry.Key) || + targetedFields.Relationships.Any(relationship => relationship.Property.Name == entry.Key); } } } diff --git a/src/JsonApiDotNetCore/Configuration/PageNumber.cs b/src/JsonApiDotNetCore/Configuration/PageNumber.cs index 9f094a1423..729000e6f1 100644 --- a/src/JsonApiDotNetCore/Configuration/PageNumber.cs +++ b/src/JsonApiDotNetCore/Configuration/PageNumber.cs @@ -20,7 +20,7 @@ public PageNumber(int oneBasedValue) OneBasedValue = oneBasedValue; } - public bool Equals(PageNumber other) + public bool Equals(PageNumber? other) { if (ReferenceEquals(null, other)) { @@ -35,7 +35,7 @@ public bool Equals(PageNumber other) return OneBasedValue == other.OneBasedValue; } - public override bool Equals(object other) + public override bool Equals(object? other) { return Equals(other as PageNumber); } diff --git a/src/JsonApiDotNetCore/Configuration/PageSize.cs b/src/JsonApiDotNetCore/Configuration/PageSize.cs index 4533461502..460658e064 100644 --- a/src/JsonApiDotNetCore/Configuration/PageSize.cs +++ b/src/JsonApiDotNetCore/Configuration/PageSize.cs @@ -18,7 +18,7 @@ public PageSize(int value) Value = value; } - public bool Equals(PageSize other) + public bool Equals(PageSize? other) { if (ReferenceEquals(null, other)) { @@ -33,7 +33,7 @@ public bool Equals(PageSize other) return Value == other.Value; } - public override bool Equals(object other) + public override bool Equals(object? other) { return Equals(other as PageSize); } 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..aaad96abb8 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceDescriptor.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceDescriptor.cs @@ -4,13 +4,16 @@ 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; + ArgumentGuard.NotNull(resourceClrType, nameof(resourceClrType)); + ArgumentGuard.NotNull(idClrType, nameof(idClrType)); + + ResourceClrType = resourceClrType; + IdClrType = idClrType; } } } diff --git a/src/JsonApiDotNetCore/Configuration/ResourceDescriptorAssemblyCache.cs b/src/JsonApiDotNetCore/Configuration/ResourceDescriptorAssemblyCache.cs index 3eaa0d828d..67a0329f9f 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceDescriptorAssemblyCache.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceDescriptorAssemblyCache.cs @@ -11,7 +11,7 @@ namespace JsonApiDotNetCore.Configuration internal sealed class ResourceDescriptorAssemblyCache { private readonly TypeLocator _typeLocator = new(); - private readonly Dictionary> _resourceDescriptorsPerAssembly = new(); + private readonly Dictionary?> _resourceDescriptorsPerAssembly = new(); public void RegisterAssembly(Assembly assembly) { @@ -25,7 +25,7 @@ public IReadOnlyCollection GetResourceDescriptors() { EnsureAssembliesScanned(); - return _resourceDescriptorsPerAssembly.SelectMany(pair => pair.Value).ToArray(); + return _resourceDescriptorsPerAssembly.SelectMany(pair => pair.Value!).ToArray(); } public IReadOnlyCollection GetAssemblies() @@ -47,7 +47,7 @@ private IEnumerable ScanForResourceDescriptors(Assembly asse { foreach (Type type in assembly.GetTypes()) { - ResourceDescriptor resourceDescriptor = _typeLocator.TryGetResourceDescriptor(type); + ResourceDescriptor? resourceDescriptor = _typeLocator.ResolveResourceDescriptor(type); if (resourceDescriptor != null) { diff --git a/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs index f65755b38d..418c1ba0b7 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs @@ -13,88 +13,88 @@ namespace JsonApiDotNetCore.Configuration [PublicAPI] public sealed class ResourceGraph : IResourceGraph { - private static readonly Type ProxyTargetAccessorType = Type.GetType("Castle.DynamicProxy.IProxyTargetAccessor, Castle.Core"); + 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 = FindResourceType(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? FindResourceType(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 = FindResourceType(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? FindResourceType(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)); } /// - public IReadOnlyCollection GetFields(Expression> selector) + public IReadOnlyCollection GetFields(Expression> selector) where TResource : class, IIdentifiable { ArgumentGuard.NotNull(selector, nameof(selector)); @@ -103,7 +103,7 @@ public IReadOnlyCollection GetFields(Expressi } /// - public IReadOnlyCollection GetAttributes(Expression> selector) + public IReadOnlyCollection GetAttributes(Expression> selector) where TResource : class, IIdentifiable { ArgumentGuard.NotNull(selector, nameof(selector)); @@ -112,7 +112,7 @@ public IReadOnlyCollection GetAttributes(Expression - public IReadOnlyCollection GetRelationships(Expression> selector) + public IReadOnlyCollection GetRelationships(Expression> selector) where TResource : class, IIdentifiable { ArgumentGuard.NotNull(selector, nameof(selector)); @@ -120,7 +120,7 @@ public IReadOnlyCollection GetRelationships(Ex return FilterFields(selector); } - private IReadOnlyCollection FilterFields(Expression> selector) + private IReadOnlyCollection FilterFields(Expression> selector) where TResource : class, IIdentifiable where TField : ResourceFieldAttribute { @@ -129,7 +129,7 @@ private IReadOnlyCollection FilterFields(Expression field.Property.Name == memberName); + TField? matchingField = source.FirstOrDefault(field => field.Property.Name == memberName); if (matchingField == null) { @@ -145,22 +145,22 @@ 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) + private IEnumerable ToMemberNames(Expression> selector) { Expression selectorBody = RemoveConvert(selector.Body); diff --git a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs index 0ea1fb1a14..53e206cc0f 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs @@ -3,8 +3,12 @@ using System.Linq; using System.Reflection; using JetBrains.Annotations; +using JsonApiDotNetCore.Errors; 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 +21,7 @@ public class ResourceGraphBuilder { private readonly IJsonApiOptions _options; private readonly ILogger _logger; - private readonly HashSet _resourceContexts = new(); + private readonly Dictionary _resourceTypesByClrType = new(); private readonly TypeLocator _typeLocator = new(); public ResourceGraphBuilder(IJsonApiOptions options, ILoggerFactory loggerFactory) @@ -34,39 +38,68 @@ public ResourceGraphBuilder(IJsonApiOptions options, ILoggerFactory loggerFactor /// public IResourceGraph Build() { - return new ResourceGraph(_resourceContexts); + HashSet resourceTypes = _resourceTypesByClrType.Values.ToHashSet(); + + if (!resourceTypes.Any()) + { + _logger.LogWarning("The resource graph is empty."); + } + + var resourceGraph = new ResourceGraph(resourceTypes); + + foreach (RelationshipAttribute relationship in resourceTypes.SelectMany(resourceType => resourceType.Relationships)) + { + relationship.LeftType = resourceGraph.GetResourceType(relationship.LeftClrType!); + ResourceType? rightType = resourceGraph.FindResourceType(relationship.RightClrType!); + + if (rightType == null) + { + throw new InvalidConfigurationException($"Resource type '{relationship.LeftClrType}' depends on " + + $"'{relationship.RightClrType}', which was not added to the resource graph."); + } + + relationship.RightType = rightType; + } + + return resourceGraph; } - /// - /// Adds a JSON:API resource with int as the identifier type. - /// - /// - /// The resource model 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. - /// - public ResourceGraphBuilder Add(string publicName = null) - where TResource : class, IIdentifiable + public ResourceGraphBuilder Add(DbContext dbContext) { - return Add(publicName); + 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 Entity Framework Core API usage. + return entityType is EntityType { IsImplicitlyCreatedJoinEntityType: true }; +#pragma warning restore EF1001 // Internal Entity Framework Core API usage. } /// /// Adds a JSON:API resource. /// /// - /// The resource 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) + public ResourceGraphBuilder Add(string? publicName = null) where TResource : class, IIdentifiable { return Add(typeof(TResource), typeof(TId), publicName); @@ -75,67 +108,75 @@ 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 (_resourceTypesByClrType.ContainsKey(resourceClrType)) { return this; } - if (resourceType.IsOrImplementsInterface(typeof(IIdentifiable))) + if (resourceClrType.IsOrImplementsInterface()) { - string effectivePublicName = publicName ?? FormatResourceName(resourceType); - Type effectiveIdType = idType ?? _typeLocator.TryGetIdType(resourceType); + string effectivePublicName = publicName ?? FormatResourceName(resourceClrType); + Type? effectiveIdType = idClrType ?? _typeLocator.LookupIdType(resourceClrType); + + if (effectiveIdType == null) + { + throw new InvalidConfigurationException($"Resource type '{resourceClrType}' implements 'IIdentifiable', but not 'IIdentifiable'."); + } + + ResourceType resourceType = CreateResourceType(effectivePublicName, resourceClrType, effectiveIdType); - ResourceContext resourceContext = CreateResourceContext(effectivePublicName, resourceType, effectiveIdType); - _resourceContexts.Add(resourceContext); + AssertNoDuplicatePublicName(resourceType, effectivePublicName); + + _resourceTypesByClrType.Add(resourceClrType, resourceType); } else { - _logger.LogWarning($"Entity '{resourceType}' does not implement '{nameof(IIdentifiable)}'."); + _logger.LogWarning($"Skipping: Type '{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); + + AssertNoDuplicatePublicName(attributes, relationships); - 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(); + var attributesByName = new Dictionary(); - foreach (PropertyInfo property in resourceType.GetProperties()) + foreach (PropertyInfo property in resourceClrType.GetProperties()) { - var attribute = (AttrAttribute)property.GetCustomAttribute(typeof(AttrAttribute)); - // Although strictly not correct, 'id' is added to the list of attributes for convenience. // For example, it enables to filter on ID, without the need to special-case existing logic. // And when using sparse fields, it silently adds 'id' to the set of attributes to retrieve. - if (property.Name == nameof(Identifiable.Id) && attribute == null) + if (property.Name == nameof(Identifiable.Id)) { var idAttr = new AttrAttribute { @@ -144,16 +185,18 @@ private IReadOnlyCollection GetAttributes(Type resourceType) Capabilities = _options.DefaultAttrCapabilities }; - attributes.Add(idAttr); + IncludeField(attributesByName, idAttr); continue; } + var attribute = (AttrAttribute?)property.GetCustomAttribute(typeof(AttrAttribute)); + if (attribute == null) { continue; } - attribute.PublicName ??= FormatPropertyName(property); + SetPublicName(attribute, property); attribute.Property = property; if (!attribute.HasExplicitCapabilities) @@ -161,33 +204,44 @@ private IReadOnlyCollection GetAttributes(Type resourceType) attribute.Capabilities = _options.DefaultAttrCapabilities; } - attributes.Add(attribute); + IncludeField(attributesByName, attribute); } - return attributes; + if (attributesByName.Count < 2) + { + _logger.LogWarning($"Type '{resourceClrType}' does not contain any attributes."); + } + + return attributesByName.Values; } - private IReadOnlyCollection GetRelationships(Type resourceType) + private IReadOnlyCollection GetRelationships(Type resourceClrType) { - var attributes = new List(); - PropertyInfo[] properties = resourceType.GetProperties(); + var relationshipsByName = new Dictionary(); + 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; + SetPublicName(relationship, property); + relationship.LeftClrType = resourceClrType; + relationship.RightClrType = GetRelationshipType(relationship, property); - attributes.Add(attribute); + IncludeField(relationshipsByName, relationship); } } - return attributes; + return relationshipsByName.Values; + } + + private void SetPublicName(ResourceFieldAttribute field, PropertyInfo property) + { + // ReSharper disable once ConstantNullCoalescingCondition + field.PublicName ??= FormatPropertyName(property); } private Type GetRelationshipType(RelationshipAttribute relationship, PropertyInfo property) @@ -198,32 +252,77 @@ 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) { - var attribute = (EagerLoadAttribute)property.GetCustomAttribute(typeof(EagerLoadAttribute)); + var eagerLoad = (EagerLoadAttribute?)property.GetCustomAttribute(typeof(EagerLoadAttribute)); - if (attribute == null) + if (eagerLoad == null) { continue; } Type innerType = TypeOrElementType(property.PropertyType); - attribute.Children = GetEagerLoads(innerType, recursionDepth + 1); - attribute.Property = property; + eagerLoad.Children = GetEagerLoads(innerType, recursionDepth + 1); + eagerLoad.Property = property; - attributes.Add(attribute); + attributes.Add(eagerLoad); } return attributes; } + private static void IncludeField(Dictionary fieldsByName, TField field) + where TField : ResourceFieldAttribute + { + if (fieldsByName.TryGetValue(field.PublicName, out var existingField)) + { + throw CreateExceptionForDuplicatePublicName(field.Property.DeclaringType!, existingField, field); + } + + fieldsByName.Add(field.PublicName, field); + } + + private void AssertNoDuplicatePublicName(ResourceType resourceType, string effectivePublicName) + { + var (existingClrType, _) = _resourceTypesByClrType.FirstOrDefault(type => type.Value.PublicName == resourceType.PublicName); + + if (existingClrType != null) + { + throw new InvalidConfigurationException( + $"Resource '{existingClrType}' and '{resourceType.ClrType}' both use public name '{effectivePublicName}'."); + } + } + + private void AssertNoDuplicatePublicName(IReadOnlyCollection attributes, IReadOnlyCollection relationships) + { + IEnumerable<(AttrAttribute attribute, RelationshipAttribute relationship)> query = + from attribute in attributes + from relationship in relationships + where attribute.PublicName == relationship.PublicName + select (attribute, relationship); + + (AttrAttribute? duplicateAttribute, RelationshipAttribute? duplicateRelationship) = query.FirstOrDefault(); + + if (duplicateAttribute != null && duplicateRelationship != null) + { + throw CreateExceptionForDuplicatePublicName(duplicateAttribute.Property.DeclaringType!, duplicateAttribute, duplicateRelationship); + } + } + + private static InvalidConfigurationException CreateExceptionForDuplicatePublicName(Type containingClrType, ResourceFieldAttribute existingField, + ResourceFieldAttribute field) + { + return new InvalidConfigurationException( + $"Properties '{containingClrType}.{existingField.Property.Name}' and '{containingClrType}.{field.Property.Name}' both use public name '{field.PublicName}'."); + } + [AssertionMethod] private static void AssertNoInfiniteRecursion(int recursionDepth) { @@ -241,10 +340,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..c6be029b1b 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceNameFormatter.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceNameFormatter.cs @@ -8,24 +8,26 @@ namespace JsonApiDotNetCore.Configuration { internal sealed class ResourceNameFormatter { - private readonly JsonNamingPolicy _namingPolicy; + private readonly JsonNamingPolicy? _namingPolicy; - public ResourceNameFormatter(JsonNamingPolicy namingPolicy) + public ResourceNameFormatter(JsonNamingPolicy? namingPolicy) { _namingPolicy = 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) + ArgumentGuard.NotNull(resourceClrType, nameof(resourceClrType)); + + 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 74% rename from src/JsonApiDotNetCore/Configuration/ResourceContext.cs rename to src/JsonApiDotNetCore/Configuration/ResourceType.cs index f5b42e5499..2f71fa5d2b 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) { @@ -110,60 +107,60 @@ public ResourceContext(string publicName, Type resourceType, Type identityType, public AttrAttribute GetAttributeByPublicName(string publicName) { - AttrAttribute attribute = TryGetAttributeByPublicName(publicName); + AttrAttribute? attribute = FindAttributeByPublicName(publicName); return attribute ?? throw new InvalidOperationException($"Attribute '{publicName}' does not exist on resource type '{PublicName}'."); } - public AttrAttribute TryGetAttributeByPublicName(string publicName) + public AttrAttribute? FindAttributeByPublicName(string publicName) { ArgumentGuard.NotNull(publicName, nameof(publicName)); - return _fieldsByPublicName.TryGetValue(publicName, out ResourceFieldAttribute field) && field is AttrAttribute attribute ? attribute : null; + return _fieldsByPublicName.TryGetValue(publicName, out ResourceFieldAttribute? field) && field is AttrAttribute attribute ? attribute : null; } public AttrAttribute GetAttributeByPropertyName(string propertyName) { - AttrAttribute attribute = TryGetAttributeByPropertyName(propertyName); + AttrAttribute? attribute = FindAttributeByPropertyName(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) + public AttrAttribute? FindAttributeByPropertyName(string propertyName) { ArgumentGuard.NotNull(propertyName, nameof(propertyName)); - return _fieldsByPropertyName.TryGetValue(propertyName, out ResourceFieldAttribute field) && field is AttrAttribute attribute ? attribute : null; + return _fieldsByPropertyName.TryGetValue(propertyName, out ResourceFieldAttribute? field) && field is AttrAttribute attribute ? attribute : null; } public RelationshipAttribute GetRelationshipByPublicName(string publicName) { - RelationshipAttribute relationship = TryGetRelationshipByPublicName(publicName); + RelationshipAttribute? relationship = FindRelationshipByPublicName(publicName); return relationship ?? throw new InvalidOperationException($"Relationship '{publicName}' does not exist on resource type '{PublicName}'."); } - public RelationshipAttribute TryGetRelationshipByPublicName(string publicName) + public RelationshipAttribute? FindRelationshipByPublicName(string publicName) { ArgumentGuard.NotNull(publicName, nameof(publicName)); - return _fieldsByPublicName.TryGetValue(publicName, out ResourceFieldAttribute field) && field is RelationshipAttribute relationship + return _fieldsByPublicName.TryGetValue(publicName, out ResourceFieldAttribute? field) && field is RelationshipAttribute relationship ? relationship : null; } public RelationshipAttribute GetRelationshipByPropertyName(string propertyName) { - RelationshipAttribute relationship = TryGetRelationshipByPropertyName(propertyName); + RelationshipAttribute? relationship = FindRelationshipByPropertyName(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) + public RelationshipAttribute? FindRelationshipByPropertyName(string propertyName) { ArgumentGuard.NotNull(propertyName, nameof(propertyName)); - return _fieldsByPropertyName.TryGetValue(propertyName, out ResourceFieldAttribute field) && field is RelationshipAttribute relationship + return _fieldsByPropertyName.TryGetValue(propertyName, out ResourceFieldAttribute? field) && field is RelationshipAttribute relationship ? relationship : null; } @@ -173,7 +170,7 @@ public override string ToString() return PublicName; } - public override bool Equals(object obj) + public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) { @@ -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..c69d9305ca 100644 --- a/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs +++ b/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Reflection; using JetBrains.Annotations; using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Repositories; @@ -20,9 +19,9 @@ public static class ServiceCollectionExtensions /// /// Configures JsonApiDotNetCore by registering resources manually. /// - public static IServiceCollection AddJsonApi(this IServiceCollection services, Action options = null, - Action discovery = null, Action resources = null, IMvcCoreBuilder mvcBuilder = null, - ICollection dbContextTypes = null) + public static IServiceCollection AddJsonApi(this IServiceCollection services, Action? options = null, + Action? discovery = null, Action? resources = null, IMvcCoreBuilder? mvcBuilder = null, + ICollection? dbContextTypes = null) { ArgumentGuard.NotNull(services, nameof(services)); @@ -34,30 +33,30 @@ public static IServiceCollection AddJsonApi(this IServiceCollection services, Ac /// /// Configures JsonApiDotNetCore by registering resources from an Entity Framework Core model. /// - public static IServiceCollection AddJsonApi(this IServiceCollection services, Action options = null, - Action discovery = null, Action resources = null, IMvcCoreBuilder mvcBuilder = null) + public static IServiceCollection AddJsonApi(this IServiceCollection services, Action? options = null, + Action? discovery = null, Action? resources = null, IMvcCoreBuilder? mvcBuilder = null) where TDbContext : DbContext { return AddJsonApi(services, options, discovery, resources, mvcBuilder, typeof(TDbContext).AsArray()); } - private static void SetupApplicationBuilder(IServiceCollection services, Action configureOptions, - Action configureAutoDiscovery, Action configureResourceGraph, IMvcCoreBuilder mvcBuilder, + private static void SetupApplicationBuilder(IServiceCollection services, Action? configureOptions, + Action? configureAutoDiscovery, Action? configureResources, IMvcCoreBuilder? mvcBuilder, ICollection dbContextTypes) { using var applicationBuilder = new JsonApiApplicationBuilder(services, mvcBuilder ?? services.AddMvcCore()); applicationBuilder.ConfigureJsonApiOptions(configureOptions); applicationBuilder.ConfigureAutoDiscovery(configureAutoDiscovery); - applicationBuilder.AddResourceGraph(dbContextTypes, configureResourceGraph); + applicationBuilder.ConfigureResourceGraph(dbContextTypes, configureResources); applicationBuilder.ConfigureMvc(); applicationBuilder.DiscoverInjectables(); applicationBuilder.ConfigureServiceContainer(dbContextTypes); } /// - /// Adds IoC container registrations for the various JsonApiDotNetCore resource service interfaces, such as , - /// and the various others. + /// Adds IoC container registrations for the various JsonApiDotNetCore resource service interfaces, such as , + /// and the various others. /// public static IServiceCollection AddResourceService(this IServiceCollection services) { @@ -70,7 +69,7 @@ public static IServiceCollection AddResourceService(this IServiceColle /// /// Adds IoC container registrations for the various JsonApiDotNetCore resource repository interfaces, such as - /// and . + /// and . /// public static IServiceCollection AddResourceRepository(this IServiceCollection services) { @@ -83,7 +82,7 @@ public static IServiceCollection AddResourceRepository(this IServic /// /// Adds IoC container registrations for the various JsonApiDotNetCore resource definition interfaces, such as - /// and . + /// . /// public static IServiceCollection AddResourceDefinition(this IServiceCollection services) { @@ -97,25 +96,13 @@ public static IServiceCollection AddResourceDefinition(this private static void RegisterForConstructedType(IServiceCollection services, Type implementationType, IEnumerable openGenericInterfaces) { bool seenCompatibleInterface = false; - ResourceDescriptor resourceDescriptor = TryGetResourceTypeFromServiceImplementation(implementationType); + ResourceDescriptor? resourceDescriptor = ResolveResourceTypeFromServiceImplementation(implementationType); if (resourceDescriptor != null) { foreach (Type openGenericInterface in openGenericInterfaces) { - // A shorthand interface is one where the ID type is omitted. - // e.g. IResourceService is the shorthand for IResourceService - bool isShorthandInterface = openGenericInterface.GetTypeInfo().GenericTypeParameters.Length == 1; - - if (isShorthandInterface && resourceDescriptor.IdType != 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); + Type constructedType = openGenericInterface.MakeGenericType(resourceDescriptor.ResourceClrType, resourceDescriptor.IdClrType); if (constructedType.IsAssignableFrom(implementationType)) { @@ -131,15 +118,14 @@ private static void RegisterForConstructedType(IServiceCollection services, Type } } - private static ResourceDescriptor TryGetResourceTypeFromServiceImplementation(Type serviceType) + private static ResourceDescriptor? ResolveResourceTypeFromServiceImplementation(Type? serviceType) { - foreach (Type @interface in serviceType.GetInterfaces()) + if (serviceType != null) { - Type firstGenericArgument = @interface.IsGenericType ? @interface.GenericTypeArguments.First() : null; - - if (firstGenericArgument != null) + foreach (Type @interface in serviceType.GetInterfaces()) { - ResourceDescriptor resourceDescriptor = TypeLocator.TryGetResourceDescriptor(firstGenericArgument); + Type? firstGenericArgument = @interface.IsGenericType ? @interface.GenericTypeArguments.First() : null; + ResourceDescriptor? resourceDescriptor = TypeLocator.ResolveResourceDescriptor(firstGenericArgument); if (resourceDescriptor != null) { diff --git a/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs b/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs index 7a179e5784..cdd6a65031 100644 --- a/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs +++ b/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs @@ -19,47 +19,30 @@ public sealed class ServiceDiscoveryFacade { internal static readonly HashSet ServiceInterfaces = new() { - typeof(IResourceService<>), typeof(IResourceService<,>), - typeof(IResourceCommandService<>), typeof(IResourceCommandService<,>), - typeof(IResourceQueryService<>), typeof(IResourceQueryService<,>), - typeof(IGetAllService<>), typeof(IGetAllService<,>), - typeof(IGetByIdService<>), typeof(IGetByIdService<,>), - typeof(IGetSecondaryService<>), typeof(IGetSecondaryService<,>), - typeof(IGetRelationshipService<>), typeof(IGetRelationshipService<,>), - typeof(ICreateService<>), typeof(ICreateService<,>), - typeof(IAddToRelationshipService<>), typeof(IAddToRelationshipService<,>), - typeof(IUpdateService<>), typeof(IUpdateService<,>), - typeof(ISetRelationshipService<>), typeof(ISetRelationshipService<,>), - typeof(IDeleteService<>), typeof(IDeleteService<,>), - typeof(IRemoveFromRelationshipService<>), typeof(IRemoveFromRelationshipService<,>) }; internal static readonly HashSet RepositoryInterfaces = new() { - typeof(IResourceRepository<>), typeof(IResourceRepository<,>), - typeof(IResourceWriteRepository<>), typeof(IResourceWriteRepository<,>), - typeof(IResourceReadRepository<>), typeof(IResourceReadRepository<,>) }; internal static readonly HashSet ResourceDefinitionInterfaces = new() { - typeof(IResourceDefinition<>), typeof(IResourceDefinition<,>) }; @@ -137,14 +120,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 +157,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..9ef4f3590e 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? LookupIdType(Type? resourceClrType) { - Type identifiableInterface = resourceType.GetInterfaces().FirstOrDefault(@interface => + Type? identifiableInterface = resourceClrType?.GetInterfaces().FirstOrDefault(@interface => @interface.IsGenericType && @interface.GetGenericTypeDefinition() == typeof(IIdentifiable<>)); return identifiableInterface?.GetGenericArguments()[0]; @@ -25,11 +25,11 @@ public Type TryGetIdType(Type resourceType) /// /// Attempts to get a descriptor for the specified resource type. /// - public ResourceDescriptor TryGetResourceDescriptor(Type type) + public ResourceDescriptor? ResolveResourceDescriptor(Type? type) { - if (type.IsOrImplementsInterface(typeof(IIdentifiable))) + if (type != null && type.IsOrImplementsInterface()) { - Type idType = TryGetIdType(type); + Type? idType = LookupIdType(type); if (idType != null) { @@ -126,6 +126,10 @@ private static (Type implementation, Type registrationInterface)? FindGenericInt /// public IReadOnlyCollection GetDerivedGenericTypes(Assembly assembly, Type openGenericType, params Type[] genericArguments) { + ArgumentGuard.NotNull(assembly, nameof(assembly)); + ArgumentGuard.NotNull(openGenericType, nameof(openGenericType)); + ArgumentGuard.NotNull(genericArguments, nameof(genericArguments)); + Type genericType = openGenericType.MakeGenericType(genericArguments); return GetDerivedTypes(assembly, genericType).ToArray(); } @@ -146,6 +150,9 @@ public IReadOnlyCollection GetDerivedGenericTypes(Assembly assembly, Type /// public IEnumerable GetDerivedTypes(Assembly assembly, Type inheritedType) { + ArgumentGuard.NotNull(assembly, nameof(assembly)); + ArgumentGuard.NotNull(inheritedType, nameof(inheritedType)); + foreach (Type type in assembly.GetTypes()) { if (inheritedType.IsAssignableFrom(type)) diff --git a/src/JsonApiDotNetCore/Controllers/Annotations/DisableQueryStringAttribute.cs b/src/JsonApiDotNetCore/Controllers/Annotations/DisableQueryStringAttribute.cs index 3323ba13a5..bfa842ed9a 100644 --- a/src/JsonApiDotNetCore/Controllers/Annotations/DisableQueryStringAttribute.cs +++ b/src/JsonApiDotNetCore/Controllers/Annotations/DisableQueryStringAttribute.cs @@ -7,7 +7,7 @@ namespace JsonApiDotNetCore.Controllers.Annotations { /// - /// Used on an ASP.NET Core controller class to indicate which query string parameters are blocked. + /// Used on an ASP.NET controller class to indicate which query string parameters are blocked. /// /// - /// Used on an ASP.NET Core controller class to indicate that a custom route is used instead of the built-in routing convention. + /// Used on an ASP.NET controller class to indicate that a custom route is used instead of the built-in routing convention. /// /// - /// Used on an ASP.NET Core controller class to indicate write actions must be blocked. - /// - /// - /// { - /// } - /// ]]> - [PublicAPI] - public sealed class HttpReadOnlyAttribute : HttpRestrictAttribute - { - protected override string[] Methods { get; } = - { - "POST", - "PATCH", - "DELETE" - }; - } -} diff --git a/src/JsonApiDotNetCore/Controllers/Annotations/HttpRestrictAttribute.cs b/src/JsonApiDotNetCore/Controllers/Annotations/HttpRestrictAttribute.cs deleted file mode 100644 index c2534471f9..0000000000 --- a/src/JsonApiDotNetCore/Controllers/Annotations/HttpRestrictAttribute.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Linq; -using System.Net.Http; -using System.Threading.Tasks; -using JsonApiDotNetCore.Errors; -using Microsoft.AspNetCore.Mvc.Filters; - -namespace JsonApiDotNetCore.Controllers.Annotations -{ - public abstract class HttpRestrictAttribute : ActionFilterAttribute - { - protected abstract string[] Methods { get; } - - public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) - { - ArgumentGuard.NotNull(context, nameof(context)); - ArgumentGuard.NotNull(next, nameof(next)); - - string method = context.HttpContext.Request.Method; - - if (!CanExecuteAction(method)) - { - throw new RequestMethodNotAllowedException(new HttpMethod(method)); - } - - await next(); - } - - private bool CanExecuteAction(string requestMethod) - { - return !Methods.Contains(requestMethod); - } - } -} diff --git a/src/JsonApiDotNetCore/Controllers/Annotations/NoHttpDeleteAttribute.cs b/src/JsonApiDotNetCore/Controllers/Annotations/NoHttpDeleteAttribute.cs deleted file mode 100644 index 93733d6885..0000000000 --- a/src/JsonApiDotNetCore/Controllers/Annotations/NoHttpDeleteAttribute.cs +++ /dev/null @@ -1,22 +0,0 @@ -using JetBrains.Annotations; - -namespace JsonApiDotNetCore.Controllers.Annotations -{ - /// - /// Used on an ASP.NET Core controller class to indicate the DELETE verb must be blocked. - /// - /// - /// { - /// } - /// ]]> - [PublicAPI] - public sealed class NoHttpDeleteAttribute : HttpRestrictAttribute - { - protected override string[] Methods { get; } = - { - "DELETE" - }; - } -} diff --git a/src/JsonApiDotNetCore/Controllers/Annotations/NoHttpPatchAttribute.cs b/src/JsonApiDotNetCore/Controllers/Annotations/NoHttpPatchAttribute.cs deleted file mode 100644 index 29a84b386a..0000000000 --- a/src/JsonApiDotNetCore/Controllers/Annotations/NoHttpPatchAttribute.cs +++ /dev/null @@ -1,22 +0,0 @@ -using JetBrains.Annotations; - -namespace JsonApiDotNetCore.Controllers.Annotations -{ - /// - /// Used on an ASP.NET Core controller class to indicate the PATCH verb must be blocked. - /// - /// - /// { - /// } - /// ]]> - [PublicAPI] - public sealed class NoHttpPatchAttribute : HttpRestrictAttribute - { - protected override string[] Methods { get; } = - { - "PATCH" - }; - } -} diff --git a/src/JsonApiDotNetCore/Controllers/Annotations/NoHttpPostAttribute.cs b/src/JsonApiDotNetCore/Controllers/Annotations/NoHttpPostAttribute.cs deleted file mode 100644 index 1d47890739..0000000000 --- a/src/JsonApiDotNetCore/Controllers/Annotations/NoHttpPostAttribute.cs +++ /dev/null @@ -1,22 +0,0 @@ -using JetBrains.Annotations; - -namespace JsonApiDotNetCore.Controllers.Annotations -{ - /// - /// Used on an ASP.NET Core controller class to indicate the POST verb must be blocked. - /// - /// - /// { - /// } - /// ]]> - [PublicAPI] - public sealed class NoHttpPostAttribute : HttpRestrictAttribute - { - protected override string[] Methods { get; } = - { - "POST" - }; - } -} diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index 98a4c58afe..1e37ec66d0 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs @@ -13,7 +13,7 @@ namespace JsonApiDotNetCore.Controllers { /// - /// Implements the foundational ASP.NET Core controller layer in the JsonApiDotNetCore architecture that delegates to a Resource Service. + /// Implements the foundational ASP.NET controller layer in the JsonApiDotNetCore architecture that delegates to a Resource Service. /// /// /// The resource type. @@ -25,50 +25,54 @@ public abstract class BaseJsonApiController : CoreJsonApiControl where TResource : class, IIdentifiable { private readonly IJsonApiOptions _options; - private readonly IGetAllService _getAll; - private readonly IGetByIdService _getById; - private readonly IGetSecondaryService _getSecondary; - private readonly IGetRelationshipService _getRelationship; - private readonly ICreateService _create; - private readonly IAddToRelationshipService _addToRelationship; - private readonly IUpdateService _update; - private readonly ISetRelationshipService _setRelationship; - private readonly IDeleteService _delete; - private readonly IRemoveFromRelationshipService _removeFromRelationship; + private readonly IResourceGraph _resourceGraph; + private readonly IGetAllService? _getAll; + private readonly IGetByIdService? _getById; + private readonly IGetSecondaryService? _getSecondary; + private readonly IGetRelationshipService? _getRelationship; + private readonly ICreateService? _create; + private readonly IAddToRelationshipService? _addToRelationship; + private readonly IUpdateService? _update; + private readonly ISetRelationshipService? _setRelationship; + private readonly IDeleteService? _delete; + private readonly IRemoveFromRelationshipService? _removeFromRelationship; private readonly TraceLogWriter> _traceWriter; /// /// Creates an instance from a read/write service. /// - protected BaseJsonApiController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : this(options, loggerFactory, resourceService, resourceService) + protected BaseJsonApiController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : this(options, resourceGraph, loggerFactory, resourceService, resourceService) { } /// /// Creates an instance from separate services for reading and writing. /// - protected BaseJsonApiController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceQueryService queryService = null, - IResourceCommandService commandService = null) - : this(options, loggerFactory, queryService, queryService, queryService, queryService, commandService, commandService, commandService, - commandService, commandService, commandService) + protected BaseJsonApiController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceQueryService? queryService = null, IResourceCommandService? commandService = null) + : this(options, resourceGraph, loggerFactory, queryService, queryService, queryService, queryService, commandService, commandService, + commandService, commandService, commandService, commandService) { } /// /// Creates an instance from separate services for the various individual read and write methods. /// - protected BaseJsonApiController(IJsonApiOptions options, ILoggerFactory loggerFactory, IGetAllService getAll = null, - IGetByIdService getById = null, IGetSecondaryService getSecondary = null, - IGetRelationshipService getRelationship = null, ICreateService create = null, - IAddToRelationshipService addToRelationship = null, IUpdateService update = null, - ISetRelationshipService setRelationship = null, IDeleteService delete = null, - IRemoveFromRelationshipService removeFromRelationship = null) + protected BaseJsonApiController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IGetAllService? getAll = null, IGetByIdService? getById = null, + IGetSecondaryService? getSecondary = null, IGetRelationshipService? getRelationship = null, + ICreateService? create = null, IAddToRelationshipService? addToRelationship = null, + IUpdateService? update = null, ISetRelationshipService? setRelationship = null, + IDeleteService? delete = null, IRemoveFromRelationshipService? removeFromRelationship = null) { ArgumentGuard.NotNull(options, nameof(options)); + ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); ArgumentGuard.NotNull(loggerFactory, nameof(loggerFactory)); _options = options; + _resourceGraph = resourceGraph; _traceWriter = new TraceLogWriter>(loggerFactory); _getAll = getAll; _getById = getById; @@ -83,7 +87,9 @@ protected BaseJsonApiController(IJsonApiOptions options, ILoggerFactory loggerFa } /// - /// Gets a collection of top-level (non-nested) resources. Example: GET /articles HTTP/1.1 + /// Gets a collection of primary resources. Example: /// public virtual async Task GetAsync(CancellationToken cancellationToken) { @@ -91,7 +97,7 @@ public virtual async Task GetAsync(CancellationToken cancellation if (_getAll == null) { - throw new RequestMethodNotAllowedException(HttpMethod.Get); + throw new RouteNotAvailableException(HttpMethod.Get, Request.Path); } IReadOnlyCollection resources = await _getAll.GetAsync(cancellationToken); @@ -100,7 +106,9 @@ public virtual async Task GetAsync(CancellationToken cancellation } /// - /// Gets a single top-level (non-nested) resource by ID. Example: /articles/1 + /// Gets a single primary resource by ID. Example: /// public virtual async Task GetAsync(TId id, CancellationToken cancellationToken) { @@ -111,7 +119,7 @@ public virtual async Task GetAsync(TId id, CancellationToken canc if (_getById == null) { - throw new RequestMethodNotAllowedException(HttpMethod.Get); + throw new RouteNotAvailableException(HttpMethod.Get, Request.Path); } TResource resource = await _getById.GetAsync(id, cancellationToken); @@ -120,7 +128,12 @@ public virtual async Task GetAsync(TId id, CancellationToken canc } /// - /// Gets a single resource or multiple resources at a nested endpoint. Examples: GET /articles/1/author HTTP/1.1 GET /articles/1/revisions HTTP/1.1 + /// Gets a secondary resource or collection of secondary resources. Example: Example: + /// /// public virtual async Task GetSecondaryAsync(TId id, string relationshipName, CancellationToken cancellationToken) { @@ -134,16 +147,22 @@ public virtual async Task GetSecondaryAsync(TId id, string relati if (_getSecondary == null) { - throw new RequestMethodNotAllowedException(HttpMethod.Get); + throw new RouteNotAvailableException(HttpMethod.Get, Request.Path); } - object relationship = await _getSecondary.GetSecondaryAsync(id, relationshipName, cancellationToken); + object? rightValue = await _getSecondary.GetSecondaryAsync(id, relationshipName, cancellationToken); - return Ok(relationship); + return Ok(rightValue); } /// - /// Gets a single resource relationship. Example: GET /articles/1/relationships/author HTTP/1.1 Example: GET /articles/1/relationships/revisions HTTP/1.1 + /// Gets a relationship value, which can be a null, a single object or a collection. Example: + /// Example: + /// /// public virtual async Task GetRelationshipAsync(TId id, string relationshipName, CancellationToken cancellationToken) { @@ -157,16 +176,18 @@ public virtual async Task GetRelationshipAsync(TId id, string rel if (_getRelationship == null) { - throw new RequestMethodNotAllowedException(HttpMethod.Get); + throw new RouteNotAvailableException(HttpMethod.Get, Request.Path); } - object rightResources = await _getRelationship.GetRelationshipAsync(id, relationshipName, cancellationToken); + object? rightValue = await _getRelationship.GetRelationshipAsync(id, relationshipName, cancellationToken); - return Ok(rightResources); + return Ok(rightValue); } /// - /// Creates a new resource with attributes, relationships or both. Example: POST /articles HTTP/1.1 + /// Creates a new resource with attributes, relationships or both. Example: /// public virtual async Task PostAsync([FromBody] TResource resource, CancellationToken cancellationToken) { @@ -179,23 +200,17 @@ public virtual async Task PostAsync([FromBody] TResource resource if (_create == null) { - throw new RequestMethodNotAllowedException(HttpMethod.Post); - } - - if (!_options.AllowClientGeneratedIds && resource.StringId != null) - { - throw new ResourceIdInCreateResourceNotAllowedException(); + throw new RouteNotAvailableException(HttpMethod.Post, Request.Path); } if (_options.ValidateModelState && !ModelState.IsValid) { - throw new InvalidModelStateException(ModelState, typeof(TResource), _options.IncludeExceptionStackTraceInErrors, - _options.SerializerOptions.PropertyNamingPolicy); + throw new InvalidModelStateException(ModelState, typeof(TResource), _options.IncludeExceptionStackTraceInErrors, _resourceGraph); } - TResource newResource = await _create.CreateAsync(resource, cancellationToken); + TResource? newResource = await _create.CreateAsync(resource, cancellationToken); - string resourceId = (newResource ?? resource).StringId; + string resourceId = (newResource ?? resource).StringId!; string locationUrl = $"{HttpContext.Request.Path}/{resourceId}"; if (newResource == null) @@ -208,7 +223,9 @@ public virtual async Task PostAsync([FromBody] TResource resource } /// - /// Adds resources to a to-many relationship. Example: POST /articles/1/revisions HTTP/1.1 + /// Adds resources to a to-many relationship. Example: /// /// /// Identifies the left side of the relationship. @@ -237,7 +254,7 @@ public virtual async Task PostRelationshipAsync(TId id, string re if (_addToRelationship == null) { - throw new RequestMethodNotAllowedException(HttpMethod.Post); + throw new RouteNotAvailableException(HttpMethod.Post, Request.Path); } await _addToRelationship.AddToToManyRelationshipAsync(id, relationshipName, rightResourceIds, cancellationToken); @@ -247,7 +264,9 @@ public virtual async Task PostRelationshipAsync(TId id, string re /// /// Updates the attributes and/or relationships of an existing resource. Only the values of sent attributes are replaced. And only the values of sent - /// relationships are replaced. Example: PATCH /articles/1 HTTP/1.1 + /// relationships are replaced. Example: /// public virtual async Task PatchAsync(TId id, [FromBody] TResource resource, CancellationToken cancellationToken) { @@ -261,22 +280,27 @@ public virtual async Task PatchAsync(TId id, [FromBody] TResource if (_update == null) { - throw new RequestMethodNotAllowedException(HttpMethod.Patch); + throw new RouteNotAvailableException(HttpMethod.Patch, Request.Path); } if (_options.ValidateModelState && !ModelState.IsValid) { - throw new InvalidModelStateException(ModelState, typeof(TResource), _options.IncludeExceptionStackTraceInErrors, - _options.SerializerOptions.PropertyNamingPolicy); + throw new InvalidModelStateException(ModelState, typeof(TResource), _options.IncludeExceptionStackTraceInErrors, _resourceGraph); } - TResource updated = await _update.UpdateAsync(id, resource, cancellationToken); + TResource? updated = await _update.UpdateAsync(id, resource, cancellationToken); + return updated == null ? NoContent() : Ok(updated); } /// - /// Performs a complete replacement of a relationship on an existing resource. Example: PATCH /articles/1/relationships/author HTTP/1.1 Example: PATCH - /// /articles/1/relationships/revisions HTTP/1.1 + /// Performs a complete replacement of a relationship on an existing resource. Example: + /// Example: + /// /// /// /// Identifies the left side of the relationship. @@ -290,7 +314,7 @@ public virtual async Task PatchAsync(TId id, [FromBody] TResource /// /// Propagates notification that request handling should be canceled. /// - public virtual async Task PatchRelationshipAsync(TId id, string relationshipName, [FromBody] object rightValue, + public virtual async Task PatchRelationshipAsync(TId id, string relationshipName, [FromBody] object? rightValue, CancellationToken cancellationToken) { _traceWriter.LogMethodStart(new @@ -304,7 +328,7 @@ public virtual async Task PatchRelationshipAsync(TId id, string r if (_setRelationship == null) { - throw new RequestMethodNotAllowedException(HttpMethod.Patch); + throw new RouteNotAvailableException(HttpMethod.Patch, Request.Path); } await _setRelationship.SetRelationshipAsync(id, relationshipName, rightValue, cancellationToken); @@ -313,7 +337,9 @@ public virtual async Task PatchRelationshipAsync(TId id, string r } /// - /// Deletes an existing resource. Example: DELETE /articles/1 HTTP/1.1 + /// Deletes an existing resource. Example: /// public virtual async Task DeleteAsync(TId id, CancellationToken cancellationToken) { @@ -324,7 +350,7 @@ public virtual async Task DeleteAsync(TId id, CancellationToken c if (_delete == null) { - throw new RequestMethodNotAllowedException(HttpMethod.Delete); + throw new RouteNotAvailableException(HttpMethod.Delete, Request.Path); } await _delete.DeleteAsync(id, cancellationToken); @@ -333,7 +359,9 @@ public virtual async Task DeleteAsync(TId id, CancellationToken c } /// - /// Removes resources from a to-many relationship. Example: DELETE /articles/1/relationships/revisions HTTP/1.1 + /// Removes resources from a to-many relationship. Example: /// /// /// Identifies the left side of the relationship. @@ -362,7 +390,7 @@ public virtual async Task DeleteRelationshipAsync(TId id, string if (_removeFromRelationship == null) { - throw new RequestMethodNotAllowedException(HttpMethod.Delete); + throw new RouteNotAvailableException(HttpMethod.Delete, Request.Path); } await _removeFromRelationship.RemoveFromToManyRelationshipAsync(id, relationshipName, rightResourceIds, cancellationToken); @@ -370,34 +398,4 @@ public virtual async Task DeleteRelationshipAsync(TId id, string return NoContent(); } } - - /// - public abstract class BaseJsonApiController : BaseJsonApiController - where TResource : class, IIdentifiable - { - /// - protected BaseJsonApiController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService, resourceService) - { - } - - /// - protected BaseJsonApiController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceQueryService queryService = null, - IResourceCommandService commandService = null) - : base(options, loggerFactory, queryService, commandService) - { - } - - /// - protected BaseJsonApiController(IJsonApiOptions options, ILoggerFactory loggerFactory, IGetAllService getAll = null, - IGetByIdService getById = null, IGetSecondaryService getSecondary = null, - IGetRelationshipService getRelationship = null, ICreateService create = null, - IAddToRelationshipService addToRelationship = null, IUpdateService update = null, - ISetRelationshipService setRelationship = null, IDeleteService delete = null, - IRemoveFromRelationshipService removeFromRelationship = null) - : base(options, loggerFactory, getAll, getById, getSecondary, getRelationship, create, addToRelationship, update, setRelationship, delete, - removeFromRelationship) - { - } - } } diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs index 0ed536eb15..4a63eb5f54 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs @@ -16,28 +16,31 @@ namespace JsonApiDotNetCore.Controllers { /// - /// Implements the foundational ASP.NET Core controller layer in the JsonApiDotNetCore architecture for handling atomic:operations requests. See + /// Implements the foundational ASP.NET controller layer in the JsonApiDotNetCore architecture for handling atomic:operations requests. See /// https://jsonapi.org/ext/atomic/ for details. Delegates work to . /// [PublicAPI] public abstract class BaseJsonApiOperationsController : CoreJsonApiController { private readonly IJsonApiOptions _options; + private readonly IResourceGraph _resourceGraph; private readonly IOperationsProcessor _processor; private readonly IJsonApiRequest _request; private readonly ITargetedFields _targetedFields; private readonly TraceLogWriter _traceWriter; - protected BaseJsonApiOperationsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IOperationsProcessor processor, - IJsonApiRequest request, ITargetedFields targetedFields) + protected BaseJsonApiOperationsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IOperationsProcessor processor, IJsonApiRequest request, ITargetedFields targetedFields) { ArgumentGuard.NotNull(options, nameof(options)); + ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); ArgumentGuard.NotNull(loggerFactory, nameof(loggerFactory)); ArgumentGuard.NotNull(processor, nameof(processor)); ArgumentGuard.NotNull(request, nameof(request)); ArgumentGuard.NotNull(targetedFields, nameof(targetedFields)); _options = options; + _resourceGraph = resourceGraph; _processor = processor; _request = request; _targetedFields = targetedFields; @@ -113,89 +116,96 @@ public virtual async Task PostOperationsAsync([FromBody] IList results = await _processor.ProcessAsync(operations, cancellationToken); + IList results = await _processor.ProcessAsync(operations, cancellationToken); return results.Any(result => 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) + protected virtual void ValidateModelState(IList operations) { // We must validate the resource inside each operation manually, because they are typed as IIdentifiable. // Instead of validating IIdentifiable we need to validate the resource runtime-type. - var violations = new List(); + using IDisposable _ = new RevertRequestStateOnDispose(_request, _targetedFields); - int index = 0; + int operationIndex = 0; + var requestModelState = new List<(string key, ModelStateEntry entry)>(); + int maxErrorsRemaining = ModelState.MaxAllowedErrors; foreach (OperationContainer operation in operations) { - if (operation.Kind == WriteOperationKind.CreateResource || operation.Kind == WriteOperationKind.UpdateResource) + if (maxErrorsRemaining < 1) { - _targetedFields.Attributes = operation.TargetedFields.Attributes; - _targetedFields.Relationships = operation.TargetedFields.Relationships; - - _request.CopyFrom(operation.Request); - - var validationContext = new ActionContext(); - ObjectValidator.Validate(validationContext, null, string.Empty, operation.Resource); - - if (!validationContext.ModelState.IsValid) - { - AddValidationErrors(validationContext.ModelState, operation.Resource.GetType(), index, violations); - } + break; } - index++; - } + maxErrorsRemaining = ValidateOperation(operation, operationIndex, requestModelState, maxErrorsRemaining); - if (violations.Any()) - { - throw new InvalidModelStateException(violations, _options.IncludeExceptionStackTraceInErrors, _options.SerializerOptions.PropertyNamingPolicy); + operationIndex++; } - } - private static void AddValidationErrors(ModelStateDictionary modelState, Type resourceType, int operationIndex, List violations) - { - foreach ((string propertyName, ModelStateEntry entry) in modelState) + if (requestModelState.Any()) { - AddValidationErrors(entry, propertyName, resourceType, operationIndex, violations); + Dictionary modelStateDictionary = requestModelState.ToDictionary(tuple => tuple.key, tuple => tuple.entry); + + throw new InvalidModelStateException(modelStateDictionary, typeof(IList), _options.IncludeExceptionStackTraceInErrors, + _resourceGraph, + (collectionType, index) => collectionType == typeof(IList) ? operations[index].Resource.GetType() : null); } } - private static void AddValidationErrors(ModelStateEntry entry, string propertyName, Type resourceType, int operationIndex, - List violations) + private int ValidateOperation(OperationContainer operation, int operationIndex, List<(string key, ModelStateEntry entry)> requestModelState, + int maxErrorsRemaining) { - foreach (ModelError error in entry.Errors) + if (operation.Request.WriteOperation is WriteOperationKind.CreateResource or WriteOperationKind.UpdateResource) { - string prefix = $"/atomic:operations[{operationIndex}]/data/attributes/"; - var violation = new ModelStateViolation(prefix, propertyName, resourceType, error); + _targetedFields.CopyFrom(operation.TargetedFields); + _request.CopyFrom(operation.Request); + + var validationContext = new ActionContext + { + ModelState = + { + MaxAllowedErrors = maxErrorsRemaining + } + }; - violations.Add(violation); + ObjectValidator.Validate(validationContext, null, string.Empty, operation.Resource); + + if (!validationContext.ModelState.IsValid) + { + int errorsRemaining = maxErrorsRemaining; + + foreach (string key in validationContext.ModelState.Keys) + { + ModelStateEntry entry = validationContext.ModelState[key]; + + if (entry.ValidationState == ModelValidationState.Invalid) + { + string operationKey = $"[{operationIndex}].{nameof(OperationContainer.Resource)}.{key}"; + + if (entry.Errors.Count > 0 && entry.Errors[0].Exception is TooManyModelErrorsException) + { + requestModelState.Insert(0, (operationKey, entry)); + } + else + { + requestModelState.Add((operationKey, entry)); + } + + errorsRemaining -= entry.Errors.Count; + } + } + + return errorsRemaining; + } } + + return maxErrorsRemaining; } } } diff --git a/src/JsonApiDotNetCore/Controllers/CoreJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/CoreJsonApiController.cs index 88d9614cc7..7bb96adedf 100644 --- a/src/JsonApiDotNetCore/Controllers/CoreJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/CoreJsonApiController.cs @@ -14,22 +14,26 @@ 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)); + IReadOnlyList? errorList = ToErrorList(errors); + ArgumentGuard.NotNullNorEmpty(errorList, nameof(errors)); - var document = new Document + return new ObjectResult(errorList) { - Errors = errors.ToList() + StatusCode = (int)ErrorObject.GetResponseStatusCode(errorList) }; + } - return new ObjectResult(document) - { - StatusCode = (int)document.GetErrorStatusCode() - }; + private static IReadOnlyList? ToErrorList(IEnumerable? errors) + { + return errors?.ToArray(); } } } diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs index f2ed5c938b..bbfa52af89 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs @@ -1,18 +1,13 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Services; -using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; namespace JsonApiDotNetCore.Controllers { /// - /// The base class to derive resource-specific write-only controllers from. This class delegates all work to - /// but adds attributes for routing templates. If you want to provide routing templates yourself, - /// you should derive from BaseJsonApiController directly. + /// The base class to derive resource-specific write-only controllers from. Returns HTTP 405 on read-only endpoints. If you want to provide routing + /// templates yourself, you should derive from BaseJsonApiController directly. /// /// /// The resource type. @@ -20,70 +15,16 @@ namespace JsonApiDotNetCore.Controllers /// /// The resource identifier type. /// - public abstract class JsonApiCommandController : BaseJsonApiController + public abstract class JsonApiCommandController : JsonApiController where TResource : class, IIdentifiable { /// /// Creates an instance from a write-only service. /// - protected JsonApiCommandController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceCommandService commandService) - : base(options, loggerFactory, null, commandService) - { - } - - /// - [HttpPost] - public override async Task PostAsync([FromBody] TResource resource, CancellationToken cancellationToken) - { - return await base.PostAsync(resource, cancellationToken); - } - - /// - [HttpPost("{id}/relationships/{relationshipName}")] - public override async Task PostRelationshipAsync(TId id, string relationshipName, [FromBody] ISet rightResourceIds, - CancellationToken cancellationToken) - { - return await base.PostRelationshipAsync(id, relationshipName, rightResourceIds, cancellationToken); - } - - /// - [HttpPatch("{id}")] - public override async Task PatchAsync(TId id, [FromBody] TResource resource, CancellationToken cancellationToken) - { - return await base.PatchAsync(id, resource, cancellationToken); - } - - /// - [HttpPatch("{id}/relationships/{relationshipName}")] - public override async Task PatchRelationshipAsync(TId id, string relationshipName, [FromBody] object rightValue, - CancellationToken cancellationToken) - { - return await base.PatchRelationshipAsync(id, relationshipName, rightValue, cancellationToken); - } - - /// - [HttpDelete("{id}")] - public override async Task DeleteAsync(TId id, CancellationToken cancellationToken) - { - return await base.DeleteAsync(id, cancellationToken); - } - - /// - [HttpDelete("{id}/relationships/{relationshipName}")] - public override async Task DeleteRelationshipAsync(TId id, string relationshipName, [FromBody] ISet rightResourceIds, - CancellationToken cancellationToken) - { - return await base.DeleteRelationshipAsync(id, relationshipName, rightResourceIds, cancellationToken); - } - } - - /// - public abstract class JsonApiCommandController : JsonApiCommandController - where TResource : class, IIdentifiable - { - /// - protected JsonApiCommandController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceCommandService commandService) - : base(options, loggerFactory, commandService) + protected JsonApiCommandController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceCommandService commandService) + : base(options, resourceGraph, loggerFactory, null, null, null, null, commandService, commandService, commandService, commandService, + commandService, commandService) { } } diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs index 5fa5557ab7..6654dc534c 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs @@ -23,20 +23,21 @@ public abstract class JsonApiController : BaseJsonApiController< where TResource : class, IIdentifiable { /// - protected JsonApiController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + protected JsonApiController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } /// - protected JsonApiController(IJsonApiOptions options, ILoggerFactory loggerFactory, IGetAllService getAll = null, - IGetByIdService getById = null, IGetSecondaryService getSecondary = null, - IGetRelationshipService getRelationship = null, ICreateService create = null, - IAddToRelationshipService addToRelationship = null, IUpdateService update = null, - ISetRelationshipService setRelationship = null, IDeleteService delete = null, - IRemoveFromRelationshipService removeFromRelationship = null) - : base(options, loggerFactory, getAll, getById, getSecondary, getRelationship, create, addToRelationship, update, setRelationship, delete, - removeFromRelationship) + protected JsonApiController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IGetAllService? getAll = null, IGetByIdService? getById = null, + IGetSecondaryService? getSecondary = null, IGetRelationshipService? getRelationship = null, + ICreateService? create = null, IAddToRelationshipService? addToRelationship = null, + IUpdateService? update = null, ISetRelationshipService? setRelationship = null, + IDeleteService? delete = null, IRemoveFromRelationshipService? removeFromRelationship = null) + : base(options, resourceGraph, loggerFactory, getAll, getById, getSecondary, getRelationship, create, addToRelationship, update, setRelationship, + delete, removeFromRelationship) { } @@ -96,7 +97,7 @@ public override async Task PatchAsync(TId id, [FromBody] TResourc /// [HttpPatch("{id}/relationships/{relationshipName}")] - public override async Task PatchRelationshipAsync(TId id, string relationshipName, [FromBody] object rightValue, + public override async Task PatchRelationshipAsync(TId id, string relationshipName, [FromBody] object? rightValue, CancellationToken cancellationToken) { return await base.PatchRelationshipAsync(id, relationshipName, rightValue, cancellationToken); @@ -117,27 +118,4 @@ public override async Task DeleteRelationshipAsync(TId id, string return await base.DeleteRelationshipAsync(id, relationshipName, rightResourceIds, cancellationToken); } } - - /// - public abstract class JsonApiController : JsonApiController - where TResource : class, IIdentifiable - { - /// - protected JsonApiController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { - } - - /// - protected JsonApiController(IJsonApiOptions options, ILoggerFactory loggerFactory, IGetAllService getAll = null, - IGetByIdService getById = null, IGetSecondaryService getSecondary = null, - IGetRelationshipService getRelationship = null, ICreateService create = null, - IAddToRelationshipService addToRelationship = null, IUpdateService update = null, - ISetRelationshipService setRelationship = null, IDeleteService delete = null, - IRemoveFromRelationshipService removeFromRelationship = null) - : base(options, loggerFactory, getAll, getById, getSecondary, getRelationship, create, addToRelationship, update, setRelationship, delete, - removeFromRelationship) - { - } - } } diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiOperationsController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiOperationsController.cs index 7e8e4956ac..3f034c08d8 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiOperationsController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiOperationsController.cs @@ -16,9 +16,9 @@ namespace JsonApiDotNetCore.Controllers /// public abstract class JsonApiOperationsController : BaseJsonApiOperationsController { - protected JsonApiOperationsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IOperationsProcessor processor, IJsonApiRequest request, - ITargetedFields targetedFields) - : base(options, loggerFactory, processor, request, targetedFields) + protected JsonApiOperationsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IOperationsProcessor processor, IJsonApiRequest request, ITargetedFields targetedFields) + : base(options, resourceGraph, loggerFactory, processor, request, targetedFields) { } diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiQueryController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiQueryController.cs index 7ab85612c8..d56eab8d60 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiQueryController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiQueryController.cs @@ -1,17 +1,13 @@ -using System.Threading; -using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Services; -using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; namespace JsonApiDotNetCore.Controllers { /// - /// The base class to derive resource-specific read-only controllers from. This class delegates all work to - /// but adds attributes for routing templates. If you want to provide routing templates yourself, - /// you should derive from BaseJsonApiController directly. + /// The base class to derive resource-specific read-only controllers from. Returns HTTP 405 on write-only endpoints. If you want to provide routing + /// templates yourself, you should derive from BaseJsonApiController directly. /// /// /// The resource type. @@ -19,53 +15,15 @@ namespace JsonApiDotNetCore.Controllers /// /// The resource identifier type. /// - public abstract class JsonApiQueryController : BaseJsonApiController + public abstract class JsonApiQueryController : JsonApiController where TResource : class, IIdentifiable { /// /// Creates an instance from a read-only service. /// - protected JsonApiQueryController(IJsonApiOptions context, ILoggerFactory loggerFactory, IResourceQueryService queryService) - : base(context, loggerFactory, queryService) - { - } - - /// - [HttpGet] - public override async Task GetAsync(CancellationToken cancellationToken) - { - return await base.GetAsync(cancellationToken); - } - - /// - [HttpGet("{id}")] - public override async Task GetAsync(TId id, CancellationToken cancellationToken) - { - return await base.GetAsync(id, cancellationToken); - } - - /// - [HttpGet("{id}/{relationshipName}")] - public override async Task GetSecondaryAsync(TId id, string relationshipName, CancellationToken cancellationToken) - { - return await base.GetSecondaryAsync(id, relationshipName, cancellationToken); - } - - /// - [HttpGet("{id}/relationships/{relationshipName}")] - public override async Task GetRelationshipAsync(TId id, string relationshipName, CancellationToken cancellationToken) - { - return await base.GetRelationshipAsync(id, relationshipName, cancellationToken); - } - } - - /// - public abstract class JsonApiQueryController : JsonApiQueryController - where TResource : class, IIdentifiable - { - /// - protected JsonApiQueryController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceQueryService queryService) - : base(options, loggerFactory, queryService) + protected JsonApiQueryController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceQueryService queryService) + : base(options, resourceGraph, loggerFactory, queryService, queryService, queryService, queryService) { } } diff --git a/src/JsonApiDotNetCore/Controllers/ModelStateViolation.cs b/src/JsonApiDotNetCore/Controllers/ModelStateViolation.cs deleted file mode 100644 index 2a4c8cfb84..0000000000 --- a/src/JsonApiDotNetCore/Controllers/ModelStateViolation.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System; -using JetBrains.Annotations; -using Microsoft.AspNetCore.Mvc.ModelBinding; - -namespace JsonApiDotNetCore.Controllers -{ - /// - /// Represents the violation of a model state validation rule. - /// - [PublicAPI] - public sealed class ModelStateViolation - { - public string Prefix { get; } - public string PropertyName { get; } - public Type ResourceType { get; set; } - public ModelError Error { get; } - - public ModelStateViolation(string prefix, string propertyName, Type resourceType, ModelError error) - { - ArgumentGuard.NotNullNorEmpty(prefix, nameof(prefix)); - ArgumentGuard.NotNullNorEmpty(propertyName, nameof(propertyName)); - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - ArgumentGuard.NotNull(error, nameof(error)); - - Prefix = prefix; - PropertyName = propertyName; - ResourceType = resourceType; - Error = error; - } - } -} diff --git a/src/JsonApiDotNetCore/Diagnostics/AspNetCodeTimerSession.cs b/src/JsonApiDotNetCore/Diagnostics/AspNetCodeTimerSession.cs index 2cfca080a1..997580b00e 100644 --- a/src/JsonApiDotNetCore/Diagnostics/AspNetCodeTimerSession.cs +++ b/src/JsonApiDotNetCore/Diagnostics/AspNetCodeTimerSession.cs @@ -14,15 +14,15 @@ public sealed class AspNetCodeTimerSession : ICodeTimerSession { private const string HttpContextItemKey = "CascadingCodeTimer:Session"; - private readonly HttpContext _httpContext; - private readonly IHttpContextAccessor _httpContextAccessor; + private readonly HttpContext? _httpContext; + private readonly IHttpContextAccessor? _httpContextAccessor; public ICodeTimer CodeTimer { get { HttpContext httpContext = GetHttpContext(); - var codeTimer = (ICodeTimer)httpContext.Items[HttpContextItemKey]; + var codeTimer = (ICodeTimer?)httpContext.Items[HttpContextItemKey]; if (codeTimer == null) { @@ -34,7 +34,7 @@ public ICodeTimer CodeTimer } } - public event EventHandler Disposed; + public event EventHandler? Disposed; public AspNetCodeTimerSession(IHttpContextAccessor httpContextAccessor) { @@ -52,13 +52,13 @@ public AspNetCodeTimerSession(HttpContext httpContext) public void Dispose() { - HttpContext httpContext = TryGetHttpContext(); - var codeTimer = (ICodeTimer)httpContext?.Items[HttpContextItemKey]; + HttpContext? httpContext = TryGetHttpContext(); + var codeTimer = (ICodeTimer?)httpContext?.Items[HttpContextItemKey]; if (codeTimer != null) { codeTimer.Dispose(); - httpContext.Items[HttpContextItemKey] = null; + httpContext!.Items[HttpContextItemKey] = null; } OnDisposed(); @@ -71,11 +71,11 @@ private void OnDisposed() private HttpContext GetHttpContext() { - HttpContext httpContext = TryGetHttpContext(); + HttpContext? httpContext = TryGetHttpContext(); return httpContext ?? throw new InvalidOperationException("An active HTTP request is required."); } - private HttpContext TryGetHttpContext() + private HttpContext? TryGetHttpContext() { return _httpContext ?? _httpContextAccessor?.HttpContext; } diff --git a/src/JsonApiDotNetCore/Diagnostics/CascadingCodeTimer.cs b/src/JsonApiDotNetCore/Diagnostics/CascadingCodeTimer.cs index 5da5a33b01..3b8d5ced72 100644 --- a/src/JsonApiDotNetCore/Diagnostics/CascadingCodeTimer.cs +++ b/src/JsonApiDotNetCore/Diagnostics/CascadingCodeTimer.cs @@ -45,7 +45,7 @@ public IDisposable Measure(string name, bool excludeInRelativeCost = false) private MeasureScope CreateChildScope(string name, bool excludeInRelativeCost) { - if (_activeScopeStack.TryPeek(out MeasureScope topScope)) + if (_activeScopeStack.TryPeek(out MeasureScope? topScope)) { return topScope.SpawnChild(this, name, excludeInRelativeCost); } @@ -55,7 +55,7 @@ private MeasureScope CreateChildScope(string name, bool excludeInRelativeCost) private void Close(MeasureScope scope) { - if (!_activeScopeStack.TryPeek(out MeasureScope topScope) || topScope != scope) + if (!_activeScopeStack.TryPeek(out MeasureScope? topScope) || topScope != scope) { throw new InvalidOperationException($"Scope '{scope.Name}' cannot be disposed at this time, because it is not the currently active scope."); } diff --git a/src/JsonApiDotNetCore/Diagnostics/CodeTimingSessionManager.cs b/src/JsonApiDotNetCore/Diagnostics/CodeTimingSessionManager.cs index 9160791f87..2d6b8eaae9 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 @@ -12,7 +13,7 @@ namespace JsonApiDotNetCore.Diagnostics public static class CodeTimingSessionManager { public static readonly bool IsEnabled; - private static ICodeTimerSession _session; + private static ICodeTimerSession? _session; public static ICodeTimer Current { @@ -25,14 +26,14 @@ public static ICodeTimer Current AssertHasActiveSession(); - return _session.CodeTimer; + return _session!.CodeTimer; } } 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) @@ -76,7 +83,7 @@ private static void AssertNoActiveSession() } } - private static void SessionOnDisposed(object sender, EventArgs args) + private static void SessionOnDisposed(object? sender, EventArgs args) { if (_session != null) { diff --git a/src/JsonApiDotNetCore/Diagnostics/DefaultCodeTimerSession.cs b/src/JsonApiDotNetCore/Diagnostics/DefaultCodeTimerSession.cs index b56eeab962..d35d08cd1f 100644 --- a/src/JsonApiDotNetCore/Diagnostics/DefaultCodeTimerSession.cs +++ b/src/JsonApiDotNetCore/Diagnostics/DefaultCodeTimerSession.cs @@ -9,7 +9,7 @@ namespace JsonApiDotNetCore.Diagnostics /// public sealed class DefaultCodeTimerSession : ICodeTimerSession { - private readonly AsyncLocal _codeTimerInContext = new(); + private readonly AsyncLocal _codeTimerInContext = new(); public ICodeTimer CodeTimer { @@ -17,11 +17,11 @@ public ICodeTimer CodeTimer { AssertNotDisposed(); - return _codeTimerInContext.Value; + return _codeTimerInContext.Value!; } } - public event EventHandler Disposed; + public event EventHandler? Disposed; public DefaultCodeTimerSession() { 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..2926ee43b0 --- /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/InvalidConfigurationException.cs b/src/JsonApiDotNetCore/Errors/InvalidConfigurationException.cs index ae46c9bad5..75a2275f15 100644 --- a/src/JsonApiDotNetCore/Errors/InvalidConfigurationException.cs +++ b/src/JsonApiDotNetCore/Errors/InvalidConfigurationException.cs @@ -9,7 +9,7 @@ namespace JsonApiDotNetCore.Errors [PublicAPI] public sealed class InvalidConfigurationException : Exception { - public InvalidConfigurationException(string message, Exception innerException = null) + public InvalidConfigurationException(string message, Exception? innerException = null) : base(message, innerException) { } diff --git a/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs b/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs index 4ca8586b17..6777412f4e 100644 --- a/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs +++ b/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs @@ -4,9 +4,10 @@ using System.Linq; using System.Net; using System.Reflection; -using System.Text.Json; +using System.Text.Json.Serialization; using JetBrains.Annotations; -using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.AspNetCore.Mvc.ModelBinding; @@ -14,115 +15,368 @@ namespace JsonApiDotNetCore.Errors { /// - /// The error that is thrown when model state validation fails. + /// The error that is thrown when ASP.NET ModelState validation fails. /// [PublicAPI] public sealed class InvalidModelStateException : JsonApiException { - public InvalidModelStateException(ModelStateDictionary modelState, Type resourceType, bool includeExceptionStackTraceInErrors, - JsonNamingPolicy namingPolicy) - : this(FromModelStateDictionary(modelState, resourceType), includeExceptionStackTraceInErrors, namingPolicy) + public InvalidModelStateException(IReadOnlyDictionary modelState, Type modelType, bool includeExceptionStackTraceInErrors, + IResourceGraph resourceGraph, Func? getCollectionElementTypeCallback = null) + : base(FromModelStateDictionary(modelState, modelType, resourceGraph, includeExceptionStackTraceInErrors, getCollectionElementTypeCallback)) { } - public InvalidModelStateException(IEnumerable violations, bool includeExceptionStackTraceInErrors, JsonNamingPolicy namingPolicy) - : base(FromModelStateViolations(violations, includeExceptionStackTraceInErrors, namingPolicy)) - { - } - - private static IEnumerable FromModelStateDictionary(ModelStateDictionary modelState, Type resourceType) + private static IEnumerable FromModelStateDictionary(IReadOnlyDictionary modelState, Type modelType, + IResourceGraph resourceGraph, bool includeExceptionStackTraceInErrors, Func? getCollectionElementTypeCallback) { ArgumentGuard.NotNull(modelState, nameof(modelState)); - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + ArgumentGuard.NotNull(modelType, nameof(modelType)); + ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); - var violations = new List(); + List errorObjects = new(); - foreach ((string propertyName, ModelStateEntry entry) in modelState) + foreach ((ModelStateEntry entry, string? sourcePointer) in ResolveSourcePointers(modelState, modelType, resourceGraph, + getCollectionElementTypeCallback)) { - AddValidationErrors(entry, propertyName, resourceType, violations); + AppendToErrorObjects(entry, errorObjects, sourcePointer, includeExceptionStackTraceInErrors); } - return violations; + return errorObjects; } - private static void AddValidationErrors(ModelStateEntry entry, string propertyName, Type resourceType, List violations) + private static IEnumerable<(ModelStateEntry entry, string? sourcePointer)> ResolveSourcePointers( + IReadOnlyDictionary modelState, Type modelType, IResourceGraph resourceGraph, + Func? getCollectionElementTypeCallback) { - foreach (ModelError error in entry.Errors) + foreach (string key in modelState.Keys) { - var violation = new ModelStateViolation("/data/attributes/", propertyName, resourceType, error); - violations.Add(violation); + var rootSegment = ModelStateKeySegment.Create(modelType, key, getCollectionElementTypeCallback); + string? sourcePointer = ResolveSourcePointer(rootSegment, resourceGraph); + + yield return (modelState[key], sourcePointer); } } - private static IEnumerable FromModelStateViolations(IEnumerable violations, bool includeExceptionStackTraceInErrors, - JsonNamingPolicy namingPolicy) + private static string? ResolveSourcePointer(ModelStateKeySegment segment, IResourceGraph resourceGraph) { - ArgumentGuard.NotNull(violations, nameof(violations)); - - return violations.SelectMany(violation => FromModelStateViolation(violation, includeExceptionStackTraceInErrors, namingPolicy)); - } + if (segment is ArrayIndexerSegment indexerSegment) + { + return ResolveSourcePointerInArrayIndexer(indexerSegment, resourceGraph); + } - private static IEnumerable FromModelStateViolation(ModelStateViolation violation, bool includeExceptionStackTraceInErrors, - JsonNamingPolicy namingPolicy) - { - if (violation.Error.Exception is JsonApiException jsonApiException) + if (segment is PropertySegment propertySegment) { - foreach (ErrorObject error in jsonApiException.Errors) + if (segment.IsInComplexType) + { + return ResolveSourcePointerInComplexType(propertySegment, resourceGraph); + } + + if (propertySegment.PropertyName == nameof(OperationContainer.Resource) && propertySegment.Parent != null && + propertySegment.Parent.ModelType == typeof(IList)) { - yield return error; + // Special case: Stepping over OperationContainer.Resource property. + + if (segment.GetNextSegment(propertySegment.ModelType, false, $"{segment.SourcePointer}/data") is not PropertySegment nextPropertySegment) + { + return null; + } + + propertySegment = nextPropertySegment; } + + return ResolveSourcePointerInResourceField(propertySegment, resourceGraph); } - else - { - string attributeName = GetDisplayNameForProperty(violation.PropertyName, violation.ResourceType, namingPolicy); - string attributePath = $"{violation.Prefix}{attributeName}"; - yield return FromModelError(violation.Error, attributePath, includeExceptionStackTraceInErrors); + return segment.SourcePointer; + } + + private static string? ResolveSourcePointerInArrayIndexer(ArrayIndexerSegment segment, IResourceGraph resourceGraph) + { + string sourcePointer = $"{segment.SourcePointer ?? "/atomic:operations"}[{segment.ArrayIndex}]"; + Type elementType = segment.GetCollectionElementType(); + + ModelStateKeySegment? nextSegment = segment.GetNextSegment(elementType, segment.IsInComplexType, sourcePointer); + return nextSegment != null ? ResolveSourcePointer(nextSegment, resourceGraph) : sourcePointer; + } + + private static string? ResolveSourcePointerInComplexType(PropertySegment segment, IResourceGraph resourceGraph) + { + PropertyInfo? property = segment.ModelType.GetProperty(segment.PropertyName); + + if (property == null) + { + return null; } + + string publicName = PropertySegment.GetPublicNameForProperty(property); + string? sourcePointer = segment.SourcePointer != null ? $"{segment.SourcePointer}/{publicName}" : null; + + ModelStateKeySegment? nextSegment = segment.GetNextSegment(property.PropertyType, true, sourcePointer); + return nextSegment != null ? ResolveSourcePointer(nextSegment, resourceGraph) : sourcePointer; } - private static string GetDisplayNameForProperty(string propertyName, Type resourceType, JsonNamingPolicy namingPolicy) + private static string? ResolveSourcePointerInResourceField(PropertySegment segment, IResourceGraph resourceGraph) { - PropertyInfo property = resourceType.GetProperty(propertyName); + ResourceType? resourceType = resourceGraph.FindResourceType(segment.ModelType); - if (property != null) + if (resourceType != null) { - var attrAttribute = property.GetCustomAttribute(); + AttrAttribute? attribute = resourceType.FindAttributeByPropertyName(segment.PropertyName); - if (attrAttribute?.PublicName != null) + if (attribute != null) { - return attrAttribute.PublicName; + return ResolveSourcePointerInAttribute(segment, attribute, resourceGraph); } - return namingPolicy != null ? namingPolicy.ConvertName(property.Name) : property.Name; + RelationshipAttribute? relationship = resourceType.FindRelationshipByPropertyName(segment.PropertyName); + + if (relationship != null) + { + return ResolveSourcePointerInRelationship(segment, relationship, resourceGraph); + } } - return propertyName; + return null; + } + + private static string? ResolveSourcePointerInAttribute(PropertySegment segment, AttrAttribute attribute, IResourceGraph resourceGraph) + { + string sourcePointer = attribute.Property.Name == nameof(Identifiable.Id) + ? $"{segment.SourcePointer ?? "/data"}/{attribute.PublicName}" + : $"{segment.SourcePointer ?? "/data"}/attributes/{attribute.PublicName}"; + + ModelStateKeySegment? nextSegment = segment.GetNextSegment(attribute.Property.PropertyType, true, sourcePointer); + return nextSegment != null ? ResolveSourcePointer(nextSegment, resourceGraph) : sourcePointer; + } + + private static string? ResolveSourcePointerInRelationship(PropertySegment segment, RelationshipAttribute relationship, IResourceGraph resourceGraph) + { + string sourcePointer = $"{segment.SourcePointer ?? "/data"}/relationships/{relationship.PublicName}/data"; + + ModelStateKeySegment? nextSegment = segment.GetNextSegment(relationship.RightType.ClrType, false, sourcePointer); + return nextSegment != null ? ResolveSourcePointer(nextSegment, resourceGraph) : sourcePointer; + } + + private static void AppendToErrorObjects(ModelStateEntry entry, List errorObjects, string? sourcePointer, + bool includeExceptionStackTraceInErrors) + { + foreach (ModelError error in entry.Errors) + { + if (error.Exception is JsonApiException jsonApiException) + { + errorObjects.AddRange(jsonApiException.Errors); + } + else + { + ErrorObject errorObject = FromModelError(error, sourcePointer, includeExceptionStackTraceInErrors); + errorObjects.Add(errorObject); + } + } } - private static ErrorObject FromModelError(ModelError modelError, string attributePath, bool includeExceptionStackTraceInErrors) + private static ErrorObject FromModelError(ModelError modelError, string? sourcePointer, bool includeExceptionStackTraceInErrors) { var error = new ErrorObject(HttpStatusCode.UnprocessableEntity) { Title = "Input validation failed.", - Detail = modelError.ErrorMessage, - Source = attributePath == null + Detail = modelError.Exception is TooManyModelErrorsException tooManyException ? tooManyException.Message : modelError.ErrorMessage, + Source = sourcePointer == null ? null : new ErrorSource { - Pointer = attributePath + Pointer = sourcePointer } }; 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; } + + /// + /// Base type that represents a segment in a ModelState key. + /// + private abstract class ModelStateKeySegment + { + private const char Dot = '.'; + private const char BracketOpen = '['; + private const char BracketClose = ']'; + private static readonly char[] KeySegmentStartTokens = ArrayFactory.Create(Dot, BracketOpen); + + // The right part of the full key, which nested segments are produced from. + private readonly string _nextKey; + + // Enables to resolve the runtime-type of a collection element, such as the resource type in an atomic:operation. + protected Func? GetCollectionElementTypeCallback { get; } + + // In case of a property, its declaring type. In case of an indexer, the collection type or collection element type (in case the parent is a relationship). + public Type ModelType { get; } + + // Indicates we're in a complex object, so to determine public name, inspect [JsonPropertyName] instead of [Attr], [HasOne] etc. + public bool IsInComplexType { get; } + + // The source pointer we've built up, so far. This is null whenever input is not recognized. + public string? SourcePointer { get; } + + public ModelStateKeySegment? Parent { get; } + + protected ModelStateKeySegment(Type modelType, bool isInComplexType, string nextKey, string? sourcePointer, ModelStateKeySegment? parent, + Func? getCollectionElementTypeCallback) + { + ArgumentGuard.NotNull(modelType, nameof(modelType)); + ArgumentGuard.NotNull(nextKey, nameof(nextKey)); + + ModelType = modelType; + IsInComplexType = isInComplexType; + _nextKey = nextKey; + SourcePointer = sourcePointer; + Parent = parent; + GetCollectionElementTypeCallback = getCollectionElementTypeCallback; + } + + public ModelStateKeySegment? GetNextSegment(Type modelType, bool isInComplexType, string? sourcePointer) + { + ArgumentGuard.NotNull(modelType, nameof(modelType)); + + return _nextKey == string.Empty + ? null + : CreateSegment(modelType, _nextKey, isInComplexType, this, sourcePointer, GetCollectionElementTypeCallback); + } + + public static ModelStateKeySegment Create(Type modelType, string key, Func? getCollectionElementTypeCallback) + { + ArgumentGuard.NotNull(modelType, nameof(modelType)); + ArgumentGuard.NotNull(key, nameof(key)); + + return CreateSegment(modelType, key, false, null, null, getCollectionElementTypeCallback); + } + + private static ModelStateKeySegment CreateSegment(Type modelType, string key, bool isInComplexType, ModelStateKeySegment? parent, + string? sourcePointer, Func? getCollectionElementTypeCallback) + { + string? segmentValue = null; + string? nextKey = null; + + int segmentEndIndex = key.IndexOfAny(KeySegmentStartTokens); + + if (segmentEndIndex == 0 && key[0] == BracketOpen) + { + int bracketCloseIndex = key.IndexOf(BracketClose); + + if (bracketCloseIndex != -1) + { + segmentValue = key[1.. bracketCloseIndex]; + + int nextKeyStartIndex = key.Length > bracketCloseIndex + 1 && key[bracketCloseIndex + 1] == Dot + ? bracketCloseIndex + 2 + : bracketCloseIndex + 1; + + nextKey = key[nextKeyStartIndex..]; + + if (int.TryParse(segmentValue, out int indexValue)) + { + return new ArrayIndexerSegment(indexValue, modelType, isInComplexType, nextKey, sourcePointer, parent, + getCollectionElementTypeCallback); + } + + // If the value between brackets is not numeric, consider it an unspeakable property. For example: + // "Foo[Bar]" instead of "Foo.Bar". Its unclear when this happens, but ASP.NET source contains tests for such keys. + } + } + + if (segmentValue == null) + { + segmentValue = segmentEndIndex == -1 ? key : key[..segmentEndIndex]; + + nextKey = segmentEndIndex != -1 && key.Length > segmentEndIndex && key[segmentEndIndex] == Dot + ? key[(segmentEndIndex + 1)..] + : key[segmentValue.Length..]; + } + + // Workaround for a quirk in ModelState validation. Some controller action methods have an 'id' parameter before the [FromBody] parameter. + // When a validation error occurs on top-level 'Id' in the request body, its key contains 'id' instead of 'Id' (the error message is correct, though). + // We compensate for that case here, so that we'll find 'Id' in the resource graph when building the source pointer. + if (segmentValue == "id") + { + segmentValue = "Id"; + } + + return new PropertySegment(segmentValue, modelType, isInComplexType, nextKey!, sourcePointer, parent, getCollectionElementTypeCallback); + } + } + + /// + /// Represents an array indexer in a ModelState key, such as "1" in "Customer.Orders[1].Amount". + /// + private sealed class ArrayIndexerSegment : ModelStateKeySegment + { + private static readonly CollectionConverter CollectionConverter = new(); + + public int ArrayIndex { get; } + + public ArrayIndexerSegment(int arrayIndex, Type modelType, bool isInComplexType, string nextKey, string? sourcePointer, + ModelStateKeySegment? parent, Func? getCollectionElementTypeCallback) + : base(modelType, isInComplexType, nextKey, sourcePointer, parent, getCollectionElementTypeCallback) + { + ArrayIndex = arrayIndex; + } + + public Type GetCollectionElementType() + { + Type? type = GetCollectionElementTypeCallback?.Invoke(ModelType, ArrayIndex); + return type ?? GetDeclaredCollectionElementType(); + } + + private Type GetDeclaredCollectionElementType() + { + if (ModelType != typeof(string)) + { + Type? elementType = CollectionConverter.FindCollectionElementType(ModelType); + + if (elementType != null) + { + return elementType; + } + } + + // In case of a to-many relationship, the ModelType already contains the element type. + return ModelType; + } + } + + /// + /// Represents a property in a ModelState key, such as "Orders" in "Customer.Orders[1].Amount". + /// + private sealed class PropertySegment : ModelStateKeySegment + { + public string PropertyName { get; } + + public PropertySegment(string propertyName, Type modelType, bool isInComplexType, string nextKey, string? sourcePointer, + ModelStateKeySegment? parent, Func? getCollectionElementTypeCallback) + : base(modelType, isInComplexType, nextKey, sourcePointer, parent, getCollectionElementTypeCallback) + { + ArgumentGuard.NotNull(propertyName, nameof(propertyName)); + + PropertyName = propertyName; + } + + public static string GetPublicNameForProperty(PropertyInfo property) + { + ArgumentGuard.NotNull(property, nameof(property)); + + var jsonNameAttribute = (JsonPropertyNameAttribute?)property.GetCustomAttribute(typeof(JsonPropertyNameAttribute)); + return jsonNameAttribute?.Name ?? property.Name; + } + } } } diff --git a/src/JsonApiDotNetCore/Errors/InvalidQueryException.cs b/src/JsonApiDotNetCore/Errors/InvalidQueryException.cs index fb02f2a5e4..22c8738002 100644 --- a/src/JsonApiDotNetCore/Errors/InvalidQueryException.cs +++ b/src/JsonApiDotNetCore/Errors/InvalidQueryException.cs @@ -12,12 +12,12 @@ namespace JsonApiDotNetCore.Errors [PublicAPI] public sealed class InvalidQueryException : JsonApiException { - public InvalidQueryException(string reason, Exception exception) + public InvalidQueryException(string reason, Exception? innerException) : base(new ErrorObject(HttpStatusCode.BadRequest) { Title = reason, - Detail = exception?.Message - }, exception) + Detail = innerException?.Message + }, innerException) { } } diff --git a/src/JsonApiDotNetCore/Errors/InvalidQueryStringParameterException.cs b/src/JsonApiDotNetCore/Errors/InvalidQueryStringParameterException.cs index 949710d62a..e85c7798f5 100644 --- a/src/JsonApiDotNetCore/Errors/InvalidQueryStringParameterException.cs +++ b/src/JsonApiDotNetCore/Errors/InvalidQueryStringParameterException.cs @@ -11,20 +11,20 @@ namespace JsonApiDotNetCore.Errors [PublicAPI] public sealed class InvalidQueryStringParameterException : JsonApiException { - public string QueryParameterName { get; } + public string ParameterName { get; } - public InvalidQueryStringParameterException(string queryParameterName, string genericMessage, string specificMessage, Exception innerException = null) + public InvalidQueryStringParameterException(string parameterName, string genericMessage, string specificMessage, Exception? innerException = null) : base(new ErrorObject(HttpStatusCode.BadRequest) { Title = genericMessage, Detail = specificMessage, Source = new ErrorSource { - Parameter = queryParameterName + Parameter = parameterName } }, innerException) { - QueryParameterName = queryParameterName; + ParameterName = parameterName; } } } diff --git a/src/JsonApiDotNetCore/Errors/InvalidRequestBodyException.cs b/src/JsonApiDotNetCore/Errors/InvalidRequestBodyException.cs index c435c66b2d..18929ed3d0 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..ea68e67144 100644 --- a/src/JsonApiDotNetCore/Errors/JsonApiException.cs +++ b/src/JsonApiDotNetCore/Errors/JsonApiException.cs @@ -24,9 +24,7 @@ public class JsonApiException : Exception public IReadOnlyList Errors { get; } - public override string Message => $"Errors = {JsonSerializer.Serialize(Errors, SerializerOptions)}"; - - public JsonApiException(ErrorObject error, Exception innerException = null) + public JsonApiException(ErrorObject error, Exception? innerException = null) : base(null, innerException) { ArgumentGuard.NotNull(error, nameof(error)); @@ -34,13 +32,23 @@ public JsonApiException(ErrorObject error, Exception innerException = null) Errors = error.AsArray(); } - public JsonApiException(IEnumerable errors, Exception innerException = null) + public JsonApiException(IEnumerable errors, Exception? innerException = null) : base(null, innerException) { - List errorList = errors?.ToList(); + IReadOnlyList? errorList = ToErrorList(errors); ArgumentGuard.NotNullNorEmpty(errorList, nameof(errors)); Errors = errorList; } + + private static IReadOnlyList? ToErrorList(IEnumerable? errors) + { + return errors?.ToList(); + } + + public string GetSummary() + { + return $"{nameof(JsonApiException)}: Errors = {JsonSerializer.Serialize(Errors, SerializerOptions)}"; + } } } diff --git a/src/JsonApiDotNetCore/Errors/LocalIdSingleOperationException.cs b/src/JsonApiDotNetCore/Errors/LocalIdSingleOperationException.cs new file mode 100644 index 0000000000..35e6a5f40d --- /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/RequestMethodNotAllowedException.cs b/src/JsonApiDotNetCore/Errors/RequestMethodNotAllowedException.cs deleted file mode 100644 index a4edbc0d5f..0000000000 --- a/src/JsonApiDotNetCore/Errors/RequestMethodNotAllowedException.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System.Net; -using System.Net.Http; -using JetBrains.Annotations; -using JsonApiDotNetCore.Serialization.Objects; - -namespace JsonApiDotNetCore.Errors -{ - /// - /// The error that is thrown when a request is received that contains an unsupported HTTP verb. - /// - [PublicAPI] - public sealed class RequestMethodNotAllowedException : JsonApiException - { - public HttpMethod Method { get; } - - public RequestMethodNotAllowedException(HttpMethod method) - : base(new ErrorObject(HttpStatusCode.MethodNotAllowed) - { - Title = "The request method is not allowed.", - Detail = $"Endpoint does not support {method} requests." - }) - { - Method = method; - } - } -} 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/ResourcesInRelationshipsNotFoundException.cs b/src/JsonApiDotNetCore/Errors/ResourcesInRelationshipsNotFoundException.cs index 0cb1f9cecb..550bb290df 100644 --- a/src/JsonApiDotNetCore/Errors/ResourcesInRelationshipsNotFoundException.cs +++ b/src/JsonApiDotNetCore/Errors/ResourcesInRelationshipsNotFoundException.cs @@ -19,7 +19,7 @@ public ResourcesInRelationshipsNotFoundException(IEnumerable + /// The error that is thrown when a request is received for an HTTP route that is not exposed. + /// + [PublicAPI] + public sealed class RouteNotAvailableException : JsonApiException + { + public HttpMethod Method { get; } + + public RouteNotAvailableException(HttpMethod method, string route) + : base(new ErrorObject(HttpStatusCode.Forbidden) + { + Title = "The requested endpoint is not accessible.", + Detail = $"Endpoint '{route}' is not accessible for {method} requests." + }) + { + Method = method; + } + } +} 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..6882853eab --- /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/JsonApiDotNetCore.csproj b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj index 8809659e65..b4e9e4e077 100644 --- a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj +++ b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj @@ -7,16 +7,25 @@ jsonapidotnetcore;jsonapi;json:api;dotnet;asp.net - A framework for building JSON:API compliant REST APIs using .NET Core and Entity Framework Core. Includes support for Atomic Operations. The ultimate goal of this library is to eliminate as much boilerplate as possible by offering out-of-the-box features such as sorting, filtering and pagination. You just need to focus on defining the resources and implementing your custom business logic. This library has been designed around dependency injection making extensibility incredibly easy. + A framework for building JSON:API compliant REST APIs using ASP.NET and Entity Framework Core. Includes support for Atomic Operations. The ultimate goal of this library is to eliminate as much boilerplate as possible by offering out-of-the-box features such as sorting, filtering and pagination. You just need to focus on defining the resources and implementing your custom business logic. This library has been designed around dependency injection making extensibility incredibly easy. json-api-dotnet https://www.jsonapi.net/ MIT false + See https://github.com/json-api-dotnet/JsonApiDotNetCore/releases. + logo.png true true embedded + + + True + + + + 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..5a0aab7787 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/FixedQueryFeature.cs b/src/JsonApiDotNetCore/Middleware/FixedQueryFeature.cs index 6bd345d99d..36105b5e88 100644 --- a/src/JsonApiDotNetCore/Middleware/FixedQueryFeature.cs +++ b/src/JsonApiDotNetCore/Middleware/FixedQueryFeature.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; using Microsoft.Extensions.Primitives; @@ -13,16 +14,17 @@ namespace JsonApiDotNetCore.Middleware internal sealed class FixedQueryFeature : IQueryFeature { // Lambda hoisted to static readonly field to improve inlining https://github.com/dotnet/roslyn/issues/13624 - private static readonly Func NullRequestFeature = _ => null; + private static readonly Func NullRequestFeature = _ => null; private FeatureReferences _features; - private string _original; - private IQueryCollection _parsedValues; + private string? _original; + private IQueryCollection? _parsedValues; - private IHttpRequestFeature HttpRequestFeature => _features.Fetch(ref _features.Cache, NullRequestFeature); + private IHttpRequestFeature HttpRequestFeature => _features.Fetch(ref _features.Cache, NullRequestFeature)!; /// + [AllowNull] public IQueryCollection Query { get @@ -38,7 +40,7 @@ public IQueryCollection Query { _original = current; - Dictionary result = FixedQueryHelpers.ParseNullableQuery(current); + Dictionary? result = FixedQueryHelpers.ParseNullableQuery(current); _parsedValues = result == null ? QueryCollection.Empty : new QueryCollection(result); } @@ -58,7 +60,7 @@ public IQueryCollection Query } else { - _original = QueryString.Create(_parsedValues).ToString(); + _original = QueryString.Create(_parsedValues!).ToString(); HttpRequestFeature.QueryString = _original; } } diff --git a/src/JsonApiDotNetCore/Middleware/FixedQueryHelpers.cs b/src/JsonApiDotNetCore/Middleware/FixedQueryHelpers.cs index 621aca493d..7c42d0aeea 100644 --- a/src/JsonApiDotNetCore/Middleware/FixedQueryHelpers.cs +++ b/src/JsonApiDotNetCore/Middleware/FixedQueryHelpers.cs @@ -25,7 +25,7 @@ internal static class FixedQueryHelpers /// /// A collection of parsed keys and values, null if there are no entries. /// - public static Dictionary ParseNullableQuery(string queryString) + public static Dictionary? ParseNullableQuery(string queryString) { var accumulator = new KeyValueAccumulator(); diff --git a/src/JsonApiDotNetCore/Middleware/HttpContextExtensions.cs b/src/JsonApiDotNetCore/Middleware/HttpContextExtensions.cs index b77cf79a2a..a3d137fefd 100644 --- a/src/JsonApiDotNetCore/Middleware/HttpContextExtensions.cs +++ b/src/JsonApiDotNetCore/Middleware/HttpContextExtensions.cs @@ -15,7 +15,7 @@ public static bool IsJsonApiRequest(this HttpContext httpContext) { ArgumentGuard.NotNull(httpContext, nameof(httpContext)); - string value = httpContext.Items[IsJsonApiRequestKey] as string; + string? value = httpContext.Items[IsJsonApiRequestKey] as string; return value == bool.TrueString; } diff --git a/src/JsonApiDotNetCore/Middleware/IControllerResourceMapping.cs b/src/JsonApiDotNetCore/Middleware/IControllerResourceMapping.cs index 4290b3b771..c15f3037bd 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? GetResourceTypeForController(Type? controllerType); /// /// Gets the associated controller name for the provided resource type. /// - string GetControllerNameForResourceType(Type resourceType); + string? GetControllerNameForResourceType(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..c1d353851d 100644 --- a/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs +++ b/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs @@ -14,26 +14,30 @@ public interface IJsonApiRequest public EndpointKind Kind { get; } /// - /// The ID of the primary (top-level) resource for this request. This would be null in "/blogs", "123" in "/blogs/123" or "/blogs/123/author". + /// The ID of the primary resource for this request. This would be null in "/blogs", "123" in "/blogs/123" or "/blogs/123/author". This is + /// null before and after processing operations in an atomic:operations request. /// - string PrimaryId { get; } + 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 resource type for this request. This would be "blogs" in "/blogs", "/blogs/123" or "/blogs/123/author". This is null before and + /// after processing operations in an atomic:operations request. /// - 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 - /// "/blogs/123/author" and "/blogs/123/relationships/author". + /// The secondary resource type for this request. This would be null in "/blogs", "/blogs/123" and "/blogs/123/unknownResource" or "people" in + /// "/blogs/123/author" and "/blogs/123/relationships/author". This is null before and after processing operations in an atomic:operations + /// request. /// - 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 - /// "/blogs/123/author" and "/blogs/123/relationships/author". + /// The relationship for this request. This would be null in "/blogs", "/blogs/123" and "/blogs/123/unknownResource" or "author" in + /// "/blogs/123/author" and "/blogs/123/relationships/author". This is null before and after processing operations in an atomic:operations + /// request. /// - RelationshipAttribute Relationship { get; } + RelationshipAttribute? Relationship { get; } /// /// Indicates whether this request targets a single resource or a collection of resources. @@ -41,19 +45,20 @@ public interface IJsonApiRequest bool IsCollection { get; } /// - /// Indicates whether this request targets only fetching of data (such as resources and relationships). + /// Indicates whether this request targets only fetching of data (resources and relationships), as opposed to applying changes. /// bool IsReadOnly { get; } /// - /// In case of a non-readonly request, this indicates the kind of write operation currently being processed. + /// In case of a non-readonly request, this indicates the kind of write operation currently being processed. This is null when processing a + /// read-only operation, and before and after processing operations in an atomic:operations request. /// WriteOperationKind? WriteOperation { get; } /// /// In case of an atomic:operations request, identifies the overarching transaction. /// - string TransactionId { get; } + string? TransactionId { get; } /// /// Performs a shallow copy. diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiInputFormatter.cs b/src/JsonApiDotNetCore/Middleware/JsonApiInputFormatter.cs index fc5a1e2230..46153e6502 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..934839e56e 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 = CreatePrimaryResourceType(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(); } @@ -83,7 +82,10 @@ public async Task InvokeAsync(HttpContext httpContext, IControllerResourceMappin httpContext.RegisterJsonApiRequest(); } - // Workaround for bug https://github.com/dotnet/aspnetcore/issues/33394 + // Workaround for bug https://github.com/dotnet/aspnetcore/issues/33394 (fixed in .NET 6) + // Note that integration tests do not cover this, because the query string is short-circuited through WebApplicationFactory. + // To manually test, execute a GET request such as http://localhost:14140/api/v1/todoItems?include=owner&fields[people]= + // and observe it does not fail with 400 "Unknown query string parameter". httpContext.Features.Set(new FixedQueryFeature(httpContext.Features)); using (CodeTimingSessionManager.Current.Measure("Subsequent middleware")) @@ -119,30 +121,20 @@ private async Task ValidateIfMatchHeaderAsync(HttpContext httpContext, Jso return true; } - private static ResourceContext TryCreatePrimaryResourceContext(HttpContext httpContext, IControllerResourceMapping controllerResourceMapping, - IResourceGraph resourceGraph) + private static ResourceType? CreatePrimaryResourceType(HttpContext httpContext, IControllerResourceMapping controllerResourceMapping) { - Endpoint endpoint = httpContext.GetEndpoint(); + 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.GetResourceTypeForController(controllerActionDescriptor.ControllerTypeInfo) + : null; } private static async Task ValidateContentTypeHeaderAsync(string allowedContentType, HttpContext httpContext, JsonSerializerOptions serializerOptions) { - string contentType = httpContext.Request.ContentType; + string? contentType = httpContext.Request.ContentType; // ReSharper disable once ConditionIsAlwaysTrueOrFalse // Justification: Workaround for https://github.com/dotnet/aspnetcore/issues/32097 (fixed in .NET 6) @@ -178,7 +170,7 @@ private static async Task ValidateAcceptHeaderAsync(MediaTypeHeaderValue a foreach (string acceptHeader in acceptHeaders) { - if (MediaTypeHeaderValue.TryParse(acceptHeader, out MediaTypeHeaderValue headerValue)) + if (MediaTypeHeaderValue.TryParse(acceptHeader, out MediaTypeHeaderValue? headerValue)) { headerValue.Quality = null; @@ -228,14 +220,14 @@ 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); + string? relationshipName = GetRelationshipNameForSecondaryRequest(routeValues); if (relationshipName != null) { @@ -252,12 +244,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.FindRelationshipByPublicName(relationshipName); if (requestRelationship != null) { request.Relationship = requestRelationship; - request.SecondaryResource = resourceGraph.GetResourceContext(requestRelationship.RightType); + request.SecondaryResourceType = requestRelationship.RightType; } } else @@ -280,25 +272,25 @@ private static void SetupResourceRequest(JsonApiRequest request, ResourceContext request.IsCollection = isGetAll || request.Relationship is HasManyAttribute; } - private static string GetPrimaryRequestId(RouteValueDictionary routeValues) + private static string? GetPrimaryRequestId(RouteValueDictionary routeValues) { - return routeValues.TryGetValue("id", out object id) ? (string)id : null; + return routeValues.TryGetValue("id", out object? id) ? (string?)id : null; } - private static string GetRelationshipNameForSecondaryRequest(RouteValueDictionary routeValues) + private static string? GetRelationshipNameForSecondaryRequest(RouteValueDictionary routeValues) { - return routeValues.TryGetValue("relationshipName", out object routeValue) ? (string)routeValue : null; + return routeValues.TryGetValue("relationshipName", out object? routeValue) ? (string?)routeValue : null; } private static bool IsRouteForRelationship(RouteValueDictionary routeValues) { - string actionName = (string)routeValues["action"]; + string actionName = (string)routeValues["action"]!; return actionName.EndsWith("Relationship", StringComparison.Ordinal); } private static bool IsRouteForOperations(RouteValueDictionary routeValues) { - string actionName = (string)routeValues["action"]; + string actionName = (string)routeValues["action"]!; return actionName == "PostOperations"; } 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..7801d059d3 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs @@ -12,16 +12,16 @@ public sealed class JsonApiRequest : IJsonApiRequest public EndpointKind Kind { get; set; } /// - public string PrimaryId { get; set; } + 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; } + public RelationshipAttribute? Relationship { get; set; } /// public bool IsCollection { get; set; } @@ -33,7 +33,7 @@ public sealed class JsonApiRequest : IJsonApiRequest public WriteOperationKind? WriteOperation { get; set; } /// - public string TransactionId { get; set; } + public string? TransactionId { get; set; } /// public void CopyFrom(IJsonApiRequest other) @@ -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..d304fc9d1c 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? GetResourceTypeForController(Type? controllerType) { - ArgumentGuard.NotNull(controllerType, nameof(controllerType)); - - if (_resourceContextPerControllerTypeMap.TryGetValue(controllerType, out ResourceContext resourceContext)) - { - return resourceContext.ResourceType; - } - - return null; + return controllerType != null && _resourceTypePerControllerTypeMap.TryGetValue(controllerType, out ResourceType? resourceType) + ? resourceType + : null; } /// - public string GetControllerNameForResourceType(Type resourceType) + public string? GetControllerNameForResourceType(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 resourceType != null && _controllerPerResourceTypeMap.TryGetValue(resourceType, out ControllerModel? controllerModel) + ? controllerModel.ControllerName + : null; } /// @@ -86,16 +73,21 @@ 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.FindResourceType(resourceClrType); - if (resourceContext != null) + if (resourceType != null) + { + _resourceTypePerControllerTypeMap.Add(controller.ControllerType, resourceType); + _controllerPerResourceTypeMap.Add(resourceType, controller); + } + else { - _resourceContextPerControllerTypeMap.Add(controller.ControllerType, resourceContext); - _controllerPerResourceContextMap.Add(resourceContext, controller); + throw new InvalidConfigurationException($"Controller '{controller.ControllerType}' depends on " + + $"resource type '{resourceClrType}', which does not exist in the resource graph."); } } } @@ -113,7 +105,7 @@ public void Apply(ApplicationModel application) $"Cannot register '{controller.ControllerType.FullName}' for template '{template}' because '{_registeredControllerNameByTemplate[template]}' was already registered for this template."); } - _registeredControllerNameByTemplate.Add(template, controller.ControllerType.FullName); + _registeredControllerNameByTemplate.Add(template, controller.ControllerType.FullName!); controller.Selectors[0].AttributeRouteModel = new AttributeRouteModel { @@ -131,11 +123,11 @@ private bool IsRoutingConventionEnabled(ControllerModel controller) /// /// Derives a template from the resource type, and checks if this template was already registered. /// - private string TemplateFromResource(ControllerModel model) + 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,31 +148,31 @@ 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); Type baseControllerType = typeof(BaseJsonApiController<,>); - Type currentType = type; + Type? currentType = type; while (!currentType.IsGenericType || currentType.GetGenericTypeDefinition() != baseControllerType) { - Type nextBaseType = currentType.BaseType; + Type? nextBaseType = currentType.BaseType; if ((nextBaseType == aspNetControllerType || nextBaseType == coreControllerType) && currentType.IsGenericType) { - Type resourceType = currentType.GetGenericArguments() - .FirstOrDefault(typeArgument => typeArgument.IsOrImplementsInterface(typeof(IIdentifiable))); + Type? resourceClrType = currentType.GetGenericArguments() + .FirstOrDefault(typeArgument => typeArgument.IsOrImplementsInterface()); - if (resourceType != null) + if (resourceClrType != null) { - return resourceType; + return resourceClrType; } } currentType = nextBaseType; - if (nextBaseType == null) + if (currentType == null) { break; } diff --git a/src/JsonApiDotNetCore/Middleware/TraceLogWriter.cs b/src/JsonApiDotNetCore/Middleware/TraceLogWriter.cs index 2ed2dbab48..03e38d827d 100644 --- a/src/JsonApiDotNetCore/Middleware/TraceLogWriter.cs +++ b/src/JsonApiDotNetCore/Middleware/TraceLogWriter.cs @@ -30,7 +30,7 @@ public TraceLogWriter(ILoggerFactory loggerFactory) _logger = loggerFactory.CreateLogger(typeof(T)); } - public void LogMethodStart(object parameters = null, [CallerMemberName] string memberName = "") + public void LogMethodStart(object? parameters = null, [CallerMemberName] string memberName = "") { if (IsEnabled) { @@ -48,7 +48,7 @@ public void LogMessage(Func messageFactory) } } - private static string FormatMessage(string memberName, object parameters) + private static string FormatMessage(string memberName, object? parameters) { var builder = new StringBuilder(); @@ -61,7 +61,7 @@ private static string FormatMessage(string memberName, object parameters) return builder.ToString(); } - private static void WriteProperties(StringBuilder builder, object propertyContainer) + private static void WriteProperties(StringBuilder builder, object? propertyContainer) { if (propertyContainer != null) { @@ -88,7 +88,7 @@ private static void WriteProperty(StringBuilder builder, PropertyInfo property, builder.Append(property.Name); builder.Append(": "); - object value = property.GetValue(instance); + object? value = property.GetValue(instance); if (value == null) { @@ -119,11 +119,11 @@ private static void WriteObject(StringBuilder builder, object value) } } - private static bool HasToStringOverload(Type type) + private static bool HasToStringOverload(Type? type) { if (type != null) { - MethodInfo toStringMethod = type.GetMethod("ToString", Array.Empty()); + MethodInfo? toStringMethod = type.GetMethod("ToString", Array.Empty()); if (toStringMethod != null && toStringMethod.DeclaringType != typeof(object)) { diff --git a/src/JsonApiDotNetCore/ObjectExtensions.cs b/src/JsonApiDotNetCore/ObjectExtensions.cs index 8657b64e96..e0f4ce6af7 100644 --- a/src/JsonApiDotNetCore/ObjectExtensions.cs +++ b/src/JsonApiDotNetCore/ObjectExtensions.cs @@ -21,7 +21,7 @@ public static T[] AsArray(this T element) public static List AsList(this T element) { - return new() + return new List { element }; @@ -29,7 +29,7 @@ public static List AsList(this T element) public static HashSet AsHashSet(this T element) { - return new() + return new HashSet { element }; diff --git a/src/JsonApiDotNetCore/Queries/ExpressionInScope.cs b/src/JsonApiDotNetCore/Queries/ExpressionInScope.cs index 5475baed85..5b73deee0a 100644 --- a/src/JsonApiDotNetCore/Queries/ExpressionInScope.cs +++ b/src/JsonApiDotNetCore/Queries/ExpressionInScope.cs @@ -10,10 +10,10 @@ namespace JsonApiDotNetCore.Queries [PublicAPI] public class ExpressionInScope { - public ResourceFieldChainExpression Scope { get; } + public ResourceFieldChainExpression? Scope { get; } public QueryExpression Expression { get; } - public ExpressionInScope(ResourceFieldChainExpression scope, QueryExpression expression) + public ExpressionInScope(ResourceFieldChainExpression? scope, QueryExpression expression) { ArgumentGuard.NotNull(expression, nameof(expression)); diff --git a/src/JsonApiDotNetCore/Queries/Expressions/AnyExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/AnyExpression.cs index 3c049e86b4..0682cc64a0 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/AnyExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/AnyExpression.cs @@ -49,7 +49,7 @@ public override string ToString() return builder.ToString(); } - public override bool Equals(object obj) + public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) { diff --git a/src/JsonApiDotNetCore/Queries/Expressions/ComparisonExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/ComparisonExpression.cs index ab06961738..77d0281063 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/ComparisonExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/ComparisonExpression.cs @@ -34,7 +34,7 @@ public override string ToString() return $"{Operator.ToString().Camelize()}({Left},{Right})"; } - public override bool Equals(object obj) + public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) { diff --git a/src/JsonApiDotNetCore/Queries/Expressions/CountExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/CountExpression.cs index 57163936f7..e60867c63d 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/CountExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/CountExpression.cs @@ -28,7 +28,7 @@ public override string ToString() return $"{Keywords.Count}({TargetCollection})"; } - public override bool Equals(object obj) + public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) { diff --git a/src/JsonApiDotNetCore/Queries/Expressions/HasExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/HasExpression.cs index 97f3bb5544..7e38acd447 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/HasExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/HasExpression.cs @@ -12,9 +12,9 @@ namespace JsonApiDotNetCore.Queries.Expressions public class HasExpression : FilterExpression { public ResourceFieldChainExpression TargetCollection { get; } - public FilterExpression Filter { get; } + public FilterExpression? Filter { get; } - public HasExpression(ResourceFieldChainExpression targetCollection, FilterExpression filter) + public HasExpression(ResourceFieldChainExpression targetCollection, FilterExpression? filter) { ArgumentGuard.NotNull(targetCollection, nameof(targetCollection)); @@ -45,7 +45,7 @@ public override string ToString() return builder.ToString(); } - public override bool Equals(object obj) + public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) { diff --git a/src/JsonApiDotNetCore/Queries/Expressions/IncludeChainConverter.cs b/src/JsonApiDotNetCore/Queries/Expressions/IncludeChainConverter.cs index dbe3b9b0dd..19bc92e5d6 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/IncludeChainConverter.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/IncludeChainConverter.cs @@ -68,20 +68,20 @@ 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); + var rootNode = new MutableIncludeNode(null!); foreach (ResourceFieldChainExpression chain in chains) { 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) @@ -99,13 +99,13 @@ private static void ConvertChainToElement(ResourceFieldChainExpression chain, Mu } } - private sealed class IncludeToChainsConverter : QueryExpressionVisitor + private sealed class IncludeToChainsConverter : QueryExpressionVisitor { private readonly Stack _parentRelationshipStack = new(); public List Chains { get; } = new(); - public override object VisitInclude(IncludeExpression expression, object argument) + public override object? VisitInclude(IncludeExpression expression, object? argument) { foreach (IncludeElementExpression element in expression.Elements) { @@ -115,7 +115,7 @@ public override object VisitInclude(IncludeExpression expression, object argumen return null; } - public override object VisitIncludeElement(IncludeElementExpression expression, object argument) + public override object? VisitIncludeElement(IncludeElementExpression expression, object? argument) { if (!expression.Children.Any()) { @@ -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..8cc148376b 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,14 +43,14 @@ 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('}'); } return builder.ToString(); } - public override bool Equals(object obj) + public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) { @@ -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..3c5cbeb333 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,10 +38,10 @@ 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) + public override bool Equals(object? obj) { if (ReferenceEquals(this, 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/LiteralConstantExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/LiteralConstantExpression.cs index 019301e40c..962914d83d 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/LiteralConstantExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/LiteralConstantExpression.cs @@ -28,7 +28,7 @@ public override string ToString() return $"'{value}'"; } - public override bool Equals(object obj) + public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) { diff --git a/src/JsonApiDotNetCore/Queries/Expressions/LogicalExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/LogicalExpression.cs index 30d77e8a5b..6ab16b885f 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/LogicalExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/LogicalExpression.cs @@ -34,6 +34,15 @@ public LogicalExpression(LogicalOperator @operator, IImmutableList terms = filters.WhereNotNull().ToImmutableArray(); + + return terms.Length > 1 ? new LogicalExpression(@operator, terms) : terms.FirstOrDefault(); + } + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) { return visitor.VisitLogical(this, argument); @@ -51,7 +60,7 @@ public override string ToString() return builder.ToString(); } - public override bool Equals(object obj) + public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) { diff --git a/src/JsonApiDotNetCore/Queries/Expressions/MatchTextExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/MatchTextExpression.cs index 0df64dbb44..2f82548e1d 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/MatchTextExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/MatchTextExpression.cs @@ -42,7 +42,7 @@ public override string ToString() return builder.ToString(); } - public override bool Equals(object obj) + public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) { diff --git a/src/JsonApiDotNetCore/Queries/Expressions/NotExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/NotExpression.cs index 3ac97b46bc..f7a34aa212 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/NotExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/NotExpression.cs @@ -28,7 +28,7 @@ public override string ToString() return $"{Keywords.Not}({Child})"; } - public override bool Equals(object obj) + public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) { diff --git a/src/JsonApiDotNetCore/Queries/Expressions/NullConstantExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/NullConstantExpression.cs index 1041be47dd..172a900884 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/NullConstantExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/NullConstantExpression.cs @@ -10,6 +10,12 @@ namespace JsonApiDotNetCore.Queries.Expressions [PublicAPI] public class NullConstantExpression : IdentifierExpression { + public static readonly NullConstantExpression Instance = new(); + + private NullConstantExpression() + { + } + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) { return visitor.VisitNullConstant(this, argument); @@ -20,7 +26,7 @@ public override string ToString() return Keywords.Null; } - public override bool Equals(object obj) + public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) { diff --git a/src/JsonApiDotNetCore/Queries/Expressions/PaginationElementQueryStringValueExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/PaginationElementQueryStringValueExpression.cs index d62ca621e0..69bb675bdc 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/PaginationElementQueryStringValueExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/PaginationElementQueryStringValueExpression.cs @@ -9,10 +9,10 @@ namespace JsonApiDotNetCore.Queries.Expressions [PublicAPI] public class PaginationElementQueryStringValueExpression : QueryExpression { - public ResourceFieldChainExpression Scope { get; } + public ResourceFieldChainExpression? Scope { get; } public int Value { get; } - public PaginationElementQueryStringValueExpression(ResourceFieldChainExpression scope, int value) + public PaginationElementQueryStringValueExpression(ResourceFieldChainExpression? scope, int value) { Scope = scope; Value = value; @@ -28,7 +28,7 @@ public override string ToString() return Scope == null ? Value.ToString() : $"{Scope}: {Value}"; } - public override bool Equals(object obj) + public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) { diff --git a/src/JsonApiDotNetCore/Queries/Expressions/PaginationExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/PaginationExpression.cs index 3d8f2c5870..f15e714c2e 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/PaginationExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/PaginationExpression.cs @@ -11,9 +11,9 @@ namespace JsonApiDotNetCore.Queries.Expressions public class PaginationExpression : QueryExpression { public PageNumber PageNumber { get; } - public PageSize PageSize { get; } + public PageSize? PageSize { get; } - public PaginationExpression(PageNumber pageNumber, PageSize pageSize) + public PaginationExpression(PageNumber pageNumber, PageSize? pageSize) { ArgumentGuard.NotNull(pageNumber, nameof(pageNumber)); @@ -31,7 +31,7 @@ public override string ToString() return PageSize != null ? $"Page number: {PageNumber}, size: {PageSize}" : "(none)"; } - public override bool Equals(object obj) + public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) { diff --git a/src/JsonApiDotNetCore/Queries/Expressions/PaginationQueryStringValueExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/PaginationQueryStringValueExpression.cs index 706d3d9e15..3afce49b3b 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/PaginationQueryStringValueExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/PaginationQueryStringValueExpression.cs @@ -30,7 +30,7 @@ public override string ToString() return string.Join(",", Elements.Select(constant => constant.ToString())); } - public override bool Equals(object obj) + public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) { diff --git a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs index bd4d1e4de8..8ba23c826d 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs @@ -8,9 +8,9 @@ namespace JsonApiDotNetCore.Queries.Expressions /// Building block for rewriting trees. It walks through nested expressions and updates parent on changes. /// [PublicAPI] - public class QueryExpressionRewriter : QueryExpressionVisitor + public class QueryExpressionRewriter : QueryExpressionVisitor { - public override QueryExpression Visit(QueryExpression expression, TArgument argument) + public override QueryExpression? Visit(QueryExpression expression, TArgument argument) { return expression.Accept(this, argument); } @@ -20,21 +20,21 @@ public override QueryExpression DefaultVisit(QueryExpression expression, TArgume return expression; } - public override QueryExpression VisitComparison(ComparisonExpression expression, TArgument argument) + public override QueryExpression? VisitComparison(ComparisonExpression expression, TArgument argument) { - if (expression == null) + QueryExpression? newLeft = Visit(expression.Left, argument); + QueryExpression? newRight = Visit(expression.Right, argument); + + if (newLeft != null && newRight != null) { - return null; + var newExpression = new ComparisonExpression(expression.Operator, newLeft, newRight); + return newExpression.Equals(expression) ? expression : newExpression; } - QueryExpression newLeft = Visit(expression.Left, argument); - QueryExpression newRight = Visit(expression.Right, argument); - - var newExpression = new ComparisonExpression(expression.Operator, newLeft, newRight); - return newExpression.Equals(expression) ? expression : newExpression; + return null; } - public override QueryExpression VisitResourceFieldChain(ResourceFieldChainExpression expression, TArgument argument) + public override QueryExpression? VisitResourceFieldChain(ResourceFieldChainExpression expression, TArgument argument) { return expression; } @@ -49,98 +49,83 @@ public override QueryExpression VisitNullConstant(NullConstantExpression express return expression; } - public override QueryExpression VisitLogical(LogicalExpression expression, TArgument argument) + public override QueryExpression? VisitLogical(LogicalExpression expression, TArgument argument) { - if (expression != null) - { - IImmutableList newTerms = VisitList(expression.Terms, argument); + IImmutableList newTerms = VisitList(expression.Terms, argument); - if (newTerms.Count == 1) - { - return newTerms[0]; - } + if (newTerms.Count == 1) + { + return newTerms[0]; + } - if (newTerms.Count != 0) - { - var newExpression = new LogicalExpression(expression.Operator, newTerms); - return newExpression.Equals(expression) ? expression : newExpression; - } + if (newTerms.Count != 0) + { + var newExpression = new LogicalExpression(expression.Operator, newTerms); + return newExpression.Equals(expression) ? expression : newExpression; } return null; } - public override QueryExpression VisitNot(NotExpression expression, TArgument argument) + public override QueryExpression? VisitNot(NotExpression expression, TArgument argument) { - if (expression != null) + if (Visit(expression.Child, argument) is FilterExpression newChild) { - if (Visit(expression.Child, argument) is FilterExpression newChild) - { - var newExpression = new NotExpression(newChild); - return newExpression.Equals(expression) ? expression : newExpression; - } + var newExpression = new NotExpression(newChild); + return newExpression.Equals(expression) ? expression : newExpression; } return null; } - public override QueryExpression VisitHas(HasExpression expression, TArgument argument) + public override QueryExpression? VisitHas(HasExpression expression, TArgument argument) { - if (expression != null) + if (Visit(expression.TargetCollection, argument) is ResourceFieldChainExpression newTargetCollection) { - if (Visit(expression.TargetCollection, argument) is ResourceFieldChainExpression newTargetCollection) - { - FilterExpression newFilter = expression.Filter != null ? Visit(expression.Filter, argument) as FilterExpression : null; + FilterExpression? newFilter = expression.Filter != null ? Visit(expression.Filter, argument) as FilterExpression : null; - var newExpression = new HasExpression(newTargetCollection, newFilter); - return newExpression.Equals(expression) ? expression : newExpression; - } + var newExpression = new HasExpression(newTargetCollection, newFilter); + return newExpression.Equals(expression) ? expression : newExpression; } return null; } - public override QueryExpression VisitSortElement(SortElementExpression expression, TArgument argument) + public override QueryExpression? VisitSortElement(SortElementExpression expression, TArgument argument) { - if (expression != null) - { - SortElementExpression newExpression = null; + SortElementExpression? newExpression = null; - if (expression.Count != null) + if (expression.Count != null) + { + if (Visit(expression.Count, argument) is CountExpression newCount) { - if (Visit(expression.Count, argument) is CountExpression newCount) - { - newExpression = new SortElementExpression(newCount, expression.IsAscending); - } + newExpression = new SortElementExpression(newCount, expression.IsAscending); } - else if (expression.TargetAttribute != null) + } + else if (expression.TargetAttribute != null) + { + if (Visit(expression.TargetAttribute, argument) is ResourceFieldChainExpression newTargetAttribute) { - if (Visit(expression.TargetAttribute, argument) is ResourceFieldChainExpression newTargetAttribute) - { - newExpression = new SortElementExpression(newTargetAttribute, expression.IsAscending); - } + newExpression = new SortElementExpression(newTargetAttribute, expression.IsAscending); } + } - if (newExpression != null) - { - return newExpression.Equals(expression) ? expression : newExpression; - } + if (newExpression != null) + { + return newExpression.Equals(expression) ? expression : newExpression; } return null; } - public override QueryExpression VisitSort(SortExpression expression, TArgument argument) + public override QueryExpression? VisitSort(SortExpression expression, TArgument argument) { - if (expression != null) - { - IImmutableList newElements = VisitList(expression.Elements, argument); + IImmutableList newElements = VisitList(expression.Elements, argument); - if (newElements.Count != 0) - { - var newExpression = new SortExpression(newElements); - return newExpression.Equals(expression) ? expression : newExpression; - } + if (newElements.Count != 0) + { + var newExpression = new SortExpression(newElements); + return newExpression.Equals(expression) ? expression : newExpression; } return null; @@ -151,27 +136,24 @@ public override QueryExpression VisitPagination(PaginationExpression expression, return expression; } - public override QueryExpression VisitCount(CountExpression expression, TArgument argument) + public override QueryExpression? VisitCount(CountExpression expression, TArgument argument) { - if (expression != null) + if (Visit(expression.TargetCollection, argument) is ResourceFieldChainExpression newTargetCollection) { - if (Visit(expression.TargetCollection, argument) is ResourceFieldChainExpression newTargetCollection) - { - var newExpression = new CountExpression(newTargetCollection); - return newExpression.Equals(expression) ? expression : newExpression; - } + var newExpression = new CountExpression(newTargetCollection); + return newExpression.Equals(expression) ? expression : newExpression; } return null; } - public override QueryExpression VisitMatchText(MatchTextExpression expression, TArgument argument) + public override QueryExpression? VisitMatchText(MatchTextExpression expression, TArgument argument) { - if (expression != null) - { - var newTargetAttribute = Visit(expression.TargetAttribute, argument) as ResourceFieldChainExpression; - var newTextValue = Visit(expression.TextValue, argument) as LiteralConstantExpression; + var newTargetAttribute = Visit(expression.TargetAttribute, argument) as ResourceFieldChainExpression; + var newTextValue = Visit(expression.TextValue, argument) as LiteralConstantExpression; + if (newTargetAttribute != null && newTextValue != null) + { var newExpression = new MatchTextExpression(newTargetAttribute, newTextValue, expression.MatchKind); return newExpression.Equals(expression) ? expression : newExpression; } @@ -179,13 +161,13 @@ public override QueryExpression VisitMatchText(MatchTextExpression expression, T return null; } - public override QueryExpression VisitAny(AnyExpression expression, TArgument argument) + public override QueryExpression? VisitAny(AnyExpression expression, TArgument argument) { - if (expression != null) - { - var newTargetAttribute = Visit(expression.TargetAttribute, argument) as ResourceFieldChainExpression; - IImmutableSet newConstants = VisitSet(expression.Constants, argument); + var newTargetAttribute = Visit(expression.TargetAttribute, argument) as ResourceFieldChainExpression; + IImmutableSet newConstants = VisitSet(expression.Constants, argument); + if (newTargetAttribute != null) + { var newExpression = new AnyExpression(newTargetAttribute, newConstants); return newExpression.Equals(expression) ? expression : newExpression; } @@ -193,26 +175,23 @@ public override QueryExpression VisitAny(AnyExpression expression, TArgument arg return null; } - public override QueryExpression VisitSparseFieldTable(SparseFieldTableExpression expression, TArgument argument) + public override QueryExpression? VisitSparseFieldTable(SparseFieldTableExpression expression, TArgument argument) { - 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) { - if (Visit(sparseFieldSet, argument) is SparseFieldSetExpression newSparseFieldSet) - { - newTable[resourceContext] = newSparseFieldSet; - } + newTable[resourceType] = newSparseFieldSet; } + } - if (newTable.Count > 0) - { - var newExpression = new SparseFieldTableExpression(newTable.ToImmutable()); - return newExpression.Equals(expression) ? expression : newExpression; - } + if (newTable.Count > 0) + { + var newExpression = new SparseFieldTableExpression(newTable.ToImmutable()); + return newExpression.Equals(expression) ? expression : newExpression; } return null; @@ -223,14 +202,13 @@ public override QueryExpression VisitSparseFieldSet(SparseFieldSetExpression exp return expression; } - public override QueryExpression VisitQueryStringParameterScope(QueryStringParameterScopeExpression expression, TArgument argument) + public override QueryExpression? VisitQueryStringParameterScope(QueryStringParameterScopeExpression expression, TArgument argument) { - if (expression != null) - { - var newParameterName = Visit(expression.ParameterName, argument) as LiteralConstantExpression; - - ResourceFieldChainExpression newScope = expression.Scope != null ? Visit(expression.Scope, argument) as ResourceFieldChainExpression : null; + var newParameterName = Visit(expression.ParameterName, argument) as LiteralConstantExpression; + ResourceFieldChainExpression? newScope = expression.Scope != null ? Visit(expression.Scope, argument) as ResourceFieldChainExpression : null; + if (newParameterName != null) + { var newExpression = new QueryStringParameterScopeExpression(newParameterName, newScope); return newExpression.Equals(expression) ? expression : newExpression; } @@ -240,59 +218,39 @@ public override QueryExpression VisitQueryStringParameterScope(QueryStringParame public override QueryExpression PaginationQueryStringValue(PaginationQueryStringValueExpression expression, TArgument argument) { - if (expression != null) - { - IImmutableList newElements = VisitList(expression.Elements, argument); + IImmutableList newElements = VisitList(expression.Elements, argument); - var newExpression = new PaginationQueryStringValueExpression(newElements); - return newExpression.Equals(expression) ? expression : newExpression; - } - - return null; + var newExpression = new PaginationQueryStringValueExpression(newElements); + return newExpression.Equals(expression) ? expression : newExpression; } public override QueryExpression PaginationElementQueryStringValue(PaginationElementQueryStringValueExpression expression, TArgument argument) { - if (expression != null) - { - ResourceFieldChainExpression newScope = expression.Scope != null ? Visit(expression.Scope, argument) as ResourceFieldChainExpression : null; + ResourceFieldChainExpression? newScope = expression.Scope != null ? Visit(expression.Scope, argument) as ResourceFieldChainExpression : null; - var newExpression = new PaginationElementQueryStringValueExpression(newScope, expression.Value); - return newExpression.Equals(expression) ? expression : newExpression; - } - - return null; + var newExpression = new PaginationElementQueryStringValueExpression(newScope, expression.Value); + return newExpression.Equals(expression) ? expression : newExpression; } public override QueryExpression VisitInclude(IncludeExpression expression, TArgument argument) { - if (expression != null) - { - IImmutableList newElements = VisitList(expression.Elements, argument); + IImmutableSet newElements = VisitSet(expression.Elements, argument); - if (newElements.Count == 0) - { - return IncludeExpression.Empty; - } - - var newExpression = new IncludeExpression(newElements); - return newExpression.Equals(expression) ? expression : newExpression; + if (newElements.Count == 0) + { + return IncludeExpression.Empty; } - return null; + var newExpression = new IncludeExpression(newElements); + return newExpression.Equals(expression) ? expression : newExpression; } public override QueryExpression VisitIncludeElement(IncludeElementExpression expression, TArgument argument) { - 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; - } - - return null; + var newExpression = new IncludeElementExpression(expression.Relationship, newElements); + return newExpression.Equals(expression) ? expression : newExpression; } public override QueryExpression VisitQueryableHandler(QueryableHandlerExpression expression, TArgument argument) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs index dad2958931..dc5963a573 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs @@ -15,7 +15,7 @@ public virtual TResult Visit(QueryExpression expression, TArgument argument) public virtual TResult DefaultVisit(QueryExpression expression, TArgument argument) { - return default; + return default!; } public virtual TResult VisitComparison(ComparisonExpression expression, TArgument argument) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/QueryStringParameterScopeExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryStringParameterScopeExpression.cs index 7a6071e450..dcd59928d4 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/QueryStringParameterScopeExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryStringParameterScopeExpression.cs @@ -10,9 +10,9 @@ namespace JsonApiDotNetCore.Queries.Expressions public class QueryStringParameterScopeExpression : QueryExpression { public LiteralConstantExpression ParameterName { get; } - public ResourceFieldChainExpression Scope { get; } + public ResourceFieldChainExpression? Scope { get; } - public QueryStringParameterScopeExpression(LiteralConstantExpression parameterName, ResourceFieldChainExpression scope) + public QueryStringParameterScopeExpression(LiteralConstantExpression parameterName, ResourceFieldChainExpression? scope) { ArgumentGuard.NotNull(parameterName, nameof(parameterName)); @@ -30,7 +30,7 @@ public override string ToString() return Scope == null ? ParameterName.ToString() : $"{ParameterName}: {Scope}"; } - public override bool Equals(object obj) + public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) { diff --git a/src/JsonApiDotNetCore/Queries/Expressions/QueryableHandlerExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryableHandlerExpression.cs index 9ebf5f0c2b..6e57c55172 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/QueryableHandlerExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryableHandlerExpression.cs @@ -40,7 +40,7 @@ public override string ToString() return $"handler('{_parameterValue}')"; } - public override bool Equals(object obj) + public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) { diff --git a/src/JsonApiDotNetCore/Queries/Expressions/ResourceFieldChainExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/ResourceFieldChainExpression.cs index 502cd03976..59b78d9e55 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/ResourceFieldChainExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/ResourceFieldChainExpression.cs @@ -38,7 +38,7 @@ public override string ToString() return string.Join(".", Fields.Select(field => field.PublicName)); } - public override bool Equals(object obj) + public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) { diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SortElementExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/SortElementExpression.cs index d84564ae9b..d2f342db16 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/SortElementExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SortElementExpression.cs @@ -10,8 +10,8 @@ namespace JsonApiDotNetCore.Queries.Expressions [PublicAPI] public class SortElementExpression : QueryExpression { - public ResourceFieldChainExpression TargetAttribute { get; } - public CountExpression Count { get; } + public ResourceFieldChainExpression? TargetAttribute { get; } + public CountExpression? Count { get; } public bool IsAscending { get; } public SortElementExpression(ResourceFieldChainExpression targetAttribute, bool isAscending) @@ -22,7 +22,7 @@ public SortElementExpression(ResourceFieldChainExpression targetAttribute, bool IsAscending = isAscending; } - public SortElementExpression(CountExpression count, in bool isAscending) + public SortElementExpression(CountExpression count, bool isAscending) { ArgumentGuard.NotNull(count, nameof(count)); @@ -56,7 +56,7 @@ public override string ToString() return builder.ToString(); } - public override bool Equals(object obj) + public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) { diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SortExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/SortExpression.cs index 38f68df707..c8a375d7cc 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/SortExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SortExpression.cs @@ -30,7 +30,7 @@ public override string ToString() return string.Join(",", Elements.Select(child => child.ToString())); } - public override bool Equals(object obj) + public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) { diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs index bf96dad409..6bb4611375 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs @@ -31,7 +31,7 @@ public override string ToString() return string.Join(",", Fields.Select(child => child.PublicName).OrderBy(name => name)); } - public override bool Equals(object obj) + public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) { diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpressionExtensions.cs b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpressionExtensions.cs index 35aff8b711..a9d037165a 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpressionExtensions.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpressionExtensions.cs @@ -11,14 +11,14 @@ namespace JsonApiDotNetCore.Queries.Expressions [PublicAPI] public static class SparseFieldSetExpressionExtensions { - public static SparseFieldSetExpression Including(this SparseFieldSetExpression sparseFieldSet, - Expression> fieldSelector, IResourceGraph resourceGraph) + public static SparseFieldSetExpression? Including(this SparseFieldSetExpression? sparseFieldSet, + Expression> fieldSelector, IResourceGraph resourceGraph) where TResource : class, IIdentifiable { ArgumentGuard.NotNull(fieldSelector, nameof(fieldSelector)); ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); - SparseFieldSetExpression newSparseFieldSet = sparseFieldSet; + SparseFieldSetExpression? newSparseFieldSet = sparseFieldSet; foreach (ResourceFieldAttribute field in resourceGraph.GetFields(fieldSelector)) { @@ -28,7 +28,7 @@ public static SparseFieldSetExpression Including(this SparseFieldSetE return newSparseFieldSet; } - private static SparseFieldSetExpression IncludeField(SparseFieldSetExpression sparseFieldSet, ResourceFieldAttribute fieldToInclude) + private static SparseFieldSetExpression? IncludeField(SparseFieldSetExpression? sparseFieldSet, ResourceFieldAttribute fieldToInclude) { if (sparseFieldSet == null || sparseFieldSet.Fields.Contains(fieldToInclude)) { @@ -39,14 +39,14 @@ private static SparseFieldSetExpression IncludeField(SparseFieldSetExpression sp return new SparseFieldSetExpression(newSparseFieldSet); } - public static SparseFieldSetExpression Excluding(this SparseFieldSetExpression sparseFieldSet, - Expression> fieldSelector, IResourceGraph resourceGraph) + public static SparseFieldSetExpression? Excluding(this SparseFieldSetExpression? sparseFieldSet, + Expression> fieldSelector, IResourceGraph resourceGraph) where TResource : class, IIdentifiable { ArgumentGuard.NotNull(fieldSelector, nameof(fieldSelector)); ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); - SparseFieldSetExpression newSparseFieldSet = sparseFieldSet; + SparseFieldSetExpression? newSparseFieldSet = sparseFieldSet; foreach (ResourceFieldAttribute field in resourceGraph.GetFields(fieldSelector)) { @@ -56,7 +56,7 @@ public static SparseFieldSetExpression Excluding(this SparseFieldSetE return newSparseFieldSet; } - private static SparseFieldSetExpression ExcludeField(SparseFieldSetExpression sparseFieldSet, ResourceFieldAttribute fieldToExclude) + private static SparseFieldSetExpression? ExcludeField(SparseFieldSetExpression? sparseFieldSet, ResourceFieldAttribute fieldToExclude) { // Design tradeoff: When the sparse fieldset is empty, it means all fields will be selected. // Adding an exclusion in that case is a no-op, which results in still retrieving the excluded field from data store. diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldTableExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldTableExpression.cs index 9cf7922349..8543823199 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,14 +30,14 @@ public override string ToString() { var builder = new StringBuilder(); - foreach ((ResourceContext resource, SparseFieldSetExpression fields) in Table) + foreach ((ResourceType resourceType, SparseFieldSetExpression fields) in Table) { if (builder.Length > 0) { builder.Append(','); } - builder.Append(resource.PublicName); + builder.Append(resourceType.PublicName); builder.Append('('); builder.Append(fields); builder.Append(')'); @@ -46,7 +46,7 @@ public override string ToString() return builder.ToString(); } - public override bool Equals(object obj) + public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) { @@ -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/IPaginationContext.cs b/src/JsonApiDotNetCore/Queries/IPaginationContext.cs index fb249cfbec..da769a3ade 100644 --- a/src/JsonApiDotNetCore/Queries/IPaginationContext.cs +++ b/src/JsonApiDotNetCore/Queries/IPaginationContext.cs @@ -16,7 +16,7 @@ public interface IPaginationContext /// The default page size from options, unless specified in query string. Can be null, which means no paging. Cannot be higher than /// options.MaximumPageSize. /// - PageSize PageSize { get; set; } + PageSize? PageSize { get; set; } /// /// Indicates whether the number of resources on the current page equals the page size. When true, a subsequent page might exist (assuming diff --git a/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs index c3fa8428e4..b81c9bacd4 100644 --- a/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs @@ -12,36 +12,41 @@ namespace JsonApiDotNetCore.Queries public interface IQueryLayerComposer { /// - /// Builds a top-level filter from constraints, used to determine total resource count. + /// Builds a filter from constraints, used to determine total resource count on a primary collection endpoint. /// - FilterExpression GetTopFilterFromConstraints(ResourceContext resourceContext); + FilterExpression? GetPrimaryFilterFromConstraints(ResourceType primaryResourceType); + + /// + /// Builds a filter from constraints, used to determine total resource count on a secondary collection endpoint. + /// + FilterExpression? GetSecondaryFilterFromConstraints(TId primaryId, HasManyAttribute hasManyRelationship); /// /// 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 primaryResourceType); /// /// Builds a query for each targeted relationship with a filter to match on its right resource IDs. diff --git a/src/JsonApiDotNetCore/Queries/Internal/EvaluatedIncludeCache.cs b/src/JsonApiDotNetCore/Queries/Internal/EvaluatedIncludeCache.cs index 1e9202827b..44baafc65f 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/EvaluatedIncludeCache.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/EvaluatedIncludeCache.cs @@ -5,16 +5,18 @@ namespace JsonApiDotNetCore.Queries.Internal /// internal sealed class EvaluatedIncludeCache : IEvaluatedIncludeCache { - private IncludeExpression _include; + private IncludeExpression? _include; /// public void Set(IncludeExpression include) { + ArgumentGuard.NotNull(include, nameof(include)); + _include = include; } /// - public IncludeExpression Get() + public IncludeExpression? Get() { return _include; } diff --git a/src/JsonApiDotNetCore/Queries/Internal/IEvaluatedIncludeCache.cs b/src/JsonApiDotNetCore/Queries/Internal/IEvaluatedIncludeCache.cs index d7c924f066..618caeb286 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/IEvaluatedIncludeCache.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/IEvaluatedIncludeCache.cs @@ -18,6 +18,6 @@ public interface IEvaluatedIncludeCache /// /// Gets the evaluated inclusion tree that was stored earlier. /// - IncludeExpression Get(); + IncludeExpression? Get(); } } 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..de2f246503 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); @@ -47,7 +42,7 @@ public FilterExpression Parse(string source, ResourceContext resourceContextInSc protected FilterExpression ParseFilter() { - if (TokenStack.TryPeek(out Token nextToken) && nextToken.Kind == TokenKind.Text) + if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Text) { switch (nextToken.Value) { @@ -115,7 +110,7 @@ protected LogicalExpression ParseLogical(string operatorName) term = ParseFilter(); termsBuilder.Add(term); - while (TokenStack.TryPeek(out Token nextToken) && nextToken.Kind == TokenKind.Comma) + while (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Comma) { EatSingleCharacterToken(TokenKind.Comma); @@ -159,9 +154,9 @@ protected ComparisonExpression ParseComparison(string operatorName) PropertyInfo leftProperty = leftChain.Fields[^1].Property; - if (leftProperty.Name == nameof(Identifiable.Id) && rightTerm is LiteralConstantExpression rightConstant) + if (leftProperty.Name == nameof(Identifiable.Id) && rightTerm is LiteralConstantExpression rightConstant) { - string id = DeObfuscateStringId(leftProperty.ReflectedType, rightConstant.Value); + string id = DeObfuscateStringId(leftProperty.ReflectedType!, rightConstant.Value); rightTerm = new LiteralConstantExpression(id); } } @@ -205,7 +200,7 @@ protected AnyExpression ParseAny() constant = ParseConstant(); constantsBuilder.Add(constant); - while (TokenStack.TryPeek(out Token nextToken) && nextToken.Kind == TokenKind.Comma) + while (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Comma) { EatSingleCharacterToken(TokenKind.Comma); @@ -219,7 +214,7 @@ protected AnyExpression ParseAny() PropertyInfo targetAttributeProperty = targetAttribute.Fields[^1].Property; - if (targetAttributeProperty.Name == nameof(Identifiable.Id)) + if (targetAttributeProperty.Name == nameof(Identifiable.Id)) { constantSet = DeObfuscateIdConstants(constantSet, targetAttributeProperty); } @@ -235,7 +230,7 @@ private IImmutableSet DeObfuscateIdConstants(IImmutab foreach (LiteralConstantExpression idConstant in constantSet) { string stringId = idConstant.Value; - string id = DeObfuscateStringId(targetAttributeProperty.ReflectedType, stringId); + string id = DeObfuscateStringId(targetAttributeProperty.ReflectedType!, stringId); idConstantsBuilder.Add(new LiteralConstantExpression(id)); } @@ -249,9 +244,9 @@ protected HasExpression ParseHas() EatSingleCharacterToken(TokenKind.OpenParen); ResourceFieldChainExpression targetCollection = ParseFieldChain(FieldChainRequirements.EndsInToMany, null); - FilterExpression filter = null; + FilterExpression? filter = null; - if (TokenStack.TryPeek(out Token nextToken) && nextToken.Kind == TokenKind.Comma) + if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Comma) { EatSingleCharacterToken(TokenKind.Comma); @@ -265,20 +260,19 @@ 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; } protected QueryExpression ParseCountOrField(FieldChainRequirements chainRequirements) { - CountExpression count = TryParseCount(); + CountExpression? count = TryParseCount(); if (count != null) { @@ -290,14 +284,14 @@ protected QueryExpression ParseCountOrField(FieldChainRequirements chainRequirem protected QueryExpression ParseCountOrConstantOrNullOrField(FieldChainRequirements chainRequirements) { - CountExpression count = TryParseCount(); + CountExpression? count = TryParseCount(); if (count != null) { return count; } - IdentifierExpression constantOrNull = TryParseConstantOrNull(); + IdentifierExpression? constantOrNull = TryParseConstantOrNull(); if (constantOrNull != null) { @@ -307,20 +301,20 @@ protected QueryExpression ParseCountOrConstantOrNullOrField(FieldChainRequiremen return ParseFieldChain(chainRequirements, "Count function, value between quotes, null or field name expected."); } - protected IdentifierExpression TryParseConstantOrNull() + protected IdentifierExpression? TryParseConstantOrNull() { - if (TokenStack.TryPeek(out Token nextToken)) + if (TokenStack.TryPeek(out Token? nextToken)) { if (nextToken.Kind == TokenKind.Text && nextToken.Value == Keywords.Null) { TokenStack.Pop(); - return new NullConstantExpression(); + return NullConstantExpression.Instance; } if (nextToken.Kind == TokenKind.QuotedText) { TokenStack.Pop(); - return new LiteralConstantExpression(nextToken.Value); + return new LiteralConstantExpression(nextToken.Value!); } } @@ -329,36 +323,36 @@ protected IdentifierExpression TryParseConstantOrNull() protected LiteralConstantExpression ParseConstant() { - if (TokenStack.TryPop(out Token token) && token.Kind == TokenKind.QuotedText) + if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.QuotedText) { - return new LiteralConstantExpression(token.Value); + return new LiteralConstantExpression(token.Value!); } 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(); + return tempResource.GetTypedId().ToString()!; } protected override IImmutableList OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) { 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..012fd617c3 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..dd76b4e58f 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); @@ -79,7 +78,7 @@ protected PaginationElementQueryStringValueExpression ParsePaginationElement() protected int? TryParseNumber() { - if (TokenStack.TryPeek(out Token nextToken)) + if (TokenStack.TryPeek(out Token? nextToken)) { int number; @@ -87,7 +86,7 @@ protected PaginationElementQueryStringValueExpression ParsePaginationElement() { TokenStack.Pop(); - if (TokenStack.TryPop(out Token token) && token.Kind == TokenKind.Text && int.TryParse(token.Value, out number)) + if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.Text && int.TryParse(token.Value, out number)) { return -number; } @@ -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..f0e1392e30 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; @@ -18,13 +17,8 @@ namespace JsonApiDotNetCore.Queries.Internal.Parsing [PublicAPI] public abstract class QueryExpressionParser { - protected Stack TokenStack { get; private set; } - private protected ResourceFieldChainResolver ChainResolver { get; } - - protected QueryExpressionParser(IResourceGraph resourceGraph) - { - ChainResolver = new ResourceFieldChainResolver(resourceGraph); - } + protected Stack TokenStack { get; private set; } = null!; + private protected ResourceFieldChainResolver ChainResolver { get; } = new(); /// /// Takes a dotted path and walks the resource graph to produce a chain of fields. @@ -37,11 +31,11 @@ protected virtual void Tokenize(string source) TokenStack = new Stack(tokenizer.EnumerateTokens().Reverse()); } - protected ResourceFieldChainExpression ParseFieldChain(FieldChainRequirements chainRequirements, string alternativeErrorMessage) + protected ResourceFieldChainExpression ParseFieldChain(FieldChainRequirements chainRequirements, string? alternativeErrorMessage) { - if (TokenStack.TryPop(out Token token) && token.Kind == TokenKind.Text) + if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.Text) { - IImmutableList chain = OnResolveFieldChain(token.Value, chainRequirements); + IImmutableList chain = OnResolveFieldChain(token.Value!, chainRequirements); if (chain.Any()) { @@ -52,9 +46,9 @@ protected ResourceFieldChainExpression ParseFieldChain(FieldChainRequirements ch throw new QueryParseException(alternativeErrorMessage ?? "Field name expected."); } - protected CountExpression TryParseCount() + protected CountExpression? TryParseCount() { - if (TokenStack.TryPeek(out Token nextToken) && nextToken.Kind == TokenKind.Text && nextToken.Value == Keywords.Count) + if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Text && nextToken.Value == Keywords.Count) { TokenStack.Pop(); @@ -72,7 +66,7 @@ protected CountExpression TryParseCount() protected void EatText(string text) { - if (!TokenStack.TryPop(out Token token) || token.Kind != TokenKind.Text || token.Value != text) + if (!TokenStack.TryPop(out Token? token) || token.Kind != TokenKind.Text || token.Value != text) { throw new QueryParseException($"{text} expected."); } @@ -80,7 +74,7 @@ protected void EatText(string text) protected void EatSingleCharacterToken(TokenKind kind) { - if (!TokenStack.TryPop(out Token token) || token.Kind != kind) + if (!TokenStack.TryPop(out Token? token) || token.Kind != kind) { char ch = QueryTokenizer.SingleCharacterToTokenKinds.Single(pair => pair.Value == kind).Key; throw new QueryParseException($"{ch} expected."); diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryStringParameterScopeParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryStringParameterScopeParser.cs index 7d98110362..c25ce3a1c3 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); @@ -39,16 +38,16 @@ public QueryStringParameterScopeExpression Parse(string source, ResourceContext protected QueryStringParameterScopeExpression ParseQueryStringParameterScope() { - if (!TokenStack.TryPop(out Token token) || token.Kind != TokenKind.Text) + if (!TokenStack.TryPop(out Token? token) || token.Kind != TokenKind.Text) { throw new QueryParseException("Parameter name expected."); } - var name = new LiteralConstantExpression(token.Value); + var name = new LiteralConstantExpression(token.Value!); - ResourceFieldChainExpression scope = null; + ResourceFieldChainExpression? scope = null; - if (TokenStack.TryPeek(out Token nextToken) && nextToken.Kind == TokenKind.OpenBracket) + if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.OpenBracket) { TokenStack.Pop(); @@ -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/QueryTokenizer.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryTokenizer.cs index f050d9f7b0..518531902a 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryTokenizer.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryTokenizer.cs @@ -57,7 +57,7 @@ public IEnumerable EnumerateTokens() _isInQuotedSection = false; - Token literalToken = ProduceTokenFromTextBuffer(true); + Token literalToken = ProduceTokenFromTextBuffer(true)!; yield return literalToken; } else @@ -76,7 +76,7 @@ public IEnumerable EnumerateTokens() if (singleCharacterTokenKind != null && !IsMinusInsideText(singleCharacterTokenKind.Value)) { - Token identifierToken = ProduceTokenFromTextBuffer(false); + Token? identifierToken = ProduceTokenFromTextBuffer(false); if (identifierToken != null) { @@ -104,7 +104,7 @@ public IEnumerable EnumerateTokens() throw new QueryParseException("' expected."); } - Token lastToken = ProduceTokenFromTextBuffer(false); + Token? lastToken = ProduceTokenFromTextBuffer(false); if (lastToken != null) { @@ -124,10 +124,10 @@ private bool IsMinusInsideText(TokenKind kind) private static TokenKind? TryGetSingleCharacterTokenKind(char ch) { - return SingleCharacterToTokenKinds.ContainsKey(ch) ? SingleCharacterToTokenKinds[ch] : null; + return SingleCharacterToTokenKinds.TryGetValue(ch, out TokenKind tokenKind) ? tokenKind : null; } - private Token ProduceTokenFromTextBuffer(bool isQuotedText) + private Token? ProduceTokenFromTextBuffer(bool isQuotedText) { if (isQuotedText || _textBuffer.Length > 0) { diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldChainResolver.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldChainResolver.cs index edb7a774f2..3233ec92d1 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.FindRelationshipByPublicName(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.FindAttributeByPublicName(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..a78ec99b66 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); @@ -57,13 +56,13 @@ protected SortElementExpression ParseSortElement() { bool isAscending = true; - if (TokenStack.TryPeek(out Token nextToken) && nextToken.Kind == TokenKind.Minus) + if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Minus) { TokenStack.Pop(); isAscending = false; } - CountExpression count = TryParseCount(); + CountExpression? count = TryParseCount(); if (count != null) { @@ -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..2a2fa60e5d 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldSetParser.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldSetParser.cs @@ -11,31 +11,30 @@ 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); - SparseFieldSetExpression expression = ParseSparseFieldSet(); + SparseFieldSetExpression? expression = ParseSparseFieldSet(); AssertTokenStackIsEmpty(); return expression; } - protected SparseFieldSetExpression ParseSparseFieldSet() + protected SparseFieldSetExpression? ParseSparseFieldSet() { ImmutableHashSet.Builder fieldSetBuilder = ImmutableHashSet.CreateBuilder(); @@ -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..d9c97dd220 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldTypeParser.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldTypeParser.cs @@ -12,58 +12,59 @@ 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) + if (!TokenStack.TryPop(out Token? token) || token.Kind != TokenKind.Text) { throw new QueryParseException("Parameter name expected."); } 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) + 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.FindResourceType(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/Parsing/Token.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/Token.cs index c8c8623a67..6965562d44 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/Token.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/Token.cs @@ -6,9 +6,9 @@ namespace JsonApiDotNetCore.Queries.Internal.Parsing public sealed class Token { public TokenKind Kind { get; } - public string Value { get; } + public string? Value { get; } - public Token(TokenKind kind, string value = null) + public Token(TokenKind kind, string? value = null) { Kind = kind; Value = value; diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs index 6bc933cfc0..d1a55551de 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? GetPrimaryFilterFromConstraints(ResourceType primaryResourceType) { ExpressionInScope[] constraints = _constraintProviders.SelectMany(provider => provider.GetConstraints()).ToArray(); @@ -64,17 +62,86 @@ 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 FilterExpression? GetSecondaryFilterFromConstraints(TId primaryId, HasManyAttribute hasManyRelationship) { - ArgumentGuard.NotNull(requestResource, nameof(requestResource)); + ArgumentGuard.NotNull(hasManyRelationship, nameof(hasManyRelationship)); + + if (hasManyRelationship.InverseNavigationProperty == null) + { + return null; + } + + RelationshipAttribute? inverseRelationship = + hasManyRelationship.RightType.FindRelationshipByPropertyName(hasManyRelationship.InverseNavigationProperty.Name); + + if (inverseRelationship == null) + { + return null; + } ExpressionInScope[] constraints = _constraintProviders.SelectMany(provider => provider.GetConstraints()).ToArray(); - QueryLayer topLayer = ComposeTopLayer(constraints, requestResource); + var secondaryScope = new ResourceFieldChainExpression(hasManyRelationship); + + // @formatter:wrap_chained_method_calls chop_always + // @formatter:keep_existing_linebreaks true + + FilterExpression[] filtersInSecondaryScope = constraints + .Where(constraint => secondaryScope.Equals(constraint.Scope)) + .Select(constraint => constraint.Expression) + .OfType() + .ToArray(); + + // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_chained_method_calls restore + + FilterExpression? primaryFilter = GetFilter(Array.Empty(), hasManyRelationship.LeftType); + FilterExpression? secondaryFilter = GetFilter(filtersInSecondaryScope, hasManyRelationship.RightType); + + FilterExpression inverseFilter = GetInverseRelationshipFilter(primaryId, hasManyRelationship, inverseRelationship); + + return LogicalExpression.Compose(LogicalOperator.And, inverseFilter, primaryFilter, secondaryFilter); + } + + private static FilterExpression GetInverseRelationshipFilter(TId primaryId, HasManyAttribute relationship, + RelationshipAttribute inverseRelationship) + { + return inverseRelationship is HasManyAttribute hasManyInverseRelationship + ? GetInverseHasManyRelationshipFilter(primaryId, relationship, hasManyInverseRelationship) + : GetInverseHasOneRelationshipFilter(primaryId, relationship, (HasOneAttribute)inverseRelationship); + } + + private static FilterExpression GetInverseHasOneRelationshipFilter(TId primaryId, HasManyAttribute relationship, + HasOneAttribute inverseRelationship) + { + AttrAttribute idAttribute = GetIdAttribute(relationship.LeftType); + var idChain = new ResourceFieldChainExpression(ImmutableArray.Create(inverseRelationship, idAttribute)); + + return new ComparisonExpression(ComparisonOperator.Equals, idChain, new LiteralConstantExpression(primaryId!.ToString()!)); + } + + private static FilterExpression GetInverseHasManyRelationshipFilter(TId primaryId, HasManyAttribute relationship, + HasManyAttribute inverseRelationship) + { + AttrAttribute idAttribute = GetIdAttribute(relationship.LeftType); + var idChain = new ResourceFieldChainExpression(ImmutableArray.Create(idAttribute)); + var idComparison = new ComparisonExpression(ComparisonOperator.Equals, idChain, new LiteralConstantExpression(primaryId!.ToString()!)); + + return new HasExpression(new ResourceFieldChainExpression(inverseRelationship), idComparison); + } + + /// + public QueryLayer ComposeFromConstraints(ResourceType requestResourceType) + { + ArgumentGuard.NotNull(requestResourceType, nameof(requestResourceType)); + + ExpressionInScope[] constraints = _constraintProviders.SelectMany(provider => provider.GetConstraints()).ToArray(); + + QueryLayer topLayer = ComposeTopLayer(constraints, requestResourceType); topLayer.Include = ComposeChildren(topLayer, constraints); _evaluatedIncludeCache.Set(topLayer.Include); @@ -82,7 +149,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,20 +164,16 @@ private QueryLayer ComposeTopLayer(IEnumerable constraints, R // @formatter:keep_existing_linebreaks restore // @formatter:wrap_chained_method_calls restore - PaginationExpression topPagination = GetPagination(expressionsInTopScope, resourceContext); - - if (topPagination != null) - { - _paginationContext.PageSize = topPagination.PageSize; - _paginationContext.PageNumber = topPagination.PageNumber; - } + PaginationExpression topPagination = GetPagination(expressionsInTopScope, resourceType); + _paginationContext.PageSize = topPagination.PageSize; + _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 +193,7 @@ private IncludeExpression ComposeChildren(QueryLayer topLayer, ICollection includeElements = + IImmutableSet includeElements = ProcessIncludeSet(include.Elements, topLayer, new List(), constraints); return !ReferenceEquals(includeElements, include.Elements) @@ -138,17 +201,16 @@ 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); - var updatesInChildren = new Dictionary>(); + var updatesInChildren = new Dictionary>(); foreach (IncludeElementExpression includeElement in includeElementsEvaluated) { - parentLayer.Projection ??= new Dictionary(); + parentLayer.Projection ??= new Dictionary(); if (!parentLayer.Projection.ContainsKey(includeElement.Relationship)) { @@ -169,30 +231,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,36 +258,36 @@ 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); if (fieldSelection == TopFieldSelection.OnlyIdAttribute) { - queryLayer.Projection = new Dictionary + queryLayer.Projection = new Dictionary { [idAttribute] = null }; @@ -247,93 +305,93 @@ 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); + 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; + 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); + 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 }; } - private IncludeExpression RewriteIncludeForSecondaryEndpoint(IncludeExpression relativeInclude, RelationshipAttribute secondaryRelationship) + private IncludeExpression RewriteIncludeForSecondaryEndpoint(IncludeExpression? relativeInclude, RelationshipAttribute secondaryRelationship) { IncludeElementExpression parentElement = relativeInclude != null ? 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) + private FilterExpression? CreateFilterByIds(IReadOnlyCollection ids, AttrAttribute idAttribute, FilterExpression? existingFilter) { var idChain = new ResourceFieldChainExpression(idAttribute); - FilterExpression filter = null; + FilterExpression? filter = null; if (ids.Count == 1) { - var constant = new LiteralConstantExpression(ids.Single().ToString()); + var constant = new LiteralConstantExpression(ids.Single()!.ToString()!); filter = new ComparisonExpression(ComparisonOperator.Equals, idChain, constant); } else if (ids.Count > 1) { - ImmutableHashSet constants = ids.Select(id => new LiteralConstantExpression(id.ToString())).ToImmutableHashSet(); + ImmutableHashSet constants = ids.Select(id => new LiteralConstantExpression(id!.ToString()!)).ToImmutableHashSet(); filter = new AnyExpression(idChain, constants); } - return filter == null ? existingFilter : existingFilter == null ? filter : new LogicalExpression(LogicalOperator.And, filter, existingFilter); + return LogicalExpression.Compose(LogicalOperator.And, filter, existingFilter); } /// - public QueryLayer ComposeForUpdate(TId id, ResourceContext primaryResource) + public QueryLayer ComposeForUpdate(TId id, ResourceType primaryResourceType) { - ArgumentGuard.NotNull(primaryResource, nameof(primaryResource)); + ArgumentGuard.NotNull(primaryResourceType, nameof(primaryResourceType)); - ImmutableArray includeElements = _targetedFields.Relationships - .Select(relationship => new IncludeElementExpression(relationship)).ToImmutableArray(); + IImmutableSet includeElements = _targetedFields.Relationships + .Select(relationship => new IncludeElementExpression(relationship)).ToImmutableHashSet(); - AttrAttribute primaryIdAttribute = GetIdAttribute(primaryResource); + AttrAttribute primaryIdAttribute = GetIdAttribute(primaryResourceType); - QueryLayer primaryLayer = ComposeTopLayer(Array.Empty(), primaryResource); + QueryLayer primaryLayer = ComposeTopLayer(Array.Empty(), primaryResourceType); primaryLayer.Include = includeElements.Any() ? new IncludeExpression(includeElements) : IncludeExpression.Empty; primaryLayer.Sort = null; primaryLayer.Pagination = null; @@ -350,7 +408,7 @@ public QueryLayer ComposeForUpdate(TId id, ResourceContext primaryResource) foreach (RelationshipAttribute relationship in _targetedFields.Relationships) { - object rightValue = relationship.GetValue(primaryResource); + object? rightValue = relationship.GetValue(primaryResource); ICollection rightResourceIds = _collectionConverter.ExtractResources(rightValue); if (rightResourceIds.Any()) @@ -367,19 +425,18 @@ 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 filter = CreateFilterByIds(typedIds, rightIdAttribute, baseFilter); + 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, - Projection = new Dictionary + Projection = new Dictionary { [rightIdAttribute] = null } @@ -392,26 +449,23 @@ 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); + 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 + Projection = new Dictionary { - [hasManyRelationship] = new(rightResourceContext) + [hasManyRelationship] = new(hasManyRelationship.RightType) { Filter = rightFilter, - Projection = new Dictionary + Projection = new Dictionary { [rightIdAttribute] = null } @@ -421,37 +475,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(); + FilterExpression[] filters = expressionsInScope.OfType().ToArray(); + FilterExpression? filter = LogicalExpression.Compose(LogicalOperator.And, filters); - 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(); + 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 +513,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(); + 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 +539,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); + 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..7f4cbc895e 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/IncludeClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/IncludeClauseBuilder.cs @@ -13,24 +13,21 @@ namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding /// Transforms into calls. /// [PublicAPI] - public class IncludeClauseBuilder : QueryClauseBuilder + 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) @@ -40,9 +37,9 @@ public Expression ApplyInclude(IncludeExpression include) return Visit(include, null); } - public override Expression VisitInclude(IncludeExpression expression, object argument) + 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)) { @@ -54,21 +51,20 @@ public override Expression VisitInclude(IncludeExpression expression, object arg private Expression ProcessRelationshipChain(ResourceFieldChainExpression chain, Expression source) { - string path = null; + string? path = null; Expression result = source; foreach (RelationshipAttribute relationship in chain.Fields.Cast()) { 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); + return IncludeExtensionMethodCall(result, path!); } - private Expression ApplyEagerLoads(Expression source, IEnumerable eagerLoads, string pathPrefix) + private Expression ApplyEagerLoads(Expression source, IEnumerable eagerLoads, string? pathPrefix) { Expression result = source; diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScope.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScope.cs index 8fb8b96b2f..d80b373e3a 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScope.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScope.cs @@ -15,7 +15,7 @@ public sealed class LambdaScope : IDisposable public ParameterExpression Parameter { get; } public Expression Accessor { get; } - public LambdaScope(LambdaParameterNameFactory nameFactory, Type elementType, Expression accessorExpression) + public LambdaScope(LambdaParameterNameFactory nameFactory, Type elementType, Expression? accessorExpression) { ArgumentGuard.NotNull(nameFactory, nameof(nameFactory)); ArgumentGuard.NotNull(elementType, nameof(elementType)); diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScopeFactory.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScopeFactory.cs index 26e8059ca8..d5b55fec13 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScopeFactory.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScopeFactory.cs @@ -16,7 +16,7 @@ public LambdaScopeFactory(LambdaParameterNameFactory nameFactory) _nameFactory = nameFactory; } - public LambdaScope CreateScope(Type elementType, Expression accessorExpression = null) + public LambdaScope CreateScope(Type elementType, Expression? accessorExpression = null) { ArgumentGuard.NotNull(elementType, nameof(elementType)); diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/OrderClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/OrderClauseBuilder.cs index bdbff2bb19..e2692b75de 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/OrderClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/OrderClauseBuilder.cs @@ -11,7 +11,7 @@ namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding /// calls. /// [PublicAPI] - public class OrderClauseBuilder : QueryClauseBuilder + public class OrderClauseBuilder : QueryClauseBuilder { private readonly Expression _source; private readonly Type _extensionType; @@ -33,21 +33,21 @@ public Expression ApplyOrderBy(SortExpression expression) return Visit(expression, null); } - public override Expression VisitSort(SortExpression expression, Expression argument) + public override Expression VisitSort(SortExpression expression, Expression? argument) { - Expression sortExpression = null; + Expression? sortExpression = null; foreach (SortElementExpression sortElement in expression.Elements) { sortExpression = Visit(sortElement, sortExpression); } - return sortExpression; + return sortExpression!; } - public override Expression VisitSortElement(SortElementExpression expression, Expression previousExpression) + public override Expression VisitSortElement(SortElementExpression expression, Expression? previousExpression) { - Expression body = expression.Count != null ? Visit(expression.Count, null) : Visit(expression.TargetAttribute, null); + Expression body = expression.Count != null ? Visit(expression.Count, null) : Visit(expression.TargetAttribute!, null); LambdaExpression lambda = Expression.Lambda(body, LambdaScope.Parameter); diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryClauseBuilder.cs index 2a51b561c9..60b25fb9a6 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryClauseBuilder.cs @@ -25,7 +25,7 @@ public override Expression VisitCount(CountExpression expression, TArgument argu { Expression collectionExpression = Visit(expression.TargetCollection, argument); - Expression propertyExpression = TryGetCollectionCount(collectionExpression); + Expression? propertyExpression = GetCollectionCount(collectionExpression); if (propertyExpression == null) { @@ -35,23 +35,26 @@ public override Expression VisitCount(CountExpression expression, TArgument argu return propertyExpression; } - private static Expression TryGetCollectionCount(Expression collectionExpression) + private static Expression? GetCollectionCount(Expression? collectionExpression) { - var properties = new HashSet(collectionExpression.Type.GetProperties()); - - if (collectionExpression.Type.IsInterface) + if (collectionExpression != null) { - foreach (PropertyInfo item in collectionExpression.Type.GetInterfaces().SelectMany(@interface => @interface.GetProperties())) + var properties = new HashSet(collectionExpression.Type.GetProperties()); + + if (collectionExpression.Type.IsInterface) { - properties.Add(item); + foreach (PropertyInfo item in collectionExpression.Type.GetInterfaces().SelectMany(@interface => @interface.GetProperties())) + { + properties.Add(item); + } } - } - foreach (PropertyInfo property in properties) - { - if (property.Name == "Count" || property.Name == "Length") + foreach (PropertyInfo property in properties) { - return Expression.Property(collectionExpression, property); + if (property.Name is "Count" or "Length") + { + return Expression.Property(collectionExpression, property); + } } } @@ -67,7 +70,7 @@ public override Expression VisitResourceFieldChain(ResourceFieldChainExpression private static MemberExpression CreatePropertyExpressionFromComponents(Expression source, IEnumerable components) { - MemberExpression property = null; + MemberExpression? property = null; foreach (string propertyName in components) { @@ -81,10 +84,10 @@ private static MemberExpression CreatePropertyExpressionFromComponents(Expressio property = property == null ? Expression.Property(source, propertyName) : Expression.Property(property, propertyName); } - return property; + return property!; } - protected Expression CreateTupleAccessExpressionForConstant(object value, Type type) + protected Expression CreateTupleAccessExpressionForConstant(object? value, Type type) { // To enable efficient query plan caching, inline constants (that vary per request) should be converted into query parameters. // https://stackoverflow.com/questions/54075758/building-a-parameterized-entityframework-core-expression diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryableBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryableBuilder.cs index b32c4246d3..99036e5d1d 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..c34b92c0dc 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(); @@ -84,44 +81,63 @@ private Expression CreateLambdaBodyInitializer(IDictionary ToPropertySelectors(IDictionary resourceFieldSelectors, - ResourceContext resourceContext, Type elementType) + private ICollection ToPropertySelectors(IDictionary resourceFieldSelectors, + ResourceType resourceType, Type elementType) { var propertySelectors = new Dictionary(); - // If a read-only attribute is selected, its value likely depends on another property, so select all resource properties. + // If a read-only attribute is selected, its calculated value likely depends on another property, so select all properties. bool includesReadOnlyAttribute = resourceFieldSelectors.Any(selector => selector.Key is AttrAttribute attribute && attribute.Property.SetMethod == null); + // Only selecting relationships implicitly means to select all attributes too. bool containsOnlyRelationships = resourceFieldSelectors.All(selector => selector.Key is RelationshipAttribute); - foreach ((ResourceFieldAttribute resourceField, QueryLayer queryLayer) in resourceFieldSelectors) + if (includesReadOnlyAttribute || containsOnlyRelationships) { - var propertySelector = new PropertySelector(resourceField.Property, queryLayer); - - if (propertySelector.Property.SetMethod != null) - { - propertySelectors[propertySelector.Property] = propertySelector; - } + IncludeAllProperties(elementType, propertySelectors); } - if (includesReadOnlyAttribute || containsOnlyRelationships) + IncludeFieldSelection(resourceFieldSelectors, propertySelectors); + + IncludeEagerLoads(resourceType, propertySelectors); + + return propertySelectors.Values; + } + + private void IncludeAllProperties(Type elementType, Dictionary propertySelectors) + { + IEntityType entityModel = _entityModel.GetEntityTypes().Single(type => type.ClrType == elementType); + IEnumerable entityProperties = entityModel.GetProperties().Where(property => !property.IsShadowProperty()).ToArray(); + + foreach (IProperty entityProperty in entityProperties) { - IEntityType entityModel = _entityModel.GetEntityTypes().Single(type => type.ClrType == elementType); - IEnumerable entityProperties = entityModel.GetProperties().Where(property => !property.IsShadowProperty()).ToArray(); + var propertySelector = new PropertySelector(entityProperty.PropertyInfo); + IncludeWritableProperty(propertySelector, propertySelectors); + } + } - foreach (IProperty entityProperty in entityProperties) - { - var propertySelector = new PropertySelector(entityProperty.PropertyInfo); + private static void IncludeFieldSelection(IDictionary resourceFieldSelectors, + Dictionary propertySelectors) + { + foreach ((ResourceFieldAttribute resourceField, QueryLayer? queryLayer) in resourceFieldSelectors) + { + var propertySelector = new PropertySelector(resourceField.Property, queryLayer); + IncludeWritableProperty(propertySelector, propertySelectors); + } + } - if (propertySelector.Property.SetMethod != null) - { - propertySelectors[propertySelector.Property] = propertySelector; - } - } + private static void IncludeWritableProperty(PropertySelector propertySelector, Dictionary propertySelectors) + { + if (propertySelector.Property.SetMethod != null) + { + propertySelectors[propertySelector.Property] = propertySelector; } + } - foreach (EagerLoadAttribute eagerLoad in resourceContext.EagerLoads) + private static void IncludeEagerLoads(ResourceType resourceType, Dictionary propertySelectors) + { + foreach (EagerLoadAttribute eagerLoad in resourceType.EagerLoads) { var propertySelector = new PropertySelector(eagerLoad.Property); @@ -131,11 +147,7 @@ private ICollection ToPropertySelectors(IDictionary into and calls. /// [PublicAPI] - public class SkipTakeClauseBuilder : QueryClauseBuilder + public class SkipTakeClauseBuilder : QueryClauseBuilder { private readonly Expression _source; private readonly Type _extensionType; @@ -32,7 +32,7 @@ public Expression ApplySkipTake(PaginationExpression expression) return Visit(expression, null); } - public override Expression VisitPagination(PaginationExpression expression, object argument) + public override Expression VisitPagination(PaginationExpression expression, object? argument) { Expression skipTakeExpression = _source; diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/WhereClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/WhereClauseBuilder.cs index 8009328405..fcb934d0f9 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/WhereClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/WhereClauseBuilder.cs @@ -15,7 +15,7 @@ namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding /// calls. /// [PublicAPI] - public class WhereClauseBuilder : QueryClauseBuilder + public class WhereClauseBuilder : QueryClauseBuilder { private static readonly CollectionConverter CollectionConverter = new(); private static readonly ConstantExpression NullConstant = Expression.Constant(null); @@ -56,18 +56,18 @@ private Expression WhereExtensionMethodCall(LambdaExpression predicate) return Expression.Call(_extensionType, "Where", LambdaScope.Parameter.Type.AsArray(), _source, predicate); } - public override Expression VisitHas(HasExpression expression, Type argument) + public override Expression VisitHas(HasExpression expression, Type? argument) { Expression property = Visit(expression.TargetCollection, argument); - Type elementType = CollectionConverter.TryGetCollectionElementType(property.Type); + Type? elementType = CollectionConverter.FindCollectionElementType(property.Type); if (elementType == null) { throw new InvalidOperationException("Expression must be a collection."); } - Expression predicate = null; + Expression? predicate = null; if (expression.Filter != null) { @@ -81,17 +81,14 @@ public override Expression VisitHas(HasExpression expression, Type argument) return AnyExtensionMethodCall(elementType, property, predicate); } - private static MethodCallExpression AnyExtensionMethodCall(Type elementType, Expression source, Expression predicate) + private static MethodCallExpression AnyExtensionMethodCall(Type elementType, Expression source, Expression? predicate) { - if (predicate != null) - { - return Expression.Call(typeof(Enumerable), "Any", elementType.AsArray(), source, predicate); - } - - return Expression.Call(typeof(Enumerable), "Any", elementType.AsArray(), source); + return predicate != null + ? Expression.Call(typeof(Enumerable), "Any", elementType.AsArray(), source, predicate) + : Expression.Call(typeof(Enumerable), "Any", elementType.AsArray(), source); } - public override Expression VisitMatchText(MatchTextExpression expression, Type argument) + public override Expression VisitMatchText(MatchTextExpression expression, Type? argument) { Expression property = Visit(expression.TargetAttribute, argument); @@ -115,16 +112,16 @@ public override Expression VisitMatchText(MatchTextExpression expression, Type a return Expression.Call(property, "Contains", null, text); } - public override Expression VisitAny(AnyExpression expression, Type argument) + public override Expression VisitAny(AnyExpression expression, Type? argument) { Expression property = Visit(expression.TargetAttribute, argument); - var valueList = (IList)Activator.CreateInstance(typeof(List<>).MakeGenericType(property.Type)); + var valueList = (IList)Activator.CreateInstance(typeof(List<>).MakeGenericType(property.Type))!; foreach (LiteralConstantExpression constant in expression.Constants) { - object value = ConvertTextToTargetType(constant.Value, property.Type); - valueList!.Add(value); + object? value = ConvertTextToTargetType(constant.Value, property.Type); + valueList.Add(value); } ConstantExpression collection = Expression.Constant(valueList); @@ -136,7 +133,7 @@ private static Expression ContainsExtensionMethodCall(Expression collection, Exp return Expression.Call(typeof(Enumerable), "Contains", value.Type.AsArray(), collection, value); } - public override Expression VisitLogical(LogicalExpression expression, Type argument) + public override Expression VisitLogical(LogicalExpression expression, Type? argument) { var termQueue = new Queue(expression.Terms.Select(filter => Visit(filter, argument))); @@ -169,15 +166,15 @@ private static BinaryExpression Compose(Queue argumentQueue, Func).MakeGenericType(leftType); } - Type rightType = TryResolveFixedType(right); + Type? rightType = TryResolveFixedType(right); if (rightType != null && RuntimeTypeConverter.CanContainNull(rightType)) { @@ -239,7 +236,7 @@ private Type ResolveFixedType(QueryExpression expression) return result.Type; } - private Type TryResolveFixedType(QueryExpression expression) + private Type? TryResolveFixedType(QueryExpression expression) { if (expression is CountExpression) { @@ -255,7 +252,7 @@ private Type TryResolveFixedType(QueryExpression expression) return null; } - private static Expression WrapInConvert(Expression expression, Type targetType) + private static Expression WrapInConvert(Expression expression, Type? targetType) { try { @@ -267,19 +264,19 @@ private static Expression WrapInConvert(Expression expression, Type targetType) } } - public override Expression VisitNullConstant(NullConstantExpression expression, Type expressionType) + public override Expression VisitNullConstant(NullConstantExpression expression, Type? expressionType) { return NullConstant; } - public override Expression VisitLiteralConstant(LiteralConstantExpression expression, Type expressionType) + public override Expression VisitLiteralConstant(LiteralConstantExpression expression, Type? expressionType) { - object convertedValue = expressionType != null ? ConvertTextToTargetType(expression.Value, expressionType) : expression.Value; + object? convertedValue = expressionType != null ? ConvertTextToTargetType(expression.Value, expressionType) : expression.Value; return CreateTupleAccessExpressionForConstant(convertedValue, expressionType ?? typeof(string)); } - private static object ConvertTextToTargetType(string text, Type targetType) + private static object? ConvertTextToTargetType(string text, Type targetType) { try { diff --git a/src/JsonApiDotNetCore/Queries/Internal/SparseFieldSetCache.cs b/src/JsonApiDotNetCore/Queries/Internal/SparseFieldSetCache.cs index 573b19e4a4..2915882d10 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/SparseFieldSetCache.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/SparseFieldSetCache.cs @@ -1,8 +1,8 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; -using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources; @@ -10,16 +10,14 @@ namespace JsonApiDotNetCore.Queries.Internal { - /// - /// Takes sparse fieldsets from s and invokes - /// on them. - /// - [PublicAPI] - public sealed class SparseFieldSetCache + /// + public sealed class SparseFieldSetCache : ISparseFieldSetCache { + private static readonly ConcurrentDictionary ViewableFieldSetCache = new(); + 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 +25,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 +47,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 +71,40 @@ 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]) - : null; + SparseFieldSetExpression? inputExpression = + _lazySourceTable.Value.TryGetValue(resourceType, out IImmutableSet? inputFields) + ? new SparseFieldSetExpression(inputFields) + : 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 +114,52 @@ 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); + SparseFieldSetExpression inputExpression = + _lazySourceTable.Value.TryGetValue(resourceType, out IImmutableSet? inputFields) + ? new SparseFieldSetExpression(inputFields) + : GetCachedViewableFieldSet(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); + IImmutableSet outputFields = outputExpression == null + ? GetCachedViewableFieldSet(resourceType).Fields + : inputExpression.Fields.Intersect(outputExpression.Fields); - _visitedTable[resourceContext] = outputFields; + _visitedTable[resourceType] = outputFields; } - return _visitedTable[resourceContext]; + return _visitedTable[resourceType]; } - private IImmutableSet GetResourceFields(ResourceContext resourceContext) + private static SparseFieldSetExpression GetCachedViewableFieldSet(ResourceType resourceType) { - ArgumentGuard.NotNull(resourceContext, nameof(resourceContext)); + if (!ViewableFieldSetCache.TryGetValue(resourceType, out SparseFieldSetExpression? fieldSet)) + { + IImmutableSet viewableFields = GetViewableFields(resourceType); + fieldSet = new SparseFieldSetExpression(viewableFields); + ViewableFieldSetCache[resourceType] = fieldSet; + } + return fieldSet; + } + + private static IImmutableSet GetViewableFields(ResourceType 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/PaginationContext.cs b/src/JsonApiDotNetCore/Queries/PaginationContext.cs index beb760555c..466659fe22 100644 --- a/src/JsonApiDotNetCore/Queries/PaginationContext.cs +++ b/src/JsonApiDotNetCore/Queries/PaginationContext.cs @@ -7,10 +7,10 @@ namespace JsonApiDotNetCore.Queries internal sealed class PaginationContext : IPaginationContext { /// - public PageNumber PageNumber { get; set; } + public PageNumber PageNumber { get; set; } = PageNumber.ValueOne; /// - public PageSize PageSize { get; set; } + public PageSize? PageSize { get; set; } /// public bool IsPageFull { get; set; } diff --git a/src/JsonApiDotNetCore/Queries/QueryLayer.cs b/src/JsonApiDotNetCore/Queries/QueryLayer.cs index 9340181743..9d32ca89d9 100644 --- a/src/JsonApiDotNetCore/Queries/QueryLayer.cs +++ b/src/JsonApiDotNetCore/Queries/QueryLayer.cs @@ -14,19 +14,19 @@ 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; } - public SortExpression Sort { get; set; } - public PaginationExpression Pagination { get; set; } - public IDictionary Projection { get; set; } + public IncludeExpression? Include { get; set; } + public FilterExpression? Filter { get; set; } + public SortExpression? Sort { get; set; } + public PaginationExpression? Pagination { get; set; } + public IDictionary? Projection { get; set; } - public QueryLayer(ResourceContext resourceContext) + public QueryLayer(ResourceType resourceType) { - ArgumentGuard.NotNull(resourceContext, nameof(resourceContext)); + ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - ResourceContext = resourceContext; + ResourceType = resourceType; } public override string ToString() @@ -39,9 +39,9 @@ public override string ToString() return builder.ToString(); } - private static void WriteLayer(IndentingStringWriter writer, QueryLayer layer, string prefix = null) + private static void WriteLayer(IndentingStringWriter writer, QueryLayer layer, string? prefix = null) { - writer.WriteLine($"{prefix}{nameof(QueryLayer)}<{layer.ResourceContext.ResourceType.Name}>"); + writer.WriteLine($"{prefix}{nameof(QueryLayer)}<{layer.ResourceType.ClrType.Name}>"); using (writer.Indent()) { @@ -71,7 +71,7 @@ private static void WriteLayer(IndentingStringWriter writer, QueryLayer layer, s using (writer.Indent()) { - foreach ((ResourceFieldAttribute field, QueryLayer nextLayer) in layer.Projection) + foreach ((ResourceFieldAttribute field, QueryLayer? nextLayer) in layer.Projection) { if (nextLayer == null) { @@ -97,7 +97,7 @@ public IndentingStringWriter(StringBuilder builder) _builder = builder; } - public void WriteLine(string line) + public void WriteLine(string? line) { if (_indentDepth > 0) { diff --git a/src/JsonApiDotNetCore/QueryStrings/IQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/IQueryStringParameterReader.cs index 024ee564f2..ebfdac0976 100644 --- a/src/JsonApiDotNetCore/QueryStrings/IQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/IQueryStringParameterReader.cs @@ -9,9 +9,9 @@ namespace JsonApiDotNetCore.QueryStrings public interface IQueryStringParameterReader { /// - /// Indicates whether this reader supports empty query string parameter values. Defaults to false. + /// Indicates whether this reader supports empty query string parameter values. /// - bool AllowEmptyValue => false; + bool AllowEmptyValue { get; } /// /// Indicates whether usage of this query string parameter is blocked using on a controller. diff --git a/src/JsonApiDotNetCore/QueryStrings/IQueryStringReader.cs b/src/JsonApiDotNetCore/QueryStrings/IQueryStringReader.cs index 39c07ec036..04d3ffe26f 100644 --- a/src/JsonApiDotNetCore/QueryStrings/IQueryStringReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/IQueryStringReader.cs @@ -13,6 +13,6 @@ public interface IQueryStringReader /// /// The if set on the controller that is targeted by the current request. /// - void ReadAll(DisableQueryStringAttribute disableQueryStringAttribute); + void ReadAll(DisableQueryStringAttribute? disableQueryStringAttribute); } } diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/FilterQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/FilterQueryStringParameterReader.cs index 354cb4b8ec..30d1e5d904 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/FilterQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/FilterQueryStringParameterReader.cs @@ -27,7 +27,9 @@ public class FilterQueryStringParameterReader : QueryStringParameterReader, IFil private readonly ImmutableArray.Builder _filtersInGlobalScope = ImmutableArray.CreateBuilder(); private readonly Dictionary.Builder> _filtersPerScope = new(); - private string _lastParameterName; + private string? _lastParameterName; + + public bool AllowEmptyValue => false; public FilterQueryStringParameterReader(IJsonApiRequest request, IResourceGraph resourceGraph, IResourceFactory resourceFactory, IJsonApiOptions options) @@ -36,15 +38,15 @@ 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)) { - throw new InvalidQueryStringParameterException(_lastParameterName, "Filtering on the requested attribute is not allowed.", + throw new InvalidQueryStringParameterException(_lastParameterName!, "Filtering on the requested attribute is not allowed.", $"Filtering on attribute '{attribute.PublicName}' is not allowed."); } } @@ -104,20 +106,20 @@ private void ReadSingleValue(string parameterName, string parameterValue) (name, value) = LegacyConverter.Convert(name, value); } - ResourceFieldChainExpression scope = GetScope(name); + ResourceFieldChainExpression? scope = GetScope(name); FilterExpression filter = GetFilter(value, scope); StoreFilterInScope(filter, scope); } catch (QueryParseException exception) { - throw new InvalidQueryStringParameterException(_lastParameterName, "The specified filter is invalid.", exception.Message, exception); + throw new InvalidQueryStringParameterException(_lastParameterName!, "The specified filter is invalid.", exception.Message, exception); } } - private ResourceFieldChainExpression GetScope(string parameterName) + private ResourceFieldChainExpression? GetScope(string parameterName) { - QueryStringParameterScopeExpression parameterScope = _scopeParser.Parse(parameterName, RequestResource); + QueryStringParameterScopeExpression parameterScope = _scopeParser.Parse(parameterName, RequestResourceType); if (parameterScope.Scope == null) { @@ -127,13 +129,13 @@ private ResourceFieldChainExpression GetScope(string parameterName) return parameterScope.Scope; } - private FilterExpression GetFilter(string parameterValue, ResourceFieldChainExpression scope) + 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) + private void StoreFilterInScope(FilterExpression filter, ResourceFieldChainExpression? scope) { if (scope == null) { diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/IncludeQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/IncludeQueryStringParameterReader.cs index 2bed425170..f5e98b9a20 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/IncludeQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/IncludeQueryStringParameterReader.cs @@ -18,8 +18,10 @@ public class IncludeQueryStringParameterReader : QueryStringParameterReader, IIn private readonly IJsonApiOptions _options; private readonly IncludeParser _includeParser; - private IncludeExpression _includeExpression; - private string _lastParameterName; + private IncludeExpression? _includeExpression; + private string? _lastParameterName; + + public bool AllowEmptyValue => false; public IncludeQueryStringParameterReader(IJsonApiRequest request, IResourceGraph resourceGraph, IJsonApiOptions options) : base(request, resourceGraph) @@ -27,17 +29,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.", + 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 +74,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/LegacyFilterNotationConverter.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/LegacyFilterNotationConverter.cs index d3cfaec888..0b47e4427b 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/LegacyFilterNotationConverter.cs +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/LegacyFilterNotationConverter.cs @@ -52,7 +52,7 @@ public IEnumerable ExtractConditions(string parameterValue) if (parameterValue.StartsWith(ExpressionPrefix, StringComparison.Ordinal)) { - string expression = parameterValue.Substring(ExpressionPrefix.Length); + string expression = parameterValue[ExpressionPrefix.Length..]; return (parameterName, expression); } @@ -62,7 +62,7 @@ public IEnumerable ExtractConditions(string parameterValue) { if (parameterValue.StartsWith(prefix, StringComparison.Ordinal)) { - string value = parameterValue.Substring(prefix.Length); + string value = parameterValue[prefix.Length..]; string escapedValue = EscapeQuotes(value); string expression = $"{keyword}({attributeName},'{escapedValue}')"; @@ -72,7 +72,7 @@ public IEnumerable ExtractConditions(string parameterValue) if (parameterValue.StartsWith(NotEqualsPrefix, StringComparison.Ordinal)) { - string value = parameterValue.Substring(NotEqualsPrefix.Length); + string value = parameterValue[NotEqualsPrefix.Length..]; string escapedValue = EscapeQuotes(value); string expression = $"{Keywords.Not}({Keywords.Equals}({attributeName},'{escapedValue}'))"; @@ -81,7 +81,7 @@ public IEnumerable ExtractConditions(string parameterValue) if (parameterValue.StartsWith(InPrefix, StringComparison.Ordinal)) { - string[] valueParts = parameterValue.Substring(InPrefix.Length).Split(","); + string[] valueParts = parameterValue[InPrefix.Length..].Split(","); string valueList = $"'{string.Join("','", valueParts)}'"; string expression = $"{Keywords.Any}({attributeName},{valueList})"; @@ -90,7 +90,7 @@ public IEnumerable ExtractConditions(string parameterValue) if (parameterValue.StartsWith(NotInPrefix, StringComparison.Ordinal)) { - string[] valueParts = parameterValue.Substring(NotInPrefix.Length).Split(","); + string[] valueParts = parameterValue[NotInPrefix.Length..].Split(","); string valueList = $"'{string.Join("','", valueParts)}'"; string expression = $"{Keywords.Not}({Keywords.Any}({attributeName},{valueList}))"; diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/PaginationQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/PaginationQueryStringParameterReader.cs index 47c6ec595e..023a4a67bd 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/PaginationQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/PaginationQueryStringParameterReader.cs @@ -22,8 +22,10 @@ public class PaginationQueryStringParameterReader : QueryStringParameterReader, private readonly IJsonApiOptions _options; private readonly PaginationParser _paginationParser; - private PaginationQueryStringValueExpression _pageSizeConstraint; - private PaginationQueryStringValueExpression _pageNumberConstraint; + private PaginationQueryStringValueExpression? _pageSizeConstraint; + private PaginationQueryStringValueExpression? _pageNumberConstraint; + + public bool AllowEmptyValue => false; public PaginationQueryStringParameterReader(IJsonApiRequest request, IResourceGraph resourceGraph, IJsonApiOptions options) : base(request, resourceGraph) @@ -31,7 +33,7 @@ public PaginationQueryStringParameterReader(IJsonApiRequest request, IResourceGr ArgumentGuard.NotNull(options, nameof(options)); _options = options; - _paginationParser = new PaginationParser(resourceGraph); + _paginationParser = new PaginationParser(); } /// @@ -45,7 +47,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 +81,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 +122,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,21 +135,21 @@ 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(); - public MutablePaginationEntry ResolveEntryInScope(ResourceFieldChainExpression scope) + public MutablePaginationEntry ResolveEntryInScope(ResourceFieldChainExpression? scope) { if (scope == null) { @@ -189,21 +191,21 @@ public IReadOnlyCollection GetExpressionsInScope() private IEnumerable EnumerateExpressionsInScope() { - yield return new ExpressionInScope(null, new PaginationExpression(_globalScope.PageNumber, _globalScope.PageSize)); + yield return new ExpressionInScope(null, new PaginationExpression(_globalScope.PageNumber!, _globalScope.PageSize)); foreach ((ResourceFieldChainExpression scope, MutablePaginationEntry entry) in _nestedScopes) { - yield return new ExpressionInScope(scope, new PaginationExpression(entry.PageNumber, entry.PageSize)); + yield return new ExpressionInScope(scope, new PaginationExpression(entry.PageNumber!, entry.PageSize)); } } } private sealed class MutablePaginationEntry { - public PageSize PageSize { get; set; } + public PageSize? PageSize { get; set; } public bool HasSetPageSize { get; set; } - public PageNumber PageNumber { get; set; } + public PageNumber? PageNumber { get; set; } } } } diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/QueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/QueryStringParameterReader.cs index b026ae7587..4513f8e23a 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,26 @@ protected QueryStringParameterReader(IJsonApiRequest request, IResourceGraph res _resourceGraph = resourceGraph; _isCollectionRequest = request.IsCollection; - RequestResource = request.SecondaryResource ?? request.PrimaryResource; + // There are currently no query string readers that work with operations, so non-nullable for convenience. + RequestResourceType = (request.SecondaryResourceType ?? request.PrimaryResourceType)!; IsAtomicOperationsRequest = request.Kind == EndpointKind.AtomicOperations; } - protected 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/QueryStringReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/QueryStringReader.cs index 28aabb53e5..52bfa648fe 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/QueryStringReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/QueryStringReader.cs @@ -35,7 +35,7 @@ public QueryStringReader(IJsonApiOptions options, IRequestQueryStringAccessor qu } /// - public virtual void ReadAll(DisableQueryStringAttribute disableQueryStringAttribute) + public virtual void ReadAll(DisableQueryStringAttribute? disableQueryStringAttribute) { using IDisposable _ = CodeTimingSessionManager.Current.Measure("Parse query string"); @@ -43,7 +43,7 @@ public virtual void ReadAll(DisableQueryStringAttribute disableQueryStringAttrib foreach ((string parameterName, StringValues parameterValue) in _queryStringAccessor.Query) { - IQueryStringParameterReader reader = _parameterReaders.FirstOrDefault(nextReader => nextReader.CanRead(parameterName)); + IQueryStringParameterReader? reader = _parameterReaders.FirstOrDefault(nextReader => nextReader.CanRead(parameterName)); if (reader != null) { diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/RequestQueryStringAccessor.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/RequestQueryStringAccessor.cs index fef422d2ac..1ca5fa55f5 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/RequestQueryStringAccessor.cs +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/RequestQueryStringAccessor.cs @@ -1,3 +1,4 @@ +using System; using Microsoft.AspNetCore.Http; namespace JsonApiDotNetCore.QueryStrings.Internal @@ -7,7 +8,18 @@ internal sealed class RequestQueryStringAccessor : IRequestQueryStringAccessor { private readonly IHttpContextAccessor _httpContextAccessor; - public IQueryCollection Query => _httpContextAccessor.HttpContext!.Request.Query; + public IQueryCollection Query + { + get + { + if (_httpContextAccessor.HttpContext == null) + { + throw new InvalidOperationException("An active HTTP request is required."); + } + + return _httpContextAccessor.HttpContext.Request.Query; + } + } public RequestQueryStringAccessor(IHttpContextAccessor httpContextAccessor) { diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/ResourceDefinitionQueryableParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/ResourceDefinitionQueryableParameterReader.cs index 5a3e1cab3c..bc53a9ed4d 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/ResourceDefinitionQueryableParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/ResourceDefinitionQueryableParameterReader.cs @@ -19,6 +19,8 @@ public class ResourceDefinitionQueryableParameterReader : IResourceDefinitionQue private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; private readonly List _constraints = new(); + public bool AllowEmptyValue => false; + public ResourceDefinitionQueryableParameterReader(IJsonApiRequest request, IResourceDefinitionAccessor resourceDefinitionAccessor) { ArgumentGuard.NotNull(request, nameof(request)); @@ -42,22 +44,22 @@ public virtual bool CanRead(string parameterName) return false; } - object queryableHandler = GetQueryableHandler(parameterName); + object? queryableHandler = GetQueryableHandler(parameterName); return queryableHandler != null; } /// public virtual void Read(string parameterName, StringValues parameterValue) { - object queryableHandler = GetQueryableHandler(parameterName); + object queryableHandler = GetQueryableHandler(parameterName)!; var expressionInScope = new ExpressionInScope(null, new QueryableHandlerExpression(queryableHandler, parameterValue)); _constraints.Add(expressionInScope); } - private object GetQueryableHandler(string parameterName) + 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..d231f176f5 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/SortQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/SortQueryStringParameterReader.cs @@ -19,20 +19,22 @@ public class SortQueryStringParameterReader : QueryStringParameterReader, ISortQ private readonly QueryStringParameterScopeParser _scopeParser; private readonly SortParser _sortParser; private readonly List _constraints = new(); - private string _lastParameterName; + private string? _lastParameterName; + + public bool AllowEmptyValue => false; 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)) { - throw new InvalidQueryStringParameterException(_lastParameterName, "Sorting on the requested attribute is not allowed.", + throw new InvalidQueryStringParameterException(_lastParameterName!, "Sorting on the requested attribute is not allowed.", $"Sorting on attribute '{attribute.PublicName}' is not allowed."); } } @@ -61,7 +63,7 @@ public virtual void Read(string parameterName, StringValues parameterValue) try { - ResourceFieldChainExpression scope = GetScope(parameterName); + ResourceFieldChainExpression? scope = GetScope(parameterName); SortExpression sort = GetSort(parameterValue, scope); var expressionInScope = new ExpressionInScope(scope, sort); @@ -73,9 +75,9 @@ public virtual void Read(string parameterName, StringValues parameterValue) } } - private ResourceFieldChainExpression GetScope(string parameterName) + private ResourceFieldChainExpression? GetScope(string parameterName) { - QueryStringParameterScopeExpression parameterScope = _scopeParser.Parse(parameterName, RequestResource); + QueryStringParameterScopeExpression parameterScope = _scopeParser.Parse(parameterName, RequestResourceType); if (parameterScope.Scope == null) { @@ -85,10 +87,10 @@ private ResourceFieldChainExpression GetScope(string parameterName) return parameterScope.Scope; } - private SortExpression GetSort(string parameterValue, ResourceFieldChainExpression scope) + 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..07ba665f18 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/SparseFieldSetQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/SparseFieldSetQueryStringParameterReader.cs @@ -22,10 +22,10 @@ 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; + private string? _lastParameterName; /// bool IQueryStringParameterReader.AllowEmptyValue => true; @@ -34,14 +34,14 @@ 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)) { - throw new InvalidQueryStringParameterException(_lastParameterName, "Retrieving the requested attribute is not allowed.", + throw new InvalidQueryStringParameterException(_lastParameterName!, "Retrieving the requested attribute is not allowed.", $"Retrieving the attribute '{attribute.PublicName}' is not allowed."); } } @@ -69,10 +69,10 @@ public virtual void Read(string parameterName, StringValues parameterValue) try { - ResourceContext targetResource = GetSparseFieldType(parameterName); - SparseFieldSetExpression sparseFieldSet = GetSparseFieldSet(parameterValue, targetResource); + ResourceType targetResourceType = GetSparseFieldType(parameterName); + SparseFieldSetExpression sparseFieldSet = GetSparseFieldSet(parameterValue, targetResourceType); - _sparseFieldTableBuilder[targetResource] = sparseFieldSet; + _sparseFieldTableBuilder[targetResourceType] = sparseFieldSet; } catch (QueryParseException exception) { @@ -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/DataStoreUpdateException.cs b/src/JsonApiDotNetCore/Repositories/DataStoreUpdateException.cs index 80a1b85a77..4ba6b8fc3b 100644 --- a/src/JsonApiDotNetCore/Repositories/DataStoreUpdateException.cs +++ b/src/JsonApiDotNetCore/Repositories/DataStoreUpdateException.cs @@ -9,8 +9,8 @@ namespace JsonApiDotNetCore.Repositories [PublicAPI] public sealed class DataStoreUpdateException : Exception { - public DataStoreUpdateException(Exception exception) - : base("Failed to persist changes in the underlying data store.", exception) + public DataStoreUpdateException(Exception? innerException) + : base("Failed to persist changes in the underlying data store.", innerException) { } } diff --git a/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs b/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs index 4610beb0e6..6fec658c4e 100644 --- a/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs +++ b/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs @@ -18,7 +18,7 @@ public static IIdentifiable GetTrackedOrAttach(this DbContext dbContext, IIdenti ArgumentGuard.NotNull(dbContext, nameof(dbContext)); ArgumentGuard.NotNull(resource, nameof(resource)); - var trackedIdentifiable = (IIdentifiable)dbContext.GetTrackedIdentifiable(resource); + var trackedIdentifiable = (IIdentifiable?)dbContext.GetTrackedIdentifiable(resource); if (trackedIdentifiable == null) { @@ -32,22 +32,22 @@ public static IIdentifiable GetTrackedOrAttach(this DbContext dbContext, IIdenti /// /// Searches the change tracker for an entity that matches the type and ID of . /// - public static object GetTrackedIdentifiable(this DbContext dbContext, IIdentifiable identifiable) + public static object? GetTrackedIdentifiable(this DbContext dbContext, IIdentifiable identifiable) { ArgumentGuard.NotNull(dbContext, nameof(dbContext)); ArgumentGuard.NotNull(identifiable, nameof(identifiable)); - Type resourceType = identifiable.GetType(); - string stringId = identifiable.StringId; + 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..2092f8935a 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -1,6 +1,7 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Linq.Expressions; using System.Threading; @@ -39,14 +40,14 @@ public class EntityFrameworkCoreRepository : IResourceRepository private readonly TraceLogWriter> _traceWriter; /// - public virtual string TransactionId => _dbContext.Database.CurrentTransaction?.TransactionId.ToString(); + 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 +55,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; @@ -63,18 +64,18 @@ public EntityFrameworkCoreRepository(ITargetedFields targetedFields, IDbContextR } /// - public virtual async Task> GetAsync(QueryLayer layer, CancellationToken cancellationToken) + public virtual async Task> GetAsync(QueryLayer queryLayer, CancellationToken cancellationToken) { _traceWriter.LogMethodStart(new { - layer + queryLayer }); - ArgumentGuard.NotNull(layer, nameof(layer)); + ArgumentGuard.NotNull(queryLayer, nameof(queryLayer)); using (CodeTimingSessionManager.Current.Measure("Repository - Get resource(s)")) { - IQueryable query = ApplyQueryLayer(layer); + IQueryable query = ApplyQueryLayer(queryLayer); using (CodeTimingSessionManager.Current.Measure("Execute SQL (data)", MeasurementSettings.ExcludeDatabaseInPercentages)) { @@ -84,20 +85,20 @@ public virtual async Task> GetAsync(QueryLayer la } /// - public virtual async Task CountAsync(FilterExpression topFilter, CancellationToken cancellationToken) + public virtual async Task CountAsync(FilterExpression? filter, CancellationToken cancellationToken) { _traceWriter.LogMethodStart(new { - topFilter + filter }); 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 + Filter = filter }; IQueryable query = ApplyQueryLayer(layer); @@ -109,14 +110,14 @@ public virtual async Task CountAsync(FilterExpression topFilter, Cancellati } } - protected virtual IQueryable ApplyQueryLayer(QueryLayer layer) + protected virtual IQueryable ApplyQueryLayer(QueryLayer queryLayer) { _traceWriter.LogMethodStart(new { - layer + queryLayer }); - ArgumentGuard.NotNull(layer, nameof(layer)); + ArgumentGuard.NotNull(queryLayer, nameof(queryLayer)); using (CodeTimingSessionManager.Current.Measure("Convert QueryLayer to System.Expression")) { @@ -142,10 +143,9 @@ 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); + Expression expression = builder.ApplyQuery(queryLayer); using (CodeTimingSessionManager.Current.Measure("Convert System.Expression to IQueryable")) { @@ -162,6 +162,11 @@ protected virtual IQueryable GetAll() /// public virtual Task GetForCreateAsync(TId id, CancellationToken cancellationToken) { + _traceWriter.LogMethodStart(new + { + id + }); + var resource = _resourceFactory.CreateInstance(); resource.Id = id; @@ -184,9 +189,9 @@ public virtual async Task CreateAsync(TResource resourceFromRequest, TResource r foreach (RelationshipAttribute relationship in _targetedFields.Relationships) { - object rightValue = relationship.GetValue(resourceFromRequest); + object? rightValue = relationship.GetValue(resourceFromRequest); - object rightValueEvaluated = await VisitSetRelationshipAsync(resourceForDatabase, relationship, rightValue, WriteOperationKind.CreateResource, + object? rightValueEvaluated = await VisitSetRelationshipAsync(resourceForDatabase, relationship, rightValue, WriteOperationKind.CreateResource, cancellationToken); await UpdateRelationshipAsync(relationship, resourceForDatabase, rightValueEvaluated, cancellationToken); @@ -209,12 +214,12 @@ public virtual async Task CreateAsync(TResource resourceFromRequest, TResource r _dbContext.ResetChangeTracker(); } - private async Task VisitSetRelationshipAsync(TResource leftResource, RelationshipAttribute relationship, object rightValue, + private async Task VisitSetRelationshipAsync(TResource leftResource, RelationshipAttribute relationship, object? rightValue, WriteOperationKind writeOperation, CancellationToken cancellationToken) { if (relationship is HasOneAttribute hasOneRelationship) { - return await _resourceDefinitionAccessor.OnSetToOneRelationshipAsync(leftResource, hasOneRelationship, (IIdentifiable)rightValue, + return await _resourceDefinitionAccessor.OnSetToOneRelationshipAsync(leftResource, hasOneRelationship, (IIdentifiable?)rightValue, writeOperation, cancellationToken); } @@ -232,8 +237,15 @@ await _resourceDefinitionAccessor.OnSetToManyRelationshipAsync(leftResource, has } /// - public virtual async Task GetForUpdateAsync(QueryLayer queryLayer, CancellationToken cancellationToken) + public virtual async Task GetForUpdateAsync(QueryLayer queryLayer, CancellationToken cancellationToken) { + _traceWriter.LogMethodStart(new + { + queryLayer + }); + + ArgumentGuard.NotNull(queryLayer, nameof(queryLayer)); + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Get resource for update"); IReadOnlyCollection resources = await GetAsync(queryLayer, cancellationToken); @@ -256,12 +268,12 @@ public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource r foreach (RelationshipAttribute relationship in _targetedFields.Relationships) { - object rightValue = relationship.GetValue(resourceFromRequest); + object? rightValue = relationship.GetValue(resourceFromRequest); - object rightValueEvaluated = await VisitSetRelationshipAsync(resourceFromDatabase, relationship, rightValue, WriteOperationKind.UpdateResource, + object? rightValueEvaluated = await VisitSetRelationshipAsync(resourceFromDatabase, relationship, rightValue, WriteOperationKind.UpdateResource, cancellationToken); - AssertIsNotClearingRequiredRelationship(relationship, resourceFromDatabase, rightValueEvaluated); + AssertIsNotClearingRequiredToOneRelationship(relationship, resourceFromDatabase, rightValueEvaluated); await UpdateRelationshipAsync(relationship, resourceFromDatabase, rightValueEvaluated, cancellationToken); } @@ -280,42 +292,23 @@ public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource r _dbContext.ResetChangeTracker(); } - protected void AssertIsNotClearingRequiredRelationship(RelationshipAttribute relationship, TResource leftResource, object rightValue) + protected void AssertIsNotClearingRequiredToOneRelationship(RelationshipAttribute relationship, TResource leftResource, object? rightValue) { - if (relationship is HasManyAttribute { IsManyToMany: true }) + if (relationship is HasOneAttribute) { - // Many-to-many relationships cannot be required. - return; - } - - INavigation navigation = TryGetNavigation(relationship); - bool relationshipIsRequired = navigation?.ForeignKey?.IsRequired ?? false; + INavigation? navigation = GetNavigation(relationship); + bool isRelationshipRequired = navigation?.ForeignKey?.IsRequired ?? false; - bool relationshipIsBeingCleared = relationship is HasManyAttribute hasManyRelationship - ? IsToManyRelationshipBeingCleared(hasManyRelationship, leftResource, rightValue) - : rightValue == null; + bool isClearingRelationship = rightValue == null; - if (relationshipIsRequired && relationshipIsBeingCleared) - { - string resourceType = _resourceGraph.GetResourceContext().PublicName; - throw new CannotClearRequiredRelationshipException(relationship.PublicName, leftResource.StringId, resourceType); + if (isRelationshipRequired && isClearingRelationship) + { + string resourceName = _resourceGraph.GetResourceType().PublicName; + throw new CannotClearRequiredRelationshipException(relationship.PublicName, leftResource.StringId!, resourceName); + } } } - private bool IsToManyRelationshipBeingCleared(HasManyAttribute hasManyRelationship, TResource leftResource, object valueToAssign) - { - ICollection newRightResourceIds = _collectionConverter.ExtractResources(valueToAssign); - - object existingRightValue = hasManyRelationship.GetValue(leftResource); - - HashSet existingRightResourceIds = - _collectionConverter.ExtractResources(existingRightValue).ToHashSet(IdentifiableComparer.Instance); - - existingRightResourceIds.ExceptWith(newRightResourceIds); - - return existingRightResourceIds.Any(); - } - /// public virtual async Task DeleteAsync(TId id, CancellationToken cancellationToken) { @@ -335,10 +328,10 @@ 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. + // Loads the data of the relationship, if in Entity Framework Core it is configured in such a way that loading + // the related entities into memory is required for successfully executing the selected deletion behavior. if (RequiresLoadOfRelationshipForDeletion(relationship)) { NavigationEntry navigation = GetNavigationEntry(resourceTracked, relationship); @@ -376,7 +369,7 @@ private NavigationEntry GetNavigationEntry(TResource resource, RelationshipAttri private bool RequiresLoadOfRelationshipForDeletion(RelationshipAttribute relationship) { - INavigation navigation = TryGetNavigation(relationship); + INavigation? navigation = GetNavigation(relationship); bool isClearOfForeignKeyRequired = navigation?.ForeignKey.DeleteBehavior == DeleteBehavior.ClientSetNull; bool hasForeignKeyAtLeftSide = HasForeignKeyAtLeftSide(relationship, navigation); @@ -384,19 +377,19 @@ private bool RequiresLoadOfRelationshipForDeletion(RelationshipAttribute relatio return isClearOfForeignKeyRequired && !hasForeignKeyAtLeftSide; } - private INavigation TryGetNavigation(RelationshipAttribute relationship) + private INavigation? GetNavigation(RelationshipAttribute relationship) { - IEntityType entityType = _dbContext.Model.FindEntityType(typeof(TResource)); + IEntityType? entityType = _dbContext.Model.FindEntityType(typeof(TResource)); return entityType?.FindNavigation(relationship.Property.Name); } - private bool HasForeignKeyAtLeftSide(RelationshipAttribute relationship, INavigation navigation) + private bool HasForeignKeyAtLeftSide(RelationshipAttribute relationship, INavigation? navigation) { return relationship is HasOneAttribute && navigation is { IsOnDependent: true }; } /// - public virtual async Task SetRelationshipAsync(TResource leftResource, object rightValue, CancellationToken cancellationToken) + public virtual async Task SetRelationshipAsync(TResource leftResource, object? rightValue, CancellationToken cancellationToken) { _traceWriter.LogMethodStart(new { @@ -404,14 +397,16 @@ public virtual async Task SetRelationshipAsync(TResource leftResource, object ri rightValue }); + ArgumentGuard.NotNull(leftResource, nameof(leftResource)); + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Set relationship"); RelationshipAttribute relationship = _targetedFields.Relationships.Single(); - object rightValueEvaluated = + object? rightValueEvaluated = await VisitSetRelationshipAsync(leftResource, relationship, rightValue, WriteOperationKind.SetRelationship, cancellationToken); - AssertIsNotClearingRequiredRelationship(relationship, leftResource, rightValueEvaluated); + AssertIsNotClearingRequiredToOneRelationship(relationship, leftResource, rightValueEvaluated); await UpdateRelationshipAsync(relationship, leftResource, rightValueEvaluated, cancellationToken); @@ -466,6 +461,7 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TResource leftResour rightResourceIds }); + ArgumentGuard.NotNull(leftResource, nameof(leftResource)); ArgumentGuard.NotNull(rightResourceIds, nameof(rightResourceIds)); using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Remove from to-many relationship"); @@ -479,10 +475,10 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TResource leftResour { var leftResourceTracked = (TResource)_dbContext.GetTrackedOrAttach(leftResource); - // Make EF Core believe any additional resources added from ResourceDefinition already exist in database. + // Make Entity Framework Core believe any additional resources added from ResourceDefinition already exist in database. IIdentifiable[] extraResourceIdsToRemove = rightResourceIdsToRemove.Where(rightId => !rightResourceIds.Contains(rightId)).ToArray(); - object rightValueStored = relationship.GetValue(leftResource); + object? rightValueStored = relationship.GetValue(leftResource); // @formatter:wrap_chained_method_calls chop_always // @formatter:keep_existing_linebreaks true @@ -504,15 +500,18 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TResource leftResour HashSet rightResourceIdsToStore = rightResourceIdsStored.ToHashSet(IdentifiableComparer.Instance); rightResourceIdsToStore.ExceptWith(rightResourceIdsToRemove); - AssertIsNotClearingRequiredRelationship(relationship, leftResourceTracked, rightResourceIdsToStore); + if (!rightResourceIdsToStore.SetEquals(rightResourceIdsStored)) + { + AssertIsNotClearingRequiredToOneRelationship(relationship, leftResourceTracked, rightResourceIdsToStore); - await UpdateRelationshipAsync(relationship, leftResourceTracked, rightResourceIdsToStore, cancellationToken); + await UpdateRelationshipAsync(relationship, leftResourceTracked, rightResourceIdsToStore, cancellationToken); - await _resourceDefinitionAccessor.OnWritingAsync(leftResourceTracked, WriteOperationKind.RemoveFromRelationship, cancellationToken); + await _resourceDefinitionAccessor.OnWritingAsync(leftResourceTracked, WriteOperationKind.RemoveFromRelationship, cancellationToken); - await SaveChangesAsync(cancellationToken); + await SaveChangesAsync(cancellationToken); - await _resourceDefinitionAccessor.OnWriteSucceededAsync(leftResourceTracked, WriteOperationKind.RemoveFromRelationship, cancellationToken); + await _resourceDefinitionAccessor.OnWriteSucceededAsync(leftResourceTracked, WriteOperationKind.RemoveFromRelationship, cancellationToken); + } } } @@ -523,15 +522,15 @@ private void MarkRelationshipAsLoaded(TResource leftResource, RelationshipAttrib rightCollectionEntry.IsLoaded = true; } - protected async Task UpdateRelationshipAsync(RelationshipAttribute relationship, TResource leftResource, object valueToAssign, + protected async Task UpdateRelationshipAsync(RelationshipAttribute relationship, TResource leftResource, object? valueToAssign, CancellationToken cancellationToken) { - object trackedValueToAssign = EnsureRelationshipValueToAssignIsTracked(valueToAssign, relationship.Property.PropertyType); + object? trackedValueToAssign = EnsureRelationshipValueToAssignIsTracked(valueToAssign, relationship.Property.PropertyType); if (RequireLoadOfInverseRelationship(relationship, trackedValueToAssign)) { EntityEntry entityEntry = _dbContext.Entry(trackedValueToAssign); - string inversePropertyName = relationship.InverseNavigationProperty.Name; + string inversePropertyName = relationship.InverseNavigationProperty!.Name; await entityEntry.Reference(inversePropertyName).LoadAsync(cancellationToken); } @@ -539,7 +538,7 @@ protected async Task UpdateRelationshipAsync(RelationshipAttribute relationship, relationship.SetValue(leftResource, trackedValueToAssign); } - private object EnsureRelationshipValueToAssignIsTracked(object rightValue, Type relationshipPropertyType) + private object? EnsureRelationshipValueToAssignIsTracked(object? rightValue, Type relationshipPropertyType) { if (rightValue == null) { @@ -554,7 +553,7 @@ private object EnsureRelationshipValueToAssignIsTracked(object rightValue, Type : rightResourcesTracked.Single(); } - private bool RequireLoadOfInverseRelationship(RelationshipAttribute relationship, object trackedValueToAssign) + private bool RequireLoadOfInverseRelationship(RelationshipAttribute relationship, [NotNullWhen(true)] object? trackedValueToAssign) { // See https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/502. return trackedValueToAssign != null && relationship is HasOneAttribute { IsOneToOne: true }; @@ -570,34 +569,12 @@ 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) - { - // The ResourceService calling us needs to run additional SQL queries after an aborted transaction, - // to determine error cause. This fails when a failed transaction is still in progress. - await _dbContext.Database.CurrentTransaction.RollbackAsync(cancellationToken); - } - _dbContext.ResetChangeTracker(); throw new DataStoreUpdateException(exception); } } } - - /// - /// Implements the foundational Repository layer in the JsonApiDotNetCore architecture that uses Entity Framework Core. - /// - [PublicAPI] - public class EntityFrameworkCoreRepository : EntityFrameworkCoreRepository, IResourceRepository - where TResource : class, IIdentifiable - { - public EntityFrameworkCoreRepository(ITargetedFields targetedFields, IDbContextResolver contextResolver, IResourceGraph resourceGraph, - IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory, - IResourceDefinitionAccessor resourceDefinitionAccessor) - : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor) - { - } - } } diff --git a/src/JsonApiDotNetCore/Repositories/IRepositorySupportsTransaction.cs b/src/JsonApiDotNetCore/Repositories/IRepositorySupportsTransaction.cs index 4a59e98a75..0344e3cbf9 100644 --- a/src/JsonApiDotNetCore/Repositories/IRepositorySupportsTransaction.cs +++ b/src/JsonApiDotNetCore/Repositories/IRepositorySupportsTransaction.cs @@ -3,7 +3,7 @@ namespace JsonApiDotNetCore.Repositories { /// - /// Used to indicate that an supports execution inside a transaction. + /// Used to indicate that an supports execution inside a transaction. /// [PublicAPI] public interface IRepositorySupportsTransaction @@ -11,6 +11,6 @@ public interface IRepositorySupportsTransaction /// /// Identifies the currently active transaction. /// - string TransactionId { get; } + string? TransactionId { get; } } } diff --git a/src/JsonApiDotNetCore/Repositories/IResourceReadRepository.cs b/src/JsonApiDotNetCore/Repositories/IResourceReadRepository.cs index a75d95c213..1b8a69d6bc 100644 --- a/src/JsonApiDotNetCore/Repositories/IResourceReadRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/IResourceReadRepository.cs @@ -8,12 +8,6 @@ namespace JsonApiDotNetCore.Repositories { - /// - public interface IResourceReadRepository : IResourceReadRepository - where TResource : class, IIdentifiable - { - } - /// /// Groups read operations. /// @@ -30,11 +24,11 @@ public interface IResourceReadRepository /// /// Executes a read query using the specified constraints and returns the collection of matching resources. /// - Task> GetAsync(QueryLayer layer, CancellationToken cancellationToken); + Task> GetAsync(QueryLayer queryLayer, CancellationToken cancellationToken); /// - /// Executes a read query using the specified top-level filter and returns the top-level count of matching resources. + /// Executes a read query using the specified filter and returns the count of matching resources. /// - Task CountAsync(FilterExpression topFilter, CancellationToken cancellationToken); + Task CountAsync(FilterExpression? filter, CancellationToken cancellationToken); } } diff --git a/src/JsonApiDotNetCore/Repositories/IResourceRepository.cs b/src/JsonApiDotNetCore/Repositories/IResourceRepository.cs index d43e355f06..4f20bcaca1 100644 --- a/src/JsonApiDotNetCore/Repositories/IResourceRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/IResourceRepository.cs @@ -3,19 +3,6 @@ namespace JsonApiDotNetCore.Repositories { - /// - /// Represents the foundational Resource Repository layer in the JsonApiDotNetCore architecture that provides data access to an underlying store. - /// - /// - /// The resource type. - /// - [PublicAPI] - public interface IResourceRepository - : IResourceRepository, IResourceReadRepository, IResourceWriteRepository - where TResource : class, IIdentifiable - { - } - /// /// Represents the foundational Resource Repository layer in the JsonApiDotNetCore architecture that provides data access to an underlying store. /// @@ -25,6 +12,7 @@ public interface IResourceRepository /// /// The resource identifier type. /// + [PublicAPI] public interface IResourceRepository : IResourceReadRepository, IResourceWriteRepository where TResource : class, IIdentifiable { diff --git a/src/JsonApiDotNetCore/Repositories/IResourceRepositoryAccessor.cs b/src/JsonApiDotNetCore/Repositories/IResourceRepositoryAccessor.cs index 4925697112..a2897a237d 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; @@ -16,19 +16,18 @@ public interface IResourceRepositoryAccessor /// /// Invokes . /// - Task> GetAsync(QueryLayer layer, CancellationToken cancellationToken) + Task> GetAsync(QueryLayer queryLayer, CancellationToken cancellationToken) where TResource : class, IIdentifiable; /// /// Invokes for the specified resource type. /// - Task> GetAsync(Type resourceType, QueryLayer layer, CancellationToken cancellationToken); + Task> GetAsync(ResourceType resourceType, QueryLayer queryLayer, CancellationToken cancellationToken); /// /// Invokes for the specified resource type. /// - Task CountAsync(FilterExpression topFilter, CancellationToken cancellationToken) - where TResource : class, IIdentifiable; + Task CountAsync(ResourceType resourceType, FilterExpression? filter, CancellationToken cancellationToken); /// /// Invokes . @@ -45,7 +44,7 @@ Task CreateAsync(TResource resourceFromRequest, TResource resourceFor /// /// Invokes . /// - Task GetForUpdateAsync(QueryLayer queryLayer, CancellationToken cancellationToken) + Task GetForUpdateAsync(QueryLayer queryLayer, CancellationToken cancellationToken) where TResource : class, IIdentifiable; /// @@ -63,7 +62,7 @@ Task DeleteAsync(TId id, CancellationToken cancellationToken) /// /// Invokes . /// - Task SetRelationshipAsync(TResource leftResource, object rightValue, CancellationToken cancellationToken) + Task SetRelationshipAsync(TResource leftResource, object? rightValue, CancellationToken cancellationToken) where TResource : class, IIdentifiable; /// diff --git a/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs b/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs index 9cdfee02fd..06a94748a0 100644 --- a/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs @@ -7,12 +7,6 @@ namespace JsonApiDotNetCore.Repositories { - /// - public interface IResourceWriteRepository : IResourceWriteRepository - where TResource : class, IIdentifiable - { - } - /// /// Groups write operations. /// @@ -42,7 +36,7 @@ public interface IResourceWriteRepository /// /// Retrieves a resource with all of its attributes, including the set of targeted relationships, in preparation for . /// - Task GetForUpdateAsync(QueryLayer queryLayer, CancellationToken cancellationToken); + Task GetForUpdateAsync(QueryLayer queryLayer, CancellationToken cancellationToken); /// /// Updates the attributes and relationships of an existing resource in the underlying data store. @@ -57,7 +51,7 @@ public interface IResourceWriteRepository /// /// Performs a complete replacement of the relationship in the underlying data store. /// - Task SetRelationshipAsync(TResource leftResource, object rightValue, CancellationToken cancellationToken); + Task SetRelationshipAsync(TResource leftResource, object? rightValue, CancellationToken cancellationToken); /// /// Adds resources to a to-many relationship in the underlying data store. diff --git a/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs b/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs index d1ccdb418d..806e4dc700 100644 --- a/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs +++ b/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs @@ -33,28 +33,27 @@ public ResourceRepositoryAccessor(IServiceProvider serviceProvider, IResourceGra } /// - public async Task> GetAsync(QueryLayer layer, CancellationToken cancellationToken) + public async Task> GetAsync(QueryLayer queryLayer, CancellationToken cancellationToken) where TResource : class, IIdentifiable { dynamic repository = ResolveReadRepository(typeof(TResource)); - return (IReadOnlyCollection)await repository.GetAsync(layer, cancellationToken); + return (IReadOnlyCollection)await repository.GetAsync(queryLayer, cancellationToken); } /// - public async Task> GetAsync(Type resourceType, QueryLayer layer, CancellationToken cancellationToken) + public async Task> GetAsync(ResourceType resourceType, QueryLayer queryLayer, CancellationToken cancellationToken) { ArgumentGuard.NotNull(resourceType, nameof(resourceType)); dynamic repository = ResolveReadRepository(resourceType); - return (IReadOnlyCollection)await repository.GetAsync(layer, cancellationToken); + return (IReadOnlyCollection)await repository.GetAsync(queryLayer, cancellationToken); } /// - public async Task CountAsync(FilterExpression topFilter, CancellationToken cancellationToken) - where TResource : class, IIdentifiable + public async Task CountAsync(ResourceType resourceType, FilterExpression? filter, CancellationToken cancellationToken) { - dynamic repository = ResolveReadRepository(typeof(TResource)); - return (int)await repository.CountAsync(topFilter, cancellationToken); + dynamic repository = ResolveReadRepository(resourceType); + return (int)await repository.CountAsync(filter, cancellationToken); } /// @@ -74,7 +73,7 @@ public async Task CreateAsync(TResource resourceFromRequest, TResourc } /// - public async Task GetForUpdateAsync(QueryLayer queryLayer, CancellationToken cancellationToken) + public async Task GetForUpdateAsync(QueryLayer queryLayer, CancellationToken cancellationToken) where TResource : class, IIdentifiable { dynamic repository = GetWriteRepository(typeof(TResource)); @@ -98,7 +97,7 @@ public async Task DeleteAsync(TId id, CancellationToken cancella } /// - public async Task SetRelationshipAsync(TResource leftResource, object rightValue, CancellationToken cancellationToken) + public async Task SetRelationshipAsync(TResource leftResource, object? rightValue, CancellationToken cancellationToken) where TResource : class, IIdentifiable { dynamic repository = GetWriteRepository(typeof(TResource)); @@ -122,35 +121,28 @@ 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); - - if (resourceContext.IdentityType == typeof(int)) - { - Type intRepositoryType = typeof(IResourceReadRepository<>).MakeGenericType(resourceContext.ResourceType); - object intRepository = _serviceProvider.GetService(intRepositoryType); - - if (intRepository != null) - { - return intRepository; - } - } + ResourceType resourceType = _resourceGraph.GetResourceType(resourceClrType); + return ResolveReadRepository(resourceType); + } - Type resourceDefinitionType = typeof(IResourceReadRepository<,>).MakeGenericType(resourceContext.ResourceType, resourceContext.IdentityType); + protected virtual object ResolveReadRepository(ResourceType resourceType) + { + 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,22 +154,11 @@ private object GetWriteRepository(Type resourceType) return writeRepository; } - protected virtual object ResolveWriteRepository(Type resourceType) + protected virtual object ResolveWriteRepository(Type resourceClrType) { - ResourceContext resourceContext = _resourceGraph.GetResourceContext(resourceType); - - if (resourceContext.IdentityType == typeof(int)) - { - Type intRepositoryType = typeof(IResourceWriteRepository<>).MakeGenericType(resourceContext.ResourceType); - object intRepository = _serviceProvider.GetService(intRepositoryType); - - if (intRepository != null) - { - return intRepository; - } - } + ResourceType resourceType = _resourceGraph.GetResourceType(resourceClrType); - 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/AttrAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/AttrAttribute.cs index 23461fc3dd..79ffbbed3d 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/AttrAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/AttrAttribute.cs @@ -33,37 +33,7 @@ public AttrCapabilities Capabilities set => _capabilities = value; } - /// - /// Get the value of the attribute for the given object. Throws if the attribute does not belong to the provided object. - /// - public object GetValue(object resource) - { - ArgumentGuard.NotNull(resource, nameof(resource)); - - if (Property.GetMethod == null) - { - throw new InvalidOperationException($"Property '{Property.DeclaringType?.Name}.{Property.Name}' is write-only."); - } - - return Property.GetValue(resource); - } - - /// - /// Sets the value of the attribute on the given object. - /// - public void SetValue(object resource, object newValue) - { - ArgumentGuard.NotNull(resource, nameof(resource)); - - if (Property.SetMethod == null) - { - throw new InvalidOperationException($"Property '{Property.DeclaringType?.Name}.{Property.Name}' is read-only."); - } - - Property.SetValue(resource, newValue); - } - - public override bool Equals(object obj) + public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) { diff --git a/src/JsonApiDotNetCore/Resources/Annotations/EagerLoadAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/EagerLoadAttribute.cs index 623e7eeeb2..bc81116763 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/EagerLoadAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/EagerLoadAttribute.cs @@ -38,8 +38,10 @@ namespace JsonApiDotNetCore.Resources.Annotations [AttributeUsage(AttributeTargets.Property)] public sealed class EagerLoadAttribute : Attribute { - public PropertyInfo Property { get; internal set; } + // These properties are definitely assigned after building the resource graph, which is why they are declared as non-nullable. - public IReadOnlyCollection Children { get; internal set; } + public PropertyInfo Property { get; internal set; } = null!; + + public IReadOnlyCollection Children { get; internal set; } = null!; } } diff --git a/src/JsonApiDotNetCore/Resources/Annotations/HasManyAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/HasManyAttribute.cs index 166112baed..0adb22cb77 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/HasManyAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/HasManyAttribute.cs @@ -37,7 +37,7 @@ private bool EvaluateIsManyToMany() { if (InverseNavigationProperty != null) { - Type elementType = CollectionConverter.TryGetCollectionElementType(InverseNavigationProperty.PropertyType); + Type? elementType = CollectionConverter.FindCollectionElementType(InverseNavigationProperty.PropertyType); return elementType != null; } diff --git a/src/JsonApiDotNetCore/Resources/Annotations/HasOneAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/HasOneAttribute.cs index e0ce2ecc47..05e9483260 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/HasOneAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/HasOneAttribute.cs @@ -36,7 +36,7 @@ private bool EvaluateIsOneToOne() { if (InverseNavigationProperty != null) { - Type elementType = CollectionConverter.TryGetCollectionElementType(InverseNavigationProperty.PropertyType); + Type? elementType = CollectionConverter.FindCollectionElementType(InverseNavigationProperty.PropertyType); return elementType == null; } diff --git a/src/JsonApiDotNetCore/Resources/Annotations/RelationshipAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/RelationshipAttribute.cs index eaeb10360d..6324766f96 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/RelationshipAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/RelationshipAttribute.cs @@ -16,40 +16,52 @@ 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 Entity Framework Core inverse navigation, which may or may not exist. Even if it exists, it may not be exposed + /// as a JSON:API relationship. /// /// /// Articles { get; set; } /// } /// ]]> /// - public PropertyInfo InverseNavigationProperty { get; set; } + 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; } = null!; /// - /// 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; } = null!; /// /// Configures which links to show in the object for this relationship. Defaults to @@ -67,27 +79,7 @@ public abstract class RelationshipAttribute : ResourceFieldAttribute /// public bool CanInclude { get; set; } = true; - /// - /// Gets the value of the resource property this attribute was declared on. - /// - public object GetValue(object resource) - { - ArgumentGuard.NotNull(resource, nameof(resource)); - - return Property.GetValue(resource); - } - - /// - /// Sets the value of the resource property this attribute was declared on. - /// - public void SetValue(object resource, object newValue) - { - ArgumentGuard.NotNull(resource, nameof(resource)); - - Property.SetValue(resource, newValue); - } - - public override bool Equals(object obj) + public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) { @@ -101,12 +93,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/Annotations/ResourceFieldAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/ResourceFieldAttribute.cs index 93efe49696..8463689301 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/ResourceFieldAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/ResourceFieldAttribute.cs @@ -13,14 +13,16 @@ namespace JsonApiDotNetCore.Resources.Annotations [PublicAPI] public abstract class ResourceFieldAttribute : Attribute { - private string _publicName; + // These are definitely assigned after building the resource graph, which is why their public equivalents are declared as non-nullable. + private string? _publicName; + private PropertyInfo? _property; /// /// The publicly exposed name of this JSON:API field. When not explicitly assigned, the configured naming convention is applied on the property name. /// public string PublicName { - get => _publicName; + get => _publicName!; set { if (string.IsNullOrWhiteSpace(value)) @@ -35,14 +37,72 @@ public string PublicName /// /// The resource property that this attribute is declared on. /// - public PropertyInfo Property { get; internal set; } + public PropertyInfo Property + { + get => _property!; + internal set + { + ArgumentGuard.NotNull(value, nameof(value)); + _property = value; + } + } + + /// + /// Gets the value of this field on the specified resource instance. Throws if the property is write-only or if the field does not belong to the + /// specified resource instance. + /// + public object? GetValue(object resource) + { + ArgumentGuard.NotNull(resource, nameof(resource)); + + if (Property.GetMethod == null) + { + throw new InvalidOperationException($"Property '{Property.DeclaringType?.Name}.{Property.Name}' is write-only."); + } + + try + { + return Property.GetValue(resource); + } + catch (TargetException exception) + { + throw new InvalidOperationException( + $"Unable to get property value of '{Property.DeclaringType!.Name}.{Property.Name}' on instance of type '{resource.GetType().Name}'.", + exception); + } + } + + /// + /// Sets the value of this field on the specified resource instance. Throws if the property is read-only or if the field does not belong to the specified + /// resource instance. + /// + public void SetValue(object resource, object? newValue) + { + ArgumentGuard.NotNull(resource, nameof(resource)); + + if (Property.SetMethod == null) + { + throw new InvalidOperationException($"Property '{Property.DeclaringType?.Name}.{Property.Name}' is read-only."); + } + + try + { + Property.SetValue(resource, newValue); + } + catch (TargetException exception) + { + throw new InvalidOperationException( + $"Unable to set property value of '{Property.DeclaringType!.Name}.{Property.Name}' on instance of type '{resource.GetType().Name}'.", + exception); + } + } - public override string ToString() + public override string? ToString() { - return PublicName ?? (Property != null ? Property.Name : base.ToString()); + return _publicName ?? (_property != null ? _property.Name : base.ToString()); } - public override bool Equals(object obj) + public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) { @@ -56,12 +116,12 @@ public override bool Equals(object obj) var other = (ResourceFieldAttribute)obj; - return PublicName == other.PublicName && Property == other.Property; + return _publicName == other._publicName && _property == other._property; } public override int GetHashCode() { - return HashCode.Combine(PublicName, Property); + return HashCode.Combine(_publicName, _property); } } } diff --git a/src/JsonApiDotNetCore/Resources/IIdentifiable.cs b/src/JsonApiDotNetCore/Resources/IIdentifiable.cs index 99559870a4..ee1f73942a 100644 --- a/src/JsonApiDotNetCore/Resources/IIdentifiable.cs +++ b/src/JsonApiDotNetCore/Resources/IIdentifiable.cs @@ -1,20 +1,19 @@ namespace JsonApiDotNetCore.Resources { /// - /// When implemented by a class, indicates to JsonApiDotNetCore that the class represents a JSON:API resource. Note that JsonApiDotNetCore also assumes - /// that a property named 'Id' exists. + /// Defines the basic contract for a JSON:API resource. All resource classes must implement . /// public interface IIdentifiable { /// /// The value for element 'id' in a JSON:API request or response. /// - string StringId { get; set; } + string? StringId { get; set; } /// /// The value for element 'lid' in a JSON:API request. /// - string LocalId { get; set; } + string? LocalId { get; set; } } /// @@ -26,7 +25,7 @@ public interface IIdentifiable public interface IIdentifiable : IIdentifiable { /// - /// The typed identifier as used by the underlying data store (usually numeric). + /// The typed identifier as used by the underlying data store (usually numeric or Guid). /// TId Id { get; set; } } diff --git a/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs b/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs index 4fae023853..9eed0aaa16 100644 --- a/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs +++ b/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs @@ -10,18 +10,6 @@ namespace JsonApiDotNetCore.Resources { - /// - /// Provides an extensibility point to add business logic that is resource-oriented instead of endpoint-oriented. - /// - /// - /// The resource type. - /// - [PublicAPI] - public interface IResourceDefinition : IResourceDefinition - where TResource : class, IIdentifiable - { - } - /// /// Provides an extensibility point to add business logic that is resource-oriented instead of endpoint-oriented. /// @@ -44,7 +32,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. @@ -55,7 +43,7 @@ public interface IResourceDefinition /// /// The new filter, or null to disable the existing filter. /// - FilterExpression OnApplyFilter(FilterExpression existingFilter); + FilterExpression? OnApplyFilter(FilterExpression? existingFilter); /// /// Enables to extend, replace or remove a sort order that is being applied on a set of this resource type. Tip: Use @@ -67,7 +55,7 @@ public interface IResourceDefinition /// /// The new sort order, or null to disable the existing sort order and sort by ID. /// - SortExpression OnApplySort(SortExpression existingSort); + SortExpression? OnApplySort(SortExpression? existingSort); /// /// Enables to extend, replace or remove pagination that is being applied on a set of this resource type. @@ -79,7 +67,7 @@ public interface IResourceDefinition /// The changed pagination, or null to use the first page with default size from options. To disable paging, set /// to null. /// - PaginationExpression OnApplyPagination(PaginationExpression existingPagination); + PaginationExpression? OnApplyPagination(PaginationExpression? existingPagination); /// /// Enables to extend, replace or remove a sparse fieldset that is being applied on a set of this resource type. Tip: Use @@ -98,7 +86,7 @@ public interface IResourceDefinition /// /// The new sparse fieldset, or null to discard the existing sparse fieldset and select all viewable fields. /// - SparseFieldSetExpression OnApplySparseFieldSet(SparseFieldSetExpression existingSparseFieldSet); + SparseFieldSetExpression? OnApplySparseFieldSet(SparseFieldSetExpression? existingSparseFieldSet); /// /// Enables to adapt the Entity Framework Core query, based on custom query string parameters. Note this only works on @@ -125,13 +113,13 @@ public interface IResourceDefinition /// ]]> /// #pragma warning disable AV1130 // Return type in method signature should be a collection interface instead of a concrete type - QueryStringParameterHandlers OnRegisterQueryableHandlersForQueryStringParameters(); + QueryStringParameterHandlers? OnRegisterQueryableHandlersForQueryStringParameters(); #pragma warning restore AV1130 // Return type in method signature should be a collection interface instead of a concrete type /// /// Enables to add JSON:API meta information, specific to this resource. /// - IDictionary GetMeta(TResource resource); + IDictionary? GetMeta(TResource resource); /// /// Executes after the original version of the resource has been retrieved from the underlying data store, as part of a write request. @@ -184,7 +172,7 @@ public interface IResourceDefinition /// /// The replacement resource identifier, or null to clear the relationship. Returns by default. /// - Task OnSetToOneRelationshipAsync(TResource leftResource, HasOneAttribute hasOneRelationship, IIdentifiable rightResourceId, + Task OnSetToOneRelationshipAsync(TResource leftResource, HasOneAttribute hasOneRelationship, IIdentifiable? rightResourceId, WriteOperationKind writeOperation, CancellationToken cancellationToken); /// diff --git a/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs b/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs index 8c804d3602..ed4dad1270 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. @@ -60,8 +61,8 @@ Task OnPrepareWriteAsync(TResource resource, WriteOperationKind write /// /// Invokes for the specified resource. /// - public Task OnSetToOneRelationshipAsync(TResource leftResource, HasOneAttribute hasOneRelationship, - IIdentifiable rightResourceId, WriteOperationKind writeOperation, CancellationToken cancellationToken) + public Task OnSetToOneRelationshipAsync(TResource leftResource, HasOneAttribute hasOneRelationship, + IIdentifiable? rightResourceId, WriteOperationKind writeOperation, CancellationToken cancellationToken) where TResource : class, IIdentifiable; /// 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/Identifiable.cs b/src/JsonApiDotNetCore/Resources/Identifiable.cs index aada6b312a..6a379bfcad 100644 --- a/src/JsonApiDotNetCore/Resources/Identifiable.cs +++ b/src/JsonApiDotNetCore/Resources/Identifiable.cs @@ -4,13 +4,9 @@ namespace JsonApiDotNetCore.Resources { - /// - public abstract class Identifiable : Identifiable - { - } - /// - /// A convenient basic implementation of that provides conversion between and . + /// A convenient basic implementation of that provides conversion between typed and + /// . /// /// /// The resource identifier type. @@ -18,11 +14,11 @@ public abstract class Identifiable : Identifiable public abstract class Identifiable : IIdentifiable { /// - public virtual TId Id { get; set; } + public virtual TId Id { get; set; } = default!; /// [NotMapped] - public string StringId + public string? StringId { get => GetStringId(Id); set => Id = GetTypedId(value); @@ -30,22 +26,22 @@ public string StringId /// [NotMapped] - public string LocalId { get; set; } + public string? LocalId { get; set; } /// /// Converts an outgoing typed resource identifier to string format for use in a JSON:API response. /// - protected virtual string GetStringId(TId value) + protected virtual string? GetStringId(TId value) { - return EqualityComparer.Default.Equals(value, default) ? null : value.ToString(); + return EqualityComparer.Default.Equals(value, default) ? null : value!.ToString(); } /// /// Converts an incoming 'id' element from a JSON:API request to the typed resource identifier. /// - protected virtual TId GetTypedId(string value) + protected virtual TId GetTypedId(string? value) { - return value == null ? default : (TId)RuntimeTypeConverter.ConvertType(value, typeof(TId)); + return value == null ? default! : (TId)RuntimeTypeConverter.ConvertType(value, typeof(TId))!; } } } diff --git a/src/JsonApiDotNetCore/Resources/IdentifiableComparer.cs b/src/JsonApiDotNetCore/Resources/IdentifiableComparer.cs index 5cffe890c2..67c0cc833c 100644 --- a/src/JsonApiDotNetCore/Resources/IdentifiableComparer.cs +++ b/src/JsonApiDotNetCore/Resources/IdentifiableComparer.cs @@ -17,24 +17,24 @@ private IdentifiableComparer() { } - public bool Equals(IIdentifiable x, IIdentifiable y) + public bool Equals(IIdentifiable? left, IIdentifiable? right) { - if (ReferenceEquals(x, y)) + if (ReferenceEquals(left, right)) { return true; } - if (x is null || y is null || x.GetType() != y.GetType()) + if (left is null || right is null || left.GetType() != right.GetType()) { return false; } - if (x.StringId == null && y.StringId == null) + if (left.StringId == null && right.StringId == null) { - return x.LocalId == y.LocalId; + return left.LocalId == right.LocalId; } - return x.StringId == y.StringId; + return left.StringId == right.StringId; } public int GetHashCode(IIdentifiable obj) diff --git a/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs b/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs index 807d6c3f23..2adf4c2e2c 100644 --- a/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs +++ b/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs @@ -1,22 +1,39 @@ using System; using System.Reflection; +using JsonApiDotNetCore.Resources.Internal; namespace JsonApiDotNetCore.Resources { internal static class IdentifiableExtensions { + private const string IdPropertyName = nameof(Identifiable.Id); + public static object GetTypedId(this IIdentifiable identifiable) { ArgumentGuard.NotNull(identifiable, nameof(identifiable)); - PropertyInfo property = identifiable.GetType().GetProperty(nameof(Identifiable.Id)); + PropertyInfo? property = identifiable.GetType().GetProperty(IdPropertyName); if (property == null) { - throw new InvalidOperationException($"Resource of type '{identifiable.GetType()}' does not have an 'Id' property."); + throw new InvalidOperationException($"Resource of type '{identifiable.GetType()}' does not contain a property named '{IdPropertyName}'."); + } + + object? propertyValue = property.GetValue(identifiable); + + // PERF: We want to throw when 'Id' is unassigned without doing an expensive reflection call, unless this is likely the case. + if (identifiable.StringId == null) + { + object? defaultValue = RuntimeTypeConverter.GetDefaultValue(property.PropertyType); + + if (Equals(propertyValue, defaultValue)) + { + throw new InvalidOperationException($"Property '{identifiable.GetType().Name}.{IdPropertyName}' should " + + $"have been assigned at this point, but it contains its default {property.PropertyType.Name} value '{propertyValue}'."); + } } - return property.GetValue(identifiable); + return propertyValue!; } } } diff --git a/src/JsonApiDotNetCore/Resources/Internal/RuntimeTypeConverter.cs b/src/JsonApiDotNetCore/Resources/Internal/RuntimeTypeConverter.cs index f7aaad9192..16c72ed604 100644 --- a/src/JsonApiDotNetCore/Resources/Internal/RuntimeTypeConverter.cs +++ b/src/JsonApiDotNetCore/Resources/Internal/RuntimeTypeConverter.cs @@ -8,7 +8,7 @@ namespace JsonApiDotNetCore.Resources.Internal [PublicAPI] public static class RuntimeTypeConverter { - public static object ConvertType(object value, Type type) + public static object? ConvertType(object? value, Type type) { ArgumentGuard.NotNull(type, nameof(type)); @@ -30,7 +30,7 @@ public static object ConvertType(object value, Type type) return value; } - string stringValue = value.ToString(); + string? stringValue = value.ToString(); if (string.IsNullOrEmpty(stringValue)) { @@ -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(); @@ -86,7 +85,7 @@ public static bool CanContainNull(Type type) return !type.IsValueType || Nullable.GetUnderlyingType(type) != null; } - public static object GetDefaultValue(Type type) + public static object? GetDefaultValue(Type type) { return type.IsValueType ? Activator.CreateInstance(type) : null; } diff --git a/src/JsonApiDotNetCore/Resources/JsonApiResourceDefinition.cs b/src/JsonApiDotNetCore/Resources/JsonApiResourceDefinition.cs index 755ce781cb..da1d76b395 100644 --- a/src/JsonApiDotNetCore/Resources/JsonApiResourceDefinition.cs +++ b/src/JsonApiDotNetCore/Resources/JsonApiResourceDefinition.cs @@ -14,23 +14,6 @@ namespace JsonApiDotNetCore.Resources { - /// - /// Provides a resource-centric extensibility point for executing custom code when something happens with a resource. The goal here is to reduce the need - /// for overriding the service and repository layers. - /// - /// - /// The resource type. - /// - [PublicAPI] - public class JsonApiResourceDefinition : JsonApiResourceDefinition, IResourceDefinition - where TResource : class, IIdentifiable - { - public JsonApiResourceDefinition(IResourceGraph resourceGraph) - : base(resourceGraph) - { - } - } - /// [PublicAPI] public class JsonApiResourceDefinition : IResourceDefinition @@ -41,30 +24,30 @@ 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; } /// - public virtual FilterExpression OnApplyFilter(FilterExpression existingFilter) + public virtual FilterExpression? OnApplyFilter(FilterExpression? existingFilter) { return existingFilter; } /// - public virtual SortExpression OnApplySort(SortExpression existingSort) + public virtual SortExpression? OnApplySort(SortExpression? existingSort) { return existingSort; } @@ -83,11 +66,11 @@ public virtual SortExpression OnApplySort(SortExpression existingSort) /// protected SortExpression CreateSortExpressionFromLambda(PropertySortOrder keySelectors) { - ArgumentGuard.NotNull(keySelectors, nameof(keySelectors)); + ArgumentGuard.NotNullNorEmpty(keySelectors, nameof(keySelectors)); ImmutableArray.Builder elementsBuilder = ImmutableArray.CreateBuilder(keySelectors.Count); - foreach ((Expression> keySelector, ListSortDirection sortDirection) in keySelectors) + foreach ((Expression> keySelector, ListSortDirection sortDirection) in keySelectors) { bool isAscending = sortDirection == ListSortDirection.Ascending; AttrAttribute attribute = ResourceGraph.GetAttributes(keySelector).Single(); @@ -100,25 +83,25 @@ protected SortExpression CreateSortExpressionFromLambda(PropertySortOrder keySel } /// - public virtual PaginationExpression OnApplyPagination(PaginationExpression existingPagination) + public virtual PaginationExpression? OnApplyPagination(PaginationExpression? existingPagination) { return existingPagination; } /// - public virtual SparseFieldSetExpression OnApplySparseFieldSet(SparseFieldSetExpression existingSparseFieldSet) + public virtual SparseFieldSetExpression? OnApplySparseFieldSet(SparseFieldSetExpression? existingSparseFieldSet) { return existingSparseFieldSet; } /// - public virtual QueryStringParameterHandlers OnRegisterQueryableHandlersForQueryStringParameters() + public virtual QueryStringParameterHandlers? OnRegisterQueryableHandlersForQueryStringParameters() { return null; } /// - public virtual IDictionary GetMeta(TResource resource) + public virtual IDictionary? GetMeta(TResource resource) { return null; } @@ -130,8 +113,8 @@ public virtual Task OnPrepareWriteAsync(TResource resource, WriteOperationKind w } /// - public virtual Task OnSetToOneRelationshipAsync(TResource leftResource, HasOneAttribute hasOneRelationship, - IIdentifiable rightResourceId, WriteOperationKind writeOperation, CancellationToken cancellationToken) + public virtual Task OnSetToOneRelationshipAsync(TResource leftResource, HasOneAttribute hasOneRelationship, + IIdentifiable? rightResourceId, WriteOperationKind writeOperation, CancellationToken cancellationToken) { return Task.FromResult(rightResourceId); } @@ -183,7 +166,7 @@ public virtual void OnSerialize(TResource resource) /// This is an alias type intended to simplify the implementation's method signature. See for usage /// details. /// - public sealed class PropertySortOrder : List<(Expression> KeySelector, ListSortDirection SortDirection)> + public sealed class PropertySortOrder : List<(Expression> KeySelector, ListSortDirection SortDirection)> { } } diff --git a/src/JsonApiDotNetCore/Resources/OperationContainer.cs b/src/JsonApiDotNetCore/Resources/OperationContainer.cs index d5350a34dc..4336fef3f3 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() @@ -56,7 +54,7 @@ public ISet GetSecondaryResources() private void AddSecondaryResources(RelationshipAttribute relationship, HashSet secondaryResources) { - object rightValue = relationship.GetValue(Resource); + object? rightValue = relationship.GetValue(Resource); ICollection rightResources = CollectionConverter.ExtractResources(rightValue); secondaryResources.AddRange(rightResources); diff --git a/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs b/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs index b29fe33ef1..92d797e14e 100644 --- a/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs +++ b/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs @@ -11,19 +11,19 @@ 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; - private IDictionary _requestAttributeValues; - private IDictionary _finallyStoredAttributeValues; + private IDictionary? _initiallyStoredAttributeValues; + private IDictionary? _requestAttributeValues; + private IDictionary? _finallyStoredAttributeValues; public ResourceChangeTracker(IResourceGraph resourceGraph, ITargetedFields targetedFields) { 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) @@ -59,7 +57,7 @@ private IDictionary CreateAttributeDictionary(TResource resource foreach (AttrAttribute attribute in attributes) { - object value = attribute.GetValue(resource); + object? value = attribute.GetValue(resource); string json = JsonSerializer.Serialize(value); result.Add(attribute.PublicName, json); } @@ -70,26 +68,28 @@ private IDictionary CreateAttributeDictionary(TResource resource /// public bool HasImplicitChanges() { - foreach (string key in _initiallyStoredAttributeValues.Keys) + if (_initiallyStoredAttributeValues != null && _requestAttributeValues != null && _finallyStoredAttributeValues != null) { - if (_requestAttributeValues.ContainsKey(key)) + foreach (string key in _initiallyStoredAttributeValues.Keys) { - string requestValue = _requestAttributeValues[key]; - string actualValue = _finallyStoredAttributeValues[key]; - - if (requestValue != actualValue) + if (_requestAttributeValues.TryGetValue(key, out string? requestValue)) { - return true; - } - } - else - { - string initiallyStoredValue = _initiallyStoredAttributeValues[key]; - string finallyStoredValue = _finallyStoredAttributeValues[key]; + string actualValue = _finallyStoredAttributeValues[key]; - if (initiallyStoredValue != finallyStoredValue) + if (requestValue != actualValue) + { + return true; + } + } + else { - return true; + string initiallyStoredValue = _initiallyStoredAttributeValues[key]; + string finallyStoredValue = _finallyStoredAttributeValues[key]; + + if (initiallyStoredValue != finallyStoredValue) + { + return true; + } } } } diff --git a/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs b/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs index 1923c33156..1622a32bd2 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,27 @@ 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; + if (handlers != null) + { + if (handlers.ContainsKey(parameterName)) + { + return handlers[parameterName]; + } + } + + return null; } /// - public IDictionary GetMeta(Type resourceType, IIdentifiable resourceInstance) + public IDictionary? GetMeta(ResourceType resourceType, IIdentifiable resourceInstance) { ArgumentGuard.NotNull(resourceType, nameof(resourceType)); @@ -105,8 +113,8 @@ public async Task OnPrepareWriteAsync(TResource resource, WriteOperat } /// - public async Task OnSetToOneRelationshipAsync(TResource leftResource, HasOneAttribute hasOneRelationship, - IIdentifiable rightResourceId, WriteOperationKind writeOperation, CancellationToken cancellationToken) + public async Task OnSetToOneRelationshipAsync(TResource leftResource, HasOneAttribute hasOneRelationship, + IIdentifiable? rightResourceId, WriteOperationKind writeOperation, CancellationToken cancellationToken) where TResource : class, IIdentifiable { ArgumentGuard.NotNull(leftResource, nameof(leftResource)); @@ -192,22 +200,15 @@ 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); - - if (resourceContext.IdentityType == typeof(int)) - { - Type intResourceDefinitionType = typeof(IResourceDefinition<>).MakeGenericType(resourceContext.ResourceType); - object intResourceDefinition = _serviceProvider.GetService(intResourceDefinitionType); - - if (intResourceDefinition != null) - { - return intResourceDefinition; - } - } + ResourceType resourceType = _resourceGraph.GetResourceType(resourceClrType); + return ResolveResourceDefinition(resourceType); + } - Type resourceDefinitionType = typeof(IResourceDefinition<,>).MakeGenericType(resourceContext.ResourceType, resourceContext.IdentityType); + protected virtual object ResolveResourceDefinition(ResourceType resourceType) + { + Type resourceDefinitionType = typeof(IResourceDefinition<,>).MakeGenericType(resourceType.ClrType, resourceType.IdentityClrType); return _serviceProvider.GetRequiredService(resourceDefinitionType); } } diff --git a/src/JsonApiDotNetCore/Resources/ResourceFactory.cs b/src/JsonApiDotNetCore/Resources/ResourceFactory.cs index 6ab75ded5b..f5e305a29f 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); } /// @@ -41,7 +41,7 @@ private static IIdentifiable InnerCreateInstance(Type type, IServiceProvider ser try { return hasSingleConstructorWithoutParameters - ? (IIdentifiable)Activator.CreateInstance(type) + ? (IIdentifiable)Activator.CreateInstance(type)! : (IIdentifiable)ActivatorUtilities.CreateInstance(serviceProvider, type); } #pragma warning disable AV1210 // Catch a specific exception instead of Exception, SystemException or ApplicationException @@ -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 61% rename from src/JsonApiDotNetCore/Serialization/JsonObjectConverter.cs rename to src/JsonApiDotNetCore/Serialization/JsonConverters/JsonObjectConverter.cs index 2a365317c4..f77f21cdab 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonObjectConverter.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonConverters/JsonObjectConverter.cs @@ -1,13 +1,13 @@ using System.Text.Json; using System.Text.Json.Serialization; -namespace JsonApiDotNetCore.Serialization +namespace JsonApiDotNetCore.Serialization.JsonConverters { public abstract class JsonObjectConverter : JsonConverter { - protected static TValue ReadSubTree(ref Utf8JsonReader reader, JsonSerializerOptions options) + protected static TValue? ReadSubTree(ref Utf8JsonReader reader, JsonSerializerOptions options) { - if (typeof(TValue) != typeof(object) && options?.GetConverter(typeof(TValue)) is JsonConverter converter) + if (typeof(TValue) != typeof(object) && options.GetConverter(typeof(TValue)) is JsonConverter converter) { return converter.Read(ref reader, typeof(TValue), options); } @@ -17,7 +17,7 @@ protected static TValue ReadSubTree(ref Utf8JsonReader reader, JsonSeria protected static void WriteSubTree(Utf8JsonWriter writer, TValue value, JsonSerializerOptions options) { - if (typeof(TValue) != typeof(object) && options?.GetConverter(typeof(TValue)) is JsonConverter converter) + if (typeof(TValue) != typeof(object) && options.GetConverter(typeof(TValue)) is JsonConverter converter) { converter.Write(writer, value, options); } @@ -29,7 +29,7 @@ protected static void WriteSubTree(Utf8JsonWriter writer, TValue value, protected static JsonException GetEndOfStreamError() { - return new("Unexpected end of JSON stream."); + return new JsonException("Unexpected end of JSON stream."); } } } diff --git a/src/JsonApiDotNetCore/Serialization/JsonConverters/ResourceObjectConverter.cs b/src/JsonApiDotNetCore/Serialization/JsonConverters/ResourceObjectConverter.cs index 4f0758fff0..9fb5e4c607 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>(ref reader, options); + resourceObject.Relationships = ReadSubTree>(ref reader, options); break; } case "links": @@ -108,7 +111,7 @@ public override ResourceObject Read(ref Utf8JsonReader reader, Type typeToConver } case "meta": { - resourceObject.Meta = ReadSubTree>(ref reader, options); + resourceObject.Meta = ReadSubTree>(ref reader, options); break; } default: @@ -126,7 +129,7 @@ public override ResourceObject Read(ref Utf8JsonReader reader, Type typeToConver throw GetEndOfStreamError(); } - private static string TryPeekType(ref Utf8JsonReader reader) + private static string? PeekType(ref Utf8JsonReader reader) { // https://docs.microsoft.com/en-us/dotnet/standard/serialization/system-text-json-converters-how-to?pivots=dotnet-5-0#an-alternative-way-to-do-polymorphic-deserialization Utf8JsonReader readerClone = reader; @@ -135,7 +138,7 @@ private static string TryPeekType(ref Utf8JsonReader reader) { if (readerClone.TokenType == JsonTokenType.PropertyName) { - string propertyName = readerClone.GetString(); + string? propertyName = readerClone.GetString(); readerClone.Read(); switch (propertyName) @@ -156,9 +159,9 @@ private static string TryPeekType(ref Utf8JsonReader reader) return null; } - private static IDictionary ReadAttributes(ref Utf8JsonReader reader, JsonSerializerOptions options, ResourceContext resourceContext) + private static IDictionary ReadAttributes(ref Utf8JsonReader reader, JsonSerializerOptions options, ResourceType resourceType) { - var attributes = new Dictionary(); + var attributes = new Dictionary(); while (reader.Read()) { @@ -170,17 +173,17 @@ private static IDictionary ReadAttributes(ref Utf8JsonReader rea } case JsonTokenType.PropertyName: { - string attributeName = reader.GetString(); + string attributeName = reader.GetString() ?? string.Empty; reader.Read(); - AttrAttribute attribute = resourceContext.TryGetAttributeByPublicName(attributeName); - PropertyInfo property = attribute?.Property; + AttrAttribute? attribute = resourceType.FindAttributeByPublicName(attributeName); + PropertyInfo? property = attribute?.Property; if (property != null) { - object attributeValue; + object? attributeValue; - if (property.Name == nameof(Identifiable.Id)) + if (property.Name == nameof(Identifiable.Id)) { attributeValue = JsonInvalidAttributeInfo.Id; } @@ -202,10 +205,11 @@ private static IDictionary ReadAttributes(ref Utf8JsonReader rea } } - attributes.Add(attributeName!, attributeValue); + attributes.Add(attributeName, attributeValue); } else { + attributes.Add(attributeName, null); reader.Skip(); } diff --git a/src/JsonApiDotNetCore/Serialization/JsonConverters/SingleOrManyDataConverterFactory.cs b/src/JsonApiDotNetCore/Serialization/JsonConverters/SingleOrManyDataConverterFactory.cs index 0ca65c237e..2ad8ad6e06 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonConverters/SingleOrManyDataConverterFactory.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonConverters/SingleOrManyDataConverterFactory.cs @@ -25,15 +25,15 @@ public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializer Type objectType = typeToConvert.GetGenericArguments()[0]; Type converterType = typeof(SingleOrManyDataConverter<>).MakeGenericType(objectType); - return (JsonConverter)Activator.CreateInstance(converterType, BindingFlags.Instance | BindingFlags.Public, null, null, null); + return (JsonConverter)Activator.CreateInstance(converterType, BindingFlags.Instance | BindingFlags.Public, null, null, null)!; } private sealed class SingleOrManyDataConverter : JsonObjectConverter> - where T : class, IResourceIdentity + where T : class, IResourceIdentity, new() { public override SingleOrManyData Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions serializerOptions) { - var objects = new List(); + var objects = new List(); bool isManyData = false; bool hasCompletedToMany = false; @@ -46,6 +46,15 @@ public override SingleOrManyData Read(ref Utf8JsonReader reader, Type typeToC hasCompletedToMany = true; break; } + case JsonTokenType.Null: + { + if (isManyData) + { + objects.Add(new T()); + } + + break; + } case JsonTokenType.StartObject: { var resourceObject = ReadSubTree(ref reader, serializerOptions); @@ -61,7 +70,7 @@ public override SingleOrManyData Read(ref Utf8JsonReader reader, Type typeToC } while (isManyData && !hasCompletedToMany && reader.Read()); - object data = isManyData ? objects : objects.FirstOrDefault(); + object? data = isManyData ? objects : objects.FirstOrDefault(); return new SingleOrManyData(data); } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationObject.cs b/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationObject.cs index 27c3f58c44..b4cddf1aa7 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationObject.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationObject.cs @@ -20,14 +20,14 @@ public sealed class AtomicOperationObject [JsonPropertyName("ref")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public AtomicReference Ref { get; set; } + public AtomicReference? Ref { get; set; } [JsonPropertyName("href")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string Href { get; set; } + public string? Href { get; set; } [JsonPropertyName("meta")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IDictionary Meta { get; set; } + public IDictionary? Meta { get; set; } } } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/AtomicReference.cs b/src/JsonApiDotNetCore/Serialization/Objects/AtomicReference.cs index bff24ad299..7c4f93caa9 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/AtomicReference.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/AtomicReference.cs @@ -11,18 +11,18 @@ public sealed class AtomicReference : IResourceIdentity { [JsonPropertyName("type")] [JsonIgnore(Condition = JsonIgnoreCondition.Never)] - public string Type { get; set; } + public string? Type { get; set; } [JsonPropertyName("id")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string Id { get; set; } + public string? Id { get; set; } [JsonPropertyName("lid")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string Lid { get; set; } + public string? Lid { get; set; } [JsonPropertyName("relationship")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string Relationship { get; set; } + public string? Relationship { get; set; } } } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/AtomicResultObject.cs b/src/JsonApiDotNetCore/Serialization/Objects/AtomicResultObject.cs index 14f67a5247..0d55c65a3c 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/AtomicResultObject.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/AtomicResultObject.cs @@ -16,6 +16,6 @@ public sealed class AtomicResultObject [JsonPropertyName("meta")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IDictionary Meta { get; set; } + public IDictionary? Meta { get; set; } } } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/Document.cs b/src/JsonApiDotNetCore/Serialization/Objects/Document.cs index ae3a09b9b1..9242398d34 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 @@ -13,11 +10,11 @@ public sealed class Document { [JsonPropertyName("jsonapi")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public JsonApiObject JsonApi { get; set; } + public JsonApiObject? JsonApi { get; set; } [JsonPropertyName("links")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public TopLevelLinks Links { get; set; } + public TopLevelLinks? Links { get; set; } [JsonPropertyName("data")] // JsonIgnoreCondition is determined at runtime by WriteOnlyDocumentConverter. @@ -25,40 +22,22 @@ public sealed class Document [JsonPropertyName("atomic:operations")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IList Operations { get; set; } + public IList? Operations { get; set; } [JsonPropertyName("atomic:results")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IList Results { get; set; } + public IList? Results { get; set; } [JsonPropertyName("errors")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IList Errors { get; set; } + public IList? Errors { get; set; } [JsonPropertyName("included")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IList Included { get; set; } + public IList? Included { get; set; } [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; - } + public IDictionary? Meta { get; set; } } } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/ErrorLinks.cs b/src/JsonApiDotNetCore/Serialization/Objects/ErrorLinks.cs index 3b03f68cda..e45dc22a2f 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/ErrorLinks.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/ErrorLinks.cs @@ -11,10 +11,10 @@ public sealed class ErrorLinks { [JsonPropertyName("about")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string About { get; set; } + public string? About { get; set; } [JsonPropertyName("type")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string Type { get; set; } + public string? Type { get; set; } } } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/ErrorObject.cs b/src/JsonApiDotNetCore/Serialization/Objects/ErrorObject.cs index a5ac6be1a8..9fb0eb6f85 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; @@ -14,11 +15,11 @@ public sealed class ErrorObject { [JsonPropertyName("id")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string Id { get; set; } = Guid.NewGuid().ToString(); + public string? Id { get; set; } = Guid.NewGuid().ToString(); [JsonPropertyName("links")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public ErrorLinks Links { get; set; } + public ErrorLinks? Links { get; set; } [JsonIgnore] public HttpStatusCode StatusCode { get; set; } @@ -33,27 +34,45 @@ public string Status [JsonPropertyName("code")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string Code { get; set; } + public string? Code { get; set; } [JsonPropertyName("title")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string Title { get; set; } + public string? Title { get; set; } [JsonPropertyName("detail")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string Detail { get; set; } + public string? Detail { get; set; } [JsonPropertyName("source")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public ErrorSource Source { get; set; } + public ErrorSource? Source { get; set; } [JsonPropertyName("meta")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IDictionary Meta { get; set; } + public IDictionary? Meta { get; set; } 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/Objects/ErrorSource.cs b/src/JsonApiDotNetCore/Serialization/Objects/ErrorSource.cs index ebd8ee49bd..ec363c2f8d 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/ErrorSource.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/ErrorSource.cs @@ -11,14 +11,14 @@ public sealed class ErrorSource { [JsonPropertyName("pointer")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string Pointer { get; set; } + public string? Pointer { get; set; } [JsonPropertyName("parameter")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string Parameter { get; set; } + public string? Parameter { get; set; } [JsonPropertyName("header")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string Header { get; set; } + public string? Header { get; set; } } } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/IResourceIdentity.cs b/src/JsonApiDotNetCore/Serialization/Objects/IResourceIdentity.cs index ff936f4d46..9b683c6922 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/IResourceIdentity.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/IResourceIdentity.cs @@ -2,8 +2,8 @@ namespace JsonApiDotNetCore.Serialization.Objects { public interface IResourceIdentity { - public string Type { get; } - public string Id { get; } - public string Lid { get; } + public string? Type { get; } + public string? Id { get; } + public string? Lid { get; } } } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/JsonApiObject.cs b/src/JsonApiDotNetCore/Serialization/Objects/JsonApiObject.cs index 11b214b434..d0a385e404 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/JsonApiObject.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/JsonApiObject.cs @@ -12,18 +12,18 @@ public sealed class JsonApiObject { [JsonPropertyName("version")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string Version { get; set; } + public string? Version { get; set; } [JsonPropertyName("ext")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IList Ext { get; set; } + public IList? Ext { get; set; } [JsonPropertyName("profile")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IList Profile { get; set; } + public IList? Profile { get; set; } [JsonPropertyName("meta")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IDictionary Meta { get; set; } + public IDictionary? Meta { get; set; } } } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/RelationshipLinks.cs b/src/JsonApiDotNetCore/Serialization/Objects/RelationshipLinks.cs index b66f33daa8..944b811605 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/RelationshipLinks.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/RelationshipLinks.cs @@ -11,11 +11,11 @@ public sealed class RelationshipLinks { [JsonPropertyName("self")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string Self { get; set; } + public string? Self { get; set; } [JsonPropertyName("related")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string Related { get; set; } + public string? Related { get; set; } internal bool HasValue() { diff --git a/src/JsonApiDotNetCore/Serialization/Objects/RelationshipObject.cs b/src/JsonApiDotNetCore/Serialization/Objects/RelationshipObject.cs index fb4296d70d..96f5414eea 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/RelationshipObject.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/RelationshipObject.cs @@ -12,7 +12,7 @@ public sealed class RelationshipObject { [JsonPropertyName("links")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public RelationshipLinks Links { get; set; } + public RelationshipLinks? Links { get; set; } [JsonPropertyName("data")] // JsonIgnoreCondition is determined at runtime by WriteOnlyRelationshipObjectConverter. @@ -20,6 +20,6 @@ public sealed class RelationshipObject [JsonPropertyName("meta")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IDictionary Meta { get; set; } + public IDictionary? Meta { get; set; } } } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/ResourceIdentifierObject.cs b/src/JsonApiDotNetCore/Serialization/Objects/ResourceIdentifierObject.cs index de4104d28a..9b9de3afb8 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/ResourceIdentifierObject.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/ResourceIdentifierObject.cs @@ -12,18 +12,18 @@ public sealed class ResourceIdentifierObject : IResourceIdentity { [JsonPropertyName("type")] [JsonIgnore(Condition = JsonIgnoreCondition.Never)] - public string Type { get; set; } + public string? Type { get; set; } [JsonPropertyName("id")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string Id { get; set; } + public string? Id { get; set; } [JsonPropertyName("lid")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string Lid { get; set; } + public string? Lid { get; set; } [JsonPropertyName("meta")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IDictionary Meta { get; set; } + public IDictionary? Meta { get; set; } } } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/ResourceLinks.cs b/src/JsonApiDotNetCore/Serialization/Objects/ResourceLinks.cs index 7ab1f6861e..ddee80c85d 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/ResourceLinks.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/ResourceLinks.cs @@ -11,7 +11,7 @@ public sealed class ResourceLinks { [JsonPropertyName("self")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string Self { get; set; } + public string? Self { get; set; } internal bool HasValue() { diff --git a/src/JsonApiDotNetCore/Serialization/Objects/ResourceObject.cs b/src/JsonApiDotNetCore/Serialization/Objects/ResourceObject.cs index f418a63ed1..85f340075d 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/ResourceObject.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/ResourceObject.cs @@ -12,30 +12,30 @@ public sealed class ResourceObject : IResourceIdentity { [JsonPropertyName("type")] [JsonIgnore(Condition = JsonIgnoreCondition.Never)] - public string Type { get; set; } + public string? Type { get; set; } [JsonPropertyName("id")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string Id { get; set; } + public string? Id { get; set; } [JsonPropertyName("lid")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string Lid { get; set; } + public string? Lid { get; set; } [JsonPropertyName("attributes")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IDictionary Attributes { get; set; } + public IDictionary? Attributes { get; set; } [JsonPropertyName("relationships")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IDictionary Relationships { get; set; } + public IDictionary? Relationships { get; set; } [JsonPropertyName("links")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public ResourceLinks Links { get; set; } + public ResourceLinks? Links { get; set; } [JsonPropertyName("meta")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IDictionary Meta { get; set; } + public IDictionary? Meta { get; set; } } } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/SingleOrManyData.cs b/src/JsonApiDotNetCore/Serialization/Objects/SingleOrManyData.cs index c2a6c23876..548dde07e9 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/SingleOrManyData.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/SingleOrManyData.cs @@ -13,22 +13,24 @@ namespace JsonApiDotNetCore.Serialization.Objects /// [PublicAPI] public readonly struct SingleOrManyData - where T : class, IResourceIdentity + // The "new()" constraint exists for parity with SingleOrManyDataConverterFactory, which creates empty instances + // to ensure ManyValue never contains null items. + where T : class, IResourceIdentity, new() { // ReSharper disable once MergeConditionalExpression // Justification: ReSharper reporting this is a bug, which is fixed in v2021.2.1. This condition cannot be merged. - public object Value => ManyValue != null ? ManyValue : SingleValue; + public object? Value => ManyValue != null ? ManyValue : SingleValue; [JsonIgnore] public bool IsAssigned { get; } [JsonIgnore] - public T SingleValue { get; } + public T? SingleValue { get; } [JsonIgnore] - public IList ManyValue { get; } + public IList? ManyValue { get; } - public SingleOrManyData(object value) + public SingleOrManyData(object? value) { IsAssigned = true; @@ -40,7 +42,7 @@ public SingleOrManyData(object value) else { ManyValue = null; - SingleValue = (T)value; + SingleValue = (T?)value; } } } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/TopLevelLinks.cs b/src/JsonApiDotNetCore/Serialization/Objects/TopLevelLinks.cs index 0817e56d8a..abb8a365e9 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/TopLevelLinks.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/TopLevelLinks.cs @@ -11,31 +11,31 @@ public sealed class TopLevelLinks { [JsonPropertyName("self")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string Self { get; set; } + public string? Self { get; set; } [JsonPropertyName("related")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string Related { get; set; } + public string? Related { get; set; } [JsonPropertyName("describedby")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string DescribedBy { get; set; } + public string? DescribedBy { get; set; } [JsonPropertyName("first")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string First { get; set; } + public string? First { get; set; } [JsonPropertyName("last")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string Last { get; set; } + public string? Last { get; set; } [JsonPropertyName("prev")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string Prev { get; set; } + public string? Prev { get; set; } [JsonPropertyName("next")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string Next { get; set; } + public string? Next { get; set; } internal bool HasValue() { diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicOperationObjectAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicOperationObjectAdapter.cs new file mode 100644 index 0000000000..ea157e917d --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicOperationObjectAdapter.cs @@ -0,0 +1,164 @@ +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: + { + // ReSharper disable once MergeIntoPattern + // Justification: Merging this into a pattern crashes the command-line versions of CleanupCode/InspectCode. + // Tracked at: https://youtrack.jetbrains.com/issue/RSRP-486717 + if (atomicOperationObject.Ref != null && atomicOperationObject.Ref.Relationship == null) + { + using IDisposable _ = state.Position.PushElement("ref"); + throw new ModelConversionException(state.Position, "The 'relationship' element is required.", null); + } + + return atomicOperationObject.Ref == null ? WriteOperationKind.CreateResource : WriteOperationKind.AddToRelationship; + } + 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 = CreateRefRequirements(state); + IIdentifiable? primaryResource = null; + + AtomicReferenceResult? refResult = atomicOperationObject.Ref != null + ? _atomicReferenceAdapter.Convert(atomicOperationObject.Ref, requirements, state) + : null; + + if (refResult != null) + { + state.WritableRequest!.PrimaryId = refResult.Resource.StringId; + state.WritableRequest.PrimaryResourceType = refResult.ResourceType; + state.WritableRequest.Relationship = refResult.Relationship; + state.WritableRequest.IsCollection = refResult.Relationship is HasManyAttribute; + + ConvertRefRelationship(atomicOperationObject.Data, refResult, state); + + requirements = CreateDataRequirements(refResult, requirements); + primaryResource = refResult.Resource; + } + + return (requirements, primaryResource); + } + + private ResourceIdentityRequirements CreateRefRequirements(RequestAdapterState state) + { + JsonElementConstraint? idConstraint = state.Request.WriteOperation == WriteOperationKind.CreateResource + ? _options.AllowClientGeneratedIds ? null : JsonElementConstraint.Forbidden + : JsonElementConstraint.Required; + + return new ResourceIdentityRequirements + { + IdConstraint = idConstraint + }; + } + + private static ResourceIdentityRequirements CreateDataRequirements(AtomicReferenceResult refResult, ResourceIdentityRequirements refRequirements) + { + return new ResourceIdentityRequirements + { + ResourceType = refResult.ResourceType, + IdConstraint = refRequirements.IdConstraint, + IdValue = refResult.Resource.StringId, + LidValue = refResult.Resource.LocalId, + RelationshipName = refResult.Relationship?.PublicName + }; + } + + private void ConvertRefRelationship(SingleOrManyData relationshipData, AtomicReferenceResult refResult, RequestAdapterState state) + { + if (refResult.Relationship != null) + { + state.WritableRequest!.SecondaryResourceType = refResult.Relationship.RightType; + + state.WritableTargetedFields!.Relationships.Add(refResult.Relationship); + + 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..b17c94edb6 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicReferenceAdapter.cs @@ -0,0 +1,47 @@ +using System; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Serialization.Request.Adapters +{ + /// + [PublicAPI] + public sealed class AtomicReferenceAdapter : ResourceIdentityAdapter, IAtomicReferenceAdapter + { + public AtomicReferenceAdapter(IResourceGraph resourceGraph, IResourceFactory resourceFactory) + : base(resourceGraph, resourceFactory) + { + } + + /// + public AtomicReferenceResult Convert(AtomicReference atomicReference, ResourceIdentityRequirements requirements, RequestAdapterState state) + { + ArgumentGuard.NotNull(atomicReference, nameof(atomicReference)); + ArgumentGuard.NotNull(requirements, nameof(requirements)); + ArgumentGuard.NotNull(state, nameof(state)); + + using IDisposable _ = state.Position.PushElement("ref"); + (IIdentifiable resource, ResourceType resourceType) = ConvertResourceIdentity(atomicReference, requirements, state); + + RelationshipAttribute? relationship = atomicReference.Relationship != null + ? ConvertRelationship(atomicReference.Relationship, resourceType, state) + : null; + + return new AtomicReferenceResult(resource, resourceType, relationship); + } + + private RelationshipAttribute ConvertRelationship(string relationshipName, ResourceType resourceType, RequestAdapterState state) + { + using IDisposable _ = state.Position.PushElement("relationship"); + RelationshipAttribute? relationship = resourceType.FindRelationshipByPublicName(relationshipName); + + AssertIsKnownRelationship(relationship, relationshipName, resourceType, state); + AssertToManyInAddOrRemoveRelationship(relationship, state); + + return relationship; + } + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicReferenceResult.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicReferenceResult.cs new file mode 100644 index 0000000000..724a5da96c --- /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/BaseAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/BaseAdapter.cs new file mode 100644 index 0000000000..72c4d12b1e --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/BaseAdapter.cs @@ -0,0 +1,65 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Serialization.Objects; +using SysNotNull = System.Diagnostics.CodeAnalysis.NotNullAttribute; + +namespace JsonApiDotNetCore.Serialization.Request.Adapters +{ + /// + /// Contains shared assertions for derived types. + /// + public abstract class BaseAdapter + { + [AssertionMethod] + protected static void AssertHasData(SingleOrManyData data, RequestAdapterState state) + where T : class, IResourceIdentity, new() + { + if (!data.IsAssigned) + { + throw new ModelConversionException(state.Position, "The 'data' element is required.", null); + } + } + + [AssertionMethod] + protected static void AssertDataHasSingleValue(SingleOrManyData data, bool allowNull, RequestAdapterState state) + where T : class, IResourceIdentity, new() + { + if (data.SingleValue == null) + { + if (!allowNull) + { + if (data.ManyValue == null) + { + AssertObjectIsNotNull(data.SingleValue, state); + } + + throw new ModelConversionException(state.Position, "Expected an object, instead of an array.", null); + } + + if (data.ManyValue != null) + { + throw new ModelConversionException(state.Position, "Expected an object or 'null', instead of an array.", null); + } + } + } + + [AssertionMethod] + protected static void AssertDataHasManyValue(SingleOrManyData data, RequestAdapterState state) + where T : class, IResourceIdentity, new() + { + if (data.ManyValue == null) + { + throw new ModelConversionException(state.Position, + data.SingleValue == null ? "Expected an array, instead of 'null'." : "Expected an array, instead of an object.", null); + } + } + + protected static void AssertObjectIsNotNull([SysNotNull] T? value, RequestAdapterState state) + where T : class + { + if (value is null) + { + throw new ModelConversionException(state.Position, "Expected an object, instead of 'null'.", null); + } + } + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentAdapter.cs new file mode 100644 index 0000000000..46bcc1ca25 --- /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..a5088cf1af --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentInOperationsRequestAdapter.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Serialization.Request.Adapters +{ + /// + public sealed class DocumentInOperationsRequestAdapter : BaseAdapter, 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([NotNull] 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); + AssertObjectIsNotNull(atomicOperationObject, state); + + 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..1a127b49ed --- /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..78e3ed2f1a --- /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..a1b8fc0585 --- /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..222642dc76 --- /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..f47e25dfa0 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IResourceDataInOperationsRequestAdapter.cs @@ -0,0 +1,18 @@ +using JetBrains.Annotations; +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. + /// + [PublicAPI] + 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..6cc42bacdd --- /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 : BaseAdapter, 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) + { + AssertDataHasSingleValue(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) + { + AssertDataHasManyValue(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..252c0fe2a6 --- /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..b333b61140 --- /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..6e1d72fc17 --- /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 : BaseAdapter, 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"); + AssertDataHasSingleValue(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..5ebdb6f3cd --- /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..80d927c1b0 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs @@ -0,0 +1,223 @@ +using System; +using System.Diagnostics.CodeAnalysis; +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 : BaseAdapter + { + 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.Type, state); + + using IDisposable _ = state.Position.PushElement("type"); + ResourceType? resourceType = _resourceGraph.FindResourceType(identity.Type); + + AssertIsKnownResourceType(resourceType, identity.Type, state); + AssertIsCompatibleResourceType(resourceType, requirements.ResourceType, requirements.RelationshipName, state); + + return resourceType; + } + + private static void AssertHasType([NotNull] string? identityType, RequestAdapterState state) + { + if (identityType == null) + { + throw new ModelConversionException(state.Position, "The 'type' element is required.", null); + } + } + + private static void AssertIsKnownResourceType([NotNull] 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([NotNull] 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..0483723abd --- /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..5e1ebb5311 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceObjectAdapter.cs @@ -0,0 +1,161 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +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.FindAttributeByPublicName(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); + } + + private static void AssertIsKnownAttribute([NotNull] 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, resource, resourceType, state); + } + } + + private void ConvertRelationship(string relationshipName, RelationshipObject? relationshipObject, IIdentifiable resource, ResourceType resourceType, + RequestAdapterState state) + { + using IDisposable _ = state.Position.PushElement(relationshipName); + AssertObjectIsNotNull(relationshipObject, state); + + RelationshipAttribute? relationship = resourceType.FindRelationshipByPublicName(relationshipName); + + if (relationship == null && _options.AllowUnknownFieldsInRequestBody) + { + return; + } + + AssertIsKnownRelationship(relationship, relationshipName, resourceType, state); + + object? rightValue = _relationshipDataAdapter.Convert(relationshipObject.Data, 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..7d4a915f62 --- /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 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..312c11b0b4 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/JsonApiReader.cs @@ -0,0 +1,122 @@ +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; +using SysNotNull = System.Diagnostics.CodeAnalysis.NotNullAttribute; + +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); + + var document = JsonSerializer.Deserialize(requestBody, _options.SerializerReadOptions); + + AssertHasDocument(document, requestBody); + + return document; + } + catch (JsonException exception) + { + // JsonException.Path looks great for setting error.source.pointer, but unfortunately it is wrong in most cases. + // This is due to the use of custom converters, which are unable to interact with internal position tracking. + // https://github.com/dotnet/runtime/issues/50205#issuecomment-808401245 + throw new InvalidRequestBodyException(_options.IncludeRequestBodyInErrors ? requestBody : null, null, exception.Message, null, null, exception); + } + } + + private void AssertHasDocument([SysNotNull] Document? document, string requestBody) + { + if (document == null) + { + throw new InvalidRequestBodyException(_options.IncludeRequestBodyInErrors ? requestBody : null, "Expected an object, instead of 'null'.", null, + null); + } + } + + 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 85% rename from src/JsonApiDotNetCore/Serialization/JsonInvalidAttributeInfo.cs rename to src/JsonApiDotNetCore/Serialization/Request/JsonInvalidAttributeInfo.cs index 037eaf18af..7080da1c9d 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. @@ -12,10 +12,10 @@ internal sealed class JsonInvalidAttributeInfo public string AttributeName { get; } public Type AttributeType { get; } - public string JsonValue { get; } + public string? JsonValue { get; } public JsonValueKind JsonType { get; } - public JsonInvalidAttributeInfo(string attributeName, Type attributeType, string jsonValue, JsonValueKind jsonType) + public JsonInvalidAttributeInfo(string attributeName, Type attributeType, string? jsonValue, JsonValueKind jsonType) { ArgumentGuard.NotNullNorEmpty(attributeName, nameof(attributeName)); ArgumentGuard.NotNull(attributeType, nameof(attributeType)); diff --git a/src/JsonApiDotNetCore/Serialization/Request/ModelConversionException.cs b/src/JsonApiDotNetCore/Serialization/Request/ModelConversionException.cs new file mode 100644 index 0000000000..70be3f7366 --- /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 64% rename from src/JsonApiDotNetCore/Serialization/EmptyResponseMeta.cs rename to src/JsonApiDotNetCore/Serialization/Response/EmptyResponseMeta.cs index 592a752926..a787901494 100644 --- a/src/JsonApiDotNetCore/Serialization/EmptyResponseMeta.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/EmptyResponseMeta.cs @@ -1,12 +1,12 @@ using System.Collections.Generic; -namespace JsonApiDotNetCore.Serialization +namespace JsonApiDotNetCore.Serialization.Response { /// public sealed class EmptyResponseMeta : IResponseMeta { /// - public IReadOnlyDictionary GetMeta() + public IReadOnlyDictionary? GetMeta() { return null; } 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..8c904cf032 --- /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 66% rename from src/JsonApiDotNetCore/Serialization/Building/ILinkBuilder.cs rename to src/JsonApiDotNetCore/Serialization/Response/ILinkBuilder.cs index 86462aee54..891556c7af 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. @@ -12,16 +13,16 @@ public interface ILinkBuilder /// /// Builds the links object that is included in the top-level of the document. /// - TopLevelLinks GetTopLevelLinks(); + TopLevelLinks? GetTopLevelLinks(); /// /// Builds the links object for a returned resource (primary or included). /// - ResourceLinks GetResourceLinks(string resourceName, string id); + ResourceLinks? GetResourceLinks(ResourceType resourceType, IIdentifiable resource); /// /// Builds the links object for a relationship inside a returned resource. /// - RelationshipLinks GetRelationshipLinks(RelationshipAttribute relationship, IIdentifiable leftResource); + RelationshipLinks? GetRelationshipLinks(RelationshipAttribute relationship, IIdentifiable leftResource); } } diff --git a/src/JsonApiDotNetCore/Serialization/Building/IMetaBuilder.cs b/src/JsonApiDotNetCore/Serialization/Response/IMetaBuilder.cs similarity index 77% rename from src/JsonApiDotNetCore/Serialization/Building/IMetaBuilder.cs rename to src/JsonApiDotNetCore/Serialization/Response/IMetaBuilder.cs index 1e668feca5..285f0df550 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. @@ -13,11 +13,11 @@ public interface IMetaBuilder /// Merges the specified dictionary with existing key/value pairs. In the event of a key collision, the value from the specified dictionary will /// overwrite the existing one. /// - void Add(IReadOnlyDictionary values); + void Add(IReadOnlyDictionary values); /// /// Builds the top-level meta data object. /// - IDictionary Build(); + IDictionary? Build(); } } diff --git a/src/JsonApiDotNetCore/Serialization/IResponseMeta.cs b/src/JsonApiDotNetCore/Serialization/Response/IResponseMeta.cs similarity index 84% rename from src/JsonApiDotNetCore/Serialization/IResponseMeta.cs rename to src/JsonApiDotNetCore/Serialization/Response/IResponseMeta.cs index 2561da2543..83596f9146 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 @@ -13,6 +13,6 @@ public interface IResponseMeta /// /// Gets the global top-level JSON:API meta information to add to the response. /// - IReadOnlyDictionary GetMeta(); + IReadOnlyDictionary? GetMeta(); } } diff --git a/src/JsonApiDotNetCore/Serialization/Response/IResponseModelAdapter.cs b/src/JsonApiDotNetCore/Serialization/Response/IResponseModelAdapter.cs new file mode 100644 index 0000000000..153e993b0d --- /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..793a720c3a --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Response/JsonApiWriter.cs @@ -0,0 +1,186 @@ +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 = responseBody == null ? 0 : Encoding.UTF8.GetByteCount(responseBody); + return; + } + + _traceWriter.LogMessage(() => + $"Sending {httpContext.Response.StatusCode} response for {httpContext.Request.Method} request at '{httpContext.Request.GetEncodedUrl()}' with body: <<{responseBody}>>"); + + 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); + + 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 60% rename from src/JsonApiDotNetCore/Serialization/Building/LinkBuilder.cs rename to src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs index ad82acff5b..1b95266000 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 @@ -24,32 +24,46 @@ public class LinkBuilder : ILinkBuilder private const string PageSizeParameterName = "page[size]"; private const string PageNumberParameterName = "page[number]"; - private static readonly string GetPrimaryControllerActionName = NoAsyncSuffix(nameof(BaseJsonApiController.GetAsync)); - private static readonly string GetSecondaryControllerActionName = NoAsyncSuffix(nameof(BaseJsonApiController.GetSecondaryAsync)); - private static readonly string GetRelationshipControllerActionName = NoAsyncSuffix(nameof(BaseJsonApiController.GetRelationshipAsync)); + private static readonly string GetPrimaryControllerActionName = NoAsyncSuffix(nameof(BaseJsonApiController, int>.GetAsync)); + + private static readonly string GetSecondaryControllerActionName = + NoAsyncSuffix(nameof(BaseJsonApiController, int>.GetSecondaryAsync)); + + private static readonly string GetRelationshipControllerActionName = + NoAsyncSuffix(nameof(BaseJsonApiController, int>.GetRelationshipAsync)); private 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) + private HttpContext HttpContext + { + get + { + if (_httpContextAccessor.HttpContext == null) + { + throw new InvalidOperationException("An active HTTP request is required."); + } + + return _httpContextAccessor.HttpContext; + } + } + + 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; @@ -61,39 +75,38 @@ private static string NoAsyncSuffix(string actionName) } /// - public TopLevelLinks GetTopLevelLinks() + 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); + 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); @@ -101,14 +114,13 @@ private bool ShouldIncludeTopLevelLink(LinkTypes linkType, ResourceContext resou private string GetLinkForTopLevelSelf() { - return _options.UseRelativeLinks - ? _httpContextAccessor.HttpContext!.Request.GetEncodedPathAndQuery() - : _httpContextAccessor.HttpContext!.Request.GetEncodedUrl(); + // Note: in tests, this does not properly escape special characters due to WebApplicationFactory short-circuiting. + return _options.UseRelativeLinks ? HttpContext.Request.GetEncodedPathAndQuery() : 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 +143,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]; + string pageSizeParameterValue = HttpContext.Request.Query[PageSizeParameterName]; - PageSize newTopPageSize = Equals(topPageSize, _options.DefaultPageSize) ? null : topPageSize; - return ChangeTopPageSize(pageSizeParameterValue, newTopPageSize, requestContext); + PageSize? newTopPageSize = Equals(topPageSize, _options.DefaultPageSize) ? null : topPageSize; + 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,25 +176,24 @@ 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; } - private string GetLinkForPagination(int pageOffset, string pageSizeValue) + private string GetLinkForPagination(int pageOffset, string? pageSizeValue) { string queryStringValue = GetQueryStringInPaginationLink(pageOffset, pageSizeValue); - var builder = new UriBuilder(_httpContextAccessor.HttpContext!.Request.GetEncodedUrl()) + var builder = new UriBuilder(HttpContext.Request.GetEncodedUrl()) { Query = queryStringValue }; @@ -191,10 +202,9 @@ private string GetLinkForPagination(int pageOffset, string pageSizeValue) return builder.Uri.GetComponents(components, UriFormat.SafeUnescaped); } - private string GetQueryStringInPaginationLink(int pageOffset, string pageSizeValue) + private string GetQueryStringInPaginationLink(int pageOffset, string? pageSizeValue) { - IDictionary parameters = - _httpContextAccessor.HttpContext!.Request.Query.ToDictionary(pair => pair.Key, pair => pair.Value.ToString()); + IDictionary parameters = HttpContext.Request.Query.ToDictionary(pair => pair.Key, pair => (string?)pair.Value.ToString()); if (pageSizeValue == null) { @@ -214,98 +224,90 @@ private string GetQueryStringInPaginationLink(int pageOffset, string pageSizeVal parameters[PageNumberParameterName] = pageOffset.ToString(); } - string queryStringValue = QueryString.Create(parameters).Value; - return DecodeSpecialCharacters(queryStringValue); - } - - private static string DecodeSpecialCharacters(string uri) - { - return uri.Replace("%5B", "[").Replace("%5D", "]").Replace("%27", "'").Replace("%3A", ":"); + return QueryString.Create(parameters).Value ?? string.Empty; } /// - public ResourceLinks GetResourceLinks(string resourceName, string id) + public ResourceLinks? GetResourceLinks(ResourceType resourceType, IIdentifiable resource) { - ArgumentGuard.NotNullNorEmpty(resourceName, nameof(resourceName)); - ArgumentGuard.NotNullNorEmpty(id, nameof(id)); + ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + ArgumentGuard.NotNull(resource, nameof(resource)); 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, resource); } 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, IIdentifiable resource) { - string controllerName = _controllerResourceMapping.GetControllerNameForResourceType(resourceContext.ResourceType); - IDictionary routeValues = GetRouteValues(resourceId, null); + string? controllerName = _controllerResourceMapping.GetControllerNameForResourceType(resourceType); + IDictionary routeValues = GetRouteValues(resource.StringId!, null); return RenderLinkForAction(controllerName, GetPrimaryControllerActionName, routeValues); } /// - public RelationshipLinks GetRelationshipLinks(RelationshipAttribute relationship, IIdentifiable leftResource) + public RelationshipLinks? GetRelationshipLinks(RelationshipAttribute relationship, IIdentifiable leftResource) { ArgumentGuard.NotNull(relationship, nameof(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); + links.Self = GetLinkForRelationshipSelf(leftResource.StringId!, relationship); } - if (ShouldIncludeRelationshipLink(LinkTypes.Related, relationship, leftResourceContext)) + if (ShouldIncludeRelationshipLink(LinkTypes.Related, relationship)) { - links.Related = GetLinkForRelationshipRelated(leftResource.StringId, relationship); + links.Related = GetLinkForRelationshipRelated(leftResource.StringId!, relationship); } return links.HasValue() ? links : null; } - private string GetLinkForRelationshipSelf(string leftId, RelationshipAttribute relationship) + private string? GetLinkForRelationshipSelf(string leftId, RelationshipAttribute relationship) { - string controllerName = _controllerResourceMapping.GetControllerNameForResourceType(relationship.LeftType); - IDictionary routeValues = GetRouteValues(leftId, relationship.PublicName); + string? controllerName = _controllerResourceMapping.GetControllerNameForResourceType(relationship.LeftType); + IDictionary routeValues = GetRouteValues(leftId, relationship.PublicName); return RenderLinkForAction(controllerName, GetRelationshipControllerActionName, routeValues); } - private string GetLinkForRelationshipRelated(string leftId, RelationshipAttribute relationship) + private string? GetLinkForRelationshipRelated(string leftId, RelationshipAttribute relationship) { - string controllerName = _controllerResourceMapping.GetControllerNameForResourceType(relationship.LeftType); - IDictionary routeValues = GetRouteValues(leftId, relationship.PublicName); + string? controllerName = _controllerResourceMapping.GetControllerNameForResourceType(relationship.LeftType); + IDictionary routeValues = GetRouteValues(leftId, relationship.PublicName); return RenderLinkForAction(controllerName, GetSecondaryControllerActionName, routeValues); } - private IDictionary GetRouteValues(string primaryId, string relationshipName) + private IDictionary GetRouteValues(string primaryId, string? relationshipName) { // By default, we copy all route parameters from the *current* endpoint, which helps in case all endpoints have the same // set of non-standard parameters. There is no way we can know which non-standard parameters a *different* endpoint needs, // so users must override RenderLinkForAction to supply them, if applicable. - RouteValueDictionary routeValues = _httpContextAccessor.HttpContext!.Request.RouteValues; + RouteValueDictionary routeValues = HttpContext.Request.RouteValues; routeValues["id"] = primaryId; routeValues["relationshipName"] = relationshipName; @@ -313,11 +315,11 @@ private IDictionary GetRouteValues(string primaryId, string rela return routeValues; } - protected virtual string RenderLinkForAction(string controllerName, string actionName, IDictionary routeValues) + protected virtual string? RenderLinkForAction(string? controllerName, string actionName, IDictionary routeValues) { return _options.UseRelativeLinks - ? _linkGenerator.GetPathByAction(_httpContextAccessor.HttpContext, actionName, controllerName, routeValues) - : _linkGenerator.GetUriByAction(_httpContextAccessor.HttpContext, actionName, controllerName, routeValues); + ? _linkGenerator.GetPathByAction(HttpContext, actionName, controllerName, routeValues) + : _linkGenerator.GetUriByAction(HttpContext, actionName, controllerName, routeValues); } /// @@ -325,16 +327,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 84% rename from src/JsonApiDotNetCore/Serialization/Building/MetaBuilder.cs rename to src/JsonApiDotNetCore/Serialization/Response/MetaBuilder.cs index dcddb2aa53..ef75f6472a 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] @@ -14,7 +14,7 @@ public sealed class MetaBuilder : IMetaBuilder private readonly IJsonApiOptions _options; private readonly IResponseMeta _responseMeta; - private Dictionary _meta = new(); + private Dictionary _meta = new(); public MetaBuilder(IPaginationContext paginationContext, IJsonApiOptions options, IResponseMeta responseMeta) { @@ -28,7 +28,7 @@ public MetaBuilder(IPaginationContext paginationContext, IJsonApiOptions options } /// - public void Add(IReadOnlyDictionary values) + public void Add(IReadOnlyDictionary values) { ArgumentGuard.NotNull(values, nameof(values)); @@ -36,7 +36,7 @@ public void Add(IReadOnlyDictionary values) } /// - public IDictionary Build() + public IDictionary? Build() { if (_paginationContext.TotalResourceCount != null) { @@ -49,7 +49,7 @@ public IDictionary Build() _meta.Add(key, _paginationContext.TotalResourceCount); } - IReadOnlyDictionary extraMeta = _responseMeta.GetMeta(); + IReadOnlyDictionary? extraMeta = _responseMeta.GetMeta(); if (extraMeta != null) { diff --git a/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs b/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs new file mode 100644 index 0000000000..f33f566249 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs @@ -0,0 +1,280 @@ +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(ResourceType); + + // The resource this node was built for. We only store it for the LinkBuilder. + public IIdentifiable Resource { get; } + + // The resource type. We use its relationships to maintain order. + public ResourceType ResourceType { get; } + + // The produced resource object from Resource. For each resource, at most one ResourceObject and one tree node must exist. + public ResourceObject ResourceObject { get; } + + public ResourceObjectTreeNode(IIdentifiable resource, ResourceType resourceType, ResourceObject resourceObject) + { + ArgumentGuard.NotNull(resource, nameof(resource)); + ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + ArgumentGuard.NotNull(resourceObject, nameof(resourceObject)); + + Resource = resource; + ResourceType = resourceType; + ResourceObject = resourceObject; + } + + public static ResourceObjectTreeNode CreateRoot() + { + return new ResourceObjectTreeNode(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)); + + if (_childrenByRelationship == null) + { + throw new InvalidOperationException("Call EnsureHasRelationship() first."); + } + + 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.ResourceType.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 ? ResourceType.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..1d20316c1e --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs @@ -0,0 +1,365 @@ +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(); + + if (model is IEnumerable resources) + { + ResourceType resourceType = (_request.SecondaryResourceType ?? _request.PrimaryResourceType)!; + + 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) + { + ResourceType resourceType = (_request.SecondaryResourceType ?? _request.PrimaryResourceType)!; + + TraverseResource(resource, resourceType, _request.Kind, includeElements, rootNode, null); + PopulateRelationshipsInTree(rootNode, _request.Kind); + + ResourceObject resourceObject = rootNode.GetResponseData().Single(); + document.Data = new SingleOrManyData(resourceObject); + } + else if (model == null) + { + document.Data = new SingleOrManyData(null); + } + else if (model is IEnumerable operations) + { + using var _ = new RevertRequestStateOnDispose(_request, null); + document.Results = operations.Select(operation => ConvertOperation(operation, includeElements)).ToList(); + } + else if (model is IEnumerable errorObjects) + { + document.Errors = errorObjects.ToArray(); + } + else if (model is ErrorObject errorObject) + { + document.Errors = errorObject.AsArray(); + } + else + { + throw new InvalidOperationException("Data being returned must be resources, operations, errors or null."); + } + + document.JsonApi = GetApiObject(); + document.Links = _linkBuilder.GetTopLevelLinks(); + document.Meta = _metaBuilder.Build(); + document.Included = GetIncluded(rootNode); + + 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 resourceType, EndpointKind kind, + IImmutableSet includeElements, ResourceObjectTreeNode parentTreeNode, RelationshipAttribute? parentRelationship) + { + ResourceObjectTreeNode treeNode = GetOrCreateTreeNode(resource, resourceType, 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 resourceType, EndpointKind kind) + { + if (!_resourceToTreeNodeCache.TryGetValue(resource, out ResourceObjectTreeNode? treeNode)) + { + ResourceObject resourceObject = ConvertResource(resource, resourceType, kind); + treeNode = new ResourceObjectTreeNode(resource, resourceType, resourceObject); + + _resourceToTreeNodeCache.Add(resource, treeNode); + } + + return treeNode; + } + + protected virtual ResourceObject ConvertResource(IIdentifiable resource, ResourceType resourceType, EndpointKind kind) + { + bool isRelationship = kind == EndpointKind.Relationship; + + if (!isRelationship) + { + _resourceDefinitionAccessor.OnSerialize(resource); + } + + var resourceObject = new ResourceObject + { + Type = resourceType.PublicName, + Id = resource.StringId + }; + + if (!isRelationship) + { + IImmutableSet fieldSet = _sparseFieldSetCache.GetSparseFieldSetForSerializer(resourceType); + + resourceObject.Attributes = ConvertAttributes(resource, resourceType, fieldSet); + resourceObject.Links = _linkBuilder.GetResourceLinks(resourceType, resource); + resourceObject.Meta = _resourceDefinitionAccessor.GetMeta(resourceType, 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.ResourceType); + + foreach (RelationshipAttribute relationship in treeNode.ResourceType.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.ResourceType.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/IAddToRelationshipService.cs b/src/JsonApiDotNetCore/Services/IAddToRelationshipService.cs index 2fb6f82e8a..bc0ae069ff 100644 --- a/src/JsonApiDotNetCore/Services/IAddToRelationshipService.cs +++ b/src/JsonApiDotNetCore/Services/IAddToRelationshipService.cs @@ -8,12 +8,6 @@ namespace JsonApiDotNetCore.Services { - /// - public interface IAddToRelationshipService : IAddToRelationshipService - where TResource : class, IIdentifiable - { - } - /// [PublicAPI] public interface IAddToRelationshipService diff --git a/src/JsonApiDotNetCore/Services/ICreateService.cs b/src/JsonApiDotNetCore/Services/ICreateService.cs index af735de513..56286f9fe7 100644 --- a/src/JsonApiDotNetCore/Services/ICreateService.cs +++ b/src/JsonApiDotNetCore/Services/ICreateService.cs @@ -4,12 +4,6 @@ namespace JsonApiDotNetCore.Services { - /// - public interface ICreateService : ICreateService - where TResource : class, IIdentifiable - { - } - /// public interface ICreateService where TResource : class, IIdentifiable @@ -17,6 +11,6 @@ public interface ICreateService /// /// Handles a JSON:API request to create a new resource with attributes, relationships or both. /// - Task CreateAsync(TResource resource, CancellationToken cancellationToken); + Task CreateAsync(TResource resource, CancellationToken cancellationToken); } } diff --git a/src/JsonApiDotNetCore/Services/IDeleteService.cs b/src/JsonApiDotNetCore/Services/IDeleteService.cs index b3a801208d..a509f10caa 100644 --- a/src/JsonApiDotNetCore/Services/IDeleteService.cs +++ b/src/JsonApiDotNetCore/Services/IDeleteService.cs @@ -6,12 +6,6 @@ namespace JsonApiDotNetCore.Services { - /// - public interface IDeleteService : IDeleteService - where TResource : class, IIdentifiable - { - } - /// public interface IDeleteService where TResource : class, IIdentifiable diff --git a/src/JsonApiDotNetCore/Services/IGetAllService.cs b/src/JsonApiDotNetCore/Services/IGetAllService.cs index bab5aeab31..a81caadd80 100644 --- a/src/JsonApiDotNetCore/Services/IGetAllService.cs +++ b/src/JsonApiDotNetCore/Services/IGetAllService.cs @@ -5,12 +5,6 @@ namespace JsonApiDotNetCore.Services { - /// - public interface IGetAllService : IGetAllService - where TResource : class, IIdentifiable - { - } - /// public interface IGetAllService where TResource : class, IIdentifiable diff --git a/src/JsonApiDotNetCore/Services/IGetByIdService.cs b/src/JsonApiDotNetCore/Services/IGetByIdService.cs index d383cf7afc..3d942e15ce 100644 --- a/src/JsonApiDotNetCore/Services/IGetByIdService.cs +++ b/src/JsonApiDotNetCore/Services/IGetByIdService.cs @@ -4,12 +4,6 @@ namespace JsonApiDotNetCore.Services { - /// - public interface IGetByIdService : IGetByIdService - where TResource : class, IIdentifiable - { - } - /// public interface IGetByIdService where TResource : class, IIdentifiable diff --git a/src/JsonApiDotNetCore/Services/IGetRelationshipService.cs b/src/JsonApiDotNetCore/Services/IGetRelationshipService.cs index 191457172d..d57962b72d 100644 --- a/src/JsonApiDotNetCore/Services/IGetRelationshipService.cs +++ b/src/JsonApiDotNetCore/Services/IGetRelationshipService.cs @@ -6,12 +6,6 @@ namespace JsonApiDotNetCore.Services { - /// - public interface IGetRelationshipService : IGetRelationshipService - where TResource : class, IIdentifiable - { - } - /// public interface IGetRelationshipService where TResource : class, IIdentifiable @@ -19,6 +13,6 @@ public interface IGetRelationshipService /// /// Handles a JSON:API request to retrieve a single relationship. /// - Task GetRelationshipAsync(TId id, string relationshipName, CancellationToken cancellationToken); + Task GetRelationshipAsync(TId id, string relationshipName, CancellationToken cancellationToken); } } diff --git a/src/JsonApiDotNetCore/Services/IGetSecondaryService.cs b/src/JsonApiDotNetCore/Services/IGetSecondaryService.cs index 949de5a5ac..1820f435bd 100644 --- a/src/JsonApiDotNetCore/Services/IGetSecondaryService.cs +++ b/src/JsonApiDotNetCore/Services/IGetSecondaryService.cs @@ -6,12 +6,6 @@ namespace JsonApiDotNetCore.Services { - /// - public interface IGetSecondaryService : IGetSecondaryService - where TResource : class, IIdentifiable - { - } - /// public interface IGetSecondaryService where TResource : class, IIdentifiable @@ -20,6 +14,6 @@ public interface IGetSecondaryService /// Handles a JSON:API request to retrieve a single resource or a collection of resources for a secondary endpoint, such as /articles/1/author or /// /articles/1/revisions. /// - Task GetSecondaryAsync(TId id, string relationshipName, CancellationToken cancellationToken); + Task GetSecondaryAsync(TId id, string relationshipName, CancellationToken cancellationToken); } } diff --git a/src/JsonApiDotNetCore/Services/IRemoveFromRelationshipService.cs b/src/JsonApiDotNetCore/Services/IRemoveFromRelationshipService.cs index 12d0889ce1..923753ba44 100644 --- a/src/JsonApiDotNetCore/Services/IRemoveFromRelationshipService.cs +++ b/src/JsonApiDotNetCore/Services/IRemoveFromRelationshipService.cs @@ -7,12 +7,6 @@ namespace JsonApiDotNetCore.Services { - /// - public interface IRemoveFromRelationshipService : IRemoveFromRelationshipService - where TResource : class, IIdentifiable - { - } - /// public interface IRemoveFromRelationshipService where TResource : class, IIdentifiable diff --git a/src/JsonApiDotNetCore/Services/IResourceCommandService.cs b/src/JsonApiDotNetCore/Services/IResourceCommandService.cs index 334fdb26fa..06ad5af8ca 100644 --- a/src/JsonApiDotNetCore/Services/IResourceCommandService.cs +++ b/src/JsonApiDotNetCore/Services/IResourceCommandService.cs @@ -2,19 +2,6 @@ namespace JsonApiDotNetCore.Services { - /// - /// Groups write operations. - /// - /// - /// The resource type. - /// - public interface IResourceCommandService - : ICreateService, IAddToRelationshipService, IUpdateService, ISetRelationshipService, - IDeleteService, IRemoveFromRelationshipService, IResourceCommandService - where TResource : class, IIdentifiable - { - } - /// /// Groups write operations. /// diff --git a/src/JsonApiDotNetCore/Services/IResourceQueryService.cs b/src/JsonApiDotNetCore/Services/IResourceQueryService.cs index 07c89e8643..55b210a7cc 100644 --- a/src/JsonApiDotNetCore/Services/IResourceQueryService.cs +++ b/src/JsonApiDotNetCore/Services/IResourceQueryService.cs @@ -2,19 +2,6 @@ namespace JsonApiDotNetCore.Services { - /// - /// Groups read operations. - /// - /// - /// The resource type. - /// - public interface IResourceQueryService - : IGetAllService, IGetByIdService, IGetRelationshipService, IGetSecondaryService, - IResourceQueryService - where TResource : class, IIdentifiable - { - } - /// /// Groups read operations. /// diff --git a/src/JsonApiDotNetCore/Services/IResourceService.cs b/src/JsonApiDotNetCore/Services/IResourceService.cs index eb1a744c1b..d6910c93b8 100644 --- a/src/JsonApiDotNetCore/Services/IResourceService.cs +++ b/src/JsonApiDotNetCore/Services/IResourceService.cs @@ -2,17 +2,6 @@ namespace JsonApiDotNetCore.Services { - /// - /// Represents the foundational Resource Service layer in the JsonApiDotNetCore architecture that uses a Resource Repository for data access. - /// - /// - /// The resource type. - /// - public interface IResourceService : IResourceCommandService, IResourceQueryService, IResourceService - where TResource : class, IIdentifiable - { - } - /// /// Represents the foundational Resource Service layer in the JsonApiDotNetCore architecture that uses a Resource Repository for data access. /// diff --git a/src/JsonApiDotNetCore/Services/ISetRelationshipService.cs b/src/JsonApiDotNetCore/Services/ISetRelationshipService.cs index 2afc8a175a..2f6f8aefad 100644 --- a/src/JsonApiDotNetCore/Services/ISetRelationshipService.cs +++ b/src/JsonApiDotNetCore/Services/ISetRelationshipService.cs @@ -6,12 +6,6 @@ namespace JsonApiDotNetCore.Services { - /// - public interface ISetRelationshipService : ISetRelationshipService - where TResource : class, IIdentifiable - { - } - /// public interface ISetRelationshipService where TResource : class, IIdentifiable @@ -31,6 +25,6 @@ public interface ISetRelationshipService /// /// Propagates notification that request handling should be canceled. /// - Task SetRelationshipAsync(TId leftId, string relationshipName, object rightValue, CancellationToken cancellationToken); + Task SetRelationshipAsync(TId leftId, string relationshipName, object? rightValue, CancellationToken cancellationToken); } } diff --git a/src/JsonApiDotNetCore/Services/IUpdateService.cs b/src/JsonApiDotNetCore/Services/IUpdateService.cs index 83e88f6e96..93bb79bca3 100644 --- a/src/JsonApiDotNetCore/Services/IUpdateService.cs +++ b/src/JsonApiDotNetCore/Services/IUpdateService.cs @@ -4,12 +4,6 @@ namespace JsonApiDotNetCore.Services { - /// - public interface IUpdateService : IUpdateService - where TResource : class, IIdentifiable - { - } - /// public interface IUpdateService where TResource : class, IIdentifiable @@ -18,6 +12,6 @@ public interface IUpdateService /// Handles a JSON:API request to update the attributes and/or relationships of an existing resource. Only the values of sent attributes are replaced. /// And only the values of sent relationships are replaced. /// - Task UpdateAsync(TId id, TResource resource, CancellationToken cancellationToken); + Task UpdateAsync(TId id, TResource resource, CancellationToken cancellationToken); } } diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 9b659a4cdf..dab5cd54f2 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -16,6 +16,7 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using Microsoft.Extensions.Logging; +using SysNotNull = System.Diagnostics.CodeAnalysis.NotNullAttribute; namespace JsonApiDotNetCore.Services { @@ -64,10 +65,12 @@ public virtual async Task> GetAsync(CancellationT using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Get resources"); + AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); + if (_options.IncludeTotalResourceCount) { - FilterExpression topFilter = _queryLayerComposer.GetTopFilterFromConstraints(_request.PrimaryResource); - _paginationContext.TotalResourceCount = await _repositoryAccessor.CountAsync(topFilter, cancellationToken); + FilterExpression? topFilter = _queryLayerComposer.GetPrimaryFilterFromConstraints(_request.PrimaryResourceType); + _paginationContext.TotalResourceCount = await _repositoryAccessor.CountAsync(_request.PrimaryResourceType, topFilter, cancellationToken); if (_paginationContext.TotalResourceCount == 0) { @@ -75,10 +78,10 @@ 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) + if (queryLayer.Pagination?.PageSize?.Value == resources.Count) { _paginationContext.IsPageFull = true; } @@ -100,7 +103,7 @@ public virtual async Task GetAsync(TId id, CancellationToken cancella } /// - public virtual async Task GetSecondaryAsync(TId id, string relationshipName, CancellationToken cancellationToken) + public virtual async Task GetSecondaryAsync(TId id, string relationshipName, CancellationToken cancellationToken) { _traceWriter.LogMethodStart(new { @@ -110,17 +113,28 @@ public virtual async Task GetSecondaryAsync(TId id, string relationshipN using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Get secondary resource(s)"); + AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); AssertHasRelationship(_request.Relationship, relationshipName); - QueryLayer secondaryLayer = _queryLayerComposer.ComposeFromConstraints(_request.SecondaryResource); - QueryLayer primaryLayer = _queryLayerComposer.WrapLayerForSecondaryEndpoint(secondaryLayer, _request.PrimaryResource, id, _request.Relationship); + if (_options.IncludeTotalResourceCount && _request.IsCollection) + { + await RetrieveResourceCountForNonPrimaryEndpointAsync(id, (HasManyAttribute)_request.Relationship, cancellationToken); + + // We cannot return early when _paginationContext.TotalResourceCount == 0, because we don't know whether + // the parent resource exists. In case the parent does not exist, an error is produced below. + } + + QueryLayer secondaryLayer = _queryLayerComposer.ComposeFromConstraints(_request.SecondaryResourceType!); + + QueryLayer primaryLayer = + _queryLayerComposer.WrapLayerForSecondaryEndpoint(secondaryLayer, _request.PrimaryResourceType, id, _request.Relationship); IReadOnlyCollection primaryResources = await _repositoryAccessor.GetAsync(primaryLayer, cancellationToken); - TResource primaryResource = primaryResources.SingleOrDefault(); + TResource? primaryResource = primaryResources.SingleOrDefault(); AssertPrimaryResourceExists(primaryResource); - object rightValue = _request.Relationship.GetValue(primaryResource); + object? rightValue = _request.Relationship.GetValue(primaryResource); if (rightValue is ICollection rightResources && secondaryLayer.Pagination?.PageSize?.Value == rightResources.Count) { @@ -131,7 +145,7 @@ public virtual async Task GetSecondaryAsync(TId id, string relationshipN } /// - public virtual async Task GetRelationshipAsync(TId id, string relationshipName, CancellationToken cancellationToken) + public virtual async Task GetRelationshipAsync(TId id, string relationshipName, CancellationToken cancellationToken) { _traceWriter.LogMethodStart(new { @@ -143,21 +157,49 @@ public virtual async Task GetRelationshipAsync(TId id, string relationsh using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Get relationship"); + AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); AssertHasRelationship(_request.Relationship, relationshipName); - QueryLayer secondaryLayer = _queryLayerComposer.ComposeSecondaryLayerForRelationship(_request.SecondaryResource); - QueryLayer primaryLayer = _queryLayerComposer.WrapLayerForSecondaryEndpoint(secondaryLayer, _request.PrimaryResource, id, _request.Relationship); + if (_options.IncludeTotalResourceCount && _request.IsCollection) + { + await RetrieveResourceCountForNonPrimaryEndpointAsync(id, (HasManyAttribute)_request.Relationship, cancellationToken); + + // We cannot return early when _paginationContext.TotalResourceCount == 0, because we don't know whether + // the parent resource exists. In case the parent does not exist, an error is produced below. + } + + QueryLayer secondaryLayer = _queryLayerComposer.ComposeSecondaryLayerForRelationship(_request.SecondaryResourceType!); + + QueryLayer primaryLayer = + _queryLayerComposer.WrapLayerForSecondaryEndpoint(secondaryLayer, _request.PrimaryResourceType, id, _request.Relationship); IReadOnlyCollection primaryResources = await _repositoryAccessor.GetAsync(primaryLayer, cancellationToken); - TResource primaryResource = primaryResources.SingleOrDefault(); + TResource? primaryResource = primaryResources.SingleOrDefault(); AssertPrimaryResourceExists(primaryResource); - return _request.Relationship.GetValue(primaryResource); + object? rightValue = _request.Relationship.GetValue(primaryResource); + + if (rightValue is ICollection rightResources && secondaryLayer.Pagination?.PageSize?.Value == rightResources.Count) + { + _paginationContext.IsPageFull = true; + } + + return rightValue; + } + + private async Task RetrieveResourceCountForNonPrimaryEndpointAsync(TId id, HasManyAttribute relationship, CancellationToken cancellationToken) + { + FilterExpression? secondaryFilter = _queryLayerComposer.GetSecondaryFilterFromConstraints(id, relationship); + + if (secondaryFilter != null) + { + _paginationContext.TotalResourceCount = await _repositoryAccessor.CountAsync(relationship.RightType, secondaryFilter, cancellationToken); + } } /// - public virtual async Task CreateAsync(TResource resource, CancellationToken cancellationToken) + public virtual async Task CreateAsync(TResource resource, CancellationToken cancellationToken) { _traceWriter.LogMethodStart(new { @@ -165,6 +207,7 @@ public virtual async Task CreateAsync(TResource resource, Cancellatio }); ArgumentGuard.NotNull(resource, nameof(resource)); + AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Create resource"); @@ -183,17 +226,7 @@ public virtual async Task CreateAsync(TResource resource, Cancellatio } catch (DataStoreUpdateException) { - if (!Equals(resourceFromRequest.Id, default(TId))) - { - TResource existingResource = - await TryGetPrimaryResourceByIdAsync(resourceFromRequest.Id, TopFieldSelection.OnlyIdAttribute, cancellationToken); - - if (existingResource != null) - { - throw new ResourceAlreadyExistsException(resourceFromRequest.StringId, _request.PrimaryResource.PublicName); - } - } - + await AssertPrimaryResourceDoesNotExistAsync(resourceFromRequest, cancellationToken); await AssertResourcesToAssignInRelationshipsExistAsync(resourceFromRequest, cancellationToken); throw; } @@ -206,6 +239,19 @@ public virtual async Task CreateAsync(TResource resource, Cancellatio return hasImplicitChanges ? resourceFromDatabase : null; } + protected async Task AssertPrimaryResourceDoesNotExistAsync(TResource resource, CancellationToken cancellationToken) + { + if (!Equals(resource.Id, default(TId))) + { + TResource? existingResource = await GetPrimaryResourceByIdOrDefaultAsync(resource.Id, TopFieldSelection.OnlyIdAttribute, cancellationToken); + + if (existingResource != null) + { + throw new ResourceAlreadyExistsException(resource.StringId!, _request.PrimaryResourceType!.PublicName); + } + } + } + protected virtual async Task InitializeResourceAsync(TResource resourceForDatabase, CancellationToken cancellationToken) { await _resourceDefinitionAccessor.OnPrepareWriteAsync(resourceForDatabase, WriteOperationKind.CreateResource, cancellationToken); @@ -218,7 +264,7 @@ protected async Task AssertResourcesToAssignInRelationshipsExistAsync(TResource foreach ((QueryLayer queryLayer, RelationshipAttribute relationship) in _queryLayerComposer.ComposeForGetTargetedSecondaryResourceIds( primaryResource)) { - object rightValue = relationship.GetValue(primaryResource); + object? rightValue = relationship.GetValue(primaryResource); ICollection rightResourceIds = _collectionConverter.ExtractResources(rightValue); IAsyncEnumerable missingResourcesInRelationship = @@ -236,17 +282,17 @@ 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(); + string[] existingResourceIds = existingResources.Select(resource => resource.StringId!).ToArray(); foreach (IIdentifiable rightResourceId in rightResourceIds) { if (!existingResourceIds.Contains(rightResourceId.StringId)) { - yield return new MissingResourceInRelationship(relationship.PublicName, existingRightResourceIdsQueryLayer.ResourceContext.PublicName, - rightResourceId.StringId); + yield return new MissingResourceInRelationship(relationship.PublicName, existingRightResourceIdsQueryLayer.ResourceType.PublicName, + rightResourceId.StringId!); } } } @@ -290,9 +336,11 @@ public virtual async Task AddToToManyRelationshipAsync(TId leftId, string relati private async Task RemoveExistingIdsFromRelationshipRightSideAsync(HasManyAttribute hasManyRelationship, TId leftId, ISet rightResourceIds, CancellationToken cancellationToken) { + AssertRelationshipInJsonApiRequestIsNotNull(_request.Relationship); + TResource leftResource = await GetForHasManyUpdateAsync(hasManyRelationship, leftId, rightResourceIds, cancellationToken); - object rightValue = _request.Relationship.GetValue(leftResource); + object? rightValue = _request.Relationship.GetValue(leftResource); ICollection existingRightResourceIds = _collectionConverter.ExtractResources(rightValue); rightResourceIds.ExceptWith(existingRightResourceIds); @@ -302,16 +350,16 @@ private async Task GetForHasManyUpdateAsync(HasManyAttribute hasManyR CancellationToken cancellationToken) { QueryLayer queryLayer = _queryLayerComposer.ComposeForHasMany(hasManyRelationship, leftId, rightResourceIds); - IReadOnlyCollection leftResources = await _repositoryAccessor.GetAsync(queryLayer, cancellationToken); - - TResource leftResource = leftResources.FirstOrDefault(); + var leftResource = await _repositoryAccessor.GetForUpdateAsync(queryLayer, cancellationToken); AssertPrimaryResourceExists(leftResource); return leftResource; } - protected async Task AssertRightResourcesExistAsync(object rightValue, CancellationToken cancellationToken) + protected async Task AssertRightResourcesExistAsync(object? rightValue, CancellationToken cancellationToken) { + AssertRelationshipInJsonApiRequestIsNotNull(_request.Relationship); + ICollection rightResourceIds = _collectionConverter.ExtractResources(rightValue); if (rightResourceIds.Any()) @@ -329,7 +377,7 @@ protected async Task AssertRightResourcesExistAsync(object rightValue, Cancellat } /// - public virtual async Task UpdateAsync(TId id, TResource resource, CancellationToken cancellationToken) + public virtual async Task UpdateAsync(TId id, TResource resource, CancellationToken cancellationToken) { _traceWriter.LogMethodStart(new { @@ -369,7 +417,7 @@ public virtual async Task UpdateAsync(TId id, TResource resource, Can } /// - public virtual async Task SetRelationshipAsync(TId leftId, string relationshipName, object rightValue, CancellationToken cancellationToken) + public virtual async Task SetRelationshipAsync(TId leftId, string relationshipName, object? rightValue, CancellationToken cancellationToken) { _traceWriter.LogMethodStart(new { @@ -448,15 +496,17 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TId leftId, string r protected async Task GetPrimaryResourceByIdAsync(TId id, TopFieldSelection fieldSelection, CancellationToken cancellationToken) { - TResource primaryResource = await TryGetPrimaryResourceByIdAsync(id, fieldSelection, cancellationToken); + TResource? primaryResource = await GetPrimaryResourceByIdOrDefaultAsync(id, fieldSelection, cancellationToken); AssertPrimaryResourceExists(primaryResource); return primaryResource; } - private async Task TryGetPrimaryResourceByIdAsync(TId id, TopFieldSelection fieldSelection, CancellationToken cancellationToken) + private async Task GetPrimaryResourceByIdOrDefaultAsync(TId id, TopFieldSelection fieldSelection, CancellationToken cancellationToken) { - QueryLayer primaryLayer = _queryLayerComposer.ComposeForGetById(id, _request.PrimaryResource, fieldSelection); + AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); + + QueryLayer primaryLayer = _queryLayerComposer.ComposeForGetById(id, _request.PrimaryResourceType, fieldSelection); IReadOnlyCollection primaryResources = await _repositoryAccessor.GetAsync(primaryLayer, cancellationToken); return primaryResources.SingleOrDefault(); @@ -464,48 +514,52 @@ private async Task TryGetPrimaryResourceByIdAsync(TId id, TopFieldSel protected async Task GetPrimaryResourceForUpdateAsync(TId id, CancellationToken cancellationToken) { - QueryLayer queryLayer = _queryLayerComposer.ComposeForUpdate(id, _request.PrimaryResource); - var resource = await _repositoryAccessor.GetForUpdateAsync(queryLayer, cancellationToken); + AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); + QueryLayer queryLayer = _queryLayerComposer.ComposeForUpdate(id, _request.PrimaryResourceType); + var resource = await _repositoryAccessor.GetForUpdateAsync(queryLayer, cancellationToken); AssertPrimaryResourceExists(resource); + return resource; } [AssertionMethod] - private void AssertPrimaryResourceExists(TResource resource) + private void AssertPrimaryResourceExists([SysNotNull] TResource? resource) { + AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); + if (resource == null) { - throw new ResourceNotFoundException(_request.PrimaryId, _request.PrimaryResource.PublicName); + throw new ResourceNotFoundException(_request.PrimaryId!, _request.PrimaryResourceType.PublicName); } } [AssertionMethod] - private void AssertHasRelationship(RelationshipAttribute relationship, string name) + private void AssertHasRelationship([SysNotNull] RelationshipAttribute? relationship, string name) { if (relationship == null) { - throw new RelationshipNotFoundException(name, _request.PrimaryResource.PublicName); + throw new RelationshipNotFoundException(name, _request.PrimaryResourceType!.PublicName); } } - } - /// - /// Represents the foundational Resource Service layer in the JsonApiDotNetCore architecture that uses a Resource Repository for data access. - /// - /// - /// The resource type. - /// - [PublicAPI] - public class JsonApiResourceService : JsonApiResourceService, IResourceService - where TResource : class, IIdentifiable - { - public JsonApiResourceService(IResourceRepositoryAccessor repositoryAccessor, IQueryLayerComposer queryLayerComposer, - IPaginationContext paginationContext, IJsonApiOptions options, ILoggerFactory loggerFactory, IJsonApiRequest request, - IResourceChangeTracker resourceChangeTracker, IResourceDefinitionAccessor resourceDefinitionAccessor) - : base(repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, resourceChangeTracker, - resourceDefinitionAccessor) + [AssertionMethod] + private void AssertPrimaryResourceTypeInJsonApiRequestIsNotNull([SysNotNull] ResourceType? resourceType) { + if (resourceType == null) + { + throw new InvalidOperationException( + $"Expected {nameof(IJsonApiRequest)}.{nameof(IJsonApiRequest.PrimaryResourceType)} not to be null at this point."); + } + } + + [AssertionMethod] + private void AssertRelationshipInJsonApiRequestIsNotNull([SysNotNull] RelationshipAttribute? relationship) + { + if (relationship == null) + { + throw new InvalidOperationException($"Expected {nameof(IJsonApiRequest)}.{nameof(IJsonApiRequest.Relationship)} not to be null at this point."); + } } } } diff --git a/src/JsonApiDotNetCore/TypeExtensions.cs b/src/JsonApiDotNetCore/TypeExtensions.cs index 15713ac5fa..18e6dc6967 100644 --- a/src/JsonApiDotNetCore/TypeExtensions.cs +++ b/src/JsonApiDotNetCore/TypeExtensions.cs @@ -8,7 +8,15 @@ internal static class TypeExtensions /// /// Whether the specified source type implements or equals the specified interface. /// - public static bool IsOrImplementsInterface(this Type source, Type interfaceType) + public static bool IsOrImplementsInterface(this Type? source) + { + return IsOrImplementsInterface(source, typeof(TInterface)); + } + + /// + /// Whether the specified source type implements or equals the specified interface. This overload enables to test for an open generic interface. + /// + private static bool IsOrImplementsInterface(this Type? source, Type interfaceType) { ArgumentGuard.NotNull(interfaceType, nameof(interfaceType)); @@ -17,7 +25,13 @@ public static bool IsOrImplementsInterface(this Type source, Type interfaceType) return false; } - return source == interfaceType || source.GetInterfaces().Any(type => type == interfaceType); + return AreTypesEqual(interfaceType, source, interfaceType.IsGenericType) || + source.GetInterfaces().Any(type => AreTypesEqual(interfaceType, type, interfaceType.IsGenericType)); + } + + private static bool AreTypesEqual(Type left, Type right, bool isLeftGeneric) + { + return isLeftGeneric ? right.IsGenericType && right.GetGenericTypeDefinition() == left : left == right; } /// @@ -39,7 +53,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/TestResource.cs b/test/DiscoveryTests/PrivateResource.cs similarity index 72% rename from test/DiscoveryTests/TestResource.cs rename to test/DiscoveryTests/PrivateResource.cs index f394c920b0..065c63afbd 100644 --- a/test/DiscoveryTests/TestResource.cs +++ b/test/DiscoveryTests/PrivateResource.cs @@ -4,7 +4,7 @@ namespace DiscoveryTests { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class TestResource : Identifiable + public sealed class PrivateResource : Identifiable { } } diff --git a/test/DiscoveryTests/TestResourceDefinition.cs b/test/DiscoveryTests/PrivateResourceDefinition.cs similarity index 62% rename from test/DiscoveryTests/TestResourceDefinition.cs rename to test/DiscoveryTests/PrivateResourceDefinition.cs index f327916d4f..b3a33f556c 100644 --- a/test/DiscoveryTests/TestResourceDefinition.cs +++ b/test/DiscoveryTests/PrivateResourceDefinition.cs @@ -5,9 +5,9 @@ namespace DiscoveryTests { [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class TestResourceDefinition : JsonApiResourceDefinition + public sealed class PrivateResourceDefinition : JsonApiResourceDefinition { - public TestResourceDefinition(IResourceGraph resourceGraph) + public PrivateResourceDefinition(IResourceGraph resourceGraph) : base(resourceGraph) { } diff --git a/test/DiscoveryTests/TestResourceRepository.cs b/test/DiscoveryTests/PrivateResourceRepository.cs similarity index 59% rename from test/DiscoveryTests/TestResourceRepository.cs rename to test/DiscoveryTests/PrivateResourceRepository.cs index 096da8abd9..1d5b4a4a4e 100644 --- a/test/DiscoveryTests/TestResourceRepository.cs +++ b/test/DiscoveryTests/PrivateResourceRepository.cs @@ -9,12 +9,12 @@ namespace DiscoveryTests { [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class TestResourceRepository : EntityFrameworkCoreRepository + public sealed class PrivateResourceRepository : EntityFrameworkCoreRepository { - public TestResourceRepository(ITargetedFields targetedFields, IDbContextResolver contextResolver, IResourceGraph resourceGraph, + public PrivateResourceRepository(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/DiscoveryTests/TestResourceService.cs b/test/DiscoveryTests/PrivateResourceService.cs similarity index 55% rename from test/DiscoveryTests/TestResourceService.cs rename to test/DiscoveryTests/PrivateResourceService.cs index f2d565ba4d..47df356881 100644 --- a/test/DiscoveryTests/TestResourceService.cs +++ b/test/DiscoveryTests/PrivateResourceService.cs @@ -10,11 +10,11 @@ namespace DiscoveryTests { [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class TestResourceService : JsonApiResourceService + public sealed class PrivateResourceService : JsonApiResourceService { - public TestResourceService(IResourceRepositoryAccessor repositoryAccessor, IQueryLayerComposer queryLayerComposer, IPaginationContext paginationContext, - IJsonApiOptions options, ILoggerFactory loggerFactory, IJsonApiRequest request, IResourceChangeTracker resourceChangeTracker, - IResourceDefinitionAccessor resourceDefinitionAccessor) + public PrivateResourceService(IResourceRepositoryAccessor repositoryAccessor, IQueryLayerComposer queryLayerComposer, + IPaginationContext paginationContext, IJsonApiOptions options, ILoggerFactory loggerFactory, IJsonApiRequest request, + IResourceChangeTracker resourceChangeTracker, IResourceDefinitionAccessor resourceDefinitionAccessor) : base(repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, resourceChangeTracker, resourceDefinitionAccessor) { diff --git a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs index a5a2f9fd77..668cf7d66f 100644 --- a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs +++ b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs @@ -11,15 +11,15 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Moq; +using TestBuildingBlocks; using Xunit; namespace DiscoveryTests { public sealed class ServiceDiscoveryFacadeTests { - private static readonly NullLoggerFactory LoggerFactory = NullLoggerFactory.Instance; + private static readonly ILoggerFactory LoggerFactory = NullLoggerFactory.Instance; private readonly IServiceCollection _services = new ServiceCollection(); - private readonly JsonApiOptions _options = new(); private readonly ResourceGraphBuilder _resourceGraphBuilder; public ServiceDiscoveryFacadeTests() @@ -28,8 +28,10 @@ public ServiceDiscoveryFacadeTests() dbResolverMock.Setup(resolver => resolver.GetContext()).Returns(new Mock().Object); _services.AddScoped(_ => dbResolverMock.Object); - _services.AddSingleton(_options); - _services.AddSingleton(LoggerFactory); + IJsonApiOptions options = new JsonApiOptions(); + + _services.AddSingleton(options); + _services.AddSingleton(LoggerFactory); _services.AddScoped(_ => new Mock().Object); _services.AddScoped(_ => new Mock().Object); _services.AddScoped(_ => new Mock().Object); @@ -40,7 +42,7 @@ public ServiceDiscoveryFacadeTests() _services.AddScoped(_ => new Mock().Object); _services.AddScoped(_ => new Mock().Object); - _resourceGraphBuilder = new ResourceGraphBuilder(_options, LoggerFactory); + _resourceGraphBuilder = new ResourceGraphBuilder(options, LoggerFactory); } [Fact] @@ -56,11 +58,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.FindResourceType(typeof(Person)); + personType.ShouldNotBeNull(); - ResourceContext todoItemContext = resourceGraph.TryGetResourceContext(typeof(TodoItem)); - todoItemContext.Should().NotBeNull(); + ResourceType? todoItemType = resourceGraph.FindResourceType(typeof(TodoItem)); + todoItemType.ShouldNotBeNull(); } [Fact] @@ -76,8 +78,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.FindResourceType(typeof(PrivateResource)); + testResourceType.ShouldNotBeNull(); } [Fact] @@ -93,8 +95,8 @@ public void Can_add_resource_service_from_current_assembly_to_container() // Assert ServiceProvider services = _services.BuildServiceProvider(); - var resourceService = services.GetRequiredService>(); - resourceService.Should().BeOfType(); + var resourceService = services.GetRequiredService>(); + resourceService.Should().BeOfType(); } [Fact] @@ -110,8 +112,8 @@ public void Can_add_resource_repository_from_current_assembly_to_container() // Assert ServiceProvider services = _services.BuildServiceProvider(); - var resourceRepository = services.GetRequiredService>(); - resourceRepository.Should().BeOfType(); + var resourceRepository = services.GetRequiredService>(); + resourceRepository.Should().BeOfType(); } [Fact] @@ -127,8 +129,8 @@ public void Can_add_resource_definition_from_current_assembly_to_container() // Assert ServiceProvider services = _services.BuildServiceProvider(); - var resourceDefinition = services.GetRequiredService>(); - resourceDefinition.Should().BeOfType(); + var resourceDefinition = services.GetRequiredService>(); + resourceDefinition.Should().BeOfType(); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/ArchiveTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/ArchiveTests.cs index 2634ffae2a..039cd0840e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/ArchiveTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/ArchiveTests.cs @@ -52,9 +52,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Id.Should().Be(broadcast.StringId); - responseDocument.Data.SingleValue.Attributes["archivedAt"].As().Should().BeCloseTo(broadcast.ArchivedAt.GetValueOrDefault()); + + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("archivedAt") + .With(value => value.As().Should().BeCloseTo(broadcast.ArchivedAt!.Value)); } [Fact] @@ -78,9 +80,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Id.Should().Be(broadcast.StringId); - responseDocument.Data.SingleValue.Attributes["archivedAt"].As().Should().BeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("archivedAt").With(value => value.As().Should().BeNull()); } [Fact] @@ -105,9 +107,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue.ShouldHaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(broadcasts[1].StringId); - responseDocument.Data.ManyValue[0].Attributes["archivedAt"].As().Should().BeNull(); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("archivedAt").With(value => value.As().Should().BeNull()); } [Fact] @@ -132,11 +134,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(2); + responseDocument.Data.ManyValue.ShouldHaveCount(2); responseDocument.Data.ManyValue[0].Id.Should().Be(broadcasts[0].StringId); - responseDocument.Data.ManyValue[0].Attributes["archivedAt"].As().Should().BeCloseTo(broadcasts[0].ArchivedAt.GetValueOrDefault()); + + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("archivedAt") + .With(value => value.As().Should().BeCloseTo(broadcasts[0].ArchivedAt!.Value)); + responseDocument.Data.ManyValue[1].Id.Should().Be(broadcasts[1].StringId); - responseDocument.Data.ManyValue[1].Attributes["archivedAt"].Should().BeNull(); + responseDocument.Data.ManyValue[1].Attributes.ShouldContainKey("archivedAt").With(value => value.Should().BeNull()); } [Fact] @@ -161,12 +166,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Id.Should().Be(station.StringId); - responseDocument.Included.Should().HaveCount(1); + responseDocument.Included.ShouldHaveCount(1); responseDocument.Included[0].Id.Should().Be(station.Broadcasts.ElementAt(1).StringId); - responseDocument.Included[0].Attributes["archivedAt"].Should().BeNull(); + responseDocument.Included[0].Attributes.ShouldContainKey("archivedAt").With(value => value.Should().BeNull()); } [Fact] @@ -192,16 +197,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - DateTimeOffset archivedAt0 = station.Broadcasts.ElementAt(0).ArchivedAt.GetValueOrDefault(); + DateTimeOffset archivedAt0 = station.Broadcasts.ElementAt(0).ArchivedAt!.Value; - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Id.Should().Be(station.StringId); - responseDocument.Included.Should().HaveCount(2); + responseDocument.Included.ShouldHaveCount(2); responseDocument.Included[0].Id.Should().Be(station.Broadcasts.ElementAt(0).StringId); - responseDocument.Included[0].Attributes["archivedAt"].As().Should().BeCloseTo(archivedAt0); + responseDocument.Included[0].Attributes.ShouldContainKey("archivedAt").With(value => value.As().Should().BeCloseTo(archivedAt0)); responseDocument.Included[1].Id.Should().Be(station.Broadcasts.ElementAt(1).StringId); - responseDocument.Included[1].Attributes["archivedAt"].Should().BeNull(); + responseDocument.Included[1].Attributes.ShouldContainKey("archivedAt").With(value => value.Should().BeNull()); } [Fact] @@ -225,11 +230,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - DateTimeOffset archivedAt = comment.AppliesTo.ArchivedAt.GetValueOrDefault(); - - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Id.Should().Be(comment.AppliesTo.StringId); - responseDocument.Data.SingleValue.Attributes["archivedAt"].As().Should().BeCloseTo(archivedAt); + + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("archivedAt") + .With(value => value.As().Should().BeCloseTo(comment.AppliesTo.ArchivedAt!.Value)); } [Fact] @@ -254,9 +259,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue.ShouldHaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(station.Broadcasts.ElementAt(1).StringId); - responseDocument.Data.ManyValue[0].Attributes["archivedAt"].Should().BeNull(); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("archivedAt").With(value => value.Should().BeNull()); } [Fact] @@ -281,13 +286,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - DateTimeOffset archivedAt0 = station.Broadcasts.ElementAt(0).ArchivedAt.GetValueOrDefault(); - - responseDocument.Data.ManyValue.Should().HaveCount(2); + responseDocument.Data.ManyValue.ShouldHaveCount(2); responseDocument.Data.ManyValue[0].Id.Should().Be(station.Broadcasts.ElementAt(0).StringId); - responseDocument.Data.ManyValue[0].Attributes["archivedAt"].As().Should().BeCloseTo(archivedAt0); + + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("archivedAt").With(value => + value.As().Should().BeCloseTo(station.Broadcasts.ElementAt(0).ArchivedAt!.Value)); + responseDocument.Data.ManyValue[1].Id.Should().Be(station.Broadcasts.ElementAt(1).StringId); - responseDocument.Data.ManyValue[1].Attributes["archivedAt"].Should().BeNull(); + responseDocument.Data.ManyValue[1].Attributes.ShouldContainKey("archivedAt").With(value => value.Should().BeNull()); } [Fact] @@ -313,12 +319,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue.ShouldHaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(network.Stations.ElementAt(0).StringId); - responseDocument.Included.Should().HaveCount(1); + responseDocument.Included.ShouldHaveCount(1); responseDocument.Included[0].Id.Should().Be(network.Stations.ElementAt(0).Broadcasts.ElementAt(1).StringId); - responseDocument.Included[0].Attributes["archivedAt"].Should().BeNull(); + responseDocument.Included[0].Attributes.ShouldContainKey("archivedAt").With(value => value.Should().BeNull()); } [Fact] @@ -344,16 +350,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - DateTimeOffset archivedAt0 = network.Stations.ElementAt(0).Broadcasts.ElementAt(0).ArchivedAt.GetValueOrDefault(); + DateTimeOffset archivedAt0 = network.Stations.ElementAt(0).Broadcasts.ElementAt(0).ArchivedAt!.Value; - responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue.ShouldHaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(network.Stations.ElementAt(0).StringId); - responseDocument.Included.Should().HaveCount(2); + responseDocument.Included.ShouldHaveCount(2); responseDocument.Included[0].Id.Should().Be(network.Stations.ElementAt(0).Broadcasts.ElementAt(0).StringId); - responseDocument.Included[0].Attributes["archivedAt"].As().Should().BeCloseTo(archivedAt0); + responseDocument.Included[0].Attributes.ShouldContainKey("archivedAt").With(value => value.As().Should().BeCloseTo(archivedAt0)); responseDocument.Included[1].Id.Should().Be(network.Stations.ElementAt(0).Broadcasts.ElementAt(1).StringId); - responseDocument.Included[1].Attributes["archivedAt"].Should().BeNull(); + responseDocument.Included[1].Attributes.ShouldContainKey("archivedAt").With(value => value.Should().BeNull()); } [Fact] @@ -378,7 +384,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue.ShouldHaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(station.Broadcasts.ElementAt(1).StringId); } @@ -404,7 +410,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(2); + responseDocument.Data.ManyValue.ShouldHaveCount(2); responseDocument.Data.ManyValue[0].Id.Should().Be(station.Broadcasts.ElementAt(0).StringId); responseDocument.Data.ManyValue[1].Id.Should().Be(station.Broadcasts.ElementAt(1).StringId); } @@ -436,10 +442,13 @@ public async Task Can_create_unarchived_resource() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.Should().NotBeNull(); - responseDocument.Data.SingleValue.Attributes["title"].Should().Be(newBroadcast.Title); - responseDocument.Data.SingleValue.Attributes["airedAt"].As().Should().BeCloseTo(newBroadcast.AiredAt); - responseDocument.Data.SingleValue.Attributes["archivedAt"].Should().BeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newBroadcast.Title)); + + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("airedAt") + .With(value => value.As().Should().BeCloseTo(newBroadcast.AiredAt)); + + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("archivedAt").With(value => value.Should().BeNull()); } [Fact] @@ -470,7 +479,7 @@ public async Task Cannot_create_archived_resource() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.Forbidden); @@ -602,7 +611,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.Forbidden); @@ -634,7 +643,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - TelevisionBroadcast broadcastInDatabase = await dbContext.Broadcasts.FirstWithIdOrDefaultAsync(broadcast.Id); + TelevisionBroadcast? broadcastInDatabase = await dbContext.Broadcasts.FirstWithIdOrDefaultAsync(broadcast.Id); broadcastInDatabase.Should().BeNull(); }); @@ -661,7 +670,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.Forbidden); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/BroadcastComment.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/BroadcastComment.cs index 623a603b6b..1a5e733de0 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/BroadcastComment.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/BroadcastComment.cs @@ -6,15 +6,15 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Archiving { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class BroadcastComment : Identifiable + public sealed class BroadcastComment : Identifiable { [Attr] - public string Text { get; set; } + public string Text { get; set; } = null!; [Attr] public DateTimeOffset CreatedAt { get; set; } [HasOne] - public TelevisionBroadcast AppliesTo { get; set; } + public TelevisionBroadcast AppliesTo { get; set; } = null!; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/BroadcastCommentsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/BroadcastCommentsController.cs index 01780a1b50..d801d2eaa5 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/BroadcastCommentsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/BroadcastCommentsController.cs @@ -5,10 +5,11 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Archiving { - public sealed class BroadcastCommentsController : JsonApiController + public sealed class BroadcastCommentsController : JsonApiController { - public BroadcastCommentsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public BroadcastCommentsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionBroadcast.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionBroadcast.cs index 4713b956fa..07c57183cc 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionBroadcast.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionBroadcast.cs @@ -7,10 +7,10 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Archiving { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class TelevisionBroadcast : Identifiable + public sealed class TelevisionBroadcast : Identifiable { [Attr] - public string Title { get; set; } + public string Title { get; set; } = null!; [Attr] public DateTimeOffset AiredAt { get; set; } @@ -19,9 +19,9 @@ public sealed class TelevisionBroadcast : Identifiable public DateTimeOffset? ArchivedAt { get; set; } [HasOne] - public TelevisionStation AiredOn { get; set; } + public TelevisionStation? AiredOn { get; set; } [HasMany] - public ISet Comments { get; set; } + public ISet Comments { get; set; } = new HashSet(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionBroadcastDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionBroadcastDefinition.cs index 862612d978..35df2904e3 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionBroadcastDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionBroadcastDefinition.cs @@ -13,12 +13,12 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; namespace JsonApiDotNetCoreTests.IntegrationTests.Archiving { [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class TelevisionBroadcastDefinition : JsonApiResourceDefinition + public sealed class TelevisionBroadcastDefinition : JsonApiResourceDefinition { private readonly TelevisionDbContext _dbContext; private readonly IJsonApiRequest _request; @@ -35,7 +35,7 @@ public TelevisionBroadcastDefinition(IResourceGraph resourceGraph, TelevisionDbC _constraintProviders = constraintProviders; } - public override FilterExpression OnApplyFilter(FilterExpression existingFilter) + public override FilterExpression? OnApplyFilter(FilterExpression? existingFilter) { if (_request.IsReadOnly) { @@ -43,16 +43,16 @@ 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()); + FilterExpression isUnarchived = new ComparisonExpression(ComparisonOperator.Equals, archivedAtChain, NullConstantExpression.Instance); - return existingFilter == null ? isUnarchived : new LogicalExpression(LogicalOperator.And, existingFilter, isUnarchived); + return LogicalExpression.Compose(LogicalOperator.And, existingFilter, isUnarchived); } } - return base.OnApplyFilter(existingFilter); + return existingFilter; } private bool IsReturningCollectionOfTelevisionBroadcasts() @@ -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; } @@ -99,7 +99,7 @@ private bool IsIncludingCollectionOfTelevisionBroadcasts() return false; } - private bool HasFilterOnArchivedAt(FilterExpression existingFilter) + private bool HasFilterOnArchivedAt(FilterExpression? existingFilter) { if (existingFilter == null) { @@ -119,7 +119,7 @@ public override Task OnPrepareWriteAsync(TelevisionBroadcast broadcast, WriteOpe _storedArchivedAt = broadcast.ArchivedAt; } - return base.OnPrepareWriteAsync(broadcast, writeOperation, cancellationToken); + return Task.CompletedTask; } public override async Task OnWritingAsync(TelevisionBroadcast broadcast, WriteOperationKind writeOperation, CancellationToken cancellationToken) @@ -134,8 +134,7 @@ public override async Task OnWritingAsync(TelevisionBroadcast broadcast, WriteOp } else if (writeOperation == WriteOperationKind.DeleteResource) { - TelevisionBroadcast broadcastToDelete = - await _dbContext.Broadcasts.FirstOrDefaultAsync(resource => resource.Id == broadcast.Id, cancellationToken); + TelevisionBroadcast? broadcastToDelete = await _dbContext.Broadcasts.FirstWithIdAsync(broadcast.Id, cancellationToken); if (broadcastToDelete != null) { @@ -182,11 +181,11 @@ private static void AssertIsArchived(TelevisionBroadcast broadcast) } } - private sealed class FilterWalker : QueryExpressionRewriter + private sealed class FilterWalker : QueryExpressionRewriter { public bool HasFilterOnArchivedAt { get; private set; } - public override QueryExpression VisitResourceFieldChain(ResourceFieldChainExpression expression, object argument) + public override QueryExpression? VisitResourceFieldChain(ResourceFieldChainExpression expression, object? argument) { if (expression.Fields[0].Property.Name == nameof(TelevisionBroadcast.ArchivedAt)) { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionBroadcastsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionBroadcastsController.cs index abcb32f36c..d5cd933b56 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionBroadcastsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionBroadcastsController.cs @@ -5,10 +5,11 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Archiving { - public sealed class TelevisionBroadcastsController : JsonApiController + public sealed class TelevisionBroadcastsController : JsonApiController { - public TelevisionBroadcastsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public TelevisionBroadcastsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionDbContext.cs index 9566f3b424..5ff8a1406c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionDbContext.cs @@ -6,10 +6,10 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Archiving [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class TelevisionDbContext : DbContext { - public DbSet Networks { get; set; } - public DbSet Stations { get; set; } - public DbSet Broadcasts { get; set; } - public DbSet Comments { get; set; } + public DbSet Networks => Set(); + public DbSet Stations => Set(); + public DbSet Broadcasts => Set(); + public DbSet Comments => Set(); public TelevisionDbContext(DbContextOptions options) : base(options) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionNetwork.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionNetwork.cs index 55ab73ffe3..fb4dafda39 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionNetwork.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionNetwork.cs @@ -6,12 +6,12 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Archiving { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class TelevisionNetwork : Identifiable + public sealed class TelevisionNetwork : Identifiable { [Attr] - public string Name { get; set; } + public string Name { get; set; } = null!; [HasMany] - public ISet Stations { get; set; } + public ISet Stations { get; set; } = new HashSet(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionNetworksController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionNetworksController.cs index 0c4432b63d..4b981a8586 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionNetworksController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionNetworksController.cs @@ -5,10 +5,11 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Archiving { - public sealed class TelevisionNetworksController : JsonApiController + public sealed class TelevisionNetworksController : JsonApiController { - public TelevisionNetworksController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public TelevisionNetworksController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionStation.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionStation.cs index b0c8a711f4..9f49047ddc 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionStation.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionStation.cs @@ -6,12 +6,12 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Archiving { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class TelevisionStation : Identifiable + public sealed class TelevisionStation : Identifiable { [Attr] - public string Name { get; set; } + public string Name { get; set; } = null!; [HasMany] - public ISet Broadcasts { get; set; } + public ISet Broadcasts { get; set; } = new HashSet(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionStationsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionStationsController.cs index 7b98448441..4ab018ea8d 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionStationsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionStationsController.cs @@ -5,10 +5,11 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Archiving { - public sealed class TelevisionStationsController : JsonApiController + public sealed class TelevisionStationsController : JsonApiController { - public TelevisionStationsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public TelevisionStationsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/AtomicConstrainedOperationsControllerTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/AtomicConstrainedOperationsControllerTests.cs index d00cda4a39..022ae35c3c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/AtomicConstrainedOperationsControllerTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/AtomicConstrainedOperationsControllerTests.cs @@ -67,7 +67,7 @@ public async Task Can_create_resources_for_matching_resource_type() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(2); + responseDocument.Results.ShouldHaveCount(2); } [Fact] @@ -100,12 +100,13 @@ public async Task Cannot_create_resource_for_mismatching_resource_type() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Unsupported combination of operation code and resource type at this endpoint."); error.Detail.Should().Be("This endpoint can only be used to create resources of type 'musicTracks'."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]"); } @@ -148,12 +149,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Unsupported combination of operation code and resource type at this endpoint."); error.Detail.Should().Be("This endpoint can only be used to create resources of type 'musicTracks'."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]"); } @@ -203,12 +205,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Unsupported combination of operation code and resource type at this endpoint."); error.Detail.Should().Be("This endpoint can only be used to create resources of type 'musicTracks'."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]"); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/CreateMusicTrackOperationsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/CreateMusicTrackOperationsController.cs index 5806456a9c..257920b48f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/CreateMusicTrackOperationsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/CreateMusicTrackOperationsController.cs @@ -19,9 +19,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Controllers [Route("/operations/musicTracks/create")] public sealed class CreateMusicTrackOperationsController : JsonApiOperationsController { - public CreateMusicTrackOperationsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IOperationsProcessor processor, - IJsonApiRequest request, ITargetedFields targetedFields) - : base(options, loggerFactory, processor, request, targetedFields) + public CreateMusicTrackOperationsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IOperationsProcessor processor, IJsonApiRequest request, ITargetedFields targetedFields) + : base(options, resourceGraph, loggerFactory, processor, request, targetedFields) { } @@ -38,7 +38,7 @@ private static void AssertOnlyCreatingMusicTracks(IEnumerable(); testContext.UseController(); testContext.UseController(); + + var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); + options.AllowUnknownFieldsInRequestBody = false; } [Fact] public async Task Can_create_resource() { // Arrange - string newArtistName = _fakers.Performer.Generate().ArtistName; + string newArtistName = _fakers.Performer.Generate().ArtistName!; DateTimeOffset newBornAt = _fakers.Performer.Generate().BornAt; var requestBody = new @@ -65,14 +70,17 @@ public async Task Can_create_resource() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(1); - responseDocument.Results[0].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[0].Data.SingleValue.Type.Should().Be("performers"); - responseDocument.Results[0].Data.SingleValue.Attributes["artistName"].Should().Be(newArtistName); - responseDocument.Results[0].Data.SingleValue.Attributes["bornAt"].As().Should().BeCloseTo(newBornAt); - responseDocument.Results[0].Data.SingleValue.Relationships.Should().BeNull(); + responseDocument.Results.ShouldHaveCount(1); + + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("performers"); + resource.Attributes.ShouldContainKey("artistName").With(value => value.Should().Be(newArtistName)); + resource.Attributes.ShouldContainKey("bornAt").With(value => value.As().Should().BeCloseTo(newBornAt)); + resource.Relationships.Should().BeNull(); + }); - int newPerformerId = int.Parse(responseDocument.Results[0].Data.SingleValue.Id); + int newPerformerId = int.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -125,28 +133,35 @@ public async Task Can_create_resources() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(elementCount); + responseDocument.Results.ShouldHaveCount(elementCount); for (int index = 0; index < elementCount; index++) { - ResourceObject singleData = responseDocument.Results[index].Data.SingleValue; + responseDocument.Results[index].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.ShouldNotBeNull(); + resource.Type.Should().Be("musicTracks"); + resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTracks[index].Title)); + + resource.Attributes.ShouldContainKey("lengthInSeconds") + .With(value => value.As().Should().BeApproximately(newTracks[index].LengthInSeconds)); - singleData.Should().NotBeNull(); - singleData.Type.Should().Be("musicTracks"); - singleData.Attributes["title"].Should().Be(newTracks[index].Title); - singleData.Attributes["lengthInSeconds"].As().Should().BeApproximately(newTracks[index].LengthInSeconds); - singleData.Attributes["genre"].Should().Be(newTracks[index].Genre); - singleData.Attributes["releasedAt"].As().Should().BeCloseTo(newTracks[index].ReleasedAt); - singleData.Relationships.Should().NotBeEmpty(); + resource.Attributes.ShouldContainKey("genre").With(value => value.Should().Be(newTracks[index].Genre)); + + resource.Attributes.ShouldContainKey("releasedAt") + .With(value => value.As().Should().BeCloseTo(newTracks[index].ReleasedAt)); + + resource.Relationships.ShouldNotBeEmpty(); + }); } - Guid[] newTrackIds = responseDocument.Results.Select(result => Guid.Parse(result.Data.SingleValue.Id)).ToArray(); + Guid[] newTrackIds = responseDocument.Results.Select(result => Guid.Parse(result.Data.SingleValue!.Id.ShouldNotBeNull())).ToArray(); await _testContext.RunOnDatabaseAsync(async dbContext => { List tracksInDatabase = await dbContext.MusicTracks.Where(musicTrack => newTrackIds.Contains(musicTrack.Id)).ToListAsync(); - tracksInDatabase.Should().HaveCount(elementCount); + tracksInDatabase.ShouldHaveCount(elementCount); for (int index = 0; index < elementCount; index++) { @@ -193,14 +208,17 @@ public async Task Can_create_resource_without_attributes_or_relationships() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(1); - responseDocument.Results[0].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[0].Data.SingleValue.Type.Should().Be("performers"); - responseDocument.Results[0].Data.SingleValue.Attributes["artistName"].Should().BeNull(); - responseDocument.Results[0].Data.SingleValue.Attributes["bornAt"].As().Should().BeCloseTo(default); - responseDocument.Results[0].Data.SingleValue.Relationships.Should().BeNull(); + responseDocument.Results.ShouldHaveCount(1); + + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("performers"); + resource.Attributes.ShouldContainKey("artistName").With(value => value.Should().BeNull()); + resource.Attributes.ShouldContainKey("bornAt").With(value => value.As().Should().BeCloseTo(default)); + resource.Relationships.Should().BeNull(); + }); - int newPerformerId = int.Parse(responseDocument.Results[0].Data.SingleValue.Id); + int newPerformerId = int.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -211,10 +229,58 @@ 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.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Unknown attribute found."); + error.Detail.Should().Be("Attribute 'doesNotExist' does not exist on resource type 'playlists'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/doesNotExist"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } + [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 @@ -245,13 +311,16 @@ public async Task Can_create_resource_with_unknown_attribute() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(1); - responseDocument.Results[0].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[0].Data.SingleValue.Type.Should().Be("playlists"); - responseDocument.Results[0].Data.SingleValue.Attributes["name"].Should().Be(newName); - responseDocument.Results[0].Data.SingleValue.Relationships.Should().NotBeEmpty(); + responseDocument.Results.ShouldHaveCount(1); - long newPlaylistId = long.Parse(responseDocument.Results[0].Data.SingleValue.Id); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("playlists"); + resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newName)); + resource.Relationships.ShouldNotBeEmpty(); + }); + + long newPlaylistId = long.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -261,10 +330,64 @@ 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.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Unknown relationship found."); + error.Detail.Should().Be("Relationship 'doesNotExist' does not exist on resource type 'lyrics'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/doesNotExist"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } + [Fact] public async Task Can_create_resource_with_unknown_relationship() { // Arrange + var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); + options.AllowUnknownFieldsInRequestBody = true; + + string newLyricText = _fakers.Lyric.Generate().Text; + var requestBody = new { atomic__operations = new[] @@ -275,6 +398,10 @@ public async Task Can_create_resource_with_unknown_relationship() data = new { type = "lyrics", + attributes = new + { + text = newLyricText + }, relationships = new { doesNotExist = new @@ -299,19 +426,22 @@ public async Task Can_create_resource_with_unknown_relationship() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(1); - responseDocument.Results[0].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[0].Data.SingleValue.Type.Should().Be("lyrics"); - responseDocument.Results[0].Data.SingleValue.Attributes.Should().NotBeEmpty(); - responseDocument.Results[0].Data.SingleValue.Relationships.Should().NotBeEmpty(); + responseDocument.Results.ShouldHaveCount(1); + + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("lyrics"); + resource.Attributes.ShouldNotBeEmpty(); + resource.Relationships.ShouldNotBeEmpty(); + }); - long newLyricId = long.Parse(responseDocument.Results[0].Data.SingleValue.Id); + long newLyricId = long.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { Lyric lyricInDatabase = await dbContext.Lyrics.FirstWithIdAsync(newLyricId); - lyricInDatabase.Should().NotBeNull(); + lyricInDatabase.ShouldNotBeNull(); }); } @@ -350,13 +480,15 @@ public async Task Cannot_create_resource_with_client_generated_ID() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); 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.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/id"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -383,13 +515,15 @@ public async Task Cannot_create_resource_for_href_element() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: 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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/href"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -419,13 +553,15 @@ public async Task Cannot_create_resource_for_ref_element() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref.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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -451,19 +587,58 @@ public async Task Cannot_create_resource_for_missing_data() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: The 'data' element is required."); error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [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.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Expected an object, instead of 'null'."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } + + [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 +646,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 + } } } } @@ -489,17 +669,19 @@ public async Task Cannot_create_resource_for_missing_type() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data.type' element is required."); + error.Title.Should().Be("Failed to deserialize request body: Expected an object, instead of an array."); error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] - public async Task Cannot_create_resource_for_unknown_type() + public async Task Cannot_create_resource_for_missing_type() { // Arrange var requestBody = new @@ -511,7 +693,9 @@ public async Task Cannot_create_resource_for_unknown_type() op = "add", data = new { - type = Unknown.ResourceType + attributes = new + { + } } } } @@ -525,21 +709,21 @@ public async Task Cannot_create_resource_for_unknown_type() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: 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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] - public async Task Cannot_create_resource_for_array() + public async Task Cannot_create_resource_for_unknown_type() { // Arrange - string newArtistName = _fakers.Performer.Generate().ArtistName; - var requestBody = new { atomic__operations = new[] @@ -547,16 +731,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 } } } @@ -570,13 +747,15 @@ public async Task Cannot_create_resource_for_array() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected 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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -610,13 +789,15 @@ public async Task Cannot_create_resource_attribute_with_blocked_capability() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: 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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/createdAt"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -653,13 +834,15 @@ public async Task Cannot_create_resource_with_readonly_attribute() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Attribute is read-only."); - error.Detail.Should().Be("Attribute 'isArchived' 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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/isArchived"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -693,13 +876,15 @@ public async Task Cannot_create_resource_with_incompatible_attribute_value() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body."); + error.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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/bornAt"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -775,13 +960,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(1); - responseDocument.Results[0].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[0].Data.SingleValue.Type.Should().Be("musicTracks"); - responseDocument.Results[0].Data.SingleValue.Attributes["title"].Should().Be(newTitle); - responseDocument.Results[0].Data.SingleValue.Relationships.Should().NotBeEmpty(); + responseDocument.Results.ShouldHaveCount(1); + + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("musicTracks"); + resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTitle)); + resource.Relationships.ShouldNotBeEmpty(); + }); - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue.Id); + Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -799,13 +987,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => trackInDatabase.Title.Should().Be(newTitle); - trackInDatabase.Lyric.Should().NotBeNull(); + trackInDatabase.Lyric.ShouldNotBeNull(); trackInDatabase.Lyric.Id.Should().Be(existingLyric.Id); - trackInDatabase.OwnedBy.Should().NotBeNull(); + trackInDatabase.OwnedBy.ShouldNotBeNull(); trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); - trackInDatabase.Performers.Should().HaveCount(1); + trackInDatabase.Performers.ShouldHaveCount(1); trackInDatabase.Performers[0].Id.Should().Be(existingPerformer.Id); }); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs index eb3780a140..cc663f92f8 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs @@ -29,6 +29,8 @@ public AtomicCreateResourceWithClientGeneratedIdTests(IntegrationTestContext { services.AddResourceDefinition(); + + services.AddSingleton(); }); var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); @@ -72,12 +74,15 @@ public async Task Can_create_resource_with_client_generated_guid_ID_having_side_ string isoCode = $"{newLanguage.IsoCode}{ImplicitlyChangingTextLanguageDefinition.Suffix}"; - responseDocument.Results.Should().HaveCount(1); - responseDocument.Results[0].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[0].Data.SingleValue.Type.Should().Be("textLanguages"); - responseDocument.Results[0].Data.SingleValue.Attributes["isoCode"].Should().Be(isoCode); - responseDocument.Results[0].Data.SingleValue.Attributes.Should().NotContainKey("isRightToLeft"); - responseDocument.Results[0].Data.SingleValue.Relationships.Should().NotBeEmpty(); + responseDocument.Results.ShouldHaveCount(1); + + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("textLanguages"); + resource.Attributes.ShouldContainKey("isoCode").With(value => value.Should().Be(isoCode)); + resource.Attributes.Should().NotContainKey("isRightToLeft"); + resource.Relationships.ShouldNotBeEmpty(); + }); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -179,13 +184,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.Conflict); error.Title.Should().Be("Another resource with the specified ID already exists."); error.Detail.Should().Be($"Another resource of type 'textLanguages' with ID '{languageToCreate.StringId}' already exists."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Meta.Should().NotContainKey("requestBody"); } [Fact] @@ -221,13 +228,15 @@ public async Task Cannot_create_resource_for_incompatible_ID() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body."); + error.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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/id"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -259,13 +268,15 @@ public async Task Cannot_create_resource_for_ID_and_local_ID() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data.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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs index d35107a8b8..ebb7dbc272 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs @@ -87,19 +87,22 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(1); - responseDocument.Results[0].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[0].Data.SingleValue.Type.Should().Be("musicTracks"); - responseDocument.Results[0].Data.SingleValue.Attributes.Should().NotBeEmpty(); - responseDocument.Results[0].Data.SingleValue.Relationships.Should().NotBeEmpty(); + responseDocument.Results.ShouldHaveCount(1); - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue.Id); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("musicTracks"); + resource.Attributes.ShouldNotBeEmpty(); + resource.Relationships.ShouldNotBeEmpty(); + }); + + Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(newTrackId); - trackInDatabase.Performers.Should().HaveCount(2); + trackInDatabase.Performers.ShouldHaveCount(2); trackInDatabase.Performers.Should().ContainSingle(performer => performer.Id == existingPerformers[0].Id); trackInDatabase.Performers.Should().ContainSingle(performer => performer.Id == existingPerformers[1].Id); }); @@ -169,19 +172,22 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(1); - responseDocument.Results[0].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[0].Data.SingleValue.Type.Should().Be("playlists"); - responseDocument.Results[0].Data.SingleValue.Attributes.Should().NotBeEmpty(); - responseDocument.Results[0].Data.SingleValue.Relationships.Should().NotBeEmpty(); + responseDocument.Results.ShouldHaveCount(1); - long newPlaylistId = long.Parse(responseDocument.Results[0].Data.SingleValue.Id); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("playlists"); + resource.Attributes.ShouldNotBeEmpty(); + resource.Relationships.ShouldNotBeEmpty(); + }); + + long newPlaylistId = long.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(newPlaylistId); - playlistInDatabase.Tracks.Should().HaveCount(3); + playlistInDatabase.Tracks.ShouldHaveCount(3); playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[0].Id); playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[1].Id); playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[2].Id); @@ -228,13 +234,15 @@ public async Task Cannot_create_for_missing_relationship_type() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: 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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data[0]"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -278,13 +286,15 @@ public async Task Cannot_create_for_unknown_relationship_type() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: 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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data[0]/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -327,13 +337,15 @@ public async Task Cannot_create_for_missing_relationship_ID() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: 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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data[0]"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -391,19 +403,23 @@ public async Task Cannot_create_for_unknown_relationship_IDs() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.Should().HaveCount(2); + responseDocument.Errors.ShouldHaveCount(2); ErrorObject error1 = responseDocument.Errors[0]; error1.StatusCode.Should().Be(HttpStatusCode.NotFound); error1.Title.Should().Be("A related resource does not exist."); error1.Detail.Should().Be($"Related resource of type 'performers' with ID '{performerId1}' in relationship 'performers' does not exist."); + error1.Source.ShouldNotBeNull(); error1.Source.Pointer.Should().Be("/atomic:operations[0]"); + error1.Meta.Should().NotContainKey("requestBody"); ErrorObject error2 = responseDocument.Errors[1]; error2.StatusCode.Should().Be(HttpStatusCode.NotFound); error2.Title.Should().Be("A related resource does not exist."); error2.Detail.Should().Be($"Related resource of type 'performers' with ID '{performerId2}' in relationship 'performers' does not exist."); + error2.Source.ShouldNotBeNull(); error2.Source.Pointer.Should().Be("/atomic:operations[0]"); + error2.Meta.Should().NotContainKey("requestBody"); } [Fact] @@ -445,15 +461,17 @@ 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); + responseDocument.Errors.ShouldHaveCount(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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data[0]/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -515,25 +533,28 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(1); - responseDocument.Results[0].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[0].Data.SingleValue.Type.Should().Be("musicTracks"); - responseDocument.Results[0].Data.SingleValue.Attributes.Should().NotBeEmpty(); - responseDocument.Results[0].Data.SingleValue.Relationships.Should().NotBeEmpty(); + responseDocument.Results.ShouldHaveCount(1); + + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("musicTracks"); + resource.Attributes.ShouldNotBeEmpty(); + resource.Relationships.ShouldNotBeEmpty(); + }); - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue.Id); + Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(newTrackId); - trackInDatabase.Performers.Should().HaveCount(1); + trackInDatabase.Performers.ShouldHaveCount(1); trackInDatabase.Performers[0].Id.Should().Be(existingPerformer.Id); }); } [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 +571,6 @@ public async Task Cannot_create_with_null_data_in_OneToMany_relationship() { performers = new { - data = (object)null } } } @@ -566,13 +586,15 @@ public async Task Cannot_create_with_null_data_in_OneToMany_relationship() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected 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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -593,7 +615,54 @@ public async Task Cannot_create_with_null_data_in_ManyToMany_relationship() { tracks = new { - data = (object)null + 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.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Expected an array, instead of 'null'."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/tracks/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } + + [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 + { + } } } } @@ -609,13 +678,15 @@ public async Task Cannot_create_with_null_data_in_ManyToMany_relationship() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected 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, instead of an object."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/tracks/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs index 9153bb404d..776947b303 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs @@ -36,6 +36,8 @@ public async Task Can_create_OneToOne_relationship_from_principal_side() // Arrange MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + string newLyricText = _fakers.Lyric.Generate().Text; + await _testContext.RunOnDatabaseAsync(async dbContext => { dbContext.MusicTracks.Add(existingTrack); @@ -52,6 +54,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = new { type = "lyrics", + attributes = new + { + text = newLyricText + }, relationships = new { track = new @@ -76,19 +82,22 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(1); - responseDocument.Results[0].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[0].Data.SingleValue.Type.Should().Be("lyrics"); - responseDocument.Results[0].Data.SingleValue.Attributes.Should().NotBeEmpty(); - responseDocument.Results[0].Data.SingleValue.Relationships.Should().NotBeEmpty(); + responseDocument.Results.ShouldHaveCount(1); + + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("lyrics"); + resource.Attributes.ShouldNotBeEmpty(); + resource.Relationships.ShouldNotBeEmpty(); + }); - long newLyricId = long.Parse(responseDocument.Results[0].Data.SingleValue.Id); + long newLyricId = long.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { Lyric lyricInDatabase = await dbContext.Lyrics.Include(lyric => lyric.Track).FirstWithIdAsync(newLyricId); - lyricInDatabase.Track.Should().NotBeNull(); + lyricInDatabase.Track.ShouldNotBeNull(); lyricInDatabase.Track.Id.Should().Be(existingTrack.Id); }); } @@ -144,19 +153,22 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(1); - responseDocument.Results[0].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[0].Data.SingleValue.Type.Should().Be("musicTracks"); - responseDocument.Results[0].Data.SingleValue.Attributes.Should().NotBeEmpty(); - responseDocument.Results[0].Data.SingleValue.Relationships.Should().NotBeEmpty(); + responseDocument.Results.ShouldHaveCount(1); + + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("musicTracks"); + resource.Attributes.ShouldNotBeEmpty(); + resource.Relationships.ShouldNotBeEmpty(); + }); - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue.Id); + Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Lyric).FirstWithIdAsync(newTrackId); - trackInDatabase.Lyric.Should().NotBeNull(); + trackInDatabase.Lyric.ShouldNotBeNull(); trackInDatabase.Lyric.Id.Should().Be(existingLyric.Id); }); } @@ -218,16 +230,18 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(elementCount); + responseDocument.Results.ShouldHaveCount(elementCount); for (int index = 0; index < elementCount; index++) { - responseDocument.Results[index].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[index].Data.SingleValue.Type.Should().Be("musicTracks"); - responseDocument.Results[index].Data.SingleValue.Attributes["title"].Should().Be(newTrackTitles[index]); + responseDocument.Results[index].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("musicTracks"); + resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitles[index])); + }); } - Guid[] newTrackIds = responseDocument.Results.Select(result => Guid.Parse(result.Data.SingleValue.Id)).ToArray(); + Guid[] newTrackIds = responseDocument.Results.Select(result => Guid.Parse(result.Data.SingleValue!.Id.ShouldNotBeNull())).ToArray(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -242,7 +256,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // @formatter:keep_existing_linebreaks restore // @formatter:wrap_chained_method_calls restore - tracksInDatabase.Should().HaveCount(elementCount); + tracksInDatabase.ShouldHaveCount(elementCount); for (int index = 0; index < elementCount; index++) { @@ -250,12 +264,150 @@ await _testContext.RunOnDatabaseAsync(async dbContext => trackInDatabase.Title.Should().Be(newTrackTitles[index]); - trackInDatabase.OwnedBy.Should().NotBeNull(); + trackInDatabase.OwnedBy.ShouldNotBeNull(); trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); } }); } + [Fact] + public async Task Cannot_create_for_null_relationship() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "musicTracks", + relationships = new + { + lyric = (object?)null + } + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Expected an object, instead of 'null'."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } + + [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.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'data' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } + + [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.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Expected an object or 'null', instead of an array."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } + [Fact] public async Task Cannot_create_for_missing_relationship_type() { @@ -293,13 +445,15 @@ public async Task Cannot_create_for_missing_relationship_type() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: 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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -340,13 +494,15 @@ public async Task Cannot_create_for_unknown_relationship_type() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: 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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -386,19 +542,23 @@ public async Task Cannot_create_for_missing_relationship_ID() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: 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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] public async Task Cannot_create_with_unknown_relationship_ID() { // Arrange + string newTrackTitle = _fakers.MusicTrack.Generate().Title; + string lyricId = Unknown.StringId.For(); var requestBody = new @@ -411,6 +571,10 @@ public async Task Cannot_create_with_unknown_relationship_ID() data = new { type = "musicTracks", + attributes = new + { + title = newTrackTitle + }, relationships = new { lyric = new @@ -435,13 +599,15 @@ public async Task Cannot_create_with_unknown_relationship_ID() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); error.Title.Should().Be("A related resource does not exist."); error.Detail.Should().Be($"Related resource of type 'lyrics' with ID '{lyricId}' in relationship 'lyric' does not exist."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Meta.Should().NotContainKey("requestBody"); } [Fact] @@ -480,15 +646,17 @@ 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); + responseDocument.Errors.ShouldHaveCount(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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -552,71 +720,24 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(1); - responseDocument.Results[0].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[0].Data.SingleValue.Type.Should().Be("musicTracks"); - responseDocument.Results[0].Data.SingleValue.Attributes.Should().NotBeEmpty(); - responseDocument.Results[0].Data.SingleValue.Relationships.Should().NotBeEmpty(); + responseDocument.Results.ShouldHaveCount(1); + + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("musicTracks"); + resource.Attributes.ShouldNotBeEmpty(); + resource.Relationships.ShouldNotBeEmpty(); + }); - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue.Id); + Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(newTrackId); - trackInDatabase.OwnedBy.Should().NotBeNull(); + trackInDatabase.OwnedBy.ShouldNotBeNull(); 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..e17e0966bc 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs @@ -64,7 +64,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - Performer performerInDatabase = await dbContext.Performers.FirstWithIdOrDefaultAsync(existingPerformer.Id); + Performer? performerInDatabase = await dbContext.Performers.FirstWithIdOrDefaultAsync(existingPerformer.Id); performerInDatabase.Should().BeNull(); }); @@ -164,9 +164,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - Lyric lyricsInDatabase = await dbContext.Lyrics.FirstWithIdOrDefaultAsync(existingLyric.Id); + Lyric? lyricInDatabase = await dbContext.Lyrics.FirstWithIdOrDefaultAsync(existingLyric.Id); - lyricsInDatabase.Should().BeNull(); + lyricInDatabase.Should().BeNull(); MusicTrack trackInDatabase = await dbContext.MusicTracks.FirstWithIdAsync(existingLyric.Track.Id); @@ -215,9 +215,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - MusicTrack tracksInDatabase = await dbContext.MusicTracks.FirstWithIdOrDefaultAsync(existingTrack.Id); + MusicTrack? trackInDatabase = await dbContext.MusicTracks.FirstWithIdOrDefaultAsync(existingTrack.Id); - tracksInDatabase.Should().BeNull(); + trackInDatabase.Should().BeNull(); Lyric lyricInDatabase = await dbContext.Lyrics.FirstWithIdAsync(existingTrack.Lyric.Id); @@ -266,7 +266,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - MusicTrack trackInDatabase = await dbContext.MusicTracks.FirstWithIdOrDefaultAsync(existingTrack.Id); + MusicTrack? trackInDatabase = await dbContext.MusicTracks.FirstWithIdOrDefaultAsync(existingTrack.Id); trackInDatabase.Should().BeNull(); @@ -318,13 +318,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - Playlist playlistInDatabase = await dbContext.Playlists.FirstWithIdOrDefaultAsync(existingPlaylist.Id); + Playlist? playlistInDatabase = await dbContext.Playlists.FirstWithIdOrDefaultAsync(existingPlaylist.Id); playlistInDatabase.Should().BeNull(); - MusicTrack trackInDatabase = await dbContext.MusicTracks.FirstWithIdOrDefaultAsync(existingPlaylist.Tracks[0].Id); + MusicTrack? trackInDatabase = await dbContext.MusicTracks.FirstWithIdOrDefaultAsync(existingPlaylist.Tracks[0].Id); - trackInDatabase.Should().NotBeNull(); + trackInDatabase.ShouldNotBeNull(); }); } @@ -352,13 +352,15 @@ public async Task Cannot_delete_resource_for_href_element() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: 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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/href"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -384,13 +386,15 @@ public async Task Cannot_delete_resource_for_missing_ref_element() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: The 'ref' element is required."); error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -420,13 +424,15 @@ public async Task Cannot_delete_resource_for_missing_type() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref.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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -457,13 +463,15 @@ public async Task Cannot_delete_resource_for_unknown_type() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: 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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -493,13 +501,15 @@ public async Task Cannot_delete_resource_for_missing_ID() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref.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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -532,13 +542,15 @@ public async Task Cannot_delete_resource_for_unknown_ID() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); error.Title.Should().Be("The requested resource does not exist."); error.Detail.Should().Be($"Resource of type 'performers' with ID '{performerId}' does not exist."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Meta.Should().NotContainKey("requestBody"); } [Fact] @@ -571,13 +583,15 @@ public async Task Cannot_delete_resource_for_incompatible_ID() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body."); + error.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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/id"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -609,13 +623,15 @@ public async Task Cannot_delete_resource_for_ID_and_local_ID() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref.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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ImplicitlyChangingTextLanguageDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ImplicitlyChangingTextLanguageDefinition.cs index 3f5aadc2b3..ec80bc14b8 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ImplicitlyChangingTextLanguageDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ImplicitlyChangingTextLanguageDefinition.cs @@ -4,7 +4,6 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Resources; using Microsoft.EntityFrameworkCore; namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations @@ -13,20 +12,22 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations /// Used to simulate side effects that occur in the database while saving, typically caused by database triggers. /// [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public class ImplicitlyChangingTextLanguageDefinition : JsonApiResourceDefinition + public class ImplicitlyChangingTextLanguageDefinition : HitCountingResourceDefinition { internal const string Suffix = " (changed)"; private readonly OperationsDbContext _dbContext; - public ImplicitlyChangingTextLanguageDefinition(IResourceGraph resourceGraph, OperationsDbContext dbContext) - : base(resourceGraph) + public ImplicitlyChangingTextLanguageDefinition(IResourceGraph resourceGraph, ResourceDefinitionHitCounter hitCounter, OperationsDbContext dbContext) + : base(resourceGraph, hitCounter) { _dbContext = dbContext; } public override async Task OnWriteSucceededAsync(TextLanguage resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) { + await base.OnWriteSucceededAsync(resource, writeOperation, cancellationToken); + if (writeOperation is not WriteOperationKind.DeleteResource) { string statement = $"Update \"TextLanguages\" SET \"IsoCode\" = '{resource.IsoCode}{Suffix}' WHERE \"Id\" = '{resource.StringId}'"; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Links/AtomicAbsoluteLinksTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Links/AtomicAbsoluteLinksTests.cs index fb3246013a..8331548d1f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Links/AtomicAbsoluteLinksTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Links/AtomicAbsoluteLinksTests.cs @@ -85,29 +85,41 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(2); - - string languageLink = $"{HostPrefix}/textLanguages/{existingLanguage.StringId}"; - - ResourceObject singleData1 = responseDocument.Results[0].Data.SingleValue; - singleData1.Should().NotBeNull(); - singleData1.Links.Should().NotBeNull(); - singleData1.Links.Self.Should().Be(languageLink); - singleData1.Relationships.Should().NotBeEmpty(); - singleData1.Relationships["lyrics"].Links.Should().NotBeNull(); - singleData1.Relationships["lyrics"].Links.Self.Should().Be($"{languageLink}/relationships/lyrics"); - singleData1.Relationships["lyrics"].Links.Related.Should().Be($"{languageLink}/lyrics"); - - string companyLink = $"{HostPrefix}/recordCompanies/{existingCompany.StringId}"; - - ResourceObject singleData2 = responseDocument.Results[1].Data.SingleValue; - singleData2.Should().NotBeNull(); - singleData2.Links.Should().NotBeNull(); - singleData2.Links.Self.Should().Be(companyLink); - singleData2.Relationships.Should().NotBeEmpty(); - singleData2.Relationships["tracks"].Links.Should().NotBeNull(); - singleData2.Relationships["tracks"].Links.Self.Should().Be($"{companyLink}/relationships/tracks"); - singleData2.Relationships["tracks"].Links.Related.Should().Be($"{companyLink}/tracks"); + responseDocument.Results.ShouldHaveCount(2); + + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + string languageLink = $"{HostPrefix}/textLanguages/{existingLanguage.StringId}"; + + resource.ShouldNotBeNull(); + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(languageLink); + + resource.Relationships.ShouldContainKey("lyrics").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{languageLink}/relationships/lyrics"); + value.Links.Related.Should().Be($"{languageLink}/lyrics"); + }); + }); + + responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + { + string companyLink = $"{HostPrefix}/recordCompanies/{existingCompany.StringId}"; + + resource.ShouldNotBeNull(); + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(companyLink); + + resource.Relationships.ShouldContainKey("tracks").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{companyLink}/relationships/tracks"); + value.Links.Related.Should().Be($"{companyLink}/tracks"); + }); + }); } [Fact] @@ -149,12 +161,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(1); + responseDocument.Results.ShouldHaveCount(1); - ResourceObject singleData = responseDocument.Results[0].Data.SingleValue; - singleData.Should().NotBeNull(); - singleData.Links.Should().BeNull(); - singleData.Relationships.Should().BeNull(); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.ShouldNotBeNull(); + resource.Links.Should().BeNull(); + resource.Relationships.Should().BeNull(); + }); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Links/AtomicRelativeLinksWithNamespaceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Links/AtomicRelativeLinksWithNamespaceTests.cs index 176b7162dc..06ebfbab3c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Links/AtomicRelativeLinksWithNamespaceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Links/AtomicRelativeLinksWithNamespaceTests.cs @@ -16,6 +16,7 @@ public sealed class AtomicRelativeLinksWithNamespaceTests : IClassFixture, OperationsDbContext>> { private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new(); public AtomicRelativeLinksWithNamespaceTests( IntegrationTestContext, OperationsDbContext> testContext) @@ -38,6 +39,8 @@ public AtomicRelativeLinksWithNamespaceTests( public async Task Create_resource_with_side_effects_returns_relative_links() { // Arrange + string newCompanyName = _fakers.RecordCompany.Generate().Name; + var requestBody = new { atomic__operations = new object[] @@ -61,6 +64,7 @@ public async Task Create_resource_with_side_effects_returns_relative_links() type = "recordCompanies", attributes = new { + name = newCompanyName } } } @@ -75,29 +79,43 @@ public async Task Create_resource_with_side_effects_returns_relative_links() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(2); + responseDocument.Results.ShouldHaveCount(2); - responseDocument.Results[0].Data.SingleValue.Should().NotBeNull(); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull(); - string languageLink = $"/api/textLanguages/{Guid.Parse(responseDocument.Results[0].Data.SingleValue.Id)}"; + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + string languageLink = $"/api/textLanguages/{Guid.Parse(resource.Id.ShouldNotBeNull())}"; - responseDocument.Results[0].Data.SingleValue.Links.Should().NotBeNull(); - responseDocument.Results[0].Data.SingleValue.Links.Self.Should().Be(languageLink); - responseDocument.Results[0].Data.SingleValue.Relationships.Should().NotBeEmpty(); - responseDocument.Results[0].Data.SingleValue.Relationships["lyrics"].Links.Should().NotBeNull(); - responseDocument.Results[0].Data.SingleValue.Relationships["lyrics"].Links.Self.Should().Be($"{languageLink}/relationships/lyrics"); - responseDocument.Results[0].Data.SingleValue.Relationships["lyrics"].Links.Related.Should().Be($"{languageLink}/lyrics"); + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(languageLink); - responseDocument.Results[1].Data.SingleValue.Should().NotBeNull(); + resource.Relationships.ShouldContainKey("lyrics").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{languageLink}/relationships/lyrics"); + value.Links.Related.Should().Be($"{languageLink}/lyrics"); + }); + }); - string companyLink = $"/api/recordCompanies/{short.Parse(responseDocument.Results[1].Data.SingleValue.Id)}"; + responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull(); - responseDocument.Results[1].Data.SingleValue.Links.Should().NotBeNull(); - responseDocument.Results[1].Data.SingleValue.Links.Self.Should().Be(companyLink); - responseDocument.Results[1].Data.SingleValue.Relationships.Should().NotBeEmpty(); - responseDocument.Results[1].Data.SingleValue.Relationships["tracks"].Links.Should().NotBeNull(); - responseDocument.Results[1].Data.SingleValue.Relationships["tracks"].Links.Self.Should().Be($"{companyLink}/relationships/tracks"); - responseDocument.Results[1].Data.SingleValue.Relationships["tracks"].Links.Related.Should().Be($"{companyLink}/tracks"); + responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + { + string companyLink = $"/api/recordCompanies/{short.Parse(resource.Id.ShouldNotBeNull())}"; + + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(companyLink); + + resource.Relationships.ShouldContainKey("tracks").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{companyLink}/relationships/tracks"); + value.Links.Related.Should().Be($"{companyLink}/tracks"); + }); + }); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/LocalIds/AtomicLocalIdTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/LocalIds/AtomicLocalIdTests.cs index 036cb640af..11af30d546 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/LocalIds/AtomicLocalIdTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/LocalIds/AtomicLocalIdTests.cs @@ -84,21 +84,25 @@ public async Task Can_create_resource_with_ManyToOne_relationship_using_local_ID // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(2); + responseDocument.Results.ShouldHaveCount(2); - responseDocument.Results[0].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[0].Data.SingleValue.Type.Should().Be("recordCompanies"); - responseDocument.Results[0].Data.SingleValue.Lid.Should().BeNull(); - responseDocument.Results[0].Data.SingleValue.Attributes["name"].Should().Be(newCompany.Name); - responseDocument.Results[0].Data.SingleValue.Attributes["countryOfResidence"].Should().Be(newCompany.CountryOfResidence); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("recordCompanies"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newCompany.Name)); + resource.Attributes.ShouldContainKey("countryOfResidence").With(value => value.Should().Be(newCompany.CountryOfResidence)); + }); - responseDocument.Results[1].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[1].Data.SingleValue.Type.Should().Be("musicTracks"); - responseDocument.Results[1].Data.SingleValue.Lid.Should().BeNull(); - responseDocument.Results[1].Data.SingleValue.Attributes["title"].Should().Be(newTrackTitle); + responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("musicTracks"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); + }); - short newCompanyId = short.Parse(responseDocument.Results[0].Data.SingleValue.Id); - Guid newTrackId = Guid.Parse(responseDocument.Results[1].Data.SingleValue.Id); + short newCompanyId = short.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + Guid newTrackId = Guid.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -106,7 +110,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => trackInDatabase.Title.Should().Be(newTrackTitle); - trackInDatabase.OwnedBy.Should().NotBeNull(); + trackInDatabase.OwnedBy.ShouldNotBeNull(); trackInDatabase.OwnedBy.Id.Should().Be(newCompanyId); trackInDatabase.OwnedBy.Name.Should().Be(newCompany.Name); trackInDatabase.OwnedBy.CountryOfResidence.Should().Be(newCompany.CountryOfResidence); @@ -177,21 +181,25 @@ public async Task Can_create_resource_with_OneToMany_relationship_using_local_ID // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(2); + responseDocument.Results.ShouldHaveCount(2); - responseDocument.Results[0].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[0].Data.SingleValue.Type.Should().Be("performers"); - responseDocument.Results[0].Data.SingleValue.Lid.Should().BeNull(); - responseDocument.Results[0].Data.SingleValue.Attributes["artistName"].Should().Be(newPerformer.ArtistName); - responseDocument.Results[0].Data.SingleValue.Attributes["bornAt"].As().Should().BeCloseTo(newPerformer.BornAt); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("performers"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("artistName").With(value => value.Should().Be(newPerformer.ArtistName)); + resource.Attributes.ShouldContainKey("bornAt").With(value => value.As().Should().BeCloseTo(newPerformer.BornAt)); + }); - responseDocument.Results[1].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[1].Data.SingleValue.Type.Should().Be("musicTracks"); - responseDocument.Results[1].Data.SingleValue.Lid.Should().BeNull(); - responseDocument.Results[1].Data.SingleValue.Attributes["title"].Should().Be(newTrackTitle); + responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("musicTracks"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); + }); - int newPerformerId = int.Parse(responseDocument.Results[0].Data.SingleValue.Id); - Guid newTrackId = Guid.Parse(responseDocument.Results[1].Data.SingleValue.Id); + int newPerformerId = int.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + Guid newTrackId = Guid.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -199,7 +207,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => trackInDatabase.Title.Should().Be(newTrackTitle); - trackInDatabase.Performers.Should().HaveCount(1); + trackInDatabase.Performers.ShouldHaveCount(1); trackInDatabase.Performers[0].Id.Should().Be(newPerformerId); trackInDatabase.Performers[0].ArtistName.Should().Be(newPerformer.ArtistName); trackInDatabase.Performers[0].BornAt.Should().BeCloseTo(newPerformer.BornAt); @@ -269,20 +277,24 @@ public async Task Can_create_resource_with_ManyToMany_relationship_using_local_I // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(2); + responseDocument.Results.ShouldHaveCount(2); - responseDocument.Results[0].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[0].Data.SingleValue.Type.Should().Be("musicTracks"); - responseDocument.Results[0].Data.SingleValue.Lid.Should().BeNull(); - responseDocument.Results[0].Data.SingleValue.Attributes["title"].Should().Be(newTrackTitle); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("musicTracks"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); + }); - responseDocument.Results[1].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[1].Data.SingleValue.Type.Should().Be("playlists"); - responseDocument.Results[1].Data.SingleValue.Lid.Should().BeNull(); - responseDocument.Results[1].Data.SingleValue.Attributes["name"].Should().Be(newPlaylistName); + responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("playlists"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newPlaylistName)); + }); - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue.Id); - long newPlaylistId = long.Parse(responseDocument.Results[1].Data.SingleValue.Id); + Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + long newPlaylistId = long.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -290,7 +302,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => playlistInDatabase.Name.Should().Be(newPlaylistName); - playlistInDatabase.Tracks.Should().HaveCount(1); + playlistInDatabase.Tracks.ShouldHaveCount(1); playlistInDatabase.Tracks[0].Id.Should().Be(newTrackId); playlistInDatabase.Tracks[0].Title.Should().Be(newTrackTitle); }); @@ -302,6 +314,8 @@ public async Task Cannot_consume_local_ID_that_is_assigned_in_same_operation() // Arrange const string companyLocalId = "company-1"; + string newCompanyName = _fakers.RecordCompany.Generate().Name; + var requestBody = new { atomic__operations = new object[] @@ -322,6 +336,10 @@ public async Task Cannot_consume_local_ID_that_is_assigned_in_same_operation() { type = "recordCompanies", lid = companyLocalId, + attributes = new + { + name = newCompanyName + }, relationships = new { parent = new @@ -346,12 +364,13 @@ public async Task Cannot_consume_local_ID_that_is_assigned_in_same_operation() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("Local ID cannot be both defined and used within the same operation."); error.Detail.Should().Be("Local ID 'company-1' cannot be both defined and used within the same operation."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[1]"); } @@ -412,12 +431,13 @@ public async Task Cannot_reassign_local_ID() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("Another local ID with the same name is already defined at this point."); error.Detail.Should().Be("Another local ID with name 'playlist-1' is already defined at this point."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[2]"); } @@ -426,7 +446,7 @@ public async Task Can_update_resource_using_local_ID() { // Arrange string newTrackTitle = _fakers.MusicTrack.Generate().Title; - string newTrackGenre = _fakers.MusicTrack.Generate().Genre; + string newTrackGenre = _fakers.MusicTrack.Generate().Genre!; const string trackLocalId = "track-1"; @@ -471,17 +491,19 @@ public async Task Can_update_resource_using_local_ID() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(2); + responseDocument.Results.ShouldHaveCount(2); - responseDocument.Results[0].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[0].Data.SingleValue.Type.Should().Be("musicTracks"); - responseDocument.Results[0].Data.SingleValue.Lid.Should().BeNull(); - responseDocument.Results[0].Data.SingleValue.Attributes["title"].Should().Be(newTrackTitle); - responseDocument.Results[0].Data.SingleValue.Attributes["genre"].Should().BeNull(); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("musicTracks"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); + resource.Attributes.ShouldContainKey("genre").With(value => value.Should().BeNull()); + }); responseDocument.Results[1].Data.Value.Should().BeNull(); - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue.Id); + Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -497,7 +519,7 @@ public async Task Can_update_resource_with_relationships_using_local_ID() { // Arrange string newTrackTitle = _fakers.MusicTrack.Generate().Title; - string newArtistName = _fakers.Performer.Generate().ArtistName; + string newArtistName = _fakers.Performer.Generate().ArtistName!; string newCompanyName = _fakers.RecordCompany.Generate().Name; const string trackLocalId = "track-1"; @@ -589,28 +611,34 @@ public async Task Can_update_resource_with_relationships_using_local_ID() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(4); + responseDocument.Results.ShouldHaveCount(4); - responseDocument.Results[0].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[0].Data.SingleValue.Type.Should().Be("musicTracks"); - responseDocument.Results[0].Data.SingleValue.Lid.Should().BeNull(); - responseDocument.Results[0].Data.SingleValue.Attributes["title"].Should().Be(newTrackTitle); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("musicTracks"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); + }); - responseDocument.Results[1].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[1].Data.SingleValue.Type.Should().Be("performers"); - responseDocument.Results[1].Data.SingleValue.Lid.Should().BeNull(); - responseDocument.Results[1].Data.SingleValue.Attributes["artistName"].Should().Be(newArtistName); + responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("performers"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("artistName").With(value => value.Should().Be(newArtistName)); + }); - responseDocument.Results[2].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[2].Data.SingleValue.Type.Should().Be("recordCompanies"); - responseDocument.Results[2].Data.SingleValue.Lid.Should().BeNull(); - responseDocument.Results[2].Data.SingleValue.Attributes["name"].Should().Be(newCompanyName); + responseDocument.Results[2].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("recordCompanies"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newCompanyName)); + }); responseDocument.Results[3].Data.Value.Should().BeNull(); - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue.Id); - int newPerformerId = int.Parse(responseDocument.Results[1].Data.SingleValue.Id); - short newCompanyId = short.Parse(responseDocument.Results[2].Data.SingleValue.Id); + Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + int newPerformerId = int.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); + short newCompanyId = short.Parse(responseDocument.Results[2].Data.SingleValue!.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -627,10 +655,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => trackInDatabase.Title.Should().Be(newTrackTitle); - trackInDatabase.OwnedBy.Should().NotBeNull(); + trackInDatabase.OwnedBy.ShouldNotBeNull(); trackInDatabase.OwnedBy.Id.Should().Be(newCompanyId); - trackInDatabase.Performers.Should().HaveCount(1); + trackInDatabase.Performers.ShouldHaveCount(1); trackInDatabase.Performers[0].Id.Should().Be(newPerformerId); trackInDatabase.Performers[0].ArtistName.Should().Be(newArtistName); }); @@ -702,22 +730,26 @@ public async Task Can_create_ManyToOne_relationship_using_local_ID() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(3); + responseDocument.Results.ShouldHaveCount(3); - responseDocument.Results[0].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[0].Data.SingleValue.Type.Should().Be("musicTracks"); - responseDocument.Results[0].Data.SingleValue.Lid.Should().BeNull(); - responseDocument.Results[0].Data.SingleValue.Attributes["title"].Should().Be(newTrackTitle); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("musicTracks"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); + }); - responseDocument.Results[1].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[1].Data.SingleValue.Type.Should().Be("recordCompanies"); - responseDocument.Results[1].Data.SingleValue.Lid.Should().BeNull(); - responseDocument.Results[1].Data.SingleValue.Attributes["name"].Should().Be(newCompanyName); + responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("recordCompanies"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newCompanyName)); + }); responseDocument.Results[2].Data.Value.Should().BeNull(); - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue.Id); - short newCompanyId = short.Parse(responseDocument.Results[1].Data.SingleValue.Id); + Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + short newCompanyId = short.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -725,7 +757,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => trackInDatabase.Title.Should().Be(newTrackTitle); - trackInDatabase.OwnedBy.Should().NotBeNull(); + trackInDatabase.OwnedBy.ShouldNotBeNull(); trackInDatabase.OwnedBy.Id.Should().Be(newCompanyId); trackInDatabase.OwnedBy.Name.Should().Be(newCompanyName); }); @@ -736,7 +768,7 @@ public async Task Can_create_OneToMany_relationship_using_local_ID() { // Arrange string newTrackTitle = _fakers.MusicTrack.Generate().Title; - string newArtistName = _fakers.Performer.Generate().ArtistName; + string newArtistName = _fakers.Performer.Generate().ArtistName!; const string trackLocalId = "track-1"; const string performerLocalId = "performer-1"; @@ -800,22 +832,26 @@ public async Task Can_create_OneToMany_relationship_using_local_ID() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(3); + responseDocument.Results.ShouldHaveCount(3); - responseDocument.Results[0].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[0].Data.SingleValue.Type.Should().Be("musicTracks"); - responseDocument.Results[0].Data.SingleValue.Lid.Should().BeNull(); - responseDocument.Results[0].Data.SingleValue.Attributes["title"].Should().Be(newTrackTitle); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("musicTracks"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); + }); - responseDocument.Results[1].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[1].Data.SingleValue.Type.Should().Be("performers"); - responseDocument.Results[1].Data.SingleValue.Lid.Should().BeNull(); - responseDocument.Results[1].Data.SingleValue.Attributes["artistName"].Should().Be(newArtistName); + responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("performers"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("artistName").With(value => value.Should().Be(newArtistName)); + }); responseDocument.Results[2].Data.Value.Should().BeNull(); - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue.Id); - int newPerformerId = int.Parse(responseDocument.Results[1].Data.SingleValue.Id); + Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + int newPerformerId = int.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -823,7 +859,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => trackInDatabase.Title.Should().Be(newTrackTitle); - trackInDatabase.Performers.Should().HaveCount(1); + trackInDatabase.Performers.ShouldHaveCount(1); trackInDatabase.Performers[0].Id.Should().Be(newPerformerId); trackInDatabase.Performers[0].ArtistName.Should().Be(newArtistName); }); @@ -898,22 +934,26 @@ public async Task Can_create_ManyToMany_relationship_using_local_ID() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(3); + responseDocument.Results.ShouldHaveCount(3); - responseDocument.Results[0].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[0].Data.SingleValue.Type.Should().Be("playlists"); - responseDocument.Results[0].Data.SingleValue.Lid.Should().BeNull(); - responseDocument.Results[0].Data.SingleValue.Attributes["name"].Should().Be(newPlaylistName); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("playlists"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newPlaylistName)); + }); - responseDocument.Results[1].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[1].Data.SingleValue.Type.Should().Be("musicTracks"); - responseDocument.Results[1].Data.SingleValue.Lid.Should().BeNull(); - responseDocument.Results[1].Data.SingleValue.Attributes["title"].Should().Be(newTrackTitle); + responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("musicTracks"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); + }); responseDocument.Results[2].Data.Value.Should().BeNull(); - long newPlaylistId = long.Parse(responseDocument.Results[0].Data.SingleValue.Id); - Guid newTrackId = Guid.Parse(responseDocument.Results[1].Data.SingleValue.Id); + long newPlaylistId = long.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + Guid newTrackId = Guid.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -921,7 +961,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => playlistInDatabase.Name.Should().Be(newPlaylistName); - playlistInDatabase.Tracks.Should().HaveCount(1); + playlistInDatabase.Tracks.ShouldHaveCount(1); playlistInDatabase.Tracks[0].Id.Should().Be(newTrackId); playlistInDatabase.Tracks[0].Title.Should().Be(newTrackTitle); }); @@ -934,7 +974,7 @@ public async Task Can_replace_OneToMany_relationship_using_local_ID() Performer existingPerformer = _fakers.Performer.Generate(); string newTrackTitle = _fakers.MusicTrack.Generate().Title; - string newArtistName = _fakers.Performer.Generate().ArtistName; + string newArtistName = _fakers.Performer.Generate().ArtistName!; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -1018,22 +1058,26 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(3); + responseDocument.Results.ShouldHaveCount(3); - responseDocument.Results[0].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[0].Data.SingleValue.Type.Should().Be("musicTracks"); - responseDocument.Results[0].Data.SingleValue.Lid.Should().BeNull(); - responseDocument.Results[0].Data.SingleValue.Attributes["title"].Should().Be(newTrackTitle); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("musicTracks"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); + }); - responseDocument.Results[1].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[1].Data.SingleValue.Type.Should().Be("performers"); - responseDocument.Results[1].Data.SingleValue.Lid.Should().BeNull(); - responseDocument.Results[1].Data.SingleValue.Attributes["artistName"].Should().Be(newArtistName); + responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("performers"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("artistName").With(value => value.Should().Be(newArtistName)); + }); responseDocument.Results[2].Data.Value.Should().BeNull(); - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue.Id); - int newPerformerId = int.Parse(responseDocument.Results[1].Data.SingleValue.Id); + Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + int newPerformerId = int.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -1041,7 +1085,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => trackInDatabase.Title.Should().Be(newTrackTitle); - trackInDatabase.Performers.Should().HaveCount(1); + trackInDatabase.Performers.ShouldHaveCount(1); trackInDatabase.Performers[0].Id.Should().Be(newPerformerId); trackInDatabase.Performers[0].ArtistName.Should().Be(newArtistName); }); @@ -1138,22 +1182,26 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(3); + responseDocument.Results.ShouldHaveCount(3); - responseDocument.Results[0].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[0].Data.SingleValue.Type.Should().Be("playlists"); - responseDocument.Results[0].Data.SingleValue.Lid.Should().BeNull(); - responseDocument.Results[0].Data.SingleValue.Attributes["name"].Should().Be(newPlaylistName); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("playlists"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newPlaylistName)); + }); - responseDocument.Results[1].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[1].Data.SingleValue.Type.Should().Be("musicTracks"); - responseDocument.Results[1].Data.SingleValue.Lid.Should().BeNull(); - responseDocument.Results[1].Data.SingleValue.Attributes["title"].Should().Be(newTrackTitle); + responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("musicTracks"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); + }); responseDocument.Results[2].Data.Value.Should().BeNull(); - long newPlaylistId = long.Parse(responseDocument.Results[0].Data.SingleValue.Id); - Guid newTrackId = Guid.Parse(responseDocument.Results[1].Data.SingleValue.Id); + long newPlaylistId = long.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + Guid newTrackId = Guid.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -1161,7 +1209,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => playlistInDatabase.Name.Should().Be(newPlaylistName); - playlistInDatabase.Tracks.Should().HaveCount(1); + playlistInDatabase.Tracks.ShouldHaveCount(1); playlistInDatabase.Tracks[0].Id.Should().Be(newTrackId); playlistInDatabase.Tracks[0].Title.Should().Be(newTrackTitle); }); @@ -1174,7 +1222,7 @@ public async Task Can_add_to_OneToMany_relationship_using_local_ID() Performer existingPerformer = _fakers.Performer.Generate(); string newTrackTitle = _fakers.MusicTrack.Generate().Title; - string newArtistName = _fakers.Performer.Generate().ArtistName; + string newArtistName = _fakers.Performer.Generate().ArtistName!; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -1258,22 +1306,26 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(3); + responseDocument.Results.ShouldHaveCount(3); - responseDocument.Results[0].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[0].Data.SingleValue.Type.Should().Be("musicTracks"); - responseDocument.Results[0].Data.SingleValue.Lid.Should().BeNull(); - responseDocument.Results[0].Data.SingleValue.Attributes["title"].Should().Be(newTrackTitle); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("musicTracks"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); + }); - responseDocument.Results[1].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[1].Data.SingleValue.Type.Should().Be("performers"); - responseDocument.Results[1].Data.SingleValue.Lid.Should().BeNull(); - responseDocument.Results[1].Data.SingleValue.Attributes["artistName"].Should().Be(newArtistName); + responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("performers"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("artistName").With(value => value.Should().Be(newArtistName)); + }); responseDocument.Results[2].Data.Value.Should().BeNull(); - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue.Id); - int newPerformerId = int.Parse(responseDocument.Results[1].Data.SingleValue.Id); + Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + int newPerformerId = int.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -1281,7 +1333,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => trackInDatabase.Title.Should().Be(newTrackTitle); - trackInDatabase.Performers.Should().HaveCount(2); + trackInDatabase.Performers.ShouldHaveCount(2); trackInDatabase.Performers[0].Id.Should().Be(existingPerformer.Id); trackInDatabase.Performers[0].ArtistName.Should().Be(existingPerformer.ArtistName); @@ -1400,24 +1452,28 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(4); + responseDocument.Results.ShouldHaveCount(4); - responseDocument.Results[0].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[0].Data.SingleValue.Type.Should().Be("playlists"); - responseDocument.Results[0].Data.SingleValue.Lid.Should().BeNull(); - responseDocument.Results[0].Data.SingleValue.Attributes["name"].Should().Be(newPlaylistName); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("playlists"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newPlaylistName)); + }); - responseDocument.Results[1].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[1].Data.SingleValue.Type.Should().Be("musicTracks"); - responseDocument.Results[1].Data.SingleValue.Lid.Should().BeNull(); - responseDocument.Results[1].Data.SingleValue.Attributes["title"].Should().Be(newTrackTitle); + responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("musicTracks"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); + }); responseDocument.Results[2].Data.Value.Should().BeNull(); responseDocument.Results[3].Data.Value.Should().BeNull(); - long newPlaylistId = long.Parse(responseDocument.Results[0].Data.SingleValue.Id); - Guid newTrackId = Guid.Parse(responseDocument.Results[1].Data.SingleValue.Id); + long newPlaylistId = long.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + Guid newTrackId = Guid.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -1425,7 +1481,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => playlistInDatabase.Name.Should().Be(newPlaylistName); - playlistInDatabase.Tracks.Should().HaveCount(3); + playlistInDatabase.Tracks.ShouldHaveCount(3); playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[0].Id); playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[1].Id); playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == newTrackId); @@ -1439,8 +1495,8 @@ public async Task Can_remove_from_OneToMany_relationship_using_local_ID() Performer existingPerformer = _fakers.Performer.Generate(); string newTrackTitle = _fakers.MusicTrack.Generate().Title; - string newArtistName1 = _fakers.Performer.Generate().ArtistName; - string newArtistName2 = _fakers.Performer.Generate().ArtistName; + string newArtistName1 = _fakers.Performer.Generate().ArtistName!; + string newArtistName2 = _fakers.Performer.Generate().ArtistName!; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -1553,26 +1609,32 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(4); + responseDocument.Results.ShouldHaveCount(4); - responseDocument.Results[0].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[0].Data.SingleValue.Type.Should().Be("performers"); - responseDocument.Results[0].Data.SingleValue.Lid.Should().BeNull(); - responseDocument.Results[0].Data.SingleValue.Attributes["artistName"].Should().Be(newArtistName1); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("performers"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("artistName").With(value => value.Should().Be(newArtistName1)); + }); - responseDocument.Results[1].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[1].Data.SingleValue.Type.Should().Be("performers"); - responseDocument.Results[1].Data.SingleValue.Lid.Should().BeNull(); - responseDocument.Results[1].Data.SingleValue.Attributes["artistName"].Should().Be(newArtistName2); + responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("performers"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("artistName").With(value => value.Should().Be(newArtistName2)); + }); - responseDocument.Results[2].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[2].Data.SingleValue.Type.Should().Be("musicTracks"); - responseDocument.Results[2].Data.SingleValue.Lid.Should().BeNull(); - responseDocument.Results[2].Data.SingleValue.Attributes["title"].Should().Be(newTrackTitle); + responseDocument.Results[2].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("musicTracks"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); + }); responseDocument.Results[3].Data.Value.Should().BeNull(); - Guid newTrackId = Guid.Parse(responseDocument.Results[2].Data.SingleValue.Id); + Guid newTrackId = Guid.Parse(responseDocument.Results[2].Data.SingleValue!.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -1580,7 +1642,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => trackInDatabase.Title.Should().Be(newTrackTitle); - trackInDatabase.Performers.Should().HaveCount(1); + trackInDatabase.Performers.ShouldHaveCount(1); trackInDatabase.Performers[0].Id.Should().Be(existingPerformer.Id); trackInDatabase.Performers[0].ArtistName.Should().Be(existingPerformer.ArtistName); }); @@ -1685,12 +1747,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(4); + responseDocument.Results.ShouldHaveCount(4); - responseDocument.Results[0].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[0].Data.SingleValue.Type.Should().Be("musicTracks"); - responseDocument.Results[0].Data.SingleValue.Lid.Should().BeNull(); - responseDocument.Results[0].Data.SingleValue.Attributes["title"].Should().Be(newTrackTitle); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("musicTracks"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); + }); responseDocument.Results[1].Data.Value.Should().BeNull(); @@ -1702,7 +1766,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(existingPlaylist.Id); - playlistInDatabase.Tracks.Should().HaveCount(1); + playlistInDatabase.Tracks.ShouldHaveCount(1); playlistInDatabase.Tracks[0].Id.Should().Be(existingPlaylist.Tracks[0].Id); }); } @@ -1752,20 +1816,22 @@ public async Task Can_delete_resource_using_local_ID() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(2); + responseDocument.Results.ShouldHaveCount(2); - responseDocument.Results[0].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[0].Data.SingleValue.Type.Should().Be("musicTracks"); - responseDocument.Results[0].Data.SingleValue.Lid.Should().BeNull(); - responseDocument.Results[0].Data.SingleValue.Attributes["title"].Should().Be(newTrackTitle); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("musicTracks"); + resource.Lid.Should().BeNull(); + resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); + }); responseDocument.Results[1].Data.Value.Should().BeNull(); - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue.Id); + Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { - MusicTrack trackInDatabase = await dbContext.MusicTracks.FirstWithIdOrDefaultAsync(newTrackId); + MusicTrack? trackInDatabase = await dbContext.MusicTracks.FirstWithIdOrDefaultAsync(newTrackId); trackInDatabase.Should().BeNull(); }); @@ -1808,12 +1874,13 @@ public async Task Cannot_consume_unassigned_local_ID_in_ref() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("Server-generated value for local ID is not available at this point."); error.Detail.Should().Be($"Server-generated value for local ID '{Unknown.LocalId}' is not available at this point."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[1]"); } @@ -1857,12 +1924,13 @@ public async Task Cannot_consume_unassigned_local_ID_in_data_element() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("Server-generated value for local ID is not available at this point."); error.Detail.Should().Be($"Server-generated value for local ID '{Unknown.LocalId}' is not available at this point."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[1]"); } @@ -1920,12 +1988,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("Server-generated value for local ID is not available at this point."); error.Detail.Should().Be($"Server-generated value for local ID '{Unknown.LocalId}' is not available at this point."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[1]"); } @@ -1933,6 +2002,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_consume_unassigned_local_ID_in_relationship_data_element() { // Arrange + string newTrackTitle = _fakers.MusicTrack.Generate().Title; + var requestBody = new { atomic__operations = new object[] @@ -1952,6 +2023,10 @@ public async Task Cannot_consume_unassigned_local_ID_in_relationship_data_elemen data = new { type = "musicTracks", + attributes = new + { + title = newTrackTitle + }, relationships = new { ownedBy = new @@ -1976,12 +2051,13 @@ public async Task Cannot_consume_unassigned_local_ID_in_relationship_data_elemen // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("Server-generated value for local ID is not available at this point."); error.Detail.Should().Be($"Server-generated value for local ID '{Unknown.LocalId}' is not available at this point."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[1]"); } @@ -1989,6 +2065,8 @@ public async Task Cannot_consume_unassigned_local_ID_in_relationship_data_elemen public async Task Cannot_consume_unassigned_local_ID_in_relationship_data_array() { // Arrange + string newPlaylistName = _fakers.Playlist.Generate().Name; + var requestBody = new { atomic__operations = new object[] @@ -2008,6 +2086,10 @@ public async Task Cannot_consume_unassigned_local_ID_in_relationship_data_array( data = new { type = "playlists", + attributes = new + { + name = newPlaylistName + }, relationships = new { tracks = new @@ -2035,12 +2117,13 @@ public async Task Cannot_consume_unassigned_local_ID_in_relationship_data_array( // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("Server-generated value for local ID is not available at this point."); error.Detail.Should().Be($"Server-generated value for local ID '{Unknown.LocalId}' is not available at this point."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[1]"); } @@ -2049,6 +2132,7 @@ public async Task Cannot_consume_local_ID_of_different_type_in_same_operation() { // Arrange const string trackLocalId = "track-1"; + string newTrackTitle = _fakers.MusicTrack.Generate().Title; var requestBody = new { @@ -2070,6 +2154,10 @@ public async Task Cannot_consume_local_ID_of_different_type_in_same_operation() { type = "musicTracks", lid = trackLocalId, + attributes = new + { + title = newTrackTitle + }, relationships = new { ownedBy = new @@ -2094,12 +2182,13 @@ public async Task Cannot_consume_local_ID_of_different_type_in_same_operation() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); 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.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[1]"); } @@ -2109,6 +2198,8 @@ public async Task Cannot_consume_local_ID_of_different_type_in_ref() // Arrange const string companyLocalId = "company-1"; + string newCompanyName = _fakers.RecordCompany.Generate().Name; + var requestBody = new { atomic__operations = new object[] @@ -2128,7 +2219,11 @@ public async Task Cannot_consume_local_ID_of_different_type_in_ref() data = new { type = "recordCompanies", - lid = companyLocalId + lid = companyLocalId, + attributes = new + { + name = newCompanyName + } } }, new @@ -2151,12 +2246,13 @@ public async Task Cannot_consume_local_ID_of_different_type_in_ref() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); 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.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[2]"); } @@ -2211,12 +2307,13 @@ public async Task Cannot_consume_local_ID_of_different_type_in_data_element() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); 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.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[2]"); } @@ -2228,6 +2325,8 @@ public async Task Cannot_consume_local_ID_of_different_type_in_data_array() const string companyLocalId = "company-1"; + string newCompanyName = _fakers.RecordCompany.Generate().Name; + await _testContext.RunOnDatabaseAsync(async dbContext => { dbContext.MusicTracks.Add(existingTrack); @@ -2253,7 +2352,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = new { type = "recordCompanies", - lid = companyLocalId + lid = companyLocalId, + attributes = new + { + name = newCompanyName + } } }, new @@ -2285,12 +2388,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); 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.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[2]"); } @@ -2299,6 +2403,7 @@ public async Task Cannot_consume_local_ID_of_different_type_in_relationship_data { // Arrange string newPlaylistName = _fakers.Playlist.Generate().Name; + string newTrackTitle = _fakers.MusicTrack.Generate().Title; const string playlistLocalId = "playlist-1"; @@ -2334,6 +2439,10 @@ public async Task Cannot_consume_local_ID_of_different_type_in_relationship_data data = new { type = "musicTracks", + attributes = new + { + title = newTrackTitle + }, relationships = new { ownedBy = new @@ -2358,12 +2467,13 @@ public async Task Cannot_consume_local_ID_of_different_type_in_relationship_data // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); 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.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[2]"); } @@ -2372,6 +2482,7 @@ public async Task Cannot_consume_local_ID_of_different_type_in_relationship_data { // Arrange const string performerLocalId = "performer-1"; + string newPlaylistName = _fakers.Playlist.Generate().Name; var requestBody = new { @@ -2401,6 +2512,10 @@ public async Task Cannot_consume_local_ID_of_different_type_in_relationship_data data = new { type = "playlists", + attributes = new + { + name = newPlaylistName + }, relationships = new { tracks = new @@ -2428,12 +2543,13 @@ public async Task Cannot_consume_local_ID_of_different_type_in_relationship_data // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); 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.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[2]"); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Lyric.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Lyric.cs index 84b9a2c108..e39e52ffdd 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Lyric.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Lyric.cs @@ -9,18 +9,18 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations public sealed class Lyric : Identifiable { [Attr] - public string Format { get; set; } + public string? Format { get; set; } [Attr] - public string Text { get; set; } + public string Text { get; set; } = null!; [Attr(Capabilities = AttrCapabilities.None)] public DateTimeOffset CreatedAt { get; set; } [HasOne] - public TextLanguage Language { get; set; } + public TextLanguage? Language { get; set; } [HasOne] - public MusicTrack Track { get; set; } + public MusicTrack? Track { get; set; } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/LyricsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/LyricsController.cs index 12d83708d9..24936babb9 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/LyricsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/LyricsController.cs @@ -7,8 +7,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations { public sealed class LyricsController : JsonApiController { - public LyricsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public LyricsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs index a13c92eb2c..6f26bd8b9b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs @@ -85,18 +85,34 @@ public async Task Returns_resource_meta_in_create_resource_with_side_effects() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(2); + responseDocument.Results.ShouldHaveCount(2); - responseDocument.Results[0].Data.SingleValue.Meta.Should().HaveCount(1); - ((JsonElement)responseDocument.Results[0].Data.SingleValue.Meta["copyright"]).GetString().Should().Be("(C) 2018. All rights reserved."); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Meta.ShouldHaveCount(1); - responseDocument.Results[1].Data.SingleValue.Meta.Should().HaveCount(1); - ((JsonElement)responseDocument.Results[1].Data.SingleValue.Meta["copyright"]).GetString().Should().Be("(C) 1994. All rights reserved."); + resource.Meta.ShouldContainKey("copyright").With(value => + { + JsonElement element = value.Should().BeOfType().Subject; + element.GetString().Should().Be("(C) 2018. All rights reserved."); + }); + }); + + responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Meta.ShouldHaveCount(1); + + resource.Meta.ShouldContainKey("copyright").With(value => + { + JsonElement element = value.Should().BeOfType().Subject; + element.GetString().Should().Be("(C) 1994. All rights reserved."); + }); + }); hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(MusicTrack), ResourceDefinitionHitCounter.ExtensibilityPoint.GetMeta), - (typeof(MusicTrack), ResourceDefinitionHitCounter.ExtensibilityPoint.GetMeta) + (typeof(MusicTrack), ResourceDefinitionExtensibilityPoints.GetMeta), + (typeof(MusicTrack), ResourceDefinitionExtensibilityPoints.GetMeta) }, options => options.WithStrictOrdering()); } @@ -141,13 +157,22 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(1); - responseDocument.Results[0].Data.SingleValue.Meta.Should().HaveCount(1); - ((JsonElement)responseDocument.Results[0].Data.SingleValue.Meta["notice"]).GetString().Should().Be(TextLanguageMetaDefinition.NoticeText); + responseDocument.Results.ShouldHaveCount(1); + + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Meta.ShouldHaveCount(1); + + resource.Meta.ShouldContainKey("notice").With(value => + { + JsonElement element = value.Should().BeOfType().Subject; + element.GetString().Should().Be(TextLanguageMetaDefinition.NoticeText); + }); + }); hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(TextLanguage), ResourceDefinitionHitCounter.ExtensibilityPoint.GetMeta) + (typeof(TextLanguage), ResourceDefinitionExtensibilityPoints.GetMeta) }, options => options.WithStrictOrdering()); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMeta.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMeta.cs index c6fede0077..f1b4d771b1 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMeta.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMeta.cs @@ -1,13 +1,13 @@ using System.Collections.Generic; -using JsonApiDotNetCore.Serialization; +using JsonApiDotNetCore.Serialization.Response; namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Meta { public sealed class AtomicResponseMeta : IResponseMeta { - public IReadOnlyDictionary GetMeta() + public IReadOnlyDictionary GetMeta() { - return new Dictionary + return new Dictionary { ["license"] = "MIT", ["projectUrl"] = "https://github.com/json-api-dotnet/JsonApiDotNetCore/", diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMetaTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMetaTests.cs index f775ea6c79..5f74d971fa 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; @@ -28,6 +28,7 @@ public AtomicResponseMetaTests(IntegrationTestContext(); + services.AddSingleton(); services.AddSingleton(); }); } @@ -62,17 +63,31 @@ public async Task Returns_top_level_meta_in_create_resource_with_side_effects() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Meta.Should().HaveCount(3); - ((JsonElement)responseDocument.Meta["license"]).GetString().Should().Be("MIT"); - ((JsonElement)responseDocument.Meta["projectUrl"]).GetString().Should().Be("https://github.com/json-api-dotnet/JsonApiDotNetCore/"); + responseDocument.Meta.ShouldHaveCount(3); - string[] versionArray = ((JsonElement)responseDocument.Meta["versions"]).EnumerateArray().Select(element => element.GetString()).ToArray(); + responseDocument.Meta.ShouldContainKey("license").With(value => + { + JsonElement element = value.Should().BeOfType().Subject; + element.GetString().Should().Be("MIT"); + }); - versionArray.Should().HaveCount(4); - versionArray.Should().Contain("v4.0.0"); - versionArray.Should().Contain("v3.1.0"); - versionArray.Should().Contain("v2.5.2"); - versionArray.Should().Contain("v1.3.1"); + responseDocument.Meta.ShouldContainKey("projectUrl").With(value => + { + JsonElement element = value.Should().BeOfType().Subject; + element.GetString().Should().Be("https://github.com/json-api-dotnet/JsonApiDotNetCore/"); + }); + + responseDocument.Meta.ShouldContainKey("versions").With(value => + { + JsonElement element = value.Should().BeOfType().Subject; + string?[] versionArray = element.EnumerateArray().Select(arrayItem => arrayItem.GetString()).ToArray(); + + versionArray.ShouldHaveCount(4); + versionArray.Should().Contain("v4.0.0"); + versionArray.Should().Contain("v3.1.0"); + versionArray.Should().Contain("v2.5.2"); + versionArray.Should().Contain("v1.3.1"); + }); } [Fact] @@ -114,17 +129,31 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Meta.Should().HaveCount(3); - ((JsonElement)responseDocument.Meta["license"]).GetString().Should().Be("MIT"); - ((JsonElement)responseDocument.Meta["projectUrl"]).GetString().Should().Be("https://github.com/json-api-dotnet/JsonApiDotNetCore/"); + responseDocument.Meta.ShouldHaveCount(3); - string[] versionArray = ((JsonElement)responseDocument.Meta["versions"]).EnumerateArray().Select(element => element.GetString()).ToArray(); + responseDocument.Meta.ShouldContainKey("license").With(value => + { + JsonElement element = value.Should().BeOfType().Subject; + element.GetString().Should().Be("MIT"); + }); - versionArray.Should().HaveCount(4); - versionArray.Should().Contain("v4.0.0"); - versionArray.Should().Contain("v3.1.0"); - versionArray.Should().Contain("v2.5.2"); - versionArray.Should().Contain("v1.3.1"); + responseDocument.Meta.ShouldContainKey("projectUrl").With(value => + { + JsonElement element = value.Should().BeOfType().Subject; + element.GetString().Should().Be("https://github.com/json-api-dotnet/JsonApiDotNetCore/"); + }); + + responseDocument.Meta.ShouldContainKey("versions").With(value => + { + JsonElement element = value.Should().BeOfType().Subject; + string?[] versionArray = element.EnumerateArray().Select(arrayItem => arrayItem.GetString()).ToArray(); + + versionArray.ShouldHaveCount(4); + versionArray.Should().Contain("v4.0.0"); + versionArray.Should().Contain("v3.1.0"); + versionArray.Should().Contain("v2.5.2"); + versionArray.Should().Contain("v1.3.1"); + }); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/MusicTrackMetaDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/MusicTrackMetaDefinition.cs index b2b9ae37fc..69557713bd 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/MusicTrackMetaDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/MusicTrackMetaDefinition.cs @@ -2,26 +2,24 @@ using System.Collections.Generic; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Resources; namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Meta { [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class MusicTrackMetaDefinition : JsonApiResourceDefinition + public sealed class MusicTrackMetaDefinition : HitCountingResourceDefinition { - private readonly ResourceDefinitionHitCounter _hitCounter; + protected override ResourceDefinitionExtensibilityPoints ExtensibilityPointsToTrack => ResourceDefinitionExtensibilityPoints.GetMeta; public MusicTrackMetaDefinition(IResourceGraph resourceGraph, ResourceDefinitionHitCounter hitCounter) - : base(resourceGraph) + : base(resourceGraph, hitCounter) { - _hitCounter = hitCounter; } - public override IDictionary GetMeta(MusicTrack resource) + public override IDictionary GetMeta(MusicTrack resource) { - _hitCounter.TrackInvocation(ResourceDefinitionHitCounter.ExtensibilityPoint.GetMeta); + base.GetMeta(resource); - return new Dictionary + return new Dictionary { ["Copyright"] = $"(C) {resource.ReleasedAt.Year}. All rights reserved." }; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/TextLanguageMetaDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/TextLanguageMetaDefinition.cs index 28a0322e91..2badcd6f88 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/TextLanguageMetaDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/TextLanguageMetaDefinition.cs @@ -9,19 +9,18 @@ public sealed class TextLanguageMetaDefinition : ImplicitlyChangingTextLanguageD { internal const string NoticeText = "See https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes for ISO 639-1 language codes."; - private readonly ResourceDefinitionHitCounter _hitCounter; + protected override ResourceDefinitionExtensibilityPoints ExtensibilityPointsToTrack => ResourceDefinitionExtensibilityPoints.GetMeta; - public TextLanguageMetaDefinition(IResourceGraph resourceGraph, OperationsDbContext dbContext, ResourceDefinitionHitCounter hitCounter) - : base(resourceGraph, dbContext) + public TextLanguageMetaDefinition(IResourceGraph resourceGraph, ResourceDefinitionHitCounter hitCounter, OperationsDbContext dbContext) + : base(resourceGraph, hitCounter, dbContext) { - _hitCounter = hitCounter; } - public override IDictionary GetMeta(TextLanguage resource) + public override IDictionary GetMeta(TextLanguage resource) { - _hitCounter.TrackInvocation(ResourceDefinitionHitCounter.ExtensibilityPoint.GetMeta); + base.GetMeta(resource); - return new Dictionary + return new Dictionary { ["Notice"] = NoticeText }; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicLoggingTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicLoggingTests.cs new file mode 100644 index 0000000000..f37cbd170f --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicLoggingTests.cs @@ -0,0 +1,187 @@ +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.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.InternalServerError); + error.Title.Should().Be("An unhandled error occurred while processing an operation in this request."); + error.Detail.Should().Be("Simulated failure."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]"); + + loggerFactory.Logger.Messages.ShouldNotBeEmpty(); + + 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.ShouldHaveCount(1); + + loggerFactory.Logger.Messages.ShouldNotBeEmpty(); + + 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..8bc07968e8 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; @@ -29,27 +27,43 @@ public async Task Cannot_process_for_missing_request_body() const string route = "/operations"; // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, null); + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, null!); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); 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(); + error.Meta.Should().NotContainKey("requestBody"); + } - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List performersInDatabase = await dbContext.Performers.ToListAsync(); - performersInDatabase.Should().BeEmpty(); + [Fact] + public async Task Cannot_process_for_null_request_body() + { + // Arrange + const string requestBody = "null"; - List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.Should().BeEmpty(); - }); + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Expected an object, instead of 'null'."); + error.Detail.Should().BeNull(); + error.Source.Should().BeNull(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -66,7 +80,7 @@ public async Task Cannot_process_for_broken_JSON_request_body() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); @@ -75,6 +89,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.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: No operations found."); + error.Detail.Should().BeNull(); + error.Source.Should().BeNull(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } + [Fact] public async Task Cannot_process_empty_operations_array() { @@ -92,22 +136,45 @@ public async Task Cannot_process_empty_operations_array() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: No operations found."); error.Detail.Should().BeNull(); error.Source.Should().BeNull(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } - await _testContext.RunOnDatabaseAsync(async dbContext => + [Fact] + public async Task Cannot_process_null_operation() + { + // Arrange + var requestBody = new { - List performersInDatabase = await dbContext.Performers.ToListAsync(); - performersInDatabase.Should().BeEmpty(); + atomic__operations = new[] + { + (object?)null + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.ShouldHaveCount(1); - List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.Should().BeEmpty(); - }); + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Expected an object, instead of 'null'."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -140,22 +207,13 @@ public async Task Cannot_process_for_unknown_operation_code() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body."); error.Detail.Should().StartWith("The JSON value could not be converted to "); error.Source.Should().BeNull(); - - 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..358bb9935d 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,13 @@ 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(); + + services.AddSingleton(); }); var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); @@ -39,32 +38,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 + { + } + } + }, + new + { + op = "add", + data = new + { + type = "textLanguages", + id = newLanguage.StringId, attributes = new { - artistName = newPerformer.ArtistName, - bornAt = newPerformer.BornAt + isoCode = newLanguage.IsoCode } } } @@ -86,17 +99,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 +169,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..b15a8215e2 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/MaximumOperationsPerRequestTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/MaximumOperationsPerRequestTests.cs @@ -68,13 +68,15 @@ public async Task Cannot_process_more_operations_than_maximum() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: 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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs index 39b43ecf01..9fad376626 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs @@ -3,20 +3,18 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreTests.Startups; using Microsoft.EntityFrameworkCore; using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.ModelStateValidation { - public sealed class AtomicModelStateValidationTests - : IClassFixture, OperationsDbContext>> + public sealed class AtomicModelStateValidationTests : IClassFixture, OperationsDbContext>> { - private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly IntegrationTestContext, OperationsDbContext> _testContext; private readonly OperationsFakers _fakers = new(); - public AtomicModelStateValidationTests(IntegrationTestContext, OperationsDbContext> testContext) + public AtomicModelStateValidationTests(IntegrationTestContext, OperationsDbContext> testContext) { _testContext = testContext; @@ -54,18 +52,20 @@ public async Task Cannot_create_resource_with_multiple_violations() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(2); + responseDocument.Errors.ShouldHaveCount(2); ErrorObject error1 = responseDocument.Errors[0]; error1.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error1.Title.Should().Be("Input validation failed."); error1.Detail.Should().Be("The Title field is required."); + error1.Source.ShouldNotBeNull(); error1.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/title"); ErrorObject error2 = responseDocument.Errors[1]; error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error2.Title.Should().Be("Input validation failed."); error2.Detail.Should().Be("The field LengthInSeconds must be between 1 and 1440."); + error2.Source.ShouldNotBeNull(); error2.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/lengthInSeconds"); } @@ -123,15 +123,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(1); + responseDocument.Results.ShouldHaveCount(1); - long newPlaylistId = long.Parse(responseDocument.Results[0].Data.SingleValue.Id); + long newPlaylistId = long.Parse(responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(newPlaylistId); - playlistInDatabase.Tracks.Should().HaveCount(1); + playlistInDatabase.Tracks.ShouldHaveCount(1); playlistInDatabase.Tracks[0].Id.Should().Be(existingTrack.Id); }); } @@ -161,7 +161,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => id = existingTrack.StringId, attributes = new { - title = (string)null, + title = (string?)null, lengthInSeconds = -1 } } @@ -177,18 +177,20 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(2); + responseDocument.Errors.ShouldHaveCount(2); ErrorObject error1 = responseDocument.Errors[0]; error1.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error1.Title.Should().Be("Input validation failed."); error1.Detail.Should().Be("The Title field is required."); + error1.Source.ShouldNotBeNull(); error1.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/title"); ErrorObject error2 = responseDocument.Errors[1]; error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error2.Title.Should().Be("Input validation failed."); error2.Detail.Should().Be("The field LengthInSeconds must be between 1 and 1440."); + error2.Source.ShouldNotBeNull(); error2.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/lengthInSeconds"); } @@ -197,7 +199,7 @@ public async Task Can_update_resource_with_omitted_required_attribute() { // Arrange MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - string newTrackGenre = _fakers.MusicTrack.Generate().Genre; + string newTrackGenre = _fakers.MusicTrack.Generate().Genre!; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -301,7 +303,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(existingPlaylist.Id); - playlistInDatabase.Tracks.Should().HaveCount(1); + playlistInDatabase.Tracks.ShouldHaveCount(1); playlistInDatabase.Tracks[0].Id.Should().Be(existingTrack.Id); }); } @@ -355,7 +357,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(existingTrack.Id); - trackInDatabase.OwnedBy.Should().NotBeNull(); + trackInDatabase.OwnedBy.ShouldNotBeNull(); trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); }); } @@ -412,7 +414,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(existingPlaylist.Id); - playlistInDatabase.Tracks.Should().HaveCount(1); + playlistInDatabase.Tracks.ShouldHaveCount(1); playlistInDatabase.Tracks[0].Id.Should().Be(existingTrack.Id); }); } @@ -434,7 +436,7 @@ public async Task Validates_all_operations_before_execution_starts() id = Unknown.StringId.For(), attributes = new { - name = (string)null + name = (string?)null } } }, @@ -446,6 +448,7 @@ public async Task Validates_all_operations_before_execution_starts() type = "musicTracks", attributes = new { + title = "some", lengthInSeconds = -1 } } @@ -461,25 +464,111 @@ public async Task Validates_all_operations_before_execution_starts() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(3); + responseDocument.Errors.ShouldHaveCount(2); ErrorObject error1 = responseDocument.Errors[0]; error1.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error1.Title.Should().Be("Input validation failed."); error1.Detail.Should().Be("The Name field is required."); + error1.Source.ShouldNotBeNull(); error1.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/name"); ErrorObject error2 = responseDocument.Errors[1]; error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error2.Title.Should().Be("Input validation failed."); - error2.Detail.Should().Be("The Title field is required."); - error2.Source.Pointer.Should().Be("/atomic:operations[1]/data/attributes/title"); + error2.Detail.Should().Be("The field LengthInSeconds must be between 1 and 1440."); + error2.Source.ShouldNotBeNull(); + error2.Source.Pointer.Should().Be("/atomic:operations[1]/data/attributes/lengthInSeconds"); + } + + [Fact] + public async Task Does_not_exceed_MaxModelValidationErrors() + { + // Arrange + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "add", + data = new + { + type = "playlists", + attributes = new + { + name = (string?)null + } + } + }, + new + { + op = "add", + data = new + { + type = "playlists", + attributes = new + { + name = (string?)null + } + } + }, + new + { + op = "add", + data = new + { + type = "musicTracks", + attributes = new + { + lengthInSeconds = -1 + } + } + }, + new + { + op = "add", + data = new + { + type = "playlists", + attributes = new + { + name = (string?)null + } + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.ShouldHaveCount(3); + + ErrorObject error1 = responseDocument.Errors[0]; + error1.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error1.Title.Should().Be("Input validation failed."); + error1.Detail.Should().Be("The maximum number of allowed model errors has been reached."); + error1.Source.Should().BeNull(); + + ErrorObject error2 = responseDocument.Errors[1]; + error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error2.Title.Should().Be("Input validation failed."); + error2.Detail.Should().Be("The Name field is required."); + error2.Source.ShouldNotBeNull(); + error2.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/name"); ErrorObject error3 = responseDocument.Errors[2]; error3.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error3.Title.Should().Be("Input validation failed."); - error3.Detail.Should().Be("The field LengthInSeconds must be between 1 and 1440."); - error3.Source.Pointer.Should().Be("/atomic:operations[1]/data/attributes/lengthInSeconds"); + error3.Detail.Should().Be("The Name field is required."); + error3.Source.ShouldNotBeNull(); + error3.Source.Pointer.Should().Be("/atomic:operations[1]/data/attributes/name"); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/MusicTrack.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/MusicTrack.cs index 5cca1995b5..8e3071aeb7 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/MusicTrack.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/MusicTrack.cs @@ -14,29 +14,28 @@ public sealed class MusicTrack : Identifiable public override Guid Id { get; set; } [Attr] - [Required] - public string Title { get; set; } + public string Title { get; set; } = null!; [Attr] [Range(1, 24 * 60)] public decimal? LengthInSeconds { get; set; } [Attr] - public string Genre { get; set; } + public string? Genre { get; set; } [Attr] public DateTimeOffset ReleasedAt { get; set; } [HasOne] - public Lyric Lyric { get; set; } + public Lyric? Lyric { get; set; } [HasOne] - public RecordCompany OwnedBy { get; set; } + public RecordCompany? OwnedBy { get; set; } [HasMany] - public IList Performers { get; set; } + public IList Performers { get; set; } = new List(); [HasMany] - public IList OccursIn { get; set; } + public IList OccursIn { get; set; } = new List(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/MusicTracksController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/MusicTracksController.cs index c7c17f0c43..697ba4a00e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/MusicTracksController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/MusicTracksController.cs @@ -8,8 +8,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations { public sealed class MusicTracksController : JsonApiController { - public MusicTracksController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public MusicTracksController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/OperationsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/OperationsController.cs index 851a0eceb2..eb1aa68911 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/OperationsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/OperationsController.cs @@ -9,9 +9,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations { public sealed class OperationsController : JsonApiOperationsController { - public OperationsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IOperationsProcessor processor, IJsonApiRequest request, - ITargetedFields targetedFields) - : base(options, loggerFactory, processor, request, targetedFields) + public OperationsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IOperationsProcessor processor, + IJsonApiRequest request, ITargetedFields targetedFields) + : base(options, resourceGraph, loggerFactory, processor, request, targetedFields) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/OperationsDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/OperationsDbContext.cs index 6c62dfaa0f..dc46d4e672 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/OperationsDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/OperationsDbContext.cs @@ -8,12 +8,12 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class OperationsDbContext : DbContext { - public DbSet Playlists { get; set; } - public DbSet MusicTracks { get; set; } - public DbSet Lyrics { get; set; } - public DbSet TextLanguages { get; set; } - public DbSet Performers { get; set; } - public DbSet RecordCompanies { get; set; } + public DbSet Playlists => Set(); + public DbSet MusicTracks => Set(); + public DbSet Lyrics => Set(); + public DbSet TextLanguages => Set(); + public DbSet Performers => Set(); + public DbSet RecordCompanies => Set(); public OperationsDbContext(DbContextOptions options) : base(options) @@ -24,7 +24,7 @@ protected override void OnModelCreating(ModelBuilder builder) { builder.Entity() .HasOne(musicTrack => musicTrack.Lyric) - .WithOne(lyric => lyric.Track) + .WithOne(lyric => lyric!.Track!) .HasForeignKey("LyricId"); builder.Entity() diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Performer.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Performer.cs index f910e6706c..d0403e340b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Performer.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Performer.cs @@ -6,10 +6,10 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Performer : Identifiable + public sealed class Performer : Identifiable { [Attr] - public string ArtistName { get; set; } + public string? ArtistName { get; set; } [Attr] public DateTimeOffset BornAt { get; set; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/PerformersController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/PerformersController.cs index eb9b756655..59c5dfc60c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/PerformersController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/PerformersController.cs @@ -5,10 +5,11 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations { - public sealed class PerformersController : JsonApiController + public sealed class PerformersController : JsonApiController { - public PerformersController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public PerformersController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Playlist.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Playlist.cs index c63467b3ec..43d05609a9 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Playlist.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Playlist.cs @@ -1,5 +1,4 @@ using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; @@ -11,14 +10,13 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations public sealed class Playlist : Identifiable { [Attr] - [Required] - public string Name { get; set; } + public string Name { get; set; } = null!; [NotMapped] [Attr] public bool IsArchived => false; [HasMany] - public IList Tracks { get; set; } + public IList Tracks { get; set; } = new List(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/PlaylistsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/PlaylistsController.cs index e5da241908..1b893615f3 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/PlaylistsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/PlaylistsController.cs @@ -7,8 +7,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations { public sealed class PlaylistsController : JsonApiController { - public PlaylistsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public PlaylistsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs index cd63f41dd1..a9d0f8a1c7 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs @@ -69,12 +69,13 @@ public async Task Cannot_include_on_operations_endpoint() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("Usage of one or more query string parameters is not allowed at the requested endpoint."); error.Detail.Should().Be("The parameter 'include' cannot be used at this endpoint."); + error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("include"); } @@ -108,12 +109,13 @@ public async Task Cannot_filter_on_operations_endpoint() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("Usage of one or more query string parameters is not allowed at the requested endpoint."); error.Detail.Should().Be("The parameter 'filter' cannot be used at this endpoint."); + error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("filter"); } @@ -147,12 +149,13 @@ public async Task Cannot_sort_on_operations_endpoint() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("Usage of one or more query string parameters is not allowed at the requested endpoint."); error.Detail.Should().Be("The parameter 'sort' cannot be used at this endpoint."); + error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("sort"); } @@ -186,12 +189,13 @@ public async Task Cannot_use_pagination_number_on_operations_endpoint() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("Usage of one or more query string parameters is not allowed at the requested endpoint."); error.Detail.Should().Be("The parameter 'page[number]' cannot be used at this endpoint."); + error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("page[number]"); } @@ -225,12 +229,13 @@ public async Task Cannot_use_pagination_size_on_operations_endpoint() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("Usage of one or more query string parameters is not allowed at the requested endpoint."); error.Detail.Should().Be("The parameter 'page[size]' cannot be used at this endpoint."); + error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("page[size]"); } @@ -264,12 +269,13 @@ public async Task Cannot_use_sparse_fieldset_on_operations_endpoint() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("Usage of one or more query string parameters is not allowed at the requested endpoint."); error.Detail.Should().Be("The parameter 'fields[recordCompanies]' cannot be used at this endpoint."); + error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("fields[recordCompanies]"); } @@ -297,7 +303,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue.ShouldHaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(musicTracks[2].StringId); } @@ -334,7 +340,7 @@ public async Task Cannot_use_Queryable_handler_on_operations_endpoint() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); @@ -343,6 +349,7 @@ public async Task Cannot_use_Queryable_handler_on_operations_endpoint() error.Detail.Should().Be("Query string parameter 'isRecentlyReleased' is unknown. " + "Set 'AllowUnknownQueryStringParameters' to 'true' in options to ignore unknown parameters."); + error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("isRecentlyReleased"); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/MusicTrackReleaseDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/MusicTrackReleaseDefinition.cs index 7544515cbc..1d3264bda7 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/MusicTrackReleaseDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/MusicTrackReleaseDefinition.cs @@ -24,7 +24,7 @@ public MusicTrackReleaseDefinition(IResourceGraph resourceGraph, ISystemClock sy public override QueryStringParameterHandlers OnRegisterQueryableHandlersForQueryStringParameters() { - return new() + return new QueryStringParameterHandlers { ["isRecentlyReleased"] = FilterOnRecentlyReleased }; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/RecordCompaniesController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/RecordCompaniesController.cs index a2fff83748..6cc63b23db 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/RecordCompaniesController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/RecordCompaniesController.cs @@ -7,8 +7,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations { public sealed class RecordCompaniesController : JsonApiController { - public RecordCompaniesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public RecordCompaniesController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/RecordCompany.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/RecordCompany.cs index 3a4611cbeb..b8ab7be551 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/RecordCompany.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/RecordCompany.cs @@ -9,15 +9,15 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations public sealed class RecordCompany : Identifiable { [Attr] - public string Name { get; set; } + public string Name { get; set; } = null!; [Attr] - public string CountryOfResidence { get; set; } + public string? CountryOfResidence { get; set; } [HasMany] - public IList Tracks { get; set; } + public IList Tracks { get; set; } = new List(); [HasOne] - public RecordCompany Parent { get; set; } + public RecordCompany? Parent { get; set; } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/AtomicSerializationResourceDefinitionTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/AtomicSerializationResourceDefinitionTests.cs index fbde96e586..277b3f6122 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/AtomicSerializationResourceDefinitionTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/AtomicSerializationResourceDefinitionTests.cs @@ -91,18 +91,28 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(2); + responseDocument.Results.ShouldHaveCount(2); - responseDocument.Results[0].Data.SingleValue.Attributes["name"].Should().Be(newCompanies[0].Name.ToUpperInvariant()); - responseDocument.Results[0].Data.SingleValue.Attributes["countryOfResidence"].Should().Be(newCompanies[0].CountryOfResidence.ToUpperInvariant()); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newCompanies[0].Name.ToUpperInvariant())); + + string countryOfResidence = newCompanies[0].CountryOfResidence!.ToUpperInvariant(); + resource.Attributes.ShouldContainKey("countryOfResidence").With(value => value.Should().Be(countryOfResidence)); + }); - responseDocument.Results[1].Data.SingleValue.Attributes["name"].Should().Be(newCompanies[1].Name.ToUpperInvariant()); - responseDocument.Results[1].Data.SingleValue.Attributes["countryOfResidence"].Should().Be(newCompanies[1].CountryOfResidence.ToUpperInvariant()); + responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newCompanies[1].Name.ToUpperInvariant())); + + string countryOfResidence = newCompanies[1].CountryOfResidence!.ToUpperInvariant(); + resource.Attributes.ShouldContainKey("countryOfResidence").With(value => value.Should().Be(countryOfResidence)); + }); await _testContext.RunOnDatabaseAsync(async dbContext => { List companiesInDatabase = await dbContext.RecordCompanies.ToListAsync(); - companiesInDatabase.Should().HaveCount(2); + companiesInDatabase.ShouldHaveCount(2); companiesInDatabase[0].Name.Should().Be(newCompanies[0].Name.ToUpperInvariant()); companiesInDatabase[0].CountryOfResidence.Should().Be(newCompanies[0].CountryOfResidence); @@ -113,10 +123,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(RecordCompany), ResourceDefinitionHitCounter.ExtensibilityPoint.OnDeserialize), - (typeof(RecordCompany), ResourceDefinitionHitCounter.ExtensibilityPoint.OnDeserialize), - (typeof(RecordCompany), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSerialize), - (typeof(RecordCompany), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSerialize) + (typeof(RecordCompany), ResourceDefinitionExtensibilityPoints.OnDeserialize), + (typeof(RecordCompany), ResourceDefinitionExtensibilityPoints.OnDeserialize), + (typeof(RecordCompany), ResourceDefinitionExtensibilityPoints.OnSerialize), + (typeof(RecordCompany), ResourceDefinitionExtensibilityPoints.OnSerialize) }, options => options.WithStrictOrdering()); } @@ -174,7 +184,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(1); + responseDocument.Results.ShouldHaveCount(1); hitCounter.HitExtensibilityPoints.Should().BeEmpty(); } @@ -233,20 +243,28 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - string country0 = existingCompanies[0].CountryOfResidence.ToUpperInvariant(); - string country1 = existingCompanies[1].CountryOfResidence.ToUpperInvariant(); + responseDocument.Results.ShouldHaveCount(2); - responseDocument.Results.Should().HaveCount(2); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(existingCompanies[0].Name)); + + string countryOfResidence = existingCompanies[0].CountryOfResidence!.ToUpperInvariant(); + resource.Attributes.ShouldContainKey("countryOfResidence").With(value => value.Should().Be(countryOfResidence)); + }); - responseDocument.Results[0].Data.SingleValue.Attributes["name"].Should().Be(existingCompanies[0].Name); - responseDocument.Results[0].Data.SingleValue.Attributes["countryOfResidence"].Should().Be(country0); - responseDocument.Results[1].Data.SingleValue.Attributes["name"].Should().Be(existingCompanies[1].Name); - responseDocument.Results[1].Data.SingleValue.Attributes["countryOfResidence"].Should().Be(country1); + responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(existingCompanies[1].Name)); + + string countryOfResidence = existingCompanies[1].CountryOfResidence!.ToUpperInvariant(); + resource.Attributes.ShouldContainKey("countryOfResidence").With(value => value.Should().Be(countryOfResidence)); + }); await _testContext.RunOnDatabaseAsync(async dbContext => { List companiesInDatabase = await dbContext.RecordCompanies.ToListAsync(); - companiesInDatabase.Should().HaveCount(2); + companiesInDatabase.ShouldHaveCount(2); companiesInDatabase[0].Name.Should().Be(existingCompanies[0].Name); companiesInDatabase[0].CountryOfResidence.Should().Be(existingCompanies[0].CountryOfResidence); @@ -257,10 +275,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(RecordCompany), ResourceDefinitionHitCounter.ExtensibilityPoint.OnDeserialize), - (typeof(RecordCompany), ResourceDefinitionHitCounter.ExtensibilityPoint.OnDeserialize), - (typeof(RecordCompany), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSerialize), - (typeof(RecordCompany), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSerialize) + (typeof(RecordCompany), ResourceDefinitionExtensibilityPoints.OnDeserialize), + (typeof(RecordCompany), ResourceDefinitionExtensibilityPoints.OnDeserialize), + (typeof(RecordCompany), ResourceDefinitionExtensibilityPoints.OnSerialize), + (typeof(RecordCompany), ResourceDefinitionExtensibilityPoints.OnSerialize) }, options => options.WithStrictOrdering()); } @@ -317,7 +335,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(1); + responseDocument.Results.ShouldHaveCount(1); hitCounter.HitExtensibilityPoints.Should().BeEmpty(); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/RecordCompanyDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/RecordCompanyDefinition.cs index fed5e96b21..8f46b69336 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/RecordCompanyDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/RecordCompanyDefinition.cs @@ -1,23 +1,21 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Resources; namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.ResourceDefinitions.Serialization { [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class RecordCompanyDefinition : JsonApiResourceDefinition + public sealed class RecordCompanyDefinition : HitCountingResourceDefinition { - private readonly ResourceDefinitionHitCounter _hitCounter; + protected override ResourceDefinitionExtensibilityPoints ExtensibilityPointsToTrack => ResourceDefinitionExtensibilityPoints.Serialization; public RecordCompanyDefinition(IResourceGraph resourceGraph, ResourceDefinitionHitCounter hitCounter) - : base(resourceGraph) + : base(resourceGraph, hitCounter) { - _hitCounter = hitCounter; } public override void OnDeserialize(RecordCompany resource) { - _hitCounter.TrackInvocation(ResourceDefinitionHitCounter.ExtensibilityPoint.OnDeserialize); + base.OnDeserialize(resource); if (!string.IsNullOrEmpty(resource.Name)) { @@ -27,7 +25,7 @@ public override void OnDeserialize(RecordCompany resource) public override void OnSerialize(RecordCompany resource) { - _hitCounter.TrackInvocation(ResourceDefinitionHitCounter.ExtensibilityPoint.OnSerialize); + base.OnSerialize(resource); if (!string.IsNullOrEmpty(resource.CountryOfResidence)) { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/AtomicSparseFieldSetResourceDefinitionTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/AtomicSparseFieldSetResourceDefinitionTests.cs index 4c6df1fc48..1d6f4127ce 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/AtomicSparseFieldSetResourceDefinitionTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/AtomicSparseFieldSetResourceDefinitionTests.cs @@ -89,20 +89,26 @@ public async Task Hides_text_in_create_resource_with_side_effects() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(2); + responseDocument.Results.ShouldHaveCount(2); - responseDocument.Results[0].Data.SingleValue.Attributes["format"].Should().Be(newLyrics[0].Format); - responseDocument.Results[0].Data.SingleValue.Attributes.Should().NotContainKey("text"); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Attributes.ShouldContainKey("format").With(value => value.Should().Be(newLyrics[0].Format)); + resource.Attributes.Should().NotContainKey("text"); + }); - responseDocument.Results[1].Data.SingleValue.Attributes["format"].Should().Be(newLyrics[1].Format); - responseDocument.Results[1].Data.SingleValue.Attributes.Should().NotContainKey("text"); + responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Attributes.ShouldContainKey("format").With(value => value.Should().Be(newLyrics[1].Format)); + resource.Attributes.Should().NotContainKey("text"); + }); hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(Lyric), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplySparseFieldSet), - (typeof(Lyric), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplySparseFieldSet), - (typeof(Lyric), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplySparseFieldSet), - (typeof(Lyric), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplySparseFieldSet) + (typeof(Lyric), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Lyric), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Lyric), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Lyric), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet) }, options => options.WithStrictOrdering()); } @@ -162,20 +168,26 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(2); + responseDocument.Results.ShouldHaveCount(2); - responseDocument.Results[0].Data.SingleValue.Attributes["format"].Should().Be(existingLyrics[0].Format); - responseDocument.Results[0].Data.SingleValue.Attributes.Should().NotContainKey("text"); + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Attributes.ShouldContainKey("format").With(value => value.Should().Be(existingLyrics[0].Format)); + resource.Attributes.Should().NotContainKey("text"); + }); - responseDocument.Results[1].Data.SingleValue.Attributes["format"].Should().Be(existingLyrics[1].Format); - responseDocument.Results[1].Data.SingleValue.Attributes.Should().NotContainKey("text"); + responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Attributes.ShouldContainKey("format").With(value => value.Should().Be(existingLyrics[1].Format)); + resource.Attributes.Should().NotContainKey("text"); + }); hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(Lyric), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplySparseFieldSet), - (typeof(Lyric), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplySparseFieldSet), - (typeof(Lyric), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplySparseFieldSet), - (typeof(Lyric), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplySparseFieldSet) + (typeof(Lyric), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Lyric), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Lyric), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Lyric), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet) }, options => options.WithStrictOrdering()); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/LyricTextDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/LyricTextDefinition.cs index 094338a329..8bab070619 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/LyricTextDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/LyricTextDefinition.cs @@ -1,30 +1,27 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Resources; namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.ResourceDefinitions.SparseFieldSets { [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class LyricTextDefinition : JsonApiResourceDefinition + public sealed class LyricTextDefinition : HitCountingResourceDefinition { private readonly LyricPermissionProvider _lyricPermissionProvider; - private readonly ResourceDefinitionHitCounter _hitCounter; + + protected override ResourceDefinitionExtensibilityPoints ExtensibilityPointsToTrack => ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet; public LyricTextDefinition(IResourceGraph resourceGraph, LyricPermissionProvider lyricPermissionProvider, ResourceDefinitionHitCounter hitCounter) - : base(resourceGraph) + : base(resourceGraph, hitCounter) { _lyricPermissionProvider = lyricPermissionProvider; - _hitCounter = hitCounter; } - public override SparseFieldSetExpression OnApplySparseFieldSet(SparseFieldSetExpression existingSparseFieldSet) + public override SparseFieldSetExpression? OnApplySparseFieldSet(SparseFieldSetExpression? existingSparseFieldSet) { - _hitCounter.TrackInvocation(ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplySparseFieldSet); + base.OnApplySparseFieldSet(existingSparseFieldSet); - return _lyricPermissionProvider.CanViewText - ? base.OnApplySparseFieldSet(existingSparseFieldSet) - : existingSparseFieldSet.Excluding(lyric => lyric.Text, ResourceGraph); + return _lyricPermissionProvider.CanViewText ? existingSparseFieldSet : existingSparseFieldSet.Excluding(lyric => lyric.Text, ResourceGraph); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/TextLanguage.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/TextLanguage.cs index d78ddd55a0..4606858341 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/TextLanguage.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/TextLanguage.cs @@ -10,12 +10,12 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations public sealed class TextLanguage : Identifiable { [Attr] - public string IsoCode { get; set; } + public string? IsoCode { get; set; } [Attr(Capabilities = AttrCapabilities.None)] public bool IsRightToLeft { get; set; } [HasMany] - public ICollection Lyrics { get; set; } + public ICollection Lyrics { get; set; } = new List(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/TextLanguagesController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/TextLanguagesController.cs index 6da4f35d38..b2d2683313 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/TextLanguagesController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/TextLanguagesController.cs @@ -8,8 +8,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations { public sealed class TextLanguagesController : JsonApiController { - public TextLanguagesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public TextLanguagesController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicRollbackTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicRollbackTests.cs index 542a6971b3..8b4c1d49f1 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicRollbackTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicRollbackTests.cs @@ -27,7 +27,7 @@ public AtomicRollbackTests(IntegrationTestContext await dbContext.ClearTablesAsync(); }); - string performerId = Unknown.StringId.For(); + string unknownPerformerId = Unknown.StringId.For(); var requestBody = new { @@ -74,7 +74,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => new { type = "performers", - id = performerId + id = unknownPerformerId } } } @@ -92,12 +92,93 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); error.Title.Should().Be("A related resource does not exist."); - error.Detail.Should().Be($"Related resource of type 'performers' with ID '{performerId}' in relationship 'performers' does not exist."); + error.Detail.Should().Be($"Related resource of type 'performers' with ID '{unknownPerformerId}' in relationship 'performers' does not exist."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[1]"); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List performersInDatabase = await dbContext.Performers.ToListAsync(); + performersInDatabase.Should().BeEmpty(); + + List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); + tracksInDatabase.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Can_restore_to_previous_savepoint_on_error() + { + // Arrange + string newTrackTitle = _fakers.MusicTrack.Generate().Title; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTablesAsync(); + }); + + const string trackLid = "track-1"; + + string unknownPerformerId = Unknown.StringId.For(); + + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "add", + data = new + { + type = "musicTracks", + lid = trackLid, + attributes = new + { + title = newTrackTitle + } + } + }, + new + { + op = "add", + @ref = new + { + type = "musicTracks", + lid = trackLid, + relationship = "performers" + }, + data = new[] + { + new + { + type = "performers", + id = unknownPerformerId + } + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("A related resource does not exist."); + error.Detail.Should().Be($"Related resource of type 'performers' with ID '{unknownPerformerId}' in relationship 'performers' does not exist."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[1]"); await _testContext.RunOnDatabaseAsync(async dbContext => diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs index 245af3c761..1f7cc37cef 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs @@ -15,6 +15,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Transactions public sealed class AtomicTransactionConsistencyTests : IClassFixture, OperationsDbContext>> { private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new(); public AtomicTransactionConsistencyTests(IntegrationTestContext, OperationsDbContext> testContext) { @@ -65,12 +66,13 @@ public async Task Cannot_use_non_transactional_repository() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Unsupported resource type in atomic:operations request."); error.Detail.Should().Be("Operations on resources of type 'performers' cannot be used because transaction support is unavailable."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]"); } @@ -78,6 +80,8 @@ public async Task Cannot_use_non_transactional_repository() public async Task Cannot_use_transactional_repository_without_active_transaction() { // Arrange + string newTrackTitle = _fakers.MusicTrack.Generate().Title; + var requestBody = new { atomic__operations = new object[] @@ -90,6 +94,7 @@ public async Task Cannot_use_transactional_repository_without_active_transaction type = "musicTracks", attributes = new { + title = newTrackTitle } } } @@ -104,12 +109,13 @@ public async Task Cannot_use_transactional_repository_without_active_transaction // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Unsupported combination of resource types in atomic:operations request."); error.Detail.Should().Be("All operations need to participate in a single shared transaction, which is not the case for this request."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]"); } @@ -117,6 +123,8 @@ public async Task Cannot_use_transactional_repository_without_active_transaction public async Task Cannot_use_distributed_transaction() { // Arrange + string newLyricText = _fakers.Lyric.Generate().Text; + var requestBody = new { atomic__operations = new object[] @@ -129,6 +137,7 @@ public async Task Cannot_use_distributed_transaction() type = "lyrics", attributes = new { + text = newLyricText } } } @@ -143,12 +152,13 @@ public async Task Cannot_use_distributed_transaction() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Unsupported combination of resource types in atomic:operations request."); error.Detail.Should().Be("All operations need to participate in a single shared transaction, which is not the case for this request."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]"); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/LyricRepository.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/LyricRepository.cs index 60dfb891b0..d6e25823bf 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/LyricRepository.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/LyricRepository.cs @@ -13,12 +13,12 @@ public sealed class LyricRepository : EntityFrameworkCoreRepository { private readonly ExtraDbContext _extraDbContext; - public override string TransactionId => _extraDbContext.Database.CurrentTransaction.TransactionId.ToString(); + public override string? TransactionId => _extraDbContext.Database.CurrentTransaction?.TransactionId.ToString(); - public LyricRepository(ExtraDbContext extraDbContext, ITargetedFields targetedFields, IDbContextResolver 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..524439bc18 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/MusicTrackRepository.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/MusicTrackRepository.cs @@ -12,12 +12,12 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Transactions [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] public sealed class MusicTrackRepository : EntityFrameworkCoreRepository { - public override string TransactionId => null; + public override string? TransactionId => 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/Transactions/PerformerRepository.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/PerformerRepository.cs index 75f68434f8..ead5d9234a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/PerformerRepository.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/PerformerRepository.cs @@ -11,14 +11,14 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Transactions { [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class PerformerRepository : IResourceRepository + public sealed class PerformerRepository : IResourceRepository { - public Task> GetAsync(QueryLayer layer, CancellationToken cancellationToken) + public Task> GetAsync(QueryLayer queryLayer, CancellationToken cancellationToken) { throw new NotImplementedException(); } - public Task CountAsync(FilterExpression topFilter, CancellationToken cancellationToken) + public Task CountAsync(FilterExpression? filter, CancellationToken cancellationToken) { throw new NotImplementedException(); } @@ -33,7 +33,7 @@ public Task CreateAsync(Performer resourceFromRequest, Performer resourceForData throw new NotImplementedException(); } - public Task GetForUpdateAsync(QueryLayer queryLayer, CancellationToken cancellationToken) + public Task GetForUpdateAsync(QueryLayer queryLayer, CancellationToken cancellationToken) { throw new NotImplementedException(); } @@ -48,7 +48,7 @@ public Task DeleteAsync(int id, CancellationToken cancellationToken) throw new NotImplementedException(); } - public Task SetRelationshipAsync(Performer leftResource, object rightValue, CancellationToken cancellationToken) + public Task SetRelationshipAsync(Performer leftResource, object? rightValue, CancellationToken cancellationToken) { throw new NotImplementedException(); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs index ada44f47e7..1dac3f571d 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs @@ -64,14 +64,17 @@ 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); + responseDocument.Errors.ShouldHaveCount(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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/relationship"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -147,7 +150,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(existingTrack.Id); - trackInDatabase.Performers.Should().HaveCount(3); + trackInDatabase.Performers.ShouldHaveCount(3); trackInDatabase.Performers.Should().ContainSingle(performer => performer.Id == existingTrack.Performers[0].Id); trackInDatabase.Performers.Should().ContainSingle(performer => performer.Id == existingPerformers[0].Id); trackInDatabase.Performers.Should().ContainSingle(performer => performer.Id == existingPerformers[1].Id); @@ -227,7 +230,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(existingPlaylist.Id); - playlistInDatabase.Tracks.Should().HaveCount(3); + playlistInDatabase.Tracks.ShouldHaveCount(3); playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingPlaylist.Tracks[0].Id); playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[0].Id); playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[1].Id); @@ -258,13 +261,15 @@ public async Task Cannot_add_for_href_element() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: 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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/href"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -295,13 +300,15 @@ public async Task Cannot_add_for_missing_type_in_ref() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref.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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -333,13 +340,15 @@ public async Task Cannot_add_for_unknown_type_in_ref() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: 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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -370,13 +379,15 @@ public async Task Cannot_add_for_missing_ID_in_ref() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref.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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -426,13 +437,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); error.Title.Should().Be("The requested resource does not exist."); error.Detail.Should().Be($"Resource of type 'recordCompanies' with ID '{companyId}' does not exist."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Meta.Should().NotContainKey("requestBody"); } [Fact] @@ -465,13 +478,15 @@ public async Task Cannot_add_for_ID_and_local_ID_in_ref() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref.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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -502,13 +517,15 @@ public async Task Cannot_add_for_missing_relationship_in_ref() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref.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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -540,13 +557,63 @@ public async Task Cannot_add_for_unknown_relationship_in_ref() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Unknown relationship found."); + error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' does not exist on resource type 'performers'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/relationship"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } + + [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.ShouldHaveCount(1); 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: The 'data' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -574,7 +641,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => id = existingTrack.StringId, relationship = "performers" }, - data = (object)null + data = (object?)null } } }; @@ -587,13 +654,66 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected 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, instead of 'null'."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } + + [Fact] + public async Task Cannot_add_for_object_data() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + + 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.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Expected an array, instead of an object."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -632,13 +752,15 @@ public async Task Cannot_add_for_missing_type_in_data() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data[].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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -678,13 +800,15 @@ public async Task Cannot_add_for_unknown_type_in_data() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: 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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -723,13 +847,15 @@ public async Task Cannot_add_for_missing_ID_in_data() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data[].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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -770,13 +896,15 @@ public async Task Cannot_add_for_ID_and_local_ID_in_data() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data[].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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -835,18 +963,20 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.Should().HaveCount(2); + responseDocument.Errors.ShouldHaveCount(2); ErrorObject error1 = responseDocument.Errors[0]; error1.StatusCode.Should().Be(HttpStatusCode.NotFound); error1.Title.Should().Be("A related resource does not exist."); error1.Detail.Should().Be($"Related resource of type 'musicTracks' with ID '{trackIds[0]}' in relationship 'tracks' does not exist."); + error1.Source.ShouldNotBeNull(); error1.Source.Pointer.Should().Be("/atomic:operations[0]"); ErrorObject error2 = responseDocument.Errors[1]; error2.StatusCode.Should().Be(HttpStatusCode.NotFound); error2.Title.Should().Be("A related resource does not exist."); error2.Detail.Should().Be($"Related resource of type 'musicTracks' with ID '{trackIds[1]}' in relationship 'tracks' does not exist."); + error2.Source.ShouldNotBeNull(); error2.Source.Pointer.Should().Be("/atomic:operations[0]"); } @@ -893,15 +1023,17 @@ 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); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Resource 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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -949,7 +1081,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(existingTrack.Id); - trackInDatabase.Performers.Should().HaveCount(1); + trackInDatabase.Performers.ShouldHaveCount(1); trackInDatabase.Performers[0].Id.Should().Be(existingTrack.Performers[0].Id); }); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs index ffaff765c3..d0673adab0 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs @@ -65,14 +65,17 @@ 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); + responseDocument.Errors.ShouldHaveCount(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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/relationship"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -146,11 +149,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(existingTrack.Id); - trackInDatabase.Performers.Should().HaveCount(1); + trackInDatabase.Performers.ShouldHaveCount(1); trackInDatabase.Performers[0].Id.Should().Be(existingTrack.Performers[1].Id); List performersInDatabase = await dbContext.Performers.ToListAsync(); - performersInDatabase.Should().HaveCount(3); + performersInDatabase.ShouldHaveCount(3); }); } @@ -225,12 +228,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(existingPlaylist.Id); - playlistInDatabase.Tracks.Should().HaveCount(1); + playlistInDatabase.Tracks.ShouldHaveCount(1); playlistInDatabase.Tracks[0].Id.Should().Be(existingPlaylist.Tracks[1].Id); List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.Should().HaveCount(3); + tracksInDatabase.ShouldHaveCount(3); }); } @@ -258,13 +261,15 @@ public async Task Cannot_remove_for_href_element() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: 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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/href"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -295,13 +300,15 @@ public async Task Cannot_remove_for_missing_type_in_ref() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref.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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -333,13 +340,15 @@ public async Task Cannot_remove_for_unknown_type_in_ref() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: 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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -370,13 +379,15 @@ public async Task Cannot_remove_for_missing_ID_in_ref() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref.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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -426,13 +437,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); error.Title.Should().Be("The requested resource does not exist."); error.Detail.Should().Be($"Resource of type 'recordCompanies' with ID '{companyId}' does not exist."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Meta.Should().NotContainKey("requestBody"); } [Fact] @@ -465,13 +478,15 @@ public async Task Cannot_remove_for_ID_and_local_ID_in_ref() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref.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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -503,13 +518,63 @@ public async Task Cannot_remove_for_unknown_relationship_in_ref() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Unknown relationship found."); + error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' does not exist on resource type 'performers'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/relationship"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } + + [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.ShouldHaveCount(1); 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: The 'data' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -537,7 +602,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => id = existingTrack.StringId, relationship = "performers" }, - data = (object)null + data = (object?)null } } }; @@ -550,13 +615,66 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected 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, instead of 'null'."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } + + [Fact] + public async Task Cannot_remove_for_object_data() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + + 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.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Expected an array, instead of an object."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -595,13 +713,15 @@ public async Task Cannot_remove_for_missing_type_in_data() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data[].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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -641,13 +761,15 @@ public async Task Cannot_remove_for_unknown_type_in_data() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: 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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -686,13 +808,15 @@ public async Task Cannot_remove_for_missing_ID_in_data() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data[].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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -733,13 +857,15 @@ public async Task Cannot_remove_for_ID_and_local_ID_in_data() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data[].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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -798,18 +924,20 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.Should().HaveCount(2); + responseDocument.Errors.ShouldHaveCount(2); ErrorObject error1 = responseDocument.Errors[0]; error1.StatusCode.Should().Be(HttpStatusCode.NotFound); error1.Title.Should().Be("A related resource does not exist."); error1.Detail.Should().Be($"Related resource of type 'musicTracks' with ID '{trackIds[0]}' in relationship 'tracks' does not exist."); + error1.Source.ShouldNotBeNull(); error1.Source.Pointer.Should().Be("/atomic:operations[0]"); ErrorObject error2 = responseDocument.Errors[1]; error2.StatusCode.Should().Be(HttpStatusCode.NotFound); error2.Title.Should().Be("A related resource does not exist."); error2.Detail.Should().Be($"Related resource of type 'musicTracks' with ID '{trackIds[1]}' in relationship 'tracks' does not exist."); + error2.Source.ShouldNotBeNull(); error2.Source.Pointer.Should().Be("/atomic:operations[0]"); } @@ -856,15 +984,17 @@ 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); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Resource 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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -913,7 +1043,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(existingTrack.Id); - trackInDatabase.Performers.Should().HaveCount(1); + trackInDatabase.Performers.ShouldHaveCount(1); trackInDatabase.Performers[0].Id.Should().Be(existingTrack.Performers[0].Id); }); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs index c1426db8a9..a77441709a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs @@ -72,7 +72,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => trackInDatabase.Performers.Should().BeEmpty(); List performersInDatabase = await dbContext.Performers.ToListAsync(); - performersInDatabase.Should().HaveCount(2); + performersInDatabase.ShouldHaveCount(2); }); } @@ -126,7 +126,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.Should().HaveCount(2); + tracksInDatabase.ShouldHaveCount(2); }); } @@ -191,12 +191,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(existingTrack.Id); - trackInDatabase.Performers.Should().HaveCount(2); + trackInDatabase.Performers.ShouldHaveCount(2); trackInDatabase.Performers.Should().ContainSingle(performer => performer.Id == existingPerformers[0].Id); trackInDatabase.Performers.Should().ContainSingle(performer => performer.Id == existingPerformers[1].Id); List performersInDatabase = await dbContext.Performers.ToListAsync(); - performersInDatabase.Should().HaveCount(3); + performersInDatabase.ShouldHaveCount(3); }); } @@ -261,13 +261,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(existingPlaylist.Id); - playlistInDatabase.Tracks.Should().HaveCount(2); + playlistInDatabase.Tracks.ShouldHaveCount(2); playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[0].Id); playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[1].Id); List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.Should().HaveCount(3); + tracksInDatabase.ShouldHaveCount(3); }); } @@ -295,13 +295,15 @@ public async Task Cannot_replace_for_href_element() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: 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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/href"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -332,13 +334,15 @@ public async Task Cannot_replace_for_missing_type_in_ref() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref.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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -370,13 +374,15 @@ public async Task Cannot_replace_for_unknown_type_in_ref() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: 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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -407,13 +413,15 @@ public async Task Cannot_replace_for_missing_ID_in_ref() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref.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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -463,13 +471,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); error.Title.Should().Be("The requested resource does not exist."); error.Detail.Should().Be($"Resource of type 'recordCompanies' with ID '{companyId}' does not exist."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Meta.Should().NotContainKey("requestBody"); } [Fact] @@ -519,13 +529,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body."); + error.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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/id"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -558,13 +570,15 @@ public async Task Cannot_replace_for_ID_and_local_ID_in_ref() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref.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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -596,13 +610,63 @@ public async Task Cannot_replace_for_unknown_relationship_in_ref() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/relationship"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } + + [Fact] + public async Task Cannot_replace_for_missing_data() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + + 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.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'data' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -630,7 +694,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => id = existingTrack.StringId, relationship = "performers" }, - data = (object)null + data = (object?)null } } }; @@ -643,13 +707,66 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected 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, instead of 'null'."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } + + [Fact] + public async Task Cannot_replace_for_object_data() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + + 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.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Expected an array, instead of an object."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -688,13 +805,15 @@ public async Task Cannot_replace_for_missing_type_in_data() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data[].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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -734,13 +853,15 @@ public async Task Cannot_replace_for_unknown_type_in_data() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: 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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -779,13 +900,15 @@ public async Task Cannot_replace_for_missing_ID_in_data() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data[].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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -826,13 +949,15 @@ public async Task Cannot_replace_for_ID_and_local_ID_in_data() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data[].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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -891,18 +1016,20 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.Should().HaveCount(2); + responseDocument.Errors.ShouldHaveCount(2); ErrorObject error1 = responseDocument.Errors[0]; error1.StatusCode.Should().Be(HttpStatusCode.NotFound); error1.Title.Should().Be("A related resource does not exist."); error1.Detail.Should().Be($"Related resource of type 'musicTracks' with ID '{trackIds[0]}' in relationship 'tracks' does not exist."); + error1.Source.ShouldNotBeNull(); error1.Source.Pointer.Should().Be("/atomic:operations[0]"); ErrorObject error2 = responseDocument.Errors[1]; error2.StatusCode.Should().Be(HttpStatusCode.NotFound); error2.Title.Should().Be("A related resource does not exist."); error2.Detail.Should().Be($"Related resource of type 'musicTracks' with ID '{trackIds[1]}' in relationship 'tracks' does not exist."); + error2.Source.ShouldNotBeNull(); error2.Source.Pointer.Should().Be("/atomic:operations[0]"); } @@ -951,13 +1078,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body."); + error.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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]/id"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -1003,15 +1132,17 @@ 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); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Resource 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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs index 85c153a7f1..6569ae5638 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs @@ -50,7 +50,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => id = existingLyric.StringId, relationship = "track" }, - data = (object)null + data = (object?)null } } }; @@ -72,7 +72,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => lyricInDatabase.Track.Should().BeNull(); List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.Should().HaveCount(1); + tracksInDatabase.ShouldHaveCount(1); }); } @@ -103,7 +103,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => id = existingTrack.StringId, relationship = "lyric" }, - data = (object)null + data = (object?)null } } }; @@ -125,7 +125,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => trackInDatabase.Lyric.Should().BeNull(); List lyricsInDatabase = await dbContext.Lyrics.ToListAsync(); - lyricsInDatabase.Should().HaveCount(1); + lyricsInDatabase.ShouldHaveCount(1); }); } @@ -156,7 +156,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => id = existingTrack.StringId, relationship = "ownedBy" }, - data = (object)null + data = (object?)null } } }; @@ -178,7 +178,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => trackInDatabase.OwnedBy.Should().BeNull(); List companiesInDatabase = await dbContext.RecordCompanies.ToListAsync(); - companiesInDatabase.Should().HaveCount(1); + companiesInDatabase.ShouldHaveCount(1); }); } @@ -231,6 +231,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { Lyric lyricInDatabase = await dbContext.Lyrics.Include(lyric => lyric.Track).FirstWithIdAsync(existingLyric.Id); + lyricInDatabase.Track.ShouldNotBeNull(); lyricInDatabase.Track.Id.Should().Be(existingTrack.Id); }); } @@ -284,6 +285,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Lyric).FirstWithIdAsync(existingTrack.Id); + trackInDatabase.Lyric.ShouldNotBeNull(); trackInDatabase.Lyric.Id.Should().Be(existingLyric.Id); }); } @@ -337,6 +339,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(existingTrack.Id); + trackInDatabase.OwnedBy.ShouldNotBeNull(); trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); }); } @@ -393,10 +396,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { Lyric lyricInDatabase = await dbContext.Lyrics.Include(lyric => lyric.Track).FirstWithIdAsync(existingLyric.Id); + lyricInDatabase.Track.ShouldNotBeNull(); lyricInDatabase.Track.Id.Should().Be(existingTrack.Id); List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.Should().HaveCount(2); + tracksInDatabase.ShouldHaveCount(2); }); } @@ -452,10 +456,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Lyric).FirstWithIdAsync(existingTrack.Id); + trackInDatabase.Lyric.ShouldNotBeNull(); trackInDatabase.Lyric.Id.Should().Be(existingLyric.Id); List lyricsInDatabase = await dbContext.Lyrics.ToListAsync(); - lyricsInDatabase.Should().HaveCount(2); + lyricsInDatabase.ShouldHaveCount(2); }); } @@ -511,10 +516,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(existingTrack.Id); + trackInDatabase.OwnedBy.ShouldNotBeNull(); trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); List companiesInDatabase = await dbContext.RecordCompanies.ToListAsync(); - companiesInDatabase.Should().HaveCount(2); + companiesInDatabase.ShouldHaveCount(2); }); } @@ -542,13 +548,15 @@ public async Task Cannot_create_for_href_element() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: 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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/href"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -579,13 +587,15 @@ public async Task Cannot_create_for_missing_type_in_ref() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref.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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -617,13 +627,15 @@ public async Task Cannot_create_for_unknown_type_in_ref() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: 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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -654,13 +666,15 @@ public async Task Cannot_create_for_missing_ID_in_ref() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref.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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -707,13 +721,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); error.Title.Should().Be("The requested resource does not exist."); error.Detail.Should().Be($"Resource of type 'musicTracks' with ID '{trackId}' does not exist."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Meta.Should().NotContainKey("requestBody"); } [Fact] @@ -758,13 +774,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body."); + error.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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/id"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -797,13 +815,15 @@ public async Task Cannot_create_for_ID_and_local_ID_in_ref() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref.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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -835,17 +855,67 @@ public async Task Cannot_create_for_unknown_relationship_in_ref() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Unknown relationship found."); + error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' does not exist on resource type 'performers'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/relationship"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } + + [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.ShouldHaveCount(1); 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: The 'data' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] - public async Task Cannot_create_for_array_in_data() + public async Task Cannot_create_for_array_data() { // Arrange MusicTrack existingTrack = _fakers.MusicTrack.Generate(); @@ -889,13 +959,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected 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', instead of an array."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -931,13 +1003,15 @@ public async Task Cannot_create_for_missing_type_in_data() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data.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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -974,13 +1048,15 @@ public async Task Cannot_create_for_unknown_type_in_data() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: 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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -1016,13 +1092,15 @@ public async Task Cannot_create_for_missing_ID_in_data() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data.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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -1060,13 +1138,15 @@ public async Task Cannot_create_for_ID_and_local_ID_in_data() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data.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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -1113,13 +1193,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); error.Title.Should().Be("A related resource does not exist."); error.Detail.Should().Be($"Related resource of type 'lyrics' with ID '{lyricId}' in relationship 'lyric' does not exist."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Meta.Should().NotContainKey("requestBody"); } [Fact] @@ -1164,13 +1246,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body."); + error.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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/id"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -1213,15 +1297,17 @@ 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); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Resource 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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs index 3ee9307112..e942553a6d 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs @@ -77,7 +77,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => trackInDatabase.Performers.Should().BeEmpty(); List performersInDatabase = await dbContext.Performers.ToListAsync(); - performersInDatabase.Should().HaveCount(2); + performersInDatabase.ShouldHaveCount(2); }); } @@ -136,7 +136,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.Should().HaveCount(2); + tracksInDatabase.ShouldHaveCount(2); }); } @@ -206,12 +206,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(existingTrack.Id); - trackInDatabase.Performers.Should().HaveCount(2); + trackInDatabase.Performers.ShouldHaveCount(2); trackInDatabase.Performers.Should().ContainSingle(performer => performer.Id == existingPerformers[0].Id); trackInDatabase.Performers.Should().ContainSingle(performer => performer.Id == existingPerformers[1].Id); List performersInDatabase = await dbContext.Performers.ToListAsync(); - performersInDatabase.Should().HaveCount(3); + performersInDatabase.ShouldHaveCount(3); }); } @@ -281,18 +281,18 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(existingPlaylist.Id); - playlistInDatabase.Tracks.Should().HaveCount(2); + playlistInDatabase.Tracks.ShouldHaveCount(2); playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[0].Id); playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[1].Id); List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.Should().HaveCount(3); + tracksInDatabase.ShouldHaveCount(3); }); } [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(); @@ -318,7 +318,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { performers = new { - data = (object)null } } } @@ -334,13 +333,125 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected 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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } + + [Fact] + public async Task Cannot_replace_for_null_data_in_relationship() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + + 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 = (object?)null + } + } + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Expected an array, instead of 'null'."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } + + [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.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Expected an array, instead of an object."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -384,13 +495,15 @@ public async Task Cannot_replace_for_missing_type_in_relationship_data() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: 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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/tracks/data[0]"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -435,13 +548,15 @@ public async Task Cannot_replace_for_unknown_type_in_relationship_data() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: 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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data[0]/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -485,13 +600,15 @@ public async Task Cannot_replace_for_missing_ID_in_relationship_data() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: 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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data[0]"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -537,13 +654,15 @@ public async Task Cannot_replace_for_ID_and_local_ID_relationship_in_data() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: 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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data[0]"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -607,18 +726,20 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.Should().HaveCount(2); + responseDocument.Errors.ShouldHaveCount(2); ErrorObject error1 = responseDocument.Errors[0]; error1.StatusCode.Should().Be(HttpStatusCode.NotFound); error1.Title.Should().Be("A related resource does not exist."); error1.Detail.Should().Be($"Related resource of type 'musicTracks' with ID '{trackIds[0]}' in relationship 'tracks' does not exist."); + error1.Source.ShouldNotBeNull(); error1.Source.Pointer.Should().Be("/atomic:operations[0]"); ErrorObject error2 = responseDocument.Errors[1]; error2.StatusCode.Should().Be(HttpStatusCode.NotFound); error2.Title.Should().Be("A related resource does not exist."); error2.Detail.Should().Be($"Related resource of type 'musicTracks' with ID '{trackIds[1]}' in relationship 'tracks' does not exist."); + error2.Source.ShouldNotBeNull(); error2.Source.Pointer.Should().Be("/atomic:operations[0]"); } @@ -670,15 +791,17 @@ 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); + responseDocument.Errors.ShouldHaveCount(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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data[0]/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs index 1a6b73e239..783bacf575 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; @@ -31,7 +32,12 @@ public AtomicUpdateResourceTests(IntegrationTestContext { services.AddResourceDefinition(); + + services.AddSingleton(); }); + + var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); + options.AllowUnknownFieldsInRequestBody = false; } [Fact] @@ -88,7 +94,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.Should().HaveCount(elementCount); + tracksInDatabase.ShouldHaveCount(elementCount); for (int index = 0; index < elementCount; index++) { @@ -152,15 +158,71 @@ await _testContext.RunOnDatabaseAsync(async dbContext => trackInDatabase.Title.Should().Be(existingTrack.Title); trackInDatabase.Genre.Should().Be(existingTrack.Genre); - trackInDatabase.OwnedBy.Should().NotBeNull(); + trackInDatabase.OwnedBy.ShouldNotBeNull(); trackInDatabase.OwnedBy.Id.Should().Be(existingTrack.OwnedBy.Id); }); } + [Fact] + public async Task Cannot_update_resource_with_unknown_attribute() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + string newTitle = _fakers.MusicTrack.Generate().Title; + + 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.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Unknown attribute found."); + error.Detail.Should().Be("Attribute 'doesNotExist' does not exist on resource type 'musicTracks'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/doesNotExist"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } + [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 +271,71 @@ 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.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Unknown relationship found."); + error.Detail.Should().Be("Relationship 'doesNotExist' does not exist on resource type 'musicTracks'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/doesNotExist"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } + [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 => @@ -266,7 +389,7 @@ public async Task Can_partially_update_resource_without_side_effects() MusicTrack existingTrack = _fakers.MusicTrack.Generate(); existingTrack.OwnedBy = _fakers.RecordCompany.Generate(); - string newGenre = _fakers.MusicTrack.Generate().Genre; + string newGenre = _fakers.MusicTrack.Generate().Genre!; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -313,7 +436,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => trackInDatabase.Genre.Should().Be(newGenre); trackInDatabase.ReleasedAt.Should().BeCloseTo(existingTrack.ReleasedAt); - trackInDatabase.OwnedBy.Should().NotBeNull(); + trackInDatabase.OwnedBy.ShouldNotBeNull(); trackInDatabase.OwnedBy.Id.Should().Be(existingTrack.OwnedBy.Id); }); } @@ -327,7 +450,7 @@ public async Task Can_completely_update_resource_without_side_effects() string newTitle = _fakers.MusicTrack.Generate().Title; decimal? newLengthInSeconds = _fakers.MusicTrack.Generate().LengthInSeconds; - string newGenre = _fakers.MusicTrack.Generate().Genre; + string newGenre = _fakers.MusicTrack.Generate().Genre!; DateTimeOffset newReleasedAt = _fakers.MusicTrack.Generate().ReleasedAt; await _testContext.RunOnDatabaseAsync(async dbContext => @@ -378,7 +501,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => trackInDatabase.Genre.Should().Be(newGenre); trackInDatabase.ReleasedAt.Should().BeCloseTo(newReleasedAt); - trackInDatabase.OwnedBy.Should().NotBeNull(); + trackInDatabase.OwnedBy.ShouldNotBeNull(); trackInDatabase.OwnedBy.Id.Should().Be(existingTrack.OwnedBy.Id); }); } @@ -388,7 +511,7 @@ public async Task Can_update_resource_with_side_effects() { // Arrange TextLanguage existingLanguage = _fakers.TextLanguage.Generate(); - string newIsoCode = _fakers.TextLanguage.Generate().IsoCode; + string newIsoCode = _fakers.TextLanguage.Generate().IsoCode!; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -424,18 +547,22 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(1); - responseDocument.Results[0].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[0].Data.SingleValue.Type.Should().Be("textLanguages"); - responseDocument.Results[0].Data.SingleValue.Attributes["isoCode"].Should().Be($"{newIsoCode}{ImplicitlyChangingTextLanguageDefinition.Suffix}"); - responseDocument.Results[0].Data.SingleValue.Attributes.Should().NotContainKey("isRightToLeft"); - responseDocument.Results[0].Data.SingleValue.Relationships.Should().NotBeEmpty(); + responseDocument.Results.ShouldHaveCount(1); + + string isoCode = $"{newIsoCode}{ImplicitlyChangingTextLanguageDefinition.Suffix}"; + + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("textLanguages"); + resource.Attributes.ShouldContainKey("isoCode").With(value => value.Should().Be(isoCode)); + resource.Attributes.Should().NotContainKey("isRightToLeft"); + resource.Relationships.ShouldNotBeEmpty(); + }); await _testContext.RunOnDatabaseAsync(async dbContext => { TextLanguage languageInDatabase = await dbContext.TextLanguages.FirstWithIdAsync(existingLanguage.Id); - - languageInDatabase.IsoCode.Should().Be($"{newIsoCode}{ImplicitlyChangingTextLanguageDefinition.Suffix}"); + languageInDatabase.IsoCode.Should().Be(isoCode); }); } @@ -476,10 +603,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(1); - responseDocument.Results[0].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[0].Data.SingleValue.Relationships.Should().NotBeEmpty(); - responseDocument.Results[0].Data.SingleValue.Relationships.Values.Should().OnlyContain(relationshipObject => relationshipObject.Data.Value == null); + responseDocument.Results.ShouldHaveCount(1); + + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Relationships.ShouldNotBeEmpty(); + resource.Relationships.Values.Should().OnlyContain(value => value != null && value.Data.Value == null); + }); } [Fact] @@ -506,13 +636,15 @@ public async Task Cannot_update_resource_for_href_element() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: 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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/href"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -520,7 +652,7 @@ public async Task Can_update_resource_for_ref_element() { // Arrange Performer existingPerformer = _fakers.Performer.Generate(); - string newArtistName = _fakers.Performer.Generate().ArtistName; + string newArtistName = _fakers.Performer.Generate().ArtistName!; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -610,13 +742,15 @@ public async Task Cannot_update_resource_for_missing_type_in_ref() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref.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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -657,13 +791,15 @@ public async Task Cannot_update_resource_for_missing_ID_in_ref() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref.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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -706,13 +842,15 @@ public async Task Cannot_update_resource_for_ID_and_local_ID_in_ref() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref.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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -738,17 +876,19 @@ public async Task Cannot_update_resource_for_missing_data() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: The 'data' element is required."); error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [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 +898,59 @@ 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.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Expected an object, instead of 'null'."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } + + [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 + } } } } @@ -780,17 +965,19 @@ public async Task Cannot_update_resource_for_missing_type_in_data() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data.type' element is required."); + error.Title.Should().Be("Failed to deserialize request body: Expected an object, instead of an array."); error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] - public async Task Cannot_update_resource_for_missing_ID_in_data() + public async Task Cannot_update_resource_for_missing_type_in_data() { // Arrange var requestBody = new @@ -802,7 +989,7 @@ public async Task Cannot_update_resource_for_missing_ID_in_data() op = "update", data = new { - type = "performers", + id = Unknown.StringId.Int32, attributes = new { }, @@ -822,17 +1009,19 @@ public async Task Cannot_update_resource_for_missing_ID_in_data() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data.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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] - public async Task Cannot_update_resource_for_ID_and_local_ID_in_data() + public async Task Cannot_update_resource_for_missing_ID_in_data() { // Arrange var requestBody = new @@ -845,8 +1034,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 { }, @@ -866,27 +1053,21 @@ public async Task Cannot_update_resource_for_ID_and_local_ID_in_data() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data.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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] - public async Task Cannot_update_resource_for_array_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 +1075,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 - } } } } @@ -918,13 +1099,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected 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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -964,15 +1147,17 @@ 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); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Resource 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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -1015,15 +1200,17 @@ 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); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Resource ID 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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/id"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -1063,15 +1250,17 @@ 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); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Resource 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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/lid"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -1115,13 +1304,15 @@ public async Task Cannot_update_on_mixture_of_ID_and_local_ID_between_ref_and_da // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Resource 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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -1165,13 +1356,15 @@ public async Task Cannot_update_on_mixture_of_local_ID_and_ID_between_ref_and_da // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Resource 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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -1208,13 +1401,15 @@ public async Task Cannot_update_resource_for_unknown_type() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: 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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -1253,13 +1448,15 @@ public async Task Cannot_update_resource_for_unknown_ID() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); error.Title.Should().Be("The requested resource does not exist."); error.Detail.Should().Be($"Resource of type 'performers' with ID '{performerId}' does not exist."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Meta.Should().NotContainKey("requestBody"); } [Fact] @@ -1300,13 +1497,15 @@ public async Task Cannot_update_resource_for_incompatible_ID() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body."); + error.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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/id"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -1349,13 +1548,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: 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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/createdAt"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -1398,13 +1599,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Attribute is read-only."); - error.Detail.Should().Be("Attribute 'isArchived' 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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/isArchived"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -1447,13 +1650,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body."); - error.Detail.Should().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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/id"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -1496,13 +1701,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body."); + error.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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/bornAt"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -1514,7 +1721,7 @@ public async Task Can_update_resource_with_attributes_and_multiple_relationship_ existingTrack.OwnedBy = _fakers.RecordCompany.Generate(); existingTrack.Performers = _fakers.Performer.Generate(1); - string newGenre = _fakers.MusicTrack.Generate().Genre; + string newGenre = _fakers.MusicTrack.Generate().Genre!; Lyric existingLyric = _fakers.Lyric.Generate(); RecordCompany existingCompany = _fakers.RecordCompany.Generate(); @@ -1603,13 +1810,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => trackInDatabase.Title.Should().Be(existingTrack.Title); trackInDatabase.Genre.Should().Be(newGenre); - trackInDatabase.Lyric.Should().NotBeNull(); + trackInDatabase.Lyric.ShouldNotBeNull(); trackInDatabase.Lyric.Id.Should().Be(existingLyric.Id); - trackInDatabase.OwnedBy.Should().NotBeNull(); + trackInDatabase.OwnedBy.ShouldNotBeNull(); trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); - trackInDatabase.Performers.Should().HaveCount(1); + trackInDatabase.Performers.ShouldHaveCount(1); trackInDatabase.Performers[0].Id.Should().Be(existingPerformer.Id); }); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateToOneRelationshipTests.cs index 9487a69551..3f7c089e44 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateToOneRelationshipTests.cs @@ -52,7 +52,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { track = new { - data = (object)null + data = (object?)null } } } @@ -77,7 +77,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => lyricInDatabase.Track.Should().BeNull(); List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.Should().HaveCount(1); + tracksInDatabase.ShouldHaveCount(1); }); } @@ -110,7 +110,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { lyric = new { - data = (object)null + data = (object?)null } } } @@ -135,7 +135,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => trackInDatabase.Lyric.Should().BeNull(); List lyricsInDatabase = await dbContext.Lyrics.ToListAsync(); - lyricsInDatabase.Should().HaveCount(1); + lyricsInDatabase.ShouldHaveCount(1); }); } @@ -168,7 +168,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { ownedBy = new { - data = (object)null + data = (object?)null } } } @@ -193,7 +193,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => trackInDatabase.OwnedBy.Should().BeNull(); List companiesInDatabase = await dbContext.RecordCompanies.ToListAsync(); - companiesInDatabase.Should().HaveCount(1); + companiesInDatabase.ShouldHaveCount(1); }); } @@ -251,6 +251,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { Lyric lyricInDatabase = await dbContext.Lyrics.Include(lyric => lyric.Track).FirstWithIdAsync(existingLyric.Id); + lyricInDatabase.Track.ShouldNotBeNull(); lyricInDatabase.Track.Id.Should().Be(existingTrack.Id); }); } @@ -309,6 +310,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Lyric).FirstWithIdAsync(existingTrack.Id); + trackInDatabase.Lyric.ShouldNotBeNull(); trackInDatabase.Lyric.Id.Should().Be(existingLyric.Id); }); } @@ -367,6 +369,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(existingTrack.Id); + trackInDatabase.OwnedBy.ShouldNotBeNull(); trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); }); } @@ -428,10 +431,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { Lyric lyricInDatabase = await dbContext.Lyrics.Include(lyric => lyric.Track).FirstWithIdAsync(existingLyric.Id); + lyricInDatabase.Track.ShouldNotBeNull(); lyricInDatabase.Track.Id.Should().Be(existingTrack.Id); List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.Should().HaveCount(2); + tracksInDatabase.ShouldHaveCount(2); }); } @@ -492,10 +496,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Lyric).FirstWithIdAsync(existingTrack.Id); + trackInDatabase.Lyric.ShouldNotBeNull(); trackInDatabase.Lyric.Id.Should().Be(existingLyric.Id); List lyricsInDatabase = await dbContext.Lyrics.ToListAsync(); - lyricsInDatabase.Should().HaveCount(2); + lyricsInDatabase.ShouldHaveCount(2); }); } @@ -556,15 +561,120 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(existingTrack.Id); + trackInDatabase.OwnedBy.ShouldNotBeNull(); trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); List companiesInDatabase = await dbContext.RecordCompanies.ToListAsync(); - companiesInDatabase.Should().HaveCount(2); + companiesInDatabase.ShouldHaveCount(2); }); } [Fact] - public async Task Cannot_create_for_array_in_relationship_data() + public async Task Cannot_create_for_null_relationship() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationships = new + { + lyric = (object?)null + } + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Expected an object, instead of 'null'."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } + + [Fact] + public async Task Cannot_create_for_missing_data_in_relationship() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); + + 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.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'data' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } + + [Fact] + public async Task Cannot_create_for_array_data_in_relationship() { // Arrange MusicTrack existingTrack = _fakers.MusicTrack.Generate(); @@ -613,13 +723,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected 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', instead of an array."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -660,13 +772,15 @@ public async Task Cannot_create_for_missing_type_in_relationship_data() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: 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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/track/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -708,13 +822,15 @@ public async Task Cannot_create_for_unknown_type_in_relationship_data() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: 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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -755,13 +871,15 @@ public async Task Cannot_create_for_missing_ID_in_relationship_data() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: 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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -804,13 +922,15 @@ public async Task Cannot_create_for_ID_and_local_ID_in_relationship_data() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: 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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -862,13 +982,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); error.Title.Should().Be("A related resource does not exist."); error.Detail.Should().Be($"Related resource of type 'lyrics' with ID '{lyricId}' in relationship 'lyric' does not exist."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]"); + error.Meta.Should().NotContainKey("requestBody"); } [Fact] @@ -916,15 +1038,17 @@ 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); + responseDocument.Errors.ShouldHaveCount(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.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/Car.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/Car.cs index e50286f5a0..9146bf865a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/Car.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/Car.cs @@ -7,23 +7,27 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.CompositeKeys { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Car : Identifiable + public sealed class Car : Identifiable { [NotMapped] - public override string Id + 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 && long.TryParse(elements[0], out long regionId)) { - if (int.TryParse(elements[0], out int regionId)) - { - RegionId = regionId; - LicensePlate = elements[1]; - } + RegionId = regionId; + LicensePlate = elements[1]; } else { @@ -33,15 +37,15 @@ public override string Id } [Attr] - public string LicensePlate { get; set; } + public string? LicensePlate { get; set; } [Attr] public long RegionId { get; set; } [HasOne] - public Engine Engine { get; set; } + public Engine Engine { get; set; } = null!; [HasOne] - public Dealership Dealership { get; set; } + public Dealership? Dealership { get; set; } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarCompositeKeyAwareRepository.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarCompositeKeyAwareRepository.cs index f76e0baa66..5f3e5e01d9 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarCompositeKeyAwareRepository.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarCompositeKeyAwareRepository.cs @@ -16,52 +16,40 @@ 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); } - protected override IQueryable ApplyQueryLayer(QueryLayer layer) + protected override IQueryable ApplyQueryLayer(QueryLayer queryLayer) { - RecursiveRewriteFilterInLayer(layer); + RecursiveRewriteFilterInLayer(queryLayer); - return base.ApplyQueryLayer(layer); + return base.ApplyQueryLayer(queryLayer); } private void RecursiveRewriteFilterInLayer(QueryLayer queryLayer) { if (queryLayer.Filter != null) { - queryLayer.Filter = (FilterExpression)_writer.Visit(queryLayer.Filter, null); + queryLayer.Filter = (FilterExpression?)_writer.Visit(queryLayer.Filter, null); } if (queryLayer.Sort != null) { - queryLayer.Sort = (SortExpression)_writer.Visit(queryLayer.Sort, null); + queryLayer.Sort = (SortExpression?)_writer.Visit(queryLayer.Sort, null); } if (queryLayer.Projection != null) { - foreach (QueryLayer nextLayer in queryLayer.Projection.Values.Where(layer => layer != null)) + foreach (QueryLayer? nextLayer in queryLayer.Projection.Values.Where(layer => layer != null)) { - RecursiveRewriteFilterInLayer(nextLayer); + RecursiveRewriteFilterInLayer(nextLayer!); } } } } - - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public class CarCompositeKeyAwareRepository : CarCompositeKeyAwareRepository, IResourceRepository - where TResource : class, IIdentifiable - { - public CarCompositeKeyAwareRepository(ITargetedFields targetedFields, IDbContextResolver contextResolver, IResourceGraph resourceGraph, - IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory, - IResourceDefinitionAccessor resourceDefinitionAccessor) - : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor) - { - } - } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarExpressionRewriter.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarExpressionRewriter.cs index cce9320cce..301de2901f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarExpressionRewriter.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarExpressionRewriter.cs @@ -18,20 +18,20 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.CompositeKeys /// /// This enables queries to use , which is not mapped in the database. /// - internal sealed class CarExpressionRewriter : QueryExpressionRewriter + internal sealed class CarExpressionRewriter : QueryExpressionRewriter { private readonly AttrAttribute _regionIdAttribute; private readonly AttrAttribute _licensePlateAttribute; 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) + public override QueryExpression? VisitComparison(ComparisonExpression expression, object? argument) { if (expression.Left is ResourceFieldChainExpression leftChain && expression.Right is LiteralConstantExpression rightConstant) { @@ -51,7 +51,7 @@ public override QueryExpression VisitComparison(ComparisonExpression expression, return base.VisitComparison(expression, argument); } - public override QueryExpression VisitAny(AnyExpression expression, object argument) + public override QueryExpression? VisitAny(AnyExpression expression, object? argument) { PropertyInfo property = expression.TargetAttribute.Fields[^1].Property; @@ -64,7 +64,7 @@ public override QueryExpression VisitAny(AnyExpression expression, object argume return base.VisitAny(expression, argument); } - public override QueryExpression VisitMatchText(MatchTextExpression expression, object argument) + public override QueryExpression? VisitMatchText(MatchTextExpression expression, object? argument) { PropertyInfo property = expression.TargetAttribute.Fields[^1].Property; @@ -78,7 +78,7 @@ public override QueryExpression VisitMatchText(MatchTextExpression expression, o private static bool IsCarId(PropertyInfo property) { - return property.Name == nameof(Identifiable.Id) && property.DeclaringType == typeof(Car); + return property.Name == nameof(Identifiable.Id) && property.DeclaringType == typeof(Car); } private QueryExpression RewriteFilterOnCarStringIds(ResourceFieldChainExpression existingCarIdChain, IEnumerable carStringIds) @@ -92,7 +92,7 @@ private QueryExpression RewriteFilterOnCarStringIds(ResourceFieldChainExpression StringId = carStringId }; - FilterExpression keyComparison = CreateEqualityComparisonOnCompositeKey(existingCarIdChain, tempCar.RegionId, tempCar.LicensePlate); + FilterExpression keyComparison = CreateEqualityComparisonOnCompositeKey(existingCarIdChain, tempCar.RegionId, tempCar.LicensePlate!); outerTermsBuilder.Add(keyComparison); } @@ -115,7 +115,7 @@ private FilterExpression CreateEqualityComparisonOnCompositeKey(ResourceFieldCha return new LogicalExpression(LogicalOperator.And, regionIdComparison, licensePlateComparison); } - public override QueryExpression VisitSort(SortExpression expression, object argument) + public override QueryExpression VisitSort(SortExpression expression, object? argument) { ImmutableArray.Builder elementsBuilder = ImmutableArray.CreateBuilder(expression.Elements.Count); @@ -123,10 +123,10 @@ public override QueryExpression VisitSort(SortExpression expression, object argu { if (IsSortOnCarId(sortElement)) { - ResourceFieldChainExpression regionIdSort = ReplaceLastAttributeInChain(sortElement.TargetAttribute, _regionIdAttribute); + ResourceFieldChainExpression regionIdSort = ReplaceLastAttributeInChain(sortElement.TargetAttribute!, _regionIdAttribute); elementsBuilder.Add(new SortElementExpression(regionIdSort, sortElement.IsAscending)); - ResourceFieldChainExpression licensePlateSort = ReplaceLastAttributeInChain(sortElement.TargetAttribute, _licensePlateAttribute); + ResourceFieldChainExpression licensePlateSort = ReplaceLastAttributeInChain(sortElement.TargetAttribute!, _licensePlateAttribute); elementsBuilder.Add(new SortElementExpression(licensePlateSort, sortElement.IsAscending)); } else diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarsController.cs index ad0dbb18ce..aa0a2099be 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarsController.cs @@ -5,10 +5,11 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.CompositeKeys { - public sealed class CarsController : JsonApiController + public sealed class CarsController : JsonApiController { - public CarsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public CarsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeDbContext.cs index 62418ba2c2..25a8a04201 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeDbContext.cs @@ -8,9 +8,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.CompositeKeys [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class CompositeDbContext : DbContext { - public DbSet Cars { get; set; } - public DbSet Engines { get; set; } - public DbSet Dealerships { get; set; } + public DbSet Cars => Set(); + public DbSet Engines => Set(); + public DbSet Dealerships => Set(); public CompositeDbContext(DbContextOptions options) : base(options) @@ -28,12 +28,12 @@ protected override void OnModelCreating(ModelBuilder builder) builder.Entity() .HasOne(engine => engine.Car) - .WithOne(car => car.Engine) + .WithOne(car => car!.Engine) .HasForeignKey(); builder.Entity() .HasMany(dealership => dealership.Inventory) - .WithOne(car => car.Dealership); + .WithOne(car => car.Dealership!); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeKeyFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeKeyFakers.cs new file mode 100644 index 0000000000..a470a32b6b --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeKeyFakers.cs @@ -0,0 +1,32 @@ +using System; +using Bogus; +using TestBuildingBlocks; + +// @formatter:wrap_chained_method_calls chop_always +// @formatter:keep_existing_linebreaks true + +namespace JsonApiDotNetCoreTests.IntegrationTests.CompositeKeys +{ + internal sealed class CompositeKeyFakers : FakerContainer + { + private readonly Lazy> _lazyCarFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(car => car.LicensePlate, faker => faker.Random.Replace("??-??-##")) + .RuleFor(car => car.RegionId, faker => faker.Random.Long(100, 999))); + + private readonly Lazy> _lazyEngineFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(engine => engine.SerialCode, faker => faker.Random.Replace("????-????"))); + + private readonly Lazy> _lazyDealershipFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(dealership => dealership.Address, faker => faker.Address.FullAddress())); + + public Faker Car => _lazyCarFaker.Value; + public Faker Engine => _lazyEngineFaker.Value; + public Faker Dealership => _lazyDealershipFaker.Value; + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs index 1eff765ccd..7ad93bdec6 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using System.Linq; using System.Net; using System.Net.Http; @@ -16,6 +15,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.CompositeKeys public sealed class CompositeKeyTests : IClassFixture, CompositeDbContext>> { private readonly IntegrationTestContext, CompositeDbContext> _testContext; + private readonly CompositeKeyFakers _fakers = new(); public CompositeKeyTests(IntegrationTestContext, CompositeDbContext> testContext) { @@ -27,8 +27,8 @@ public CompositeKeyTests(IntegrationTestContext { - services.AddResourceRepository>(); - services.AddResourceRepository>(); + services.AddResourceRepository>(); + services.AddResourceRepository>(); }); var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); @@ -39,11 +39,7 @@ public CompositeKeyTests(IntegrationTestContext { @@ -52,7 +48,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - const string route = "/cars?filter=any(id,'123:AA-BB-11','999:XX-YY-22')"; + string route = $"/cars?filter=any(id,'{car.RegionId}:{car.LicensePlate}','999:XX-YY-22')"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -60,7 +56,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue.ShouldHaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(car.StringId); } @@ -68,11 +64,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_get_primary_resource_by_ID() { // Arrange - var car = new Car - { - RegionId = 123, - LicensePlate = "AA-BB-11" - }; + Car car = _fakers.Car.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -89,7 +81,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Id.Should().Be(car.StringId); } @@ -97,11 +89,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_sort_on_ID() { // Arrange - var car = new Car - { - RegionId = 123, - LicensePlate = "AA-BB-11" - }; + Car car = _fakers.Car.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -118,7 +106,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue.ShouldHaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(car.StringId); } @@ -126,11 +114,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_select_ID() { // Arrange - var car = new Car - { - RegionId = 123, - LicensePlate = "AA-BB-11" - }; + Car car = _fakers.Car.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -147,7 +131,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue.ShouldHaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(car.StringId); } @@ -155,9 +139,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_create_resource() { // Arrange + Engine existingEngine = _fakers.Engine.Generate(); + + Car newCar = _fakers.Car.Generate(); + await _testContext.RunOnDatabaseAsync(async dbContext => { await dbContext.ClearTableAsync(); + dbContext.Engines.Add(existingEngine); + await dbContext.SaveChangesAsync(); }); var requestBody = new @@ -167,8 +157,19 @@ await _testContext.RunOnDatabaseAsync(async dbContext => type = "cars", attributes = new { - regionId = 123, - licensePlate = "AA-BB-11" + regionId = newCar.RegionId, + licensePlate = newCar.LicensePlate + }, + relationships = new + { + engine = new + { + data = new + { + type = "engines", + id = existingEngine.StringId + } + } } } }; @@ -185,10 +186,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - Car carInDatabase = await dbContext.Cars.FirstOrDefaultAsync(car => car.RegionId == 123 && car.LicensePlate == "AA-BB-11"); + Car? carInDatabase = + await dbContext.Cars.FirstOrDefaultAsync(car => car.RegionId == newCar.RegionId && car.LicensePlate == newCar.LicensePlate); - carInDatabase.Should().NotBeNull(); - carInDatabase.Id.Should().Be("123:AA-BB-11"); + carInDatabase.ShouldNotBeNull(); + carInDatabase.Id.Should().Be($"{newCar.RegionId}:{newCar.LicensePlate}"); }); } @@ -196,16 +198,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_create_OneToOne_relationship() { // Arrange - var existingCar = new Car - { - RegionId = 123, - LicensePlate = "AA-BB-11" - }; - - var existingEngine = new Engine - { - SerialCode = "1234567890" - }; + Car existingCar = _fakers.Car.Generate(); + Engine existingEngine = _fakers.Engine.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -248,7 +242,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { Engine engineInDatabase = await dbContext.Engines.Include(engine => engine.Car).FirstWithIdAsync(existingEngine.Id); - engineInDatabase.Car.Should().NotBeNull(); + engineInDatabase.Car.ShouldNotBeNull(); engineInDatabase.Car.Id.Should().Be(existingCar.StringId); }); } @@ -257,15 +251,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_clear_OneToOne_relationship() { // Arrange - var existingEngine = new Engine - { - SerialCode = "1234567890", - Car = new Car - { - RegionId = 123, - LicensePlate = "AA-BB-11" - } - }; + Engine existingEngine = _fakers.Engine.Generate(); + existingEngine.Car = _fakers.Car.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -284,7 +271,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { car = new { - data = (object)null + data = (object?)null } } } @@ -312,23 +299,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_remove_from_OneToMany_relationship() { // Arrange - var existingDealership = new Dealership - { - Address = "Dam 1, 1012JS Amsterdam, the Netherlands", - Inventory = new HashSet - { - new() - { - RegionId = 123, - LicensePlate = "AA-BB-11" - }, - new() - { - RegionId = 456, - LicensePlate = "CC-DD-22" - } - } - }; + Dealership existingDealership = _fakers.Dealership.Generate(); + existingDealership.Inventory = _fakers.Car.Generate(2).ToHashSet(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -344,7 +316,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => new { type = "cars", - id = "123:AA-BB-11" + id = existingDealership.Inventory.ElementAt(0).StringId } } }; @@ -361,10 +333,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - Dealership dealershipInDatabase = await dbContext.Dealerships - .Include(dealership => dealership.Inventory).FirstWithIdOrDefaultAsync(existingDealership.Id); + Dealership dealershipInDatabase = + await dbContext.Dealerships.Include(dealership => dealership.Inventory).FirstWithIdAsync(existingDealership.Id); - dealershipInDatabase.Inventory.Should().HaveCount(1); + dealershipInDatabase.Inventory.ShouldHaveCount(1); dealershipInDatabase.Inventory.Should().ContainSingle(car => car.Id == existingDealership.Inventory.ElementAt(1).Id); }); } @@ -373,16 +345,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_add_to_OneToMany_relationship() { // Arrange - var existingDealership = new Dealership - { - Address = "Dam 1, 1012JS Amsterdam, the Netherlands" - }; - - var existingCar = new Car - { - RegionId = 123, - LicensePlate = "AA-BB-11" - }; + Dealership existingDealership = _fakers.Dealership.Generate(); + Car existingCar = _fakers.Car.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -398,7 +362,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => new { type = "cars", - id = "123:AA-BB-11" + id = existingCar.StringId } } }; @@ -415,10 +379,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - Dealership dealershipInDatabase = await dbContext.Dealerships - .Include(dealership => dealership.Inventory).FirstWithIdOrDefaultAsync(existingDealership.Id); + Dealership dealershipInDatabase = + await dbContext.Dealerships.Include(dealership => dealership.Inventory).FirstWithIdAsync(existingDealership.Id); - dealershipInDatabase.Inventory.Should().HaveCount(1); + dealershipInDatabase.Inventory.ShouldHaveCount(1); dealershipInDatabase.Inventory.Should().ContainSingle(car => car.Id == existingCar.Id); }); } @@ -427,29 +391,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_replace_OneToMany_relationship() { // Arrange - var existingDealership = new Dealership - { - Address = "Dam 1, 1012JS Amsterdam, the Netherlands", - Inventory = new HashSet - { - new() - { - RegionId = 123, - LicensePlate = "AA-BB-11" - }, - new() - { - RegionId = 456, - LicensePlate = "CC-DD-22" - } - } - }; + Dealership existingDealership = _fakers.Dealership.Generate(); + existingDealership.Inventory = _fakers.Car.Generate(2).ToHashSet(); - var existingCar = new Car - { - RegionId = 789, - LicensePlate = "EE-FF-33" - }; + Car existingCar = _fakers.Car.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -465,12 +410,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => new { type = "cars", - id = "123:AA-BB-11" + id = existingDealership.Inventory.ElementAt(0).StringId }, new { type = "cars", - id = "789:EE-FF-33" + id = existingCar.StringId } } }; @@ -487,10 +432,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - Dealership dealershipInDatabase = await dbContext.Dealerships - .Include(dealership => dealership.Inventory).FirstWithIdOrDefaultAsync(existingDealership.Id); + Dealership dealershipInDatabase = + await dbContext.Dealerships.Include(dealership => dealership.Inventory).FirstWithIdAsync(existingDealership.Id); - dealershipInDatabase.Inventory.Should().HaveCount(2); + dealershipInDatabase.Inventory.ShouldHaveCount(2); dealershipInDatabase.Inventory.Should().ContainSingle(car => car.Id == existingCar.Id); dealershipInDatabase.Inventory.Should().ContainSingle(car => car.Id == existingDealership.Inventory.ElementAt(0).Id); }); @@ -500,10 +445,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_remove_from_ManyToOne_relationship_for_unknown_relationship_ID() { // Arrange - var existingDealership = new Dealership - { - Address = "Dam 1, 1012JS Amsterdam, the Netherlands" - }; + Dealership existingDealership = _fakers.Dealership.Generate(); + + string unknownCarId = _fakers.Car.Generate().StringId!; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -519,7 +463,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => new { type = "cars", - id = "999:XX-YY-22" + id = unknownCarId } } }; @@ -532,23 +476,19 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); error.Title.Should().Be("A related resource does not exist."); - error.Detail.Should().Be("Related resource of type 'cars' with ID '999:XX-YY-22' in relationship 'inventory' does not exist."); + error.Detail.Should().Be($"Related resource of type 'cars' with ID '{unknownCarId}' in relationship 'inventory' does not exist."); } [Fact] public async Task Can_delete_resource() { // Arrange - var existingCar = new Car - { - RegionId = 123, - LicensePlate = "AA-BB-11" - }; + Car existingCar = _fakers.Car.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -569,7 +509,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - Car carInDatabase = + Car? carInDatabase = await dbContext.Cars.FirstOrDefaultAsync(car => car.RegionId == existingCar.RegionId && car.LicensePlate == existingCar.LicensePlate); carInDatabase.Should().BeNull(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/Dealership.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/Dealership.cs index cb41add695..42d11da754 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/Dealership.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/Dealership.cs @@ -6,12 +6,12 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.CompositeKeys { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Dealership : Identifiable + public sealed class Dealership : Identifiable { [Attr] - public string Address { get; set; } + public string Address { get; set; } = null!; [HasMany] - public ISet Inventory { get; set; } + public ISet Inventory { get; set; } = new HashSet(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/DealershipsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/DealershipsController.cs index f3e21f47ac..2ec7d85cda 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/DealershipsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/DealershipsController.cs @@ -5,10 +5,11 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.CompositeKeys { - public sealed class DealershipsController : JsonApiController + public sealed class DealershipsController : JsonApiController { - public DealershipsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public DealershipsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/Engine.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/Engine.cs index 7f764e5277..2a322e7513 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/Engine.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/Engine.cs @@ -5,12 +5,12 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.CompositeKeys { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Engine : Identifiable + public sealed class Engine : Identifiable { [Attr] - public string SerialCode { get; set; } + public string SerialCode { get; set; } = null!; [HasOne] - public Car Car { get; set; } + public Car? Car { get; set; } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/EnginesController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/EnginesController.cs index 971246d219..f995a72233 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/EnginesController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/EnginesController.cs @@ -5,10 +5,11 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.CompositeKeys { - public sealed class EnginesController : JsonApiController + public sealed class EnginesController : JsonApiController { - public EnginesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public EnginesController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs index cca76a2e53..35b24149f6 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs @@ -192,12 +192,13 @@ public async Task Denies_JsonApi_with_parameters_in_Accept_headers() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NotAcceptable); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotAcceptable); error.Title.Should().Be("The specified Accept header value does not contain any supported media types."); error.Detail.Should().Be("Please include 'application/vnd.api+json' in the Accept header values."); + error.Source.ShouldNotBeNull(); error.Source.Header.Should().Be("Accept"); } @@ -239,12 +240,13 @@ public async Task Denies_JsonApi_in_Accept_headers_at_operations_endpoint() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NotAcceptable); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotAcceptable); error.Title.Should().Be("The specified Accept header value does not contain any supported media types."); error.Detail.Should().Be("Please include 'application/vnd.api+json; ext=\"https://jsonapi.org/ext/atomic\"' in the Accept header values."); + error.Source.ShouldNotBeNull(); error.Source.Header.Should().Be("Accept"); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs index 1ae336c663..9c96bf0a1d 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs @@ -32,7 +32,8 @@ public async Task Returns_JsonApi_ContentType_header() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - httpResponse.Content.Headers.ContentType.Should().NotBeNull().And.Subject.ToString().Should().Be(HeaderConstants.MediaType); + httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(HeaderConstants.MediaType); } [Fact] @@ -65,7 +66,8 @@ public async Task Returns_JsonApi_ContentType_header_with_AtomicOperations_exten // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - httpResponse.Content.Headers.ContentType.Should().NotBeNull().And.Subject.ToString().Should().Be(HeaderConstants.AtomicOperationsMediaType); + httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(HeaderConstants.AtomicOperationsMediaType); } [Fact] @@ -93,12 +95,13 @@ public async Task Denies_unknown_ContentType_header() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnsupportedMediaType); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnsupportedMediaType); error.Title.Should().Be("The specified Content-Type header value is not supported."); error.Detail.Should().Be("Please specify 'application/vnd.api+json' instead of 'text/html' for the Content-Type header value."); + error.Source.ShouldNotBeNull(); error.Source.Header.Should().Be("Content-Type"); } @@ -187,12 +190,13 @@ public async Task Denies_JsonApi_ContentType_header_with_profile() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnsupportedMediaType); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnsupportedMediaType); error.Title.Should().Be("The specified Content-Type header value is not supported."); error.Detail.Should().Be($"Please specify 'application/vnd.api+json' instead of '{contentType}' for the Content-Type header value."); + error.Source.ShouldNotBeNull(); error.Source.Header.Should().Be("Content-Type"); } @@ -221,12 +225,13 @@ public async Task Denies_JsonApi_ContentType_header_with_extension() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnsupportedMediaType); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnsupportedMediaType); error.Title.Should().Be("The specified Content-Type header value is not supported."); error.Detail.Should().Be($"Please specify 'application/vnd.api+json' instead of '{contentType}' for the Content-Type header value."); + error.Source.ShouldNotBeNull(); error.Source.Header.Should().Be("Content-Type"); } @@ -255,12 +260,13 @@ public async Task Denies_JsonApi_ContentType_header_with_AtomicOperations_extens // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnsupportedMediaType); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnsupportedMediaType); error.Title.Should().Be("The specified Content-Type header value is not supported."); error.Detail.Should().Be($"Please specify 'application/vnd.api+json' instead of '{contentType}' for the Content-Type header value."); + error.Source.ShouldNotBeNull(); error.Source.Header.Should().Be("Content-Type"); } @@ -289,12 +295,13 @@ public async Task Denies_JsonApi_ContentType_header_with_CharSet() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnsupportedMediaType); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnsupportedMediaType); error.Title.Should().Be("The specified Content-Type header value is not supported."); error.Detail.Should().Be($"Please specify 'application/vnd.api+json' instead of '{contentType}' for the Content-Type header value."); + error.Source.ShouldNotBeNull(); error.Source.Header.Should().Be("Content-Type"); } @@ -323,12 +330,13 @@ public async Task Denies_JsonApi_ContentType_header_with_unknown_parameter() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnsupportedMediaType); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnsupportedMediaType); error.Title.Should().Be("The specified Content-Type header value is not supported."); error.Detail.Should().Be($"Please specify 'application/vnd.api+json' instead of '{contentType}' for the Content-Type header value."); + error.Source.ShouldNotBeNull(); error.Source.Header.Should().Be("Content-Type"); } @@ -365,7 +373,7 @@ public async Task Denies_JsonApi_ContentType_header_at_operations_endpoint() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnsupportedMediaType); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); string detail = $"Please specify '{HeaderConstants.AtomicOperationsMediaType}' instead of '{contentType}' for the Content-Type header value."; @@ -373,6 +381,7 @@ public async Task Denies_JsonApi_ContentType_header_at_operations_endpoint() error.StatusCode.Should().Be(HttpStatusCode.UnsupportedMediaType); error.Title.Should().Be("The specified Content-Type header value is not supported."); error.Detail.Should().Be(detail); + error.Source.ShouldNotBeNull(); error.Source.Header.Should().Be("Content-Type"); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/OperationsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/OperationsController.cs index 9d2a3df6f3..06cf328d23 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/OperationsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/OperationsController.cs @@ -9,9 +9,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ContentNegotiation { public sealed class OperationsController : JsonApiOperationsController { - public OperationsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IOperationsProcessor processor, IJsonApiRequest request, - ITargetedFields targetedFields) - : base(options, loggerFactory, processor, request, targetedFields) + public OperationsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IOperationsProcessor processor, + IJsonApiRequest request, ITargetedFields targetedFields) + : base(options, resourceGraph, loggerFactory, processor, request, targetedFields) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/PoliciesController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/PoliciesController.cs index 0174e16985..4edb2dfdec 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/PoliciesController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/PoliciesController.cs @@ -5,10 +5,11 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ContentNegotiation { - public sealed class PoliciesController : JsonApiController + public sealed class PoliciesController : JsonApiController { - public PoliciesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public PoliciesController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/Policy.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/Policy.cs index 5d89daa33e..27d850107c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/Policy.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/Policy.cs @@ -5,9 +5,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ContentNegotiation { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Policy : Identifiable + public sealed class Policy : Identifiable { [Attr] - public string Name { get; set; } + public string Name { get; set; } = null!; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/PolicyDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/PolicyDbContext.cs index 8fafc120b7..3e952ff6ba 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/PolicyDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/PolicyDbContext.cs @@ -6,7 +6,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ContentNegotiation [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class PolicyDbContext : DbContext { - public DbSet Policies { get; set; } + public DbSet Policies => Set(); public PolicyDbContext(DbContextOptions options) : base(options) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/ActionResultDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/ActionResultDbContext.cs index b678e21fde..c8219d8dd6 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/ActionResultDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/ActionResultDbContext.cs @@ -6,7 +6,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ControllerActionResults [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class ActionResultDbContext : DbContext { - public DbSet Toothbrushes { get; set; } + public DbSet Toothbrushes => Set(); public ActionResultDbContext(DbContextOptions options) : base(options) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/ActionResultTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/ActionResultTests.cs index 51bb1c6f5c..915b2020fc 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/ActionResultTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/ActionResultTests.cs @@ -39,7 +39,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Id.Should().Be(toothbrush.StringId); } @@ -47,7 +47,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Converts_empty_ActionResult_to_error_collection() { // Arrange - string route = $"/toothbrushes/{BaseToothbrushesController.EmptyActionResultId}"; + string route = $"/toothbrushes/{ToothbrushesController.EmptyActionResultId}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -55,7 +55,7 @@ public async Task Converts_empty_ActionResult_to_error_collection() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); @@ -67,7 +67,7 @@ public async Task Converts_empty_ActionResult_to_error_collection() public async Task Converts_ActionResult_with_error_object_to_error_collection() { // Arrange - string route = $"/toothbrushes/{BaseToothbrushesController.ActionResultWithErrorObjectId}"; + string route = $"/toothbrushes/{ToothbrushesController.ActionResultWithErrorObjectId}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -75,7 +75,7 @@ public async Task Converts_ActionResult_with_error_object_to_error_collection() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); @@ -87,7 +87,7 @@ public async Task Converts_ActionResult_with_error_object_to_error_collection() public async Task Cannot_convert_ActionResult_with_string_parameter_to_error_collection() { // Arrange - string route = $"/toothbrushes/{BaseToothbrushesController.ActionResultWithStringParameter}"; + string route = $"/toothbrushes/{ToothbrushesController.ActionResultWithStringParameter}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -95,19 +95,19 @@ public async Task Cannot_convert_ActionResult_with_string_parameter_to_error_col // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.InternalServerError); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.InternalServerError); error.Title.Should().Be("An unhandled error occurred while processing this request."); - error.Detail.Should().Be("Data being returned must be errors or resources."); + error.Detail.Should().Be("Data being returned must be resources, operations, errors or null."); } [Fact] public async Task Converts_ObjectResult_with_error_object_to_error_collection() { // Arrange - string route = $"/toothbrushes/{BaseToothbrushesController.ObjectResultWithErrorObjectId}"; + string route = $"/toothbrushes/{ToothbrushesController.ObjectResultWithErrorObjectId}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -115,7 +115,7 @@ public async Task Converts_ObjectResult_with_error_object_to_error_collection() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.BadGateway); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadGateway); @@ -127,7 +127,7 @@ public async Task Converts_ObjectResult_with_error_object_to_error_collection() public async Task Converts_ObjectResult_with_error_objects_to_error_collection() { // Arrange - string route = $"/toothbrushes/{BaseToothbrushesController.ObjectResultWithErrorCollectionId}"; + string route = $"/toothbrushes/{ToothbrushesController.ObjectResultWithErrorCollectionId}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -135,7 +135,7 @@ public async Task Converts_ObjectResult_with_error_objects_to_error_collection() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.Should().HaveCount(3); + responseDocument.Errors.ShouldHaveCount(3); ErrorObject error1 = responseDocument.Errors[0]; error1.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/BaseToothbrushesController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/BaseToothbrushesController.cs deleted file mode 100644 index 4718323321..0000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/BaseToothbrushesController.cs +++ /dev/null @@ -1,69 +0,0 @@ -using System.Net; -using System.Threading; -using System.Threading.Tasks; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCore.Services; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreTests.IntegrationTests.ControllerActionResults -{ - public abstract class BaseToothbrushesController : BaseJsonApiController - { - internal const int EmptyActionResultId = 11111111; - internal const int ActionResultWithErrorObjectId = 22222222; - internal const int ActionResultWithStringParameter = 33333333; - internal const int ObjectResultWithErrorObjectId = 44444444; - internal const int ObjectResultWithErrorCollectionId = 55555555; - - protected BaseToothbrushesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { - } - - public override async Task GetAsync(int id, CancellationToken cancellationToken) - { - if (id == EmptyActionResultId) - { - return NotFound(); - } - - if (id == ActionResultWithErrorObjectId) - { - return NotFound(new ErrorObject(HttpStatusCode.NotFound) - { - Title = "No toothbrush with that ID exists." - }); - } - - if (id == ActionResultWithStringParameter) - { - return Conflict("Something went wrong."); - } - - if (id == ObjectResultWithErrorObjectId) - { - return Error(new ErrorObject(HttpStatusCode.BadGateway)); - } - - if (id == ObjectResultWithErrorCollectionId) - { - var errors = new[] - { - new ErrorObject(HttpStatusCode.PreconditionFailed), - new ErrorObject(HttpStatusCode.Unauthorized), - new ErrorObject(HttpStatusCode.ExpectationFailed) - { - Title = "This is not a very great request." - } - }; - - return Error(errors); - } - - return await base.GetAsync(id, cancellationToken); - } - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/Toothbrush.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/Toothbrush.cs index 84299ab5d2..674513f910 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/Toothbrush.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/Toothbrush.cs @@ -5,7 +5,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ControllerActionResults { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Toothbrush : Identifiable + public sealed class Toothbrush : Identifiable { [Attr] public bool IsElectric { get; set; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/ToothbrushesController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/ToothbrushesController.cs index b3d85b070d..92bdf57157 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/ToothbrushesController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/ToothbrushesController.cs @@ -1,23 +1,71 @@ +using System.Net; using System.Threading; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCore.Services; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; namespace JsonApiDotNetCoreTests.IntegrationTests.ControllerActionResults { - public sealed class ToothbrushesController : BaseToothbrushesController + public sealed class ToothbrushesController : BaseJsonApiController { - public ToothbrushesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + internal const int EmptyActionResultId = 11111111; + internal const int ActionResultWithErrorObjectId = 22222222; + internal const int ActionResultWithStringParameter = 33333333; + internal const int ObjectResultWithErrorObjectId = 44444444; + internal const int ObjectResultWithErrorCollectionId = 55555555; + + public ToothbrushesController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } [HttpGet("{id}")] - public override Task GetAsync(int id, CancellationToken cancellationToken) + public override async Task GetAsync(int id, CancellationToken cancellationToken) { - return base.GetAsync(id, cancellationToken); + if (id == EmptyActionResultId) + { + return NotFound(); + } + + if (id == ActionResultWithErrorObjectId) + { + return NotFound(new ErrorObject(HttpStatusCode.NotFound) + { + Title = "No toothbrush with that ID exists." + }); + } + + if (id == ActionResultWithStringParameter) + { + return Conflict("Something went wrong."); + } + + if (id == ObjectResultWithErrorObjectId) + { + return Error(new ErrorObject(HttpStatusCode.BadGateway)); + } + + if (id == ObjectResultWithErrorCollectionId) + { + var errors = new[] + { + new ErrorObject(HttpStatusCode.PreconditionFailed), + new ErrorObject(HttpStatusCode.Unauthorized), + new ErrorObject(HttpStatusCode.ExpectationFailed) + { + Title = "This is not a very great request." + } + }; + + return Error(errors); + } + + return await base.GetAsync(id, cancellationToken); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/ApiControllerAttributeTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/ApiControllerAttributeTests.cs index f49c18ad9d..2d6fcca6fb 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/ApiControllerAttributeTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/ApiControllerAttributeTests.cs @@ -31,9 +31,10 @@ public async Task ApiController_attribute_transforms_NotFound_action_result_with // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; + error.Links.ShouldNotBeNull(); error.Links.About.Should().Be("https://tools.ietf.org/html/rfc7231#section-6.5.4"); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/Civilian.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/Civilian.cs index ec7ddcf5d9..13141feca6 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/Civilian.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/Civilian.cs @@ -5,9 +5,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.CustomRoutes { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Civilian : Identifiable + public sealed class Civilian : Identifiable { [Attr] - public string Name { get; set; } + public string Name { get; set; } = null!; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CiviliansController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CiviliansController.cs index afcac61376..aad9ccb421 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CiviliansController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CiviliansController.cs @@ -11,10 +11,11 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.CustomRoutes [ApiController] [DisableRoutingConvention] [Route("world-civilians")] - public sealed class CiviliansController : JsonApiController + public sealed class CiviliansController : JsonApiController { - public CiviliansController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public CiviliansController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CustomRouteDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CustomRouteDbContext.cs index c3e3b9063d..fc24c83f45 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CustomRouteDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CustomRouteDbContext.cs @@ -6,8 +6,8 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.CustomRoutes [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class CustomRouteDbContext : DbContext { - public DbSet Towns { get; set; } - public DbSet Civilians { get; set; } + public DbSet Towns => Set(); + public DbSet Civilians => Set(); public CustomRouteDbContext(DbContextOptions options) : base(options) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CustomRouteTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CustomRouteTests.cs index 05b7c0214d..4c313aa2c3 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CustomRouteTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CustomRouteTests.cs @@ -46,15 +46,24 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Type.Should().Be("towns"); responseDocument.Data.SingleValue.Id.Should().Be(town.StringId); - responseDocument.Data.SingleValue.Attributes["name"].Should().Be(town.Name); - responseDocument.Data.SingleValue.Attributes["latitude"].Should().Be(town.Latitude); - responseDocument.Data.SingleValue.Attributes["longitude"].Should().Be(town.Longitude); - responseDocument.Data.SingleValue.Relationships["civilians"].Links.Self.Should().Be($"{HostPrefix}{route}/relationships/civilians"); - responseDocument.Data.SingleValue.Relationships["civilians"].Links.Related.Should().Be($"{HostPrefix}{route}/civilians"); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("name").With(value => value.Should().Be(town.Name)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("latitude").With(value => value.Should().Be(town.Latitude)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("longitude").With(value => value.Should().Be(town.Longitude)); + + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("civilians").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{HostPrefix}{route}/relationships/civilians"); + value.Links.Related.Should().Be($"{HostPrefix}{route}/civilians"); + }); + + responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); responseDocument.Data.SingleValue.Links.Self.Should().Be($"{HostPrefix}{route}"); + responseDocument.Links.ShouldNotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); } @@ -79,10 +88,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(5); + responseDocument.Data.ManyValue.ShouldHaveCount(5); responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Type == "towns"); - responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Attributes.Any()); - responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Relationships.Any()); + responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Attributes.ShouldNotBeNull().Any()); + responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Relationships.ShouldNotBeNull().Any()); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/Town.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/Town.cs index 4ddb1898e9..ca893947a6 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/Town.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/Town.cs @@ -6,10 +6,10 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.CustomRoutes { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Town : Identifiable + public sealed class Town : Identifiable { [Attr] - public string Name { get; set; } + public string Name { get; set; } = null!; [Attr] public double Latitude { get; set; } @@ -18,6 +18,6 @@ public sealed class Town : Identifiable public double Longitude { get; set; } [HasMany] - public ISet Civilians { get; set; } + public ISet Civilians { get; set; } = new HashSet(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/TownsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/TownsController.cs index c1c5cf8e24..79b9cbc63b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/TownsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/TownsController.cs @@ -14,12 +14,13 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.CustomRoutes { [DisableRoutingConvention] [Route("world-api/civilization/popular/towns")] - public sealed class TownsController : JsonApiController + public sealed class TownsController : JsonApiController { private readonly CustomRouteDbContext _dbContext; - public TownsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService, CustomRouteDbContext dbContext) - : base(options, loggerFactory, resourceService) + public TownsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IResourceService resourceService, + CustomRouteDbContext dbContext) + : base(options, resourceGraph, loggerFactory, resourceService) { _dbContext = dbContext; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/Building.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/Building.cs index 8d423e3d4a..eb4a3d2fc7 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/Building.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/Building.cs @@ -7,27 +7,39 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.EagerLoading { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Building : Identifiable + public sealed class Building : Identifiable { - private string _tempPrimaryDoorColor; + private string? _tempPrimaryDoorColor; [Attr] - public string Number { get; set; } + public string Number { get; set; } = null!; [NotMapped] [Attr] - public int WindowCount => Windows?.Count ?? 0; + public int WindowCount => Windows.Count; [NotMapped] [Attr(Capabilities = AttrCapabilities.AllowView | AttrCapabilities.AllowChange)] public string PrimaryDoorColor { - get => _tempPrimaryDoorColor ?? PrimaryDoor.Color; + get + { + if (_tempPrimaryDoorColor == null && PrimaryDoor == null) + { + // The ASP.NET model validator reads the value of this required property, to ensure it is not null. + // When creating a resource, BuildingDefinition ensures a value is assigned. But when updating a resource + // and PrimaryDoorColor is explicitly set to null in the request body and ModelState validation is enabled, + // we want it to produce a validation error, so return null here. + return null!; + } + + return _tempPrimaryDoorColor ?? PrimaryDoor!.Color; + } set { if (PrimaryDoor == null) { - // A request body is being deserialized. At this time, related entities have not been loaded. + // A request body is being deserialized. At this time, related entities have not been loaded yet. // We cache the assigned value in a private field, so it can be used later. _tempPrimaryDoorColor = value; } @@ -40,15 +52,15 @@ public string PrimaryDoorColor [NotMapped] [Attr(Capabilities = AttrCapabilities.AllowView)] - public string SecondaryDoorColor => SecondaryDoor?.Color; + public string? SecondaryDoorColor => SecondaryDoor?.Color; [EagerLoad] - public IList Windows { get; set; } + public IList Windows { get; set; } = new List(); [EagerLoad] - public Door PrimaryDoor { get; set; } + public Door? PrimaryDoor { get; set; } [EagerLoad] - public Door SecondaryDoor { get; set; } + public Door? SecondaryDoor { get; set; } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/BuildingDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/BuildingDefinition.cs new file mode 100644 index 0000000000..a6fc726a19 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/BuildingDefinition.cs @@ -0,0 +1,35 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCoreTests.IntegrationTests.EagerLoading +{ + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] + public sealed class BuildingDefinition : JsonApiResourceDefinition + { + private readonly IJsonApiRequest _request; + + public BuildingDefinition(IResourceGraph resourceGraph, IJsonApiRequest request) + : base(resourceGraph) + { + ArgumentGuard.NotNull(request, nameof(request)); + + _request = request; + } + + public override void OnDeserialize(Building resource) + { + if (_request.WriteOperation == WriteOperationKind.CreateResource) + { + // Must ensure that an instance exists for this required relationship, + // so that ASP.NET ModelState validation does not produce a validation error. + resource.PrimaryDoor = new Door + { + Color = "(unspecified)" + }; + } + } + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/BuildingRepository.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/BuildingRepository.cs index 4c49bdc209..88a4f6d714 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/BuildingRepository.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/BuildingRepository.cs @@ -11,12 +11,12 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.EagerLoading { [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class BuildingRepository : EntityFrameworkCoreRepository + 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) { } @@ -24,8 +24,11 @@ public override async Task GetForCreateAsync(int id, CancellationToken { Building building = await base.GetForCreateAsync(id, cancellationToken); - // Must ensure that an instance exists for this required relationship, so that POST succeeds. - building.PrimaryDoor = new Door(); + // Must ensure that an instance exists for this required relationship, so that POST Resource succeeds. + building.PrimaryDoor = new Door + { + Color = "(unspecified)" + }; return building; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/BuildingsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/BuildingsController.cs index f88f3d9db5..f26107ba89 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/BuildingsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/BuildingsController.cs @@ -5,10 +5,11 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.EagerLoading { - public sealed class BuildingsController : JsonApiController + public sealed class BuildingsController : JsonApiController { - public BuildingsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public BuildingsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/City.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/City.cs index 679d002d22..ce9788abb7 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/City.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/City.cs @@ -6,12 +6,12 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.EagerLoading { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class City : Identifiable + public sealed class City : Identifiable { [Attr] - public string Name { get; set; } + public string Name { get; set; } = null!; [HasMany] - public IList Streets { get; set; } + public IList Streets { get; set; } = new List(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/Door.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/Door.cs index 73b50af911..c42235ea23 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/Door.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/Door.cs @@ -6,6 +6,6 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.EagerLoading public sealed class Door { public int Id { get; set; } - public string Color { get; set; } + public string Color { get; set; } = null!; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/EagerLoadingDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/EagerLoadingDbContext.cs index bc9e95c9e9..aec0207c25 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/EagerLoadingDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/EagerLoadingDbContext.cs @@ -8,9 +8,10 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.EagerLoading [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class EagerLoadingDbContext : DbContext { - public DbSet States { get; set; } - public DbSet Streets { get; set; } - public DbSet Buildings { get; set; } + public DbSet States => Set(); + public DbSet Streets => Set(); + public DbSet Buildings => Set(); + public DbSet Doors => Set(); public EagerLoadingDbContext(DbContextOptions options) : base(options) @@ -23,13 +24,15 @@ protected override void OnModelCreating(ModelBuilder builder) .HasOne(building => building.PrimaryDoor) .WithOne() .HasForeignKey("PrimaryDoorId") + // The PrimaryDoor relationship property is declared as nullable, because the Door type is not publicly exposed, + // so we don't want ModelState validation to fail when it isn't provided by the client. But because + // BuildingRepository ensures a value is assigned on Create, we can make it a required relationship in the database. .IsRequired(); builder.Entity() .HasOne(building => building.SecondaryDoor) .WithOne() - .HasForeignKey("SecondaryDoorId") - .IsRequired(false); + .HasForeignKey("SecondaryDoorId"); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/EagerLoadingTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/EagerLoadingTests.cs index 0e0482dbce..b0c22d0bff 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/EagerLoadingTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/EagerLoadingTests.cs @@ -25,6 +25,7 @@ public EagerLoadingTests(IntegrationTestContext { + services.AddResourceDefinition(); services.AddResourceRepository(); }); } @@ -52,12 +53,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Id.Should().Be(building.StringId); - responseDocument.Data.SingleValue.Attributes["number"].Should().Be(building.Number); - responseDocument.Data.SingleValue.Attributes["windowCount"].Should().Be(4); - responseDocument.Data.SingleValue.Attributes["primaryDoorColor"].Should().Be(building.PrimaryDoor.Color); - responseDocument.Data.SingleValue.Attributes["secondaryDoorColor"].Should().Be(building.SecondaryDoor.Color); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("number").With(value => value.Should().Be(building.Number)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("windowCount").With(value => value.Should().Be(4)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("primaryDoorColor").With(value => value.Should().Be(building.PrimaryDoor.Color)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("secondaryDoorColor").With(value => value.Should().Be(building.SecondaryDoor.Color)); } [Fact] @@ -88,12 +89,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Id.Should().Be(street.StringId); - responseDocument.Data.SingleValue.Attributes["name"].Should().Be(street.Name); - responseDocument.Data.SingleValue.Attributes["buildingCount"].Should().Be(2); - responseDocument.Data.SingleValue.Attributes["doorTotalCount"].Should().Be(3); - responseDocument.Data.SingleValue.Attributes["windowTotalCount"].Should().Be(5); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("name").With(value => value.Should().Be(street.Name)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("buildingCount").With(value => value.Should().Be(2)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("doorTotalCount").With(value => value.Should().Be(3)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("windowTotalCount").With(value => value.Should().Be(5)); } [Fact] @@ -119,10 +120,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Id.Should().Be(street.StringId); - responseDocument.Data.SingleValue.Attributes.Should().HaveCount(1); - responseDocument.Data.SingleValue.Attributes["windowTotalCount"].Should().Be(3); + responseDocument.Data.SingleValue.Attributes.ShouldHaveCount(1); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("windowTotalCount").With(value => value.Should().Be(3)); responseDocument.Data.SingleValue.Relationships.Should().BeNull(); } @@ -151,21 +152,21 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Id.Should().Be(state.StringId); - responseDocument.Data.SingleValue.Attributes["name"].Should().Be(state.Name); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("name").With(value => value.Should().Be(state.Name)); - responseDocument.Included.Should().HaveCount(2); + responseDocument.Included.ShouldHaveCount(2); responseDocument.Included[0].Type.Should().Be("cities"); responseDocument.Included[0].Id.Should().Be(state.Cities[0].StringId); - responseDocument.Included[0].Attributes["name"].Should().Be(state.Cities[0].Name); + responseDocument.Included[0].Attributes.ShouldContainKey("name").With(value => value.Should().Be(state.Cities[0].Name)); responseDocument.Included[1].Type.Should().Be("streets"); responseDocument.Included[1].Id.Should().Be(state.Cities[0].Streets[0].StringId); - responseDocument.Included[1].Attributes["buildingCount"].Should().Be(1); - responseDocument.Included[1].Attributes["doorTotalCount"].Should().Be(1); - responseDocument.Included[1].Attributes["windowTotalCount"].Should().Be(3); + responseDocument.Included[1].Attributes.ShouldContainKey("buildingCount").With(value => value.Should().Be(1)); + responseDocument.Included[1].Attributes.ShouldContainKey("doorTotalCount").With(value => value.Should().Be(1)); + responseDocument.Included[1].Attributes.ShouldContainKey("windowTotalCount").With(value => value.Should().Be(3)); } [Fact] @@ -194,18 +195,18 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue.ShouldHaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(state.Cities[0].StringId); - responseDocument.Data.ManyValue[0].Attributes.Should().HaveCount(1); - responseDocument.Data.ManyValue[0].Attributes["name"].Should().Be(state.Cities[0].Name); + responseDocument.Data.ManyValue[0].Attributes.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("name").With(value => value.Should().Be(state.Cities[0].Name)); responseDocument.Data.ManyValue[0].Relationships.Should().BeNull(); - responseDocument.Included.Should().HaveCount(1); + responseDocument.Included.ShouldHaveCount(1); responseDocument.Included[0].Type.Should().Be("streets"); responseDocument.Included[0].Id.Should().Be(state.Cities[0].Streets[0].StringId); - responseDocument.Included[0].Attributes.Should().HaveCount(2); - responseDocument.Included[0].Attributes["doorTotalCount"].Should().Be(2); - responseDocument.Included[0].Attributes["windowTotalCount"].Should().Be(1); + responseDocument.Included[0].Attributes.ShouldHaveCount(2); + responseDocument.Included[0].Attributes.ShouldContainKey("doorTotalCount").With(value => value.Should().Be(2)); + responseDocument.Included[0].Attributes.ShouldContainKey("windowTotalCount").With(value => value.Should().Be(1)); responseDocument.Included[0].Relationships.Should().BeNull(); } @@ -235,20 +236,20 @@ public async Task Can_create_resource() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.Should().NotBeNull(); - responseDocument.Data.SingleValue.Attributes["number"].Should().Be(newBuilding.Number); - responseDocument.Data.SingleValue.Attributes["windowCount"].Should().Be(0); - responseDocument.Data.SingleValue.Attributes["primaryDoorColor"].Should().BeNull(); - responseDocument.Data.SingleValue.Attributes["secondaryDoorColor"].Should().BeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("number").With(value => value.Should().Be(newBuilding.Number)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("windowCount").With(value => value.Should().Be(0)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("primaryDoorColor").With(value => value.Should().Be("(unspecified)")); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("secondaryDoorColor").With(value => value.Should().BeNull()); - int newBuildingId = int.Parse(responseDocument.Data.SingleValue.Id); + int newBuildingId = int.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { // @formatter:wrap_chained_method_calls chop_always // @formatter:keep_existing_linebreaks true - Building buildingInDatabase = await dbContext.Buildings + Building? buildingInDatabase = await dbContext.Buildings .Include(building => building.PrimaryDoor) .Include(building => building.SecondaryDoor) .Include(building => building.Windows) @@ -257,9 +258,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // @formatter:keep_existing_linebreaks restore // @formatter:wrap_chained_method_calls restore - buildingInDatabase.Should().NotBeNull(); + buildingInDatabase.ShouldNotBeNull(); buildingInDatabase.Number.Should().Be(newBuilding.Number); - buildingInDatabase.PrimaryDoor.Should().NotBeNull(); + buildingInDatabase.PrimaryDoor.ShouldNotBeNull(); + buildingInDatabase.PrimaryDoor.Color.Should().Be("(unspecified)"); buildingInDatabase.SecondaryDoor.Should().BeNull(); buildingInDatabase.Windows.Should().BeEmpty(); }); @@ -312,7 +314,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // @formatter:wrap_chained_method_calls chop_always // @formatter:keep_existing_linebreaks true - Building buildingInDatabase = await dbContext.Buildings + Building? buildingInDatabase = await dbContext.Buildings .Include(building => building.PrimaryDoor) .Include(building => building.SecondaryDoor) .Include(building => building.Windows) @@ -321,15 +323,59 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // @formatter:keep_existing_linebreaks restore // @formatter:wrap_chained_method_calls restore - buildingInDatabase.Should().NotBeNull(); + buildingInDatabase.ShouldNotBeNull(); buildingInDatabase.Number.Should().Be(newBuildingNumber); - buildingInDatabase.PrimaryDoor.Should().NotBeNull(); + buildingInDatabase.PrimaryDoor.ShouldNotBeNull(); buildingInDatabase.PrimaryDoor.Color.Should().Be(newPrimaryDoorColor); - buildingInDatabase.SecondaryDoor.Should().NotBeNull(); - buildingInDatabase.Windows.Should().HaveCount(2); + buildingInDatabase.SecondaryDoor.ShouldNotBeNull(); + buildingInDatabase.Windows.ShouldHaveCount(2); }); } + [Fact] + public async Task Cannot_update_resource_when_primaryDoorColor_is_set_to_null() + { + // Arrange + Building existingBuilding = _fakers.Building.Generate(); + existingBuilding.PrimaryDoor = _fakers.Door.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Buildings.Add(existingBuilding); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "buildings", + id = existingBuilding.StringId, + attributes = new + { + primaryDoorColor = (string?)null + } + } + }; + + string route = $"/buildings/{existingBuilding.StringId}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Input validation failed."); + error.Detail.Should().Be("The PrimaryDoorColor field is required."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data/attributes/primaryDoorColor"); + } + [Fact] public async Task Can_delete_resource() { @@ -355,7 +401,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - Building buildingInDatabase = await dbContext.Buildings.FirstWithIdOrDefaultAsync(existingBuilding.Id); + Building? buildingInDatabase = await dbContext.Buildings.FirstWithIdOrDefaultAsync(existingBuilding.Id); buildingInDatabase.Should().BeNull(); }); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/State.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/State.cs index cc1c323fbe..e15f5e2ee2 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/State.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/State.cs @@ -6,12 +6,12 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.EagerLoading { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class State : Identifiable + public sealed class State : Identifiable { [Attr] - public string Name { get; set; } + public string Name { get; set; } = null!; [HasMany] - public IList Cities { get; set; } + public IList Cities { get; set; } = new List(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/StatesController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/StatesController.cs index 11533c16b9..5e792adefd 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/StatesController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/StatesController.cs @@ -5,10 +5,11 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.EagerLoading { - public sealed class StatesController : JsonApiController + public sealed class StatesController : JsonApiController { - public StatesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public StatesController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/Street.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/Street.cs index 6566295238..d6aa1a97e3 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/Street.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/Street.cs @@ -8,24 +8,24 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.EagerLoading { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Street : Identifiable + public sealed class Street : Identifiable { [Attr] - public string Name { get; set; } + public string Name { get; set; } = null!; [NotMapped] [Attr(Capabilities = AttrCapabilities.AllowView)] - public int BuildingCount => Buildings?.Count ?? 0; + public int BuildingCount => Buildings.Count; [NotMapped] [Attr(Capabilities = AttrCapabilities.AllowView)] - public int DoorTotalCount => Buildings?.Sum(building => building.SecondaryDoor == null ? 1 : 2) ?? 0; + public int DoorTotalCount => Buildings.Sum(building => building.SecondaryDoor == null ? 1 : 2); [NotMapped] [Attr(Capabilities = AttrCapabilities.AllowView)] - public int WindowTotalCount => Buildings?.Sum(building => building.WindowCount) ?? 0; + public int WindowTotalCount => Buildings.Sum(building => building.WindowCount); [EagerLoad] - public IList Buildings { get; set; } + public IList Buildings { get; set; } = new List(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/StreetsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/StreetsController.cs index dae11e41b4..19aab24dda 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/StreetsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/StreetsController.cs @@ -5,10 +5,11 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.EagerLoading { - public sealed class StreetsController : JsonApiController + public sealed class StreetsController : JsonApiController { - public StreetsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public StreetsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/AlternateExceptionHandler.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/AlternateExceptionHandler.cs index d6af489f76..46f81275f9 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/AlternateExceptionHandler.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/AlternateExceptionHandler.cs @@ -24,17 +24,17 @@ 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) { - articleException.Errors[0].Meta = new Dictionary + articleException.Errors[0].Meta = new Dictionary { ["Support"] = $"Please contact us for info about similar articles at {articleException.SupportEmailAddress}." }; } - return base.CreateErrorDocument(exception); + return base.CreateErrorResponse(exception); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ConsumerArticle.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ConsumerArticle.cs index b70b079bb3..4c914b05b5 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ConsumerArticle.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ConsumerArticle.cs @@ -5,9 +5,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ExceptionHandling { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class ConsumerArticle : Identifiable + public sealed class ConsumerArticle : Identifiable { [Attr] - public string Code { get; set; } + public string Code { get; set; } = null!; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ConsumerArticleService.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ConsumerArticleService.cs index 1e684ba79b..ce66e0575f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ConsumerArticleService.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ConsumerArticleService.cs @@ -13,7 +13,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ExceptionHandling { [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class ConsumerArticleService : JsonApiResourceService + public sealed class ConsumerArticleService : JsonApiResourceService { private const string SupportEmailAddress = "company@email.com"; internal const string UnavailableArticlePrefix = "X"; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ConsumerArticlesController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ConsumerArticlesController.cs index 0a8e99fb31..c7e13af033 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ConsumerArticlesController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ConsumerArticlesController.cs @@ -5,10 +5,11 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ExceptionHandling { - public sealed class ConsumerArticlesController : JsonApiController + public sealed class ConsumerArticlesController : JsonApiController { - public ConsumerArticlesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public ConsumerArticlesController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ErrorDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ErrorDbContext.cs index ed118f4862..70141a7998 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ErrorDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ErrorDbContext.cs @@ -6,8 +6,8 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ExceptionHandling [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class ErrorDbContext : DbContext { - public DbSet ConsumerArticles { get; set; } - public DbSet ThrowingArticles { get; set; } + public DbSet ConsumerArticles => Set(); + public DbSet ThrowingArticles => Set(); public ErrorDbContext(DbContextOptions options) : base(options) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs index ff8a61471f..5622f87280 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs @@ -72,19 +72,67 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Gone); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.Gone); error.Title.Should().Be("The requested article is no longer available."); error.Detail.Should().Be("Article with code 'X123' is no longer available."); - ((JsonElement)error.Meta["support"]).GetString().Should().Be("Please contact us for info about similar articles at company@email.com."); - loggerFactory.Logger.Messages.Should().HaveCount(1); + error.Meta.ShouldContainKey("support").With(value => + { + JsonElement element = value.Should().BeOfType().Subject; + element.GetString().Should().Be("Please contact us for info about similar articles at company@email.com."); + }); + + responseDocument.Meta.Should().BeNull(); + + loggerFactory.Logger.Messages.ShouldHaveCount(1); loggerFactory.Logger.Messages.Single().LogLevel.Should().Be(LogLevel.Warning); loggerFactory.Logger.Messages.Single().Text.Should().Contain("Article with code 'X123' is no longer available."); } + [Fact] + public async Task Logs_and_produces_error_response_on_deserialization_failure() + { + // Arrange + var loggerFactory = _testContext.Factory.Services.GetRequiredService(); + loggerFactory.Logger.Clear(); + + 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.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); + error.Detail.Should().Be("Resource type '' does not exist."); + + error.Meta.ShouldContainKey("requestBody").With(value => + { + JsonElement element = value.Should().BeOfType().Subject; + element.GetString().Should().Be(requestBody); + }); + + error.Meta.ShouldContainKey("stackTrace").With(value => + { + JsonElement element = value.Should().BeOfType().Subject; + IEnumerable stackTraceLines = element.EnumerateArray().Select(token => token.GetString()); + + stackTraceLines.ShouldNotBeEmpty(); + }); + + loggerFactory.Logger.Messages.Should().BeEmpty(); + } + [Fact] public async Task Logs_and_produces_error_response_on_serialization_failure() { @@ -108,17 +156,24 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.InternalServerError); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.InternalServerError); error.Title.Should().Be("An unhandled error occurred while processing this request."); error.Detail.Should().Be("Exception has been thrown by the target of an invocation."); - IEnumerable stackTraceLines = ((JsonElement)error.Meta["stackTrace"]).EnumerateArray().Select(token => token.GetString()); - stackTraceLines.Should().ContainMatch("* System.InvalidOperationException: Article status could not be determined.*"); + error.Meta.ShouldContainKey("stackTrace").With(value => + { + JsonElement element = value.Should().BeOfType().Subject; + IEnumerable stackTraceLines = element.EnumerateArray().Select(token => token.GetString()); + + stackTraceLines.Should().ContainMatch("*ThrowingArticle*"); + }); + + responseDocument.Meta.Should().BeNull(); - loggerFactory.Logger.Messages.Should().HaveCount(1); + loggerFactory.Logger.Messages.ShouldHaveCount(1); loggerFactory.Logger.Messages.Single().LogLevel.Should().Be(LogLevel.Error); loggerFactory.Logger.Messages.Single().Text.Should().Contain("Exception has been thrown by the target of an invocation."); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ThrowingArticle.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ThrowingArticle.cs index 870f168c04..d0a3ce819a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ThrowingArticle.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ThrowingArticle.cs @@ -7,7 +7,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ExceptionHandling { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class ThrowingArticle : Identifiable + public sealed class ThrowingArticle : Identifiable { [Attr] [NotMapped] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ThrowingArticlesController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ThrowingArticlesController.cs index aac19cda31..d518902f47 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ThrowingArticlesController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ThrowingArticlesController.cs @@ -5,10 +5,11 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ExceptionHandling { - public sealed class ThrowingArticlesController : JsonApiController + public sealed class ThrowingArticlesController : JsonApiController { - public ThrowingArticlesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public ThrowingArticlesController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/HitCountingResourceDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/HitCountingResourceDefinition.cs new file mode 100644 index 0000000000..58cb641e49 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/HitCountingResourceDefinition.cs @@ -0,0 +1,197 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Threading; +using System.Threading.Tasks; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests +{ + /// + /// Tracks invocations on callback methods. This is used solely in our tests, so we can assert which + /// calls were made, and in which order. + /// + public abstract class HitCountingResourceDefinition : JsonApiResourceDefinition + where TResource : class, IIdentifiable + { + private readonly ResourceDefinitionHitCounter _hitCounter; + + protected virtual ResourceDefinitionExtensibilityPoints ExtensibilityPointsToTrack => ResourceDefinitionExtensibilityPoints.All; + + protected HitCountingResourceDefinition(IResourceGraph resourceGraph, ResourceDefinitionHitCounter hitCounter) + : base(resourceGraph) + { + ArgumentGuard.NotNull(hitCounter, nameof(hitCounter)); + + _hitCounter = hitCounter; + } + + public override IImmutableSet OnApplyIncludes(IImmutableSet existingIncludes) + { + if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.OnApplyIncludes)) + { + _hitCounter.TrackInvocation(ResourceDefinitionExtensibilityPoints.OnApplyIncludes); + } + + return base.OnApplyIncludes(existingIncludes); + } + + public override FilterExpression? OnApplyFilter(FilterExpression? existingFilter) + { + if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.OnApplyFilter)) + { + _hitCounter.TrackInvocation(ResourceDefinitionExtensibilityPoints.OnApplyFilter); + } + + return base.OnApplyFilter(existingFilter); + } + + public override SortExpression? OnApplySort(SortExpression? existingSort) + { + if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.OnApplySort)) + { + _hitCounter.TrackInvocation(ResourceDefinitionExtensibilityPoints.OnApplySort); + } + + return base.OnApplySort(existingSort); + } + + public override PaginationExpression? OnApplyPagination(PaginationExpression? existingPagination) + { + if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.OnApplyPagination)) + { + _hitCounter.TrackInvocation(ResourceDefinitionExtensibilityPoints.OnApplyPagination); + } + + return base.OnApplyPagination(existingPagination); + } + + public override SparseFieldSetExpression? OnApplySparseFieldSet(SparseFieldSetExpression? existingSparseFieldSet) + { + if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet)) + { + _hitCounter.TrackInvocation(ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet); + } + + return base.OnApplySparseFieldSet(existingSparseFieldSet); + } + + public override QueryStringParameterHandlers? OnRegisterQueryableHandlersForQueryStringParameters() + { + if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.OnRegisterQueryableHandlersForQueryStringParameters)) + { + _hitCounter.TrackInvocation(ResourceDefinitionExtensibilityPoints.OnRegisterQueryableHandlersForQueryStringParameters); + } + + return base.OnRegisterQueryableHandlersForQueryStringParameters(); + } + + public override IDictionary? GetMeta(TResource resource) + { + if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.GetMeta)) + { + _hitCounter.TrackInvocation(ResourceDefinitionExtensibilityPoints.GetMeta); + } + + return base.GetMeta(resource); + } + + public override Task OnPrepareWriteAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync)) + { + _hitCounter.TrackInvocation(ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync); + } + + return base.OnPrepareWriteAsync(resource, writeOperation, cancellationToken); + } + + public override Task OnSetToOneRelationshipAsync(TResource leftResource, HasOneAttribute hasOneRelationship, + IIdentifiable? rightResourceId, WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.OnSetToOneRelationshipAsync)) + { + _hitCounter.TrackInvocation(ResourceDefinitionExtensibilityPoints.OnSetToOneRelationshipAsync); + } + + return base.OnSetToOneRelationshipAsync(leftResource, hasOneRelationship, rightResourceId, writeOperation, cancellationToken); + } + + public override Task OnSetToManyRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.OnSetToManyRelationshipAsync)) + { + _hitCounter.TrackInvocation(ResourceDefinitionExtensibilityPoints.OnSetToManyRelationshipAsync); + } + + return base.OnSetToManyRelationshipAsync(leftResource, hasManyRelationship, rightResourceIds, writeOperation, cancellationToken); + } + + public override Task OnAddToRelationshipAsync(TId leftResourceId, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + CancellationToken cancellationToken) + { + if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.OnAddToRelationshipAsync)) + { + _hitCounter.TrackInvocation(ResourceDefinitionExtensibilityPoints.OnAddToRelationshipAsync); + } + + return base.OnAddToRelationshipAsync(leftResourceId, hasManyRelationship, rightResourceIds, cancellationToken); + } + + public override Task OnRemoveFromRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + CancellationToken cancellationToken) + { + if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.OnRemoveFromRelationshipAsync)) + { + _hitCounter.TrackInvocation(ResourceDefinitionExtensibilityPoints.OnRemoveFromRelationshipAsync); + } + + return base.OnRemoveFromRelationshipAsync(leftResource, hasManyRelationship, rightResourceIds, cancellationToken); + } + + public override Task OnWritingAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.OnWritingAsync)) + { + _hitCounter.TrackInvocation(ResourceDefinitionExtensibilityPoints.OnWritingAsync); + } + + return base.OnWritingAsync(resource, writeOperation, cancellationToken); + } + + public override Task OnWriteSucceededAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync)) + { + _hitCounter.TrackInvocation(ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync); + } + + return base.OnWriteSucceededAsync(resource, writeOperation, cancellationToken); + } + + public override void OnDeserialize(TResource resource) + { + if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.OnDeserialize)) + { + _hitCounter.TrackInvocation(ResourceDefinitionExtensibilityPoints.OnDeserialize); + } + + base.OnDeserialize(resource); + } + + public override void OnSerialize(TResource resource) + { + if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.OnSerialize)) + { + _hitCounter.TrackInvocation(ResourceDefinitionExtensibilityPoints.OnSerialize); + } + + base.OnSerialize(resource); + } + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/ArtGalleriesController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/ArtGalleriesController.cs index b87e6daeb7..adec47514b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/ArtGalleriesController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/ArtGalleriesController.cs @@ -5,10 +5,11 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.HostingInIIS { - public sealed class ArtGalleriesController : JsonApiController + public sealed class ArtGalleriesController : JsonApiController { - public ArtGalleriesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public ArtGalleriesController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/ArtGallery.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/ArtGallery.cs index 93cbe7665e..34bec33ba3 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/ArtGallery.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/ArtGallery.cs @@ -6,12 +6,12 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.HostingInIIS { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class ArtGallery : Identifiable + public sealed class ArtGallery : Identifiable { [Attr] - public string Theme { get; set; } + public string Theme { get; set; } = null!; [HasMany] - public ISet Paintings { get; set; } + public ISet Paintings { get; set; } = new HashSet(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/HostingDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/HostingDbContext.cs index 19077c24a2..81e9ab0a1a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/HostingDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/HostingDbContext.cs @@ -6,8 +6,8 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.HostingInIIS [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class HostingDbContext : DbContext { - public DbSet ArtGalleries { get; set; } - public DbSet Paintings { get; set; } + public DbSet ArtGalleries => Set(); + public DbSet Paintings => Set(); public HostingDbContext(DbContextOptions options) : base(options) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/HostingTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/HostingTests.cs index 02073c4bdf..009cb406ee 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/HostingTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/HostingTests.cs @@ -46,27 +46,50 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Links.ShouldNotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); - responseDocument.Links.First.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.Last.Should().Be($"{HostPrefix}{route}"); + responseDocument.Links.First.Should().Be(responseDocument.Links.Self); + responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - string galleryLink = $"{HostPrefix}/iis-application-virtual-directory/public-api/artGalleries/{gallery.StringId}"; + responseDocument.Data.ManyValue.ShouldHaveCount(1); - responseDocument.Data.ManyValue.Should().HaveCount(1); - responseDocument.Data.ManyValue[0].Links.Self.Should().Be(galleryLink); - responseDocument.Data.ManyValue[0].Relationships["paintings"].Links.Self.Should().Be($"{galleryLink}/relationships/paintings"); - responseDocument.Data.ManyValue[0].Relationships["paintings"].Links.Related.Should().Be($"{galleryLink}/paintings"); + responseDocument.Data.ManyValue[0].With(resource => + { + string galleryLink = $"{HostPrefix}/iis-application-virtual-directory/public-api/artGalleries/{gallery.StringId}"; + + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(galleryLink); + + resource.Relationships.ShouldContainKey("paintings").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{galleryLink}/relationships/paintings"); + value.Links.Related.Should().Be($"{galleryLink}/paintings"); + }); + }); string paintingLink = $"{HostPrefix}/iis-application-virtual-directory/custom/path/to/paintings-of-the-world/{gallery.Paintings.ElementAt(0).StringId}"; - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Links.Self.Should().Be(paintingLink); - responseDocument.Included[0].Relationships["exposedAt"].Links.Self.Should().Be($"{paintingLink}/relationships/exposedAt"); - responseDocument.Included[0].Relationships["exposedAt"].Links.Related.Should().Be($"{paintingLink}/exposedAt"); + responseDocument.Included.ShouldHaveCount(1); + + responseDocument.Included[0].With(resource => + { + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(paintingLink); + + resource.Relationships.ShouldContainKey("exposedAt").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{paintingLink}/relationships/exposedAt"); + value.Links.Related.Should().Be($"{paintingLink}/exposedAt"); + }); + }); } [Fact] @@ -91,26 +114,49 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Links.ShouldNotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); - responseDocument.Links.First.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.Last.Should().Be($"{HostPrefix}{route}"); + responseDocument.Links.First.Should().Be(responseDocument.Links.Self); + responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - string paintingLink = $"{HostPrefix}/iis-application-virtual-directory/custom/path/to/paintings-of-the-world/{painting.StringId}"; + responseDocument.Data.ManyValue.ShouldHaveCount(1); - responseDocument.Data.ManyValue.Should().HaveCount(1); - responseDocument.Data.ManyValue[0].Links.Self.Should().Be(paintingLink); - responseDocument.Data.ManyValue[0].Relationships["exposedAt"].Links.Self.Should().Be($"{paintingLink}/relationships/exposedAt"); - responseDocument.Data.ManyValue[0].Relationships["exposedAt"].Links.Related.Should().Be($"{paintingLink}/exposedAt"); + responseDocument.Data.ManyValue[0].With(resource => + { + string paintingLink = $"{HostPrefix}/iis-application-virtual-directory/custom/path/to/paintings-of-the-world/{painting.StringId}"; + + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(paintingLink); + + resource.Relationships.ShouldContainKey("exposedAt").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{paintingLink}/relationships/exposedAt"); + value.Links.Related.Should().Be($"{paintingLink}/exposedAt"); + }); + }); - string galleryLink = $"{HostPrefix}/iis-application-virtual-directory/public-api/artGalleries/{painting.ExposedAt.StringId}"; + responseDocument.Included.ShouldHaveCount(1); - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Links.Self.Should().Be(galleryLink); - responseDocument.Included[0].Relationships["paintings"].Links.Self.Should().Be($"{galleryLink}/relationships/paintings"); - responseDocument.Included[0].Relationships["paintings"].Links.Related.Should().Be($"{galleryLink}/paintings"); + responseDocument.Included[0].With(resource => + { + string galleryLink = $"{HostPrefix}/iis-application-virtual-directory/public-api/artGalleries/{painting.ExposedAt.StringId}"; + + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(galleryLink); + + resource.Relationships.ShouldContainKey("paintings").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{galleryLink}/relationships/paintings"); + value.Links.Related.Should().Be($"{galleryLink}/paintings"); + }); + }); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/Painting.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/Painting.cs index c00c2826e8..f2dd3082a4 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/Painting.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/Painting.cs @@ -5,12 +5,12 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.HostingInIIS { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Painting : Identifiable + public sealed class Painting : Identifiable { [Attr] - public string Title { get; set; } + public string Title { get; set; } = null!; [HasOne] - public ArtGallery ExposedAt { get; set; } + public ArtGallery? ExposedAt { get; set; } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/PaintingsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/PaintingsController.cs index 513e369126..6dcc0d930b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/PaintingsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/PaintingsController.cs @@ -9,10 +9,11 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.HostingInIIS { [DisableRoutingConvention] [Route("custom/path/to/paintings-of-the-world")] - public sealed class PaintingsController : JsonApiController + public sealed class PaintingsController : JsonApiController { - public PaintingsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public PaintingsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/BankAccount.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/BankAccount.cs index 2b1b713ecf..197cee35c6 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/BankAccount.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/BankAccount.cs @@ -8,9 +8,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.IdObfuscation public sealed class BankAccount : ObfuscatedIdentifiable { [Attr] - public string Iban { get; set; } + public string Iban { get; set; } = null!; [HasMany] - public IList Cards { get; set; } + public IList Cards { get; set; } = new List(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/BankAccountsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/BankAccountsController.cs index 304c46b34c..d064824979 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/BankAccountsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/BankAccountsController.cs @@ -6,8 +6,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.IdObfuscation { public sealed class BankAccountsController : ObfuscatedIdentifiableController { - public BankAccountsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public BankAccountsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/DebitCard.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/DebitCard.cs index bd6f12ca27..13e8cb583f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/DebitCard.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/DebitCard.cs @@ -7,12 +7,12 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.IdObfuscation public sealed class DebitCard : ObfuscatedIdentifiable { [Attr] - public string OwnerName { get; set; } + public string OwnerName { get; set; } = null!; [Attr] public short PinCode { get; set; } [HasOne] - public BankAccount Account { get; set; } + public BankAccount Account { get; set; } = null!; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/DebitCardsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/DebitCardsController.cs index bed7113897..2d733f0a34 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/DebitCardsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/DebitCardsController.cs @@ -6,8 +6,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.IdObfuscation { public sealed class DebitCardsController : ObfuscatedIdentifiableController { - public DebitCardsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public DebitCardsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/HexadecimalCodec.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/HexadecimalCodec.cs index 4d7c5b64ea..7796ec4ed5 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/HexadecimalCodec.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/HexadecimalCodec.cs @@ -10,7 +10,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.IdObfuscation { internal sealed class HexadecimalCodec { - public int Decode(string value) + public int Decode(string? value) { if (value == null) { @@ -26,7 +26,7 @@ public int Decode(string value) }); } - string stringValue = FromHexString(value.Substring(1)); + string stringValue = FromHexString(value[1..]); return int.Parse(stringValue); } @@ -45,7 +45,7 @@ private static string FromHexString(string hexString) return new string(chars); } - public string Encode(int value) + public string? Encode(int value) { if (value == 0) { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs index 8c81bee08b..bbe61c5059 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs @@ -44,7 +44,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue.ShouldHaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(accounts[1].StringId); } @@ -70,7 +70,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue.ShouldHaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(accounts[1].StringId); } @@ -86,7 +86,7 @@ public async Task Cannot_get_primary_resource_for_invalid_ID() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); @@ -99,6 +99,7 @@ public async Task Can_get_primary_resource_by_ID() { // Arrange DebitCard card = _fakers.DebitCard.Generate(); + card.Account = _fakers.BankAccount.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -114,7 +115,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Id.Should().Be(card.StringId); } @@ -139,7 +140,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(2); + responseDocument.Data.ManyValue.ShouldHaveCount(2); responseDocument.Data.ManyValue[0].Id.Should().Be(account.Cards[0].StringId); responseDocument.Data.ManyValue[1].Id.Should().Be(account.Cards[1].StringId); } @@ -165,12 +166,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Id.Should().Be(account.StringId); - responseDocument.Included.Should().HaveCount(1); + responseDocument.Included.ShouldHaveCount(1); responseDocument.Included[0].Id.Should().Be(account.Cards[0].StringId); - responseDocument.Included[0].Attributes.Should().HaveCount(1); + responseDocument.Included[0].Attributes.ShouldHaveCount(1); responseDocument.Included[0].Relationships.Should().BeNull(); } @@ -195,7 +196,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue.ShouldHaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(account.Cards[0].StringId); } @@ -244,8 +245,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.Attributes["ownerName"].Should().Be(newCard.OwnerName); - responseDocument.Data.SingleValue.Attributes["pinCode"].Should().Be(newCard.PinCode); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("ownerName").With(value => value.Should().Be(newCard.OwnerName)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("pinCode").With(value => value.Should().Be(newCard.PinCode)); var codec = new HexadecimalCodec(); int newCardId = codec.Decode(responseDocument.Data.SingleValue.Id); @@ -257,7 +259,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => cardInDatabase.OwnerName.Should().Be(newCard.OwnerName); cardInDatabase.PinCode.Should().Be(newCard.PinCode); - cardInDatabase.Account.Should().NotBeNull(); + cardInDatabase.Account.ShouldNotBeNull(); cardInDatabase.Account.Id.Should().Be(existingAccount.Id); cardInDatabase.Account.StringId.Should().Be(existingAccount.StringId); }); @@ -271,6 +273,7 @@ public async Task Can_update_resource_with_relationship() existingAccount.Cards = _fakers.DebitCard.Generate(1); DebitCard existingCard = _fakers.DebitCard.Generate(); + existingCard.Account = _fakers.BankAccount.Generate(); string newIban = _fakers.BankAccount.Generate().Iban; @@ -323,7 +326,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => accountInDatabase.Iban.Should().Be(newIban); - accountInDatabase.Cards.Should().HaveCount(1); + accountInDatabase.Cards.ShouldHaveCount(1); accountInDatabase.Cards[0].Id.Should().Be(existingCard.Id); accountInDatabase.Cards[0].StringId.Should().Be(existingCard.StringId); }); @@ -337,6 +340,7 @@ public async Task Can_add_to_ToMany_relationship() existingAccount.Cards = _fakers.DebitCard.Generate(1); DebitCard existingDebitCard = _fakers.DebitCard.Generate(); + existingDebitCard.Account = _fakers.BankAccount.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -370,7 +374,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { BankAccount accountInDatabase = await dbContext.BankAccounts.Include(account => account.Cards).FirstWithIdAsync(existingAccount.Id); - accountInDatabase.Cards.Should().HaveCount(2); + accountInDatabase.Cards.ShouldHaveCount(2); }); } @@ -413,7 +417,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { BankAccount accountInDatabase = await dbContext.BankAccounts.Include(account => account.Cards).FirstWithIdAsync(existingAccount.Id); - accountInDatabase.Cards.Should().HaveCount(1); + accountInDatabase.Cards.ShouldHaveCount(1); }); } @@ -442,7 +446,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - BankAccount accountInDatabase = await dbContext.BankAccounts.Include(account => account.Cards).FirstWithIdOrDefaultAsync(existingAccount.Id); + BankAccount? accountInDatabase = await dbContext.BankAccounts.Include(account => account.Cards).FirstWithIdOrDefaultAsync(existingAccount.Id); accountInDatabase.Should().BeNull(); }); @@ -453,7 +457,7 @@ public async Task Cannot_delete_unknown_resource() { // Arrange var codec = new HexadecimalCodec(); - string stringId = codec.Encode(Unknown.TypedId.Int32); + string? stringId = codec.Encode(Unknown.TypedId.Int32); string route = $"/bankAccounts/{stringId}"; @@ -463,7 +467,7 @@ public async Task Cannot_delete_unknown_resource() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiable.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiable.cs index 99a89e6b8c..223a29229f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiable.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiable.cs @@ -2,18 +2,18 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.IdObfuscation { - public abstract class ObfuscatedIdentifiable : Identifiable + public abstract class ObfuscatedIdentifiable : Identifiable { private static readonly HexadecimalCodec Codec = new(); - protected override string GetStringId(int value) + protected override string? GetStringId(int value) { - return Codec.Encode(value); + return value == default ? null : Codec.Encode(value); } - protected override int GetTypedId(string value) + protected override int GetTypedId(string? value) { - return Codec.Decode(value); + return value == null ? default : Codec.Decode(value); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiableController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiableController.cs index 22c934ccbf..0eb276db99 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiableController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiableController.cs @@ -10,13 +10,14 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.IdObfuscation { - public abstract class ObfuscatedIdentifiableController : BaseJsonApiController + public abstract class ObfuscatedIdentifiableController : BaseJsonApiController where TResource : class, IIdentifiable { private readonly HexadecimalCodec _codec = new(); - protected ObfuscatedIdentifiableController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + protected ObfuscatedIdentifiableController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscationDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscationDbContext.cs index a4af915fc5..4cf9791745 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscationDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscationDbContext.cs @@ -6,8 +6,8 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.IdObfuscation [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class ObfuscationDbContext : DbContext { - public DbSet BankAccounts { get; set; } - public DbSet DebitCards { get; set; } + public DbSet BankAccounts => Set(); + public DbSet DebitCards => Set(); public ObfuscationDbContext(DbContextOptions options) : base(options) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateDbContext.cs index 627d044ba2..a67e9f2647 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateDbContext.cs @@ -8,8 +8,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.ModelState [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class ModelStateDbContext : DbContext { - public DbSet Directories { get; set; } - public DbSet Files { get; set; } + public DbSet Volumes => Set(); + public DbSet Directories => Set(); + public DbSet Files => Set(); public ModelStateDbContext(DbContextOptions options) : base(options) @@ -18,9 +19,15 @@ public ModelStateDbContext(DbContextOptions options) protected override void OnModelCreating(ModelBuilder builder) { + builder.Entity() + .HasOne(systemVolume => systemVolume.RootDirectory) + .WithOne() + .HasForeignKey("RootDirectoryId") + .IsRequired(); + builder.Entity() .HasMany(systemDirectory => systemDirectory.Subdirectories) - .WithOne(systemDirectory => systemDirectory.Parent); + .WithOne(systemDirectory => systemDirectory.Parent!); builder.Entity() .HasOne(systemDirectory => systemDirectory.Self) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateFakers.cs new file mode 100644 index 0000000000..06b8829ebe --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateFakers.cs @@ -0,0 +1,34 @@ +using System; +using Bogus; +using TestBuildingBlocks; + +// @formatter:wrap_chained_method_calls chop_always +// @formatter:keep_existing_linebreaks true + +namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.ModelState +{ + internal sealed class ModelStateFakers : FakerContainer + { + private readonly Lazy> _lazySystemVolumeFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(systemVolume => systemVolume.Name, faker => faker.Lorem.Word())); + + private readonly Lazy> _lazySystemFileFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(systemFile => systemFile.FileName, faker => faker.System.FileName()) + .RuleFor(systemFile => systemFile.SizeInBytes, faker => faker.Random.Long(0, 1_000_000))); + + private readonly Lazy> _lazySystemDirectoryFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(systemDirectory => systemDirectory.Name, faker => faker.Address.City()) + .RuleFor(systemDirectory => systemDirectory.IsCaseSensitive, faker => faker.Random.Bool()) + .RuleFor(systemDirectory => systemDirectory.SizeInBytes, faker => faker.Random.Long(0, 1_000_000))); + + public Faker SystemVolume => _lazySystemVolumeFaker.Value; + public Faker SystemFile => _lazySystemFileFaker.Value; + public Faker SystemDirectory => _lazySystemDirectoryFaker.Value; + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateValidationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateValidationTests.cs index 5e819e5ce6..e314bf5461 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateValidationTests.cs @@ -1,21 +1,20 @@ -using System; -using System.Collections.Generic; +using System.Linq; using System.Net; using System.Net.Http; using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreTests.Startups; using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.ModelState { - public sealed class ModelStateValidationTests : IClassFixture, ModelStateDbContext>> + public sealed class ModelStateValidationTests : IClassFixture, ModelStateDbContext>> { - private readonly IntegrationTestContext, ModelStateDbContext> _testContext; + private readonly IntegrationTestContext, ModelStateDbContext> _testContext; + private readonly ModelStateFakers _fakers = new(); - public ModelStateValidationTests(IntegrationTestContext, ModelStateDbContext> testContext) + public ModelStateValidationTests(IntegrationTestContext, ModelStateDbContext> testContext) { _testContext = testContext; @@ -47,13 +46,14 @@ public async Task Cannot_create_resource_with_omitted_required_attribute() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Input validation failed."); error.Detail.Should().Be("The Name field is required."); - error.Source.Pointer.Should().Be("/data/attributes/name"); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data/attributes/directoryName"); } [Fact] @@ -67,7 +67,7 @@ public async Task Cannot_create_resource_with_null_for_required_attribute_value( type = "systemDirectories", attributes = new { - name = (string)null, + directoryName = (string?)null, isCaseSensitive = true } } @@ -81,13 +81,14 @@ public async Task Cannot_create_resource_with_null_for_required_attribute_value( // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Input validation failed."); error.Detail.Should().Be("The Name field is required."); - error.Source.Pointer.Should().Be("/data/attributes/name"); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data/attributes/directoryName"); } [Fact] @@ -101,7 +102,7 @@ public async Task Cannot_create_resource_with_invalid_attribute_value() type = "systemDirectories", attributes = new { - name = "!@#$%^&*().-", + directoryName = "!@#$%^&*().-", isCaseSensitive = true } } @@ -115,19 +116,22 @@ public async Task Cannot_create_resource_with_invalid_attribute_value() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Input validation failed."); error.Detail.Should().Be("The field Name must match the regular expression '^[\\w\\s]+$'."); - error.Source.Pointer.Should().Be("/data/attributes/name"); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data/attributes/directoryName"); } [Fact] public async Task Can_create_resource_with_valid_attribute_value() { // Arrange + SystemDirectory newDirectory = _fakers.SystemDirectory.Generate(); + var requestBody = new { data = new @@ -135,8 +139,8 @@ public async Task Can_create_resource_with_valid_attribute_value() type = "systemDirectories", attributes = new { - name = "Projects", - isCaseSensitive = true + directoryName = newDirectory.Name, + isCaseSensitive = newDirectory.IsCaseSensitive } } }; @@ -149,9 +153,9 @@ public async Task Can_create_resource_with_valid_attribute_value() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.Should().NotBeNull(); - responseDocument.Data.SingleValue.Attributes["name"].Should().Be("Projects"); - responseDocument.Data.SingleValue.Attributes["isCaseSensitive"].Should().Be(true); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("directoryName").With(value => value.Should().Be(newDirectory.Name)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("isCaseSensitive").With(value => value.Should().Be(newDirectory.IsCaseSensitive)); } [Fact] @@ -165,6 +169,7 @@ public async Task Cannot_create_resource_with_multiple_violations() type = "systemDirectories", attributes = new { + isCaseSensitive = false, sizeInBytes = -1 } } @@ -178,24 +183,67 @@ public async Task Cannot_create_resource_with_multiple_violations() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(3); + responseDocument.Errors.ShouldHaveCount(2); ErrorObject error1 = responseDocument.Errors[0]; error1.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error1.Title.Should().Be("Input validation failed."); error1.Detail.Should().Be("The Name field is required."); - error1.Source.Pointer.Should().Be("/data/attributes/name"); + error1.Source.ShouldNotBeNull(); + error1.Source.Pointer.Should().Be("/data/attributes/directoryName"); ErrorObject error2 = responseDocument.Errors[1]; error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error2.Title.Should().Be("Input validation failed."); error2.Detail.Should().Be("The field SizeInBytes must be between 0 and 9223372036854775807."); + error2.Source.ShouldNotBeNull(); error2.Source.Pointer.Should().Be("/data/attributes/sizeInBytes"); + } + + [Fact] + public async Task Does_not_exceed_MaxModelValidationErrors() + { + // Arrange + var requestBody = new + { + data = new + { + type = "systemDirectories", + attributes = new + { + sizeInBytes = -1 + } + } + }; + + const string route = "/systemDirectories"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.ShouldHaveCount(3); + + ErrorObject error1 = responseDocument.Errors[0]; + error1.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error1.Title.Should().Be("Input validation failed."); + error1.Detail.Should().Be("The maximum number of allowed model errors has been reached."); + error1.Source.Should().BeNull(); + + ErrorObject error2 = responseDocument.Errors[1]; + error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error2.Title.Should().Be("Input validation failed."); + error2.Detail.Should().Be("The Name field is required."); + error2.Source.ShouldNotBeNull(); + error2.Source.Pointer.Should().Be("/data/attributes/directoryName"); ErrorObject error3 = responseDocument.Errors[2]; error3.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error3.Title.Should().Be("Input validation failed."); error3.Detail.Should().Be("The IsCaseSensitive field is required."); + error3.Source.ShouldNotBeNull(); error3.Source.Pointer.Should().Be("/data/attributes/isCaseSensitive"); } @@ -203,28 +251,16 @@ public async Task Cannot_create_resource_with_multiple_violations() public async Task Can_create_resource_with_annotated_relationships() { // Arrange - var parentDirectory = new SystemDirectory - { - Name = "Shared", - IsCaseSensitive = true - }; + SystemDirectory existingParentDirectory = _fakers.SystemDirectory.Generate(); + SystemDirectory existingSubdirectory = _fakers.SystemDirectory.Generate(); + SystemFile existingFile = _fakers.SystemFile.Generate(); - var subdirectory = new SystemDirectory - { - Name = "Open Source", - IsCaseSensitive = true - }; - - var file = new SystemFile - { - FileName = "Main.cs", - SizeInBytes = 100 - }; + SystemDirectory newDirectory = _fakers.SystemDirectory.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.Directories.AddRange(parentDirectory, subdirectory); - dbContext.Files.Add(file); + dbContext.Directories.AddRange(existingParentDirectory, existingSubdirectory); + dbContext.Files.Add(existingFile); await dbContext.SaveChangesAsync(); }); @@ -235,8 +271,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => type = "systemDirectories", attributes = new { - name = "Projects", - isCaseSensitive = true + directoryName = newDirectory.Name, + isCaseSensitive = newDirectory.IsCaseSensitive }, relationships = new { @@ -247,7 +283,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => new { type = "systemDirectories", - id = subdirectory.StringId + id = existingSubdirectory.StringId } } }, @@ -258,7 +294,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => new { type = "systemFiles", - id = file.StringId + id = existingFile.StringId } } }, @@ -267,7 +303,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = new { type = "systemDirectories", - id = parentDirectory.StringId + id = existingParentDirectory.StringId } } } @@ -282,30 +318,21 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.Should().NotBeNull(); - responseDocument.Data.SingleValue.Attributes["name"].Should().Be("Projects"); - responseDocument.Data.SingleValue.Attributes["isCaseSensitive"].Should().Be(true); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("directoryName").With(value => value.Should().Be(newDirectory.Name)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("isCaseSensitive").With(value => value.Should().Be(newDirectory.IsCaseSensitive)); } [Fact] public async Task Can_add_to_annotated_ToMany_relationship() { // Arrange - var directory = new SystemDirectory - { - Name = "Projects", - IsCaseSensitive = true - }; - - var file = new SystemFile - { - FileName = "Main.cs", - SizeInBytes = 100 - }; + SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate(); + SystemFile existingFile = _fakers.SystemFile.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.AddInRange(directory, file); + dbContext.AddInRange(existingDirectory, existingFile); await dbContext.SaveChangesAsync(); }); @@ -316,12 +343,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => new { type = "systemFiles", - id = file.StringId + id = existingFile.StringId } } }; - string route = $"/systemDirectories/{directory.StringId}/relationships/files"; + string route = $"/systemDirectories/{existingDirectory.StringId}/relationships/files"; // Act (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); @@ -336,15 +363,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_update_resource_with_omitted_required_attribute_value() { // Arrange - var directory = new SystemDirectory - { - Name = "Projects", - IsCaseSensitive = true - }; + SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate(); + + long newSizeInBytes = _fakers.SystemDirectory.Generate().SizeInBytes; await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.Directories.Add(directory); + dbContext.Directories.Add(existingDirectory); await dbContext.SaveChangesAsync(); }); @@ -353,15 +378,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = new { type = "systemDirectories", - id = directory.StringId, + id = existingDirectory.StringId, attributes = new { - sizeInBytes = 100 + sizeInBytes = newSizeInBytes } } }; - string route = $"/systemDirectories/{directory.StringId}"; + string route = $"/systemDirectories/{existingDirectory.StringId}"; // Act (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -373,18 +398,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Cannot_update_resource_with_null_for_required_attribute_value() + public async Task Cannot_update_resource_with_null_for_required_attribute_values() { // Arrange - var directory = new SystemDirectory - { - Name = "Projects", - IsCaseSensitive = true - }; + SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.Directories.Add(directory); + dbContext.Directories.Add(existingDirectory); await dbContext.SaveChangesAsync(); }); @@ -393,15 +414,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = new { type = "systemDirectories", - id = directory.StringId, + id = existingDirectory.StringId, attributes = new { - name = (string)null + directoryName = (string?)null, + isCaseSensitive = (bool?)null } } }; - string route = $"/systemDirectories/{directory.StringId}"; + string route = $"/systemDirectories/{existingDirectory.StringId}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -409,28 +431,32 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(2); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Input validation failed."); - error.Detail.Should().Be("The Name field is required."); - error.Source.Pointer.Should().Be("/data/attributes/name"); + ErrorObject error1 = responseDocument.Errors[0]; + error1.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error1.Title.Should().Be("Input validation failed."); + error1.Detail.Should().Be("The Name field is required."); + error1.Source.ShouldNotBeNull(); + error1.Source.Pointer.Should().Be("/data/attributes/directoryName"); + + ErrorObject error2 = responseDocument.Errors[1]; + error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error2.Title.Should().Be("Input validation failed."); + error2.Detail.Should().Be("The IsCaseSensitive field is required."); + error2.Source.ShouldNotBeNull(); + error2.Source.Pointer.Should().Be("/data/attributes/isCaseSensitive"); } [Fact] public async Task Cannot_update_resource_with_invalid_attribute_value() { // Arrange - var directory = new SystemDirectory - { - Name = "Projects", - IsCaseSensitive = true - }; + SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.Directories.Add(directory); + dbContext.Directories.Add(existingDirectory); await dbContext.SaveChangesAsync(); }); @@ -439,15 +465,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = new { type = "systemDirectories", - id = directory.StringId, + id = existingDirectory.StringId, attributes = new { - name = "!@#$%^&*().-" + directoryName = "!@#$%^&*().-" } } }; - string route = $"/systemDirectories/{directory.StringId}"; + string route = $"/systemDirectories/{existingDirectory.StringId}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -455,41 +481,26 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Input validation failed."); error.Detail.Should().Be("The field Name must match the regular expression '^[\\w\\s]+$'."); - error.Source.Pointer.Should().Be("/data/attributes/name"); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data/attributes/directoryName"); } [Fact] public async Task Cannot_update_resource_with_invalid_ID() { // Arrange - var directory = new SystemDirectory - { - Name = "Projects", - IsCaseSensitive = true - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Directories.Add(directory); - await dbContext.SaveChangesAsync(); - }); - var requestBody = new { data = new { type = "systemDirectories", id = "-1", - attributes = new - { - name = "Repositories" - }, relationships = new { subdirectories = new @@ -515,34 +526,34 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(2); + responseDocument.Errors.ShouldHaveCount(2); ErrorObject error1 = responseDocument.Errors[0]; error1.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error1.Title.Should().Be("Input validation failed."); error1.Detail.Should().Be("The field Id must match the regular expression '^[0-9]+$'."); - error1.Source.Pointer.Should().Be("/data/attributes/id"); + error1.Source.ShouldNotBeNull(); + error1.Source.Pointer.Should().Be("/data/id"); ErrorObject error2 = responseDocument.Errors[1]; error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error2.Title.Should().Be("Input validation failed."); error2.Detail.Should().Be("The field Id must match the regular expression '^[0-9]+$'."); - error2.Source.Pointer.Should().Be("/data/attributes/Subdirectories[0].Id"); + error2.Source.ShouldNotBeNull(); + error2.Source.Pointer.Should().Be("/data/relationships/subdirectories/data[0]/id"); } [Fact] public async Task Can_update_resource_with_valid_attribute_value() { // Arrange - var directory = new SystemDirectory - { - Name = "Projects", - IsCaseSensitive = true - }; + SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate(); + + string newDirectoryName = _fakers.SystemDirectory.Generate().Name; await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.Directories.Add(directory); + dbContext.Directories.Add(existingDirectory); await dbContext.SaveChangesAsync(); }); @@ -551,15 +562,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = new { type = "systemDirectories", - id = directory.StringId, + id = existingDirectory.StringId, attributes = new { - name = "Repositories" + directoryName = newDirectoryName } } }; - string route = $"/systemDirectories/{directory.StringId}"; + string route = $"/systemDirectories/{existingDirectory.StringId}"; // Act (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -574,53 +585,21 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_update_resource_with_annotated_relationships() { // Arrange - var directory = new SystemDirectory - { - Name = "Projects", - IsCaseSensitive = false, - Subdirectories = new List - { - new() - { - Name = "C#", - IsCaseSensitive = false - } - }, - Files = new List - { - new() - { - FileName = "readme.txt" - } - }, - Parent = new SystemDirectory - { - Name = "Data", - IsCaseSensitive = false - } - }; + SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate(); + existingDirectory.Subdirectories = _fakers.SystemDirectory.Generate(1); + existingDirectory.Files = _fakers.SystemFile.Generate(1); + existingDirectory.Parent = _fakers.SystemDirectory.Generate(); - var otherParent = new SystemDirectory - { - Name = "Shared", - IsCaseSensitive = false - }; + SystemDirectory existingParent = _fakers.SystemDirectory.Generate(); + SystemDirectory existingSubdirectory = _fakers.SystemDirectory.Generate(); + SystemFile existingFile = _fakers.SystemFile.Generate(); - var otherSubdirectory = new SystemDirectory - { - Name = "Shared", - IsCaseSensitive = false - }; - - var otherFile = new SystemFile - { - FileName = "readme.md" - }; + string newDirectoryName = _fakers.SystemDirectory.Generate().Name; await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.Directories.AddRange(directory, otherParent, otherSubdirectory); - dbContext.Files.Add(otherFile); + dbContext.Directories.AddRange(existingDirectory, existingParent, existingSubdirectory); + dbContext.Files.Add(existingFile); await dbContext.SaveChangesAsync(); }); @@ -629,10 +608,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = new { type = "systemDirectories", - id = directory.StringId, + id = existingDirectory.StringId, attributes = new { - name = "Project Files" + directoryName = newDirectoryName }, relationships = new { @@ -643,7 +622,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => new { type = "systemDirectories", - id = otherSubdirectory.StringId + id = existingSubdirectory.StringId } } }, @@ -654,7 +633,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => new { type = "systemFiles", - id = otherFile.StringId + id = existingFile.StringId } } }, @@ -663,14 +642,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = new { type = "systemDirectories", - id = otherParent.StringId + id = existingParent.StringId } } } } }; - string route = $"/systemDirectories/{directory.StringId}"; + string route = $"/systemDirectories/{existingDirectory.StringId}"; // Act (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -685,15 +664,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_update_resource_with_multiple_self_references() { // Arrange - var directory = new SystemDirectory - { - Name = "Projects", - IsCaseSensitive = false - }; + SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.Directories.Add(directory); + dbContext.Directories.Add(existingDirectory); await dbContext.SaveChangesAsync(); }); @@ -702,11 +677,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = new { type = "systemDirectories", - id = directory.StringId, - attributes = new - { - name = "Project files" - }, + id = existingDirectory.StringId, relationships = new { self = new @@ -714,7 +685,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = new { type = "systemDirectories", - id = directory.StringId + id = existingDirectory.StringId } }, alsoSelf = new @@ -722,14 +693,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = new { type = "systemDirectories", - id = directory.StringId + id = existingDirectory.StringId } } } } }; - string route = $"/systemDirectories/{directory.StringId}"; + string route = $"/systemDirectories/{existingDirectory.StringId}"; // Act (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -744,15 +715,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_update_resource_with_collection_of_self_references() { // Arrange - var directory = new SystemDirectory - { - Name = "Projects", - IsCaseSensitive = false - }; + SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.Directories.Add(directory); + dbContext.Directories.Add(existingDirectory); await dbContext.SaveChangesAsync(); }); @@ -761,11 +728,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = new { type = "systemDirectories", - id = directory.StringId, - attributes = new - { - name = "Project files" - }, + id = existingDirectory.StringId, relationships = new { subdirectories = new @@ -775,7 +738,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => new { type = "systemDirectories", - id = directory.StringId + id = existingDirectory.StringId } } } @@ -783,7 +746,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - string route = $"/systemDirectories/{directory.StringId}"; + string route = $"/systemDirectories/{existingDirectory.StringId}"; // Act (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -798,26 +761,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_replace_annotated_ToOne_relationship() { // Arrange - var directory = new SystemDirectory - { - Name = "Projects", - IsCaseSensitive = true, - Parent = new SystemDirectory - { - Name = "Data", - IsCaseSensitive = true - } - }; + SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate(); + existingDirectory.Parent = _fakers.SystemDirectory.Generate(); - var otherParent = new SystemDirectory - { - Name = "Data files", - IsCaseSensitive = true - }; + SystemDirectory otherExistingDirectory = _fakers.SystemDirectory.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.Directories.AddRange(directory, otherParent); + dbContext.Directories.AddRange(existingDirectory, otherExistingDirectory); await dbContext.SaveChangesAsync(); }); @@ -826,11 +777,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = new { type = "systemDirectories", - id = otherParent.StringId + id = otherExistingDirectory.StringId } }; - string route = $"/systemDirectories/{directory.StringId}/relationships/parent"; + string route = $"/systemDirectories/{existingDirectory.StringId}/relationships/parent"; // Act (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -845,32 +796,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_replace_annotated_ToMany_relationship() { // Arrange - var directory = new SystemDirectory - { - Name = "Projects", - IsCaseSensitive = true, - Files = new List - { - new() - { - FileName = "Main.cs" - }, - new() - { - FileName = "Program.cs" - } - } - }; + SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate(); + existingDirectory.Files = _fakers.SystemFile.Generate(2); - var otherFile = new SystemFile - { - FileName = "EntryPoint.cs" - }; + SystemFile existingFile = _fakers.SystemFile.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.Directories.Add(directory); - dbContext.Files.Add(otherFile); + dbContext.AddInRange(existingDirectory, existingFile); await dbContext.SaveChangesAsync(); }); @@ -881,12 +814,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => new { type = "systemFiles", - id = otherFile.StringId + id = existingFile.StringId } } }; - string route = $"/systemDirectories/{directory.StringId}/relationships/files"; + string route = $"/systemDirectories/{existingDirectory.StringId}/relationships/files"; // Act (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -901,32 +834,28 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_remove_from_annotated_ToMany_relationship() { // Arrange - var directory = new SystemDirectory - { - Name = "Projects", - IsCaseSensitive = true, - Files = new List - { - new() - { - FileName = "Main.cs", - SizeInBytes = 100 - } - } - }; + SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate(); + existingDirectory.Files = _fakers.SystemFile.Generate(1); await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.AddInRange(directory); + dbContext.Directories.Add(existingDirectory); await dbContext.SaveChangesAsync(); }); var requestBody = new { - data = Array.Empty() + data = new[] + { + new + { + type = "systemFiles", + id = existingDirectory.Files.ElementAt(0).StringId + } + } }; - string route = $"/systemDirectories/{directory.StringId}/relationships/files"; + string route = $"/systemDirectories/{existingDirectory.StringId}/relationships/files"; // Act (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/NoModelStateValidationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/NoModelStateValidationTests.cs index bbf49d0b73..4a7968e68c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/NoModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/NoModelStateValidationTests.cs @@ -3,19 +3,23 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreTests.Startups; using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.ModelState { - public sealed class NoModelStateValidationTests : IClassFixture, ModelStateDbContext>> + public sealed class NoModelStateValidationTests + : IClassFixture, ModelStateDbContext>> { - private readonly IntegrationTestContext, ModelStateDbContext> _testContext; + private readonly IntegrationTestContext, ModelStateDbContext> _testContext; + private readonly ModelStateFakers _fakers = new(); - public NoModelStateValidationTests(IntegrationTestContext, ModelStateDbContext> testContext) + public NoModelStateValidationTests(IntegrationTestContext, ModelStateDbContext> testContext) { _testContext = testContext; + testContext.UseController(); testContext.UseController(); testContext.UseController(); } @@ -31,7 +35,7 @@ public async Task Can_create_resource_with_invalid_attribute_value() type = "systemDirectories", attributes = new { - name = "!@#$%^&*().-", + directoryName = "!@#$%^&*().-", isCaseSensitive = false } } @@ -45,23 +49,19 @@ public async Task Can_create_resource_with_invalid_attribute_value() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.Should().NotBeNull(); - responseDocument.Data.SingleValue.Attributes["name"].Should().Be("!@#$%^&*().-"); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("directoryName").With(value => value.Should().Be("!@#$%^&*().-")); } [Fact] public async Task Can_update_resource_with_invalid_attribute_value() { // Arrange - var directory = new SystemDirectory - { - Name = "Projects", - IsCaseSensitive = false - }; + SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.Directories.Add(directory); + dbContext.Directories.Add(existingDirectory); await dbContext.SaveChangesAsync(); }); @@ -70,15 +70,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = new { type = "systemDirectories", - id = directory.StringId, + id = existingDirectory.StringId, attributes = new { - name = "!@#$%^&*().-" + directoryName = "!@#$%^&*().-" } } }; - string route = $"/systemDirectories/{directory.StringId}"; + string route = $"/systemDirectories/{existingDirectory.StringId}"; // Act (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -88,5 +88,52 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Should().BeEmpty(); } + + [Fact] + public async Task Cannot_clear_required_OneToOne_relationship_through_primary_endpoint() + { + // Arrange + SystemVolume existingVolume = _fakers.SystemVolume.Generate(); + existingVolume.RootDirectory = _fakers.SystemDirectory.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Volumes.Add(existingVolume); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "systemVolumes", + id = existingVolume.StringId, + relationships = new + { + rootDirectory = new + { + data = (object?)null + } + } + } + }; + + string route = $"/systemVolumes/{existingVolume.StringId}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("Failed to clear a required relationship."); + + error.Detail.Should().Be($"The relationship 'rootDirectory' on resource type 'systemVolumes' with ID '{existingVolume.StringId}' " + + "cannot be cleared because it is a required relationship."); + } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemDirectoriesController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemDirectoriesController.cs index 17d0703a28..0c4af4fa1a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemDirectoriesController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemDirectoriesController.cs @@ -5,10 +5,11 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.ModelState { - public sealed class SystemDirectoriesController : JsonApiController + public sealed class SystemDirectoriesController : JsonApiController { - public SystemDirectoriesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public SystemDirectoriesController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemDirectory.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemDirectory.cs index 9a72870cb2..549356dbca 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemDirectory.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemDirectory.cs @@ -7,16 +7,14 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.ModelState { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class SystemDirectory : Identifiable + public sealed class SystemDirectory : Identifiable { - [Required] [RegularExpression("^[0-9]+$")] public override int Id { get; set; } - [Attr] - [Required] + [Attr(PublicName = "directoryName")] [RegularExpression(@"^[\w\s]+$")] - public string Name { get; set; } + public string Name { get; set; } = null!; [Attr] [Required] @@ -27,18 +25,18 @@ public sealed class SystemDirectory : Identifiable public long SizeInBytes { get; set; } [HasMany] - public ICollection Subdirectories { get; set; } + public ICollection Subdirectories { get; set; } = new List(); [HasMany] - public ICollection Files { get; set; } + public ICollection Files { get; set; } = new List(); [HasOne] - public SystemDirectory Self { get; set; } + public SystemDirectory? Self { get; set; } [HasOne] - public SystemDirectory AlsoSelf { get; set; } + public SystemDirectory? AlsoSelf { get; set; } [HasOne] - public SystemDirectory Parent { get; set; } + public SystemDirectory? Parent { get; set; } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemFile.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemFile.cs index de73e1f01e..7a8d796a58 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemFile.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemFile.cs @@ -6,16 +6,15 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.ModelState { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class SystemFile : Identifiable + public sealed class SystemFile : Identifiable { [Attr] - [Required] [MinLength(1)] - public string FileName { get; set; } + public string FileName { get; set; } = null!; [Attr] [Required] [Range(typeof(long), "0", "9223372036854775807")] - public long SizeInBytes { get; set; } + public long? SizeInBytes { get; set; } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemFilesController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemFilesController.cs index 94a9da2574..90fb26d246 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemFilesController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemFilesController.cs @@ -5,10 +5,11 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.ModelState { - public sealed class SystemFilesController : JsonApiController + public sealed class SystemFilesController : JsonApiController { - public SystemFilesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public SystemFilesController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemVolume.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemVolume.cs new file mode 100644 index 0000000000..b2c4ede226 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemVolume.cs @@ -0,0 +1,16 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.ModelState +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class SystemVolume : Identifiable + { + [Attr] + public string? Name { get; set; } + + [HasOne] + public SystemDirectory RootDirectory { get; set; } = null!; + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemVolumesController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemVolumesController.cs new file mode 100644 index 0000000000..a649619ec1 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemVolumesController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.ModelState +{ + public sealed class SystemVolumesController : JsonApiController + { + public SystemVolumesController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowDbContext.cs index 2eb55a784d..ab1442ea05 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowDbContext.cs @@ -8,7 +8,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.RequestBody [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class WorkflowDbContext : DbContext { - public DbSet Workflows { get; set; } + public DbSet Workflows => Set(); public WorkflowDbContext(DbContextOptions options) : base(options) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowDefinition.cs index 912bd9c9ac..cebebb9fda 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowDefinition.cs @@ -102,9 +102,8 @@ private static void AssertCanTransitionToStage(WorkflowStage fromStage, Workflow private static bool CanTransitionToStage(WorkflowStage fromStage, WorkflowStage toStage) { - if (StageTransitionTable.ContainsKey(fromStage)) + if (StageTransitionTable.TryGetValue(fromStage, out ICollection? possibleNextStages)) { - ICollection possibleNextStages = StageTransitionTable[fromStage]; return possibleNextStages.Contains(toStage); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowStage.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowStage.cs index feea8f6748..07c9dbe4ca 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowStage.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowStage.cs @@ -1,4 +1,4 @@ -namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.RequestBody +namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.RequestBody { public enum WorkflowStage { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowTests.cs index d3cc69efb6..3b7d0f9f16 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowTests.cs @@ -4,17 +4,16 @@ using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreTests.Startups; using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.RequestBody { - public sealed class WorkflowTests : IClassFixture, WorkflowDbContext>> + public sealed class WorkflowTests : IClassFixture, WorkflowDbContext>> { - private readonly IntegrationTestContext, WorkflowDbContext> _testContext; + private readonly IntegrationTestContext, WorkflowDbContext> _testContext; - public WorkflowTests(IntegrationTestContext, WorkflowDbContext> testContext) + public WorkflowTests(IntegrationTestContext, WorkflowDbContext> testContext) { _testContext = testContext; @@ -50,7 +49,7 @@ public async Task Can_create_in_valid_stage() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); } [Fact] @@ -77,12 +76,13 @@ public async Task Cannot_create_in_invalid_stage() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Invalid workflow stage."); error.Detail.Should().Be("Initial stage of workflow must be 'Created'."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/data/attributes/stage"); } @@ -122,12 +122,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Invalid workflow stage."); error.Detail.Should().Be("Cannot transition from 'OnHold' to 'Succeeded'."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/data/attributes/stage"); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowsController.cs index 737cfc2a08..b31523b7e1 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowsController.cs @@ -8,8 +8,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.RequestBody { public sealed class WorkflowsController : JsonApiController { - public WorkflowsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public WorkflowsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithNamespaceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithNamespaceTests.cs index 7f03363f30..d9a628af06 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithNamespaceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithNamespaceTests.cs @@ -17,6 +17,7 @@ public sealed class AbsoluteLinksWithNamespaceTests : IClassFixture, LinksDbContext>> { private const string HostPrefix = "http://localhost"; + private const string PathPrefix = "/api"; private readonly IntegrationTestContext, LinksDbContext> _testContext; private readonly LinksFakers _fakers = new(); @@ -49,7 +50,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = $"/api/photoAlbums/{album.StringId}"; + string route = $"{PathPrefix}/photoAlbums/{album.StringId}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -57,6 +58,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Links.ShouldNotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().BeNull(); @@ -64,10 +66,17 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); responseDocument.Data.SingleValue.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Data.SingleValue.Relationships["photos"].Links.Self.Should().Be($"{HostPrefix}{route}/relationships/photos"); - responseDocument.Data.SingleValue.Relationships["photos"].Links.Related.Should().Be($"{HostPrefix}{route}/photos"); + + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photos").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{HostPrefix}{route}/relationships/photos"); + value.Links.Related.Should().Be($"{HostPrefix}{route}/photos"); + }); } [Fact] @@ -84,7 +93,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - const string route = "/api/photoAlbums?include=photos"; + string route = $"{PathPrefix}/photoAlbums?include=photos"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -92,26 +101,49 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Links.ShouldNotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); - responseDocument.Links.First.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.Last.Should().Be($"{HostPrefix}{route}"); + responseDocument.Links.First.Should().Be(responseDocument.Links.Self); + responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - string albumLink = $"{HostPrefix}/api/photoAlbums/{album.StringId}"; + responseDocument.Data.ManyValue.ShouldHaveCount(1); + + responseDocument.Data.ManyValue[0].With(resource => + { + string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{album.StringId}"; + + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(albumLink); - responseDocument.Data.ManyValue.Should().HaveCount(1); - responseDocument.Data.ManyValue[0].Links.Self.Should().Be(albumLink); - responseDocument.Data.ManyValue[0].Relationships["photos"].Links.Self.Should().Be($"{albumLink}/relationships/photos"); - responseDocument.Data.ManyValue[0].Relationships["photos"].Links.Related.Should().Be($"{albumLink}/photos"); + resource.Relationships.ShouldContainKey("photos").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); + value.Links.Related.Should().Be($"{albumLink}/photos"); + }); + }); + + responseDocument.Included.ShouldHaveCount(1); + + responseDocument.Included[0].With(resource => + { + string photoLink = $"{HostPrefix}{PathPrefix}/photos/{album.Photos.ElementAt(0).StringId}"; - string photoLink = $"{HostPrefix}/api/photos/{album.Photos.ElementAt(0).StringId}"; + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(photoLink); - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Links.Self.Should().Be(photoLink); - responseDocument.Included[0].Relationships["album"].Links.Self.Should().Be($"{photoLink}/relationships/album"); - responseDocument.Included[0].Relationships["album"].Links.Related.Should().Be($"{photoLink}/album"); + resource.Relationships.ShouldContainKey("album").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{photoLink}/relationships/album"); + value.Links.Related.Should().Be($"{photoLink}/album"); + }); + }); } [Fact] @@ -127,7 +159,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = $"/api/photos/{photo.StringId}/album"; + string route = $"{PathPrefix}/photos/{photo.StringId}/album"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -135,6 +167,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Links.ShouldNotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().BeNull(); @@ -142,12 +175,19 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - string albumLink = $"{HostPrefix}/api/photoAlbums/{photo.Album.StringId}"; + string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{photo.Album.StringId}"; - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); responseDocument.Data.SingleValue.Links.Self.Should().Be(albumLink); - responseDocument.Data.SingleValue.Relationships["photos"].Links.Self.Should().Be($"{albumLink}/relationships/photos"); - responseDocument.Data.SingleValue.Relationships["photos"].Links.Related.Should().Be($"{albumLink}/photos"); + + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photos").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); + value.Links.Related.Should().Be($"{albumLink}/photos"); + }); } [Fact] @@ -163,7 +203,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = $"/api/photoAlbums/{album.StringId}/photos"; + string route = $"{PathPrefix}/photoAlbums/{album.StringId}/photos"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -171,19 +211,31 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Links.ShouldNotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); - responseDocument.Links.First.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.First.Should().Be(responseDocument.Links.Self); + responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - string photoLink = $"{HostPrefix}/api/photos/{album.Photos.ElementAt(0).StringId}"; + responseDocument.Data.ManyValue.ShouldHaveCount(1); + + responseDocument.Data.ManyValue[0].With(resource => + { + string photoLink = $"{HostPrefix}{PathPrefix}/photos/{album.Photos.ElementAt(0).StringId}"; - responseDocument.Data.ManyValue.Should().HaveCount(1); - responseDocument.Data.ManyValue[0].Links.Self.Should().Be(photoLink); - responseDocument.Data.ManyValue[0].Relationships["album"].Links.Self.Should().Be($"{photoLink}/relationships/album"); - responseDocument.Data.ManyValue[0].Relationships["album"].Links.Related.Should().Be($"{photoLink}/album"); + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(photoLink); + + resource.Relationships.ShouldContainKey("album").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{photoLink}/relationships/album"); + value.Links.Related.Should().Be($"{photoLink}/album"); + }); + }); } [Fact] @@ -199,7 +251,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = $"/api/photos/{photo.StringId}/relationships/album"; + string route = $"{PathPrefix}/photos/{photo.StringId}/relationships/album"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -207,14 +259,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Links.ShouldNotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.Related.Should().Be($"{HostPrefix}/api/photos/{photo.StringId}/album"); + responseDocument.Links.Related.Should().Be($"{HostPrefix}{PathPrefix}/photos/{photo.StringId}/album"); responseDocument.Links.First.Should().BeNull(); responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Links.Should().BeNull(); responseDocument.Data.SingleValue.Relationships.Should().BeNull(); } @@ -232,7 +285,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = $"/api/photoAlbums/{album.StringId}/relationships/photos"; + string route = $"{PathPrefix}/photoAlbums/{album.StringId}/relationships/photos"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -240,14 +293,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Links.ShouldNotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.Related.Should().Be($"{HostPrefix}/api/photoAlbums/{album.StringId}/photos"); - responseDocument.Links.First.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Related.Should().Be($"{HostPrefix}{PathPrefix}/photoAlbums/{album.StringId}/photos"); + responseDocument.Links.First.Should().Be(responseDocument.Links.Self); + responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue.ShouldHaveCount(1); responseDocument.Data.ManyValue[0].Links.Should().BeNull(); responseDocument.Data.ManyValue[0].Relationships.Should().BeNull(); } @@ -258,6 +312,8 @@ public async Task Create_resource_with_side_effects_and_include_returns_absolute // Arrange Photo existingPhoto = _fakers.Photo.Generate(); + string newAlbumName = _fakers.PhotoAlbum.Generate().Name; + await _testContext.RunOnDatabaseAsync(async dbContext => { dbContext.Photos.Add(existingPhoto); @@ -269,6 +325,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = new { type = "photoAlbums", + attributes = new + { + name = newAlbumName + }, relationships = new { photos = new @@ -286,7 +346,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - const string route = "/api/photoAlbums?include=photos"; + string route = $"{PathPrefix}/photoAlbums?include=photos"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); @@ -294,6 +354,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + responseDocument.Links.ShouldNotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().BeNull(); @@ -301,18 +362,38 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - string albumLink = $"{HostPrefix}/api/photoAlbums/{responseDocument.Data.SingleValue.Id}"; + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); + + string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{responseDocument.Data.SingleValue.Id}"; responseDocument.Data.SingleValue.Links.Self.Should().Be(albumLink); - responseDocument.Data.SingleValue.Relationships["photos"].Links.Self.Should().Be($"{albumLink}/relationships/photos"); - responseDocument.Data.SingleValue.Relationships["photos"].Links.Related.Should().Be($"{albumLink}/photos"); - string photoLink = $"{HostPrefix}/api/photos/{existingPhoto.StringId}"; + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photos").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); + value.Links.Related.Should().Be($"{albumLink}/photos"); + }); + + responseDocument.Included.ShouldHaveCount(1); + + responseDocument.Included[0].With(resource => + { + string photoLink = $"{HostPrefix}{PathPrefix}/photos/{existingPhoto.StringId}"; + + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(photoLink); - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Links.Self.Should().Be(photoLink); - responseDocument.Included[0].Relationships["album"].Links.Self.Should().Be($"{photoLink}/relationships/album"); - responseDocument.Included[0].Relationships["album"].Links.Related.Should().Be($"{photoLink}/album"); + resource.Relationships.ShouldContainKey("album").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{photoLink}/relationships/album"); + value.Links.Related.Should().Be($"{photoLink}/album"); + }); + }); } [Fact] @@ -348,7 +429,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - string route = $"/api/photos/{existingPhoto.StringId}?include=album"; + string route = $"{PathPrefix}/photos/{existingPhoto.StringId}?include=album"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -356,6 +437,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Links.ShouldNotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().BeNull(); @@ -363,19 +445,37 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - string photoLink = $"{HostPrefix}/api/photos/{existingPhoto.StringId}"; + string photoLink = $"{HostPrefix}{PathPrefix}/photos/{existingPhoto.StringId}"; - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); responseDocument.Data.SingleValue.Links.Self.Should().Be(photoLink); - responseDocument.Data.SingleValue.Relationships["album"].Links.Self.Should().Be($"{photoLink}/relationships/album"); - responseDocument.Data.SingleValue.Relationships["album"].Links.Related.Should().Be($"{photoLink}/album"); - string albumLink = $"{HostPrefix}/api/photoAlbums/{existingAlbum.StringId}"; + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("album").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{photoLink}/relationships/album"); + value.Links.Related.Should().Be($"{photoLink}/album"); + }); + + responseDocument.Included.ShouldHaveCount(1); - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Links.Self.Should().Be(albumLink); - responseDocument.Included[0].Relationships["photos"].Links.Self.Should().Be($"{albumLink}/relationships/photos"); - responseDocument.Included[0].Relationships["photos"].Links.Related.Should().Be($"{albumLink}/photos"); + responseDocument.Included[0].With(resource => + { + string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{existingAlbum.StringId}"; + + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(albumLink); + + resource.Relationships.ShouldContainKey("photos").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); + value.Links.Related.Should().Be($"{albumLink}/photos"); + }); + }); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithoutNamespaceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithoutNamespaceTests.cs index 26d5dcc46a..2bf54c153c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithoutNamespaceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithoutNamespaceTests.cs @@ -17,6 +17,7 @@ public sealed class AbsoluteLinksWithoutNamespaceTests : IClassFixture, LinksDbContext>> { private const string HostPrefix = "http://localhost"; + private const string PathPrefix = ""; private readonly IntegrationTestContext, LinksDbContext> _testContext; private readonly LinksFakers _fakers = new(); @@ -49,7 +50,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = $"/photoAlbums/{album.StringId}"; + string route = $"{PathPrefix}/photoAlbums/{album.StringId}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -57,6 +58,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Links.ShouldNotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().BeNull(); @@ -64,10 +66,17 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); responseDocument.Data.SingleValue.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Data.SingleValue.Relationships["photos"].Links.Self.Should().Be($"{HostPrefix}{route}/relationships/photos"); - responseDocument.Data.SingleValue.Relationships["photos"].Links.Related.Should().Be($"{HostPrefix}{route}/photos"); + + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photos").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{HostPrefix}{route}/relationships/photos"); + value.Links.Related.Should().Be($"{HostPrefix}{route}/photos"); + }); } [Fact] @@ -84,7 +93,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - const string route = "/photoAlbums?include=photos"; + string route = $"{PathPrefix}/photoAlbums?include=photos"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -92,26 +101,49 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Links.ShouldNotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); - responseDocument.Links.First.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.Last.Should().Be($"{HostPrefix}{route}"); + responseDocument.Links.First.Should().Be(responseDocument.Links.Self); + responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - string albumLink = $"{HostPrefix}/photoAlbums/{album.StringId}"; + responseDocument.Data.ManyValue.ShouldHaveCount(1); + + responseDocument.Data.ManyValue[0].With(resource => + { + string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{album.StringId}"; + + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(albumLink); - responseDocument.Data.ManyValue.Should().HaveCount(1); - responseDocument.Data.ManyValue[0].Links.Self.Should().Be(albumLink); - responseDocument.Data.ManyValue[0].Relationships["photos"].Links.Self.Should().Be($"{albumLink}/relationships/photos"); - responseDocument.Data.ManyValue[0].Relationships["photos"].Links.Related.Should().Be($"{albumLink}/photos"); + resource.Relationships.ShouldContainKey("photos").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); + value.Links.Related.Should().Be($"{albumLink}/photos"); + }); + }); + + responseDocument.Included.ShouldHaveCount(1); + + responseDocument.Included[0].With(resource => + { + string photoLink = $"{HostPrefix}{PathPrefix}/photos/{album.Photos.ElementAt(0).StringId}"; - string photoLink = $"{HostPrefix}/photos/{album.Photos.ElementAt(0).StringId}"; + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(photoLink); - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Links.Self.Should().Be(photoLink); - responseDocument.Included[0].Relationships["album"].Links.Self.Should().Be($"{photoLink}/relationships/album"); - responseDocument.Included[0].Relationships["album"].Links.Related.Should().Be($"{photoLink}/album"); + resource.Relationships.ShouldContainKey("album").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{photoLink}/relationships/album"); + value.Links.Related.Should().Be($"{photoLink}/album"); + }); + }); } [Fact] @@ -127,7 +159,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = $"/photos/{photo.StringId}/album"; + string route = $"{PathPrefix}/photos/{photo.StringId}/album"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -135,6 +167,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Links.ShouldNotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().BeNull(); @@ -142,12 +175,19 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - string albumLink = $"{HostPrefix}/photoAlbums/{photo.Album.StringId}"; + string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{photo.Album.StringId}"; - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); responseDocument.Data.SingleValue.Links.Self.Should().Be(albumLink); - responseDocument.Data.SingleValue.Relationships["photos"].Links.Self.Should().Be($"{albumLink}/relationships/photos"); - responseDocument.Data.SingleValue.Relationships["photos"].Links.Related.Should().Be($"{albumLink}/photos"); + + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photos").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); + value.Links.Related.Should().Be($"{albumLink}/photos"); + }); } [Fact] @@ -163,7 +203,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = $"/photoAlbums/{album.StringId}/photos"; + string route = $"{PathPrefix}/photoAlbums/{album.StringId}/photos"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -171,19 +211,31 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Links.ShouldNotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); - responseDocument.Links.First.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.First.Should().Be(responseDocument.Links.Self); + responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - string photoLink = $"{HostPrefix}/photos/{album.Photos.ElementAt(0).StringId}"; + responseDocument.Data.ManyValue.ShouldHaveCount(1); + + responseDocument.Data.ManyValue[0].With(resource => + { + string photoLink = $"{HostPrefix}{PathPrefix}/photos/{album.Photos.ElementAt(0).StringId}"; - responseDocument.Data.ManyValue.Should().HaveCount(1); - responseDocument.Data.ManyValue[0].Links.Self.Should().Be(photoLink); - responseDocument.Data.ManyValue[0].Relationships["album"].Links.Self.Should().Be($"{photoLink}/relationships/album"); - responseDocument.Data.ManyValue[0].Relationships["album"].Links.Related.Should().Be($"{photoLink}/album"); + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(photoLink); + + resource.Relationships.ShouldContainKey("album").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{photoLink}/relationships/album"); + value.Links.Related.Should().Be($"{photoLink}/album"); + }); + }); } [Fact] @@ -199,7 +251,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = $"/photos/{photo.StringId}/relationships/album"; + string route = $"{PathPrefix}/photos/{photo.StringId}/relationships/album"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -207,14 +259,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Links.ShouldNotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.Related.Should().Be($"{HostPrefix}/photos/{photo.StringId}/album"); + responseDocument.Links.Related.Should().Be($"{HostPrefix}{PathPrefix}/photos/{photo.StringId}/album"); responseDocument.Links.First.Should().BeNull(); responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Links.Should().BeNull(); responseDocument.Data.SingleValue.Relationships.Should().BeNull(); } @@ -232,7 +285,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = $"/photoAlbums/{album.StringId}/relationships/photos"; + string route = $"{PathPrefix}/photoAlbums/{album.StringId}/relationships/photos"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -240,14 +293,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Links.ShouldNotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.Related.Should().Be($"{HostPrefix}/photoAlbums/{album.StringId}/photos"); - responseDocument.Links.First.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Related.Should().Be($"{HostPrefix}{PathPrefix}/photoAlbums/{album.StringId}/photos"); + responseDocument.Links.First.Should().Be(responseDocument.Links.Self); + responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue.ShouldHaveCount(1); responseDocument.Data.ManyValue[0].Links.Should().BeNull(); responseDocument.Data.ManyValue[0].Relationships.Should().BeNull(); } @@ -258,6 +312,8 @@ public async Task Create_resource_with_side_effects_and_include_returns_absolute // Arrange Photo existingPhoto = _fakers.Photo.Generate(); + string newAlbumName = _fakers.PhotoAlbum.Generate().Name; + await _testContext.RunOnDatabaseAsync(async dbContext => { dbContext.Photos.Add(existingPhoto); @@ -269,6 +325,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = new { type = "photoAlbums", + attributes = new + { + name = newAlbumName + }, relationships = new { photos = new @@ -286,7 +346,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - const string route = "/photoAlbums?include=photos"; + string route = $"{PathPrefix}/photoAlbums?include=photos"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); @@ -294,6 +354,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + responseDocument.Links.ShouldNotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().BeNull(); @@ -301,18 +362,38 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - string albumLink = $"{HostPrefix}/photoAlbums/{responseDocument.Data.SingleValue.Id}"; + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); + + string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{responseDocument.Data.SingleValue.Id}"; responseDocument.Data.SingleValue.Links.Self.Should().Be(albumLink); - responseDocument.Data.SingleValue.Relationships["photos"].Links.Self.Should().Be($"{albumLink}/relationships/photos"); - responseDocument.Data.SingleValue.Relationships["photos"].Links.Related.Should().Be($"{albumLink}/photos"); - string photoLink = $"{HostPrefix}/photos/{existingPhoto.StringId}"; + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photos").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); + value.Links.Related.Should().Be($"{albumLink}/photos"); + }); + + responseDocument.Included.ShouldHaveCount(1); + + responseDocument.Included[0].With(resource => + { + string photoLink = $"{HostPrefix}{PathPrefix}/photos/{existingPhoto.StringId}"; + + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(photoLink); - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Links.Self.Should().Be(photoLink); - responseDocument.Included[0].Relationships["album"].Links.Self.Should().Be($"{photoLink}/relationships/album"); - responseDocument.Included[0].Relationships["album"].Links.Related.Should().Be($"{photoLink}/album"); + resource.Relationships.ShouldContainKey("album").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{photoLink}/relationships/album"); + value.Links.Related.Should().Be($"{photoLink}/album"); + }); + }); } [Fact] @@ -348,7 +429,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - string route = $"/photos/{existingPhoto.StringId}?include=album"; + string route = $"{PathPrefix}/photos/{existingPhoto.StringId}?include=album"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -356,6 +437,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Links.ShouldNotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().BeNull(); @@ -363,19 +445,37 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - string photoLink = $"{HostPrefix}/photos/{existingPhoto.StringId}"; + string photoLink = $"{HostPrefix}{PathPrefix}/photos/{existingPhoto.StringId}"; - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); responseDocument.Data.SingleValue.Links.Self.Should().Be(photoLink); - responseDocument.Data.SingleValue.Relationships["album"].Links.Self.Should().Be($"{photoLink}/relationships/album"); - responseDocument.Data.SingleValue.Relationships["album"].Links.Related.Should().Be($"{photoLink}/album"); - string albumLink = $"{HostPrefix}/photoAlbums/{existingAlbum.StringId}"; + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("album").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{photoLink}/relationships/album"); + value.Links.Related.Should().Be($"{photoLink}/album"); + }); + + responseDocument.Included.ShouldHaveCount(1); - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Links.Self.Should().Be(albumLink); - responseDocument.Included[0].Relationships["photos"].Links.Self.Should().Be($"{albumLink}/relationships/photos"); - responseDocument.Included[0].Relationships["photos"].Links.Related.Should().Be($"{albumLink}/photos"); + responseDocument.Included[0].With(resource => + { + string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{existingAlbum.StringId}"; + + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(albumLink); + + resource.Relationships.ShouldContainKey("photos").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); + value.Links.Related.Should().Be($"{albumLink}/photos"); + }); + }); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/LinkInclusionTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/LinkInclusionTests.cs index e7aee555c3..77eaaf376f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/LinkInclusionTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/LinkInclusionTests.cs @@ -45,21 +45,52 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Should().BeNull(); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Links.Should().BeNull(); - responseDocument.Data.SingleValue.Relationships["photo"].Links.Self.Should().BeNull(); - responseDocument.Data.SingleValue.Relationships["photo"].Links.Related.Should().NotBeNull(); - responseDocument.Data.SingleValue.Relationships["album"].Links.Should().BeNull(); - responseDocument.Included.Should().HaveCount(2); + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photo").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().BeNull(); + value.Links.Related.ShouldNotBeNull(); + }); - responseDocument.Included[0].Links.Self.Should().NotBeNull(); - responseDocument.Included[0].Relationships["location"].Links.Self.Should().NotBeNull(); - responseDocument.Included[0].Relationships["location"].Links.Related.Should().NotBeNull(); + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("album").With(value => + { + value.ShouldNotBeNull(); + value.Links.Should().BeNull(); + }); + + responseDocument.Included.ShouldHaveCount(2); + + responseDocument.Included[0].With(resource => + { + resource.Links.ShouldNotBeNull(); + resource.Links.Self.ShouldNotBeNull(); + + resource.Relationships.ShouldContainKey("location").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.ShouldNotBeNull(); + value.Links.Related.ShouldNotBeNull(); + }); + }); - responseDocument.Included[1].Links.Self.Should().NotBeNull(); - responseDocument.Included[1].Relationships["photos"].Links.Self.Should().NotBeNull(); - responseDocument.Included[1].Relationships["photos"].Links.Related.Should().NotBeNull(); + responseDocument.Included[1].With(resource => + { + resource.Links.ShouldNotBeNull(); + resource.Links.Self.ShouldNotBeNull(); + + resource.Relationships.ShouldContainKey("photos").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.ShouldNotBeNull(); + value.Links.Related.ShouldNotBeNull(); + }); + }); } [Fact] @@ -85,10 +116,17 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Should().BeNull(); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Links.Should().BeNull(); - responseDocument.Data.SingleValue.Relationships["photo"].Links.Self.Should().BeNull(); - responseDocument.Data.SingleValue.Relationships["photo"].Links.Related.Should().NotBeNull(); + + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photo").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().BeNull(); + value.Links.Related.ShouldNotBeNull(); + }); + responseDocument.Data.SingleValue.Relationships.Should().NotContainKey("album"); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/LinksDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/LinksDbContext.cs index e672034fce..935c4b6718 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/LinksDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/LinksDbContext.cs @@ -8,9 +8,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Links [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class LinksDbContext : DbContext { - public DbSet PhotoAlbums { get; set; } - public DbSet Photos { get; set; } - public DbSet PhotoLocations { get; set; } + public DbSet PhotoAlbums => Set(); + public DbSet Photos => Set(); + public DbSet PhotoLocations => Set(); public LinksDbContext(DbContextOptions options) : base(options) @@ -21,7 +21,7 @@ protected override void OnModelCreating(ModelBuilder builder) { builder.Entity() .HasOne(photo => photo.Location) - .WithOne(location => location.Photo) + .WithOne(location => location!.Photo) .HasForeignKey("LocationId"); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/Photo.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/Photo.cs index b3ef35a541..1ee92f0d58 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/Photo.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/Photo.cs @@ -9,12 +9,12 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Links public sealed class Photo : Identifiable { [Attr] - public string Url { get; set; } + public string? Url { get; set; } [HasOne] - public PhotoLocation Location { get; set; } + public PhotoLocation? Location { get; set; } [HasOne] - public PhotoAlbum Album { get; set; } + public PhotoAlbum? Album { get; set; } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/PhotoAlbum.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/PhotoAlbum.cs index 89d79a3353..9f6df3c0d5 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/PhotoAlbum.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/PhotoAlbum.cs @@ -10,9 +10,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Links public sealed class PhotoAlbum : Identifiable { [Attr] - public string Name { get; set; } + public string Name { get; set; } = null!; [HasMany] - public ISet Photos { get; set; } + public ISet Photos { get; set; } = new HashSet(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/PhotoAlbumsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/PhotoAlbumsController.cs index 065d14432c..29f80bc733 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/PhotoAlbumsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/PhotoAlbumsController.cs @@ -8,8 +8,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Links { public sealed class PhotoAlbumsController : JsonApiController { - public PhotoAlbumsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public PhotoAlbumsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/PhotoLocation.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/PhotoLocation.cs index 5112ba3cd4..5985719055 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/PhotoLocation.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/PhotoLocation.cs @@ -6,10 +6,10 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Links { [ResourceLinks(TopLevelLinks = LinkTypes.None, ResourceLinks = LinkTypes.None, RelationshipLinks = LinkTypes.Related)] [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class PhotoLocation : Identifiable + public sealed class PhotoLocation : Identifiable { [Attr] - public string PlaceName { get; set; } + public string? PlaceName { get; set; } [Attr] public double Latitude { get; set; } @@ -18,9 +18,9 @@ public sealed class PhotoLocation : Identifiable public double Longitude { get; set; } [HasOne] - public Photo Photo { get; set; } + public Photo Photo { get; set; } = null!; [HasOne(Links = LinkTypes.None)] - public PhotoAlbum Album { get; set; } + public PhotoAlbum? Album { get; set; } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/PhotoLocationsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/PhotoLocationsController.cs index ea6d605e56..c77e97d5e4 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/PhotoLocationsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/PhotoLocationsController.cs @@ -5,10 +5,11 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Links { - public sealed class PhotoLocationsController : JsonApiController + public sealed class PhotoLocationsController : JsonApiController { - public PhotoLocationsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public PhotoLocationsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/PhotosController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/PhotosController.cs index 5029d96932..0a3c83b911 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/PhotosController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/PhotosController.cs @@ -8,8 +8,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Links { public sealed class PhotosController : JsonApiController { - public PhotosController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public PhotosController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs index effa2b0d8c..5213db54dd 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs @@ -16,6 +16,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Links public sealed class RelativeLinksWithNamespaceTests : IClassFixture, LinksDbContext>> { + private const string HostPrefix = ""; + private const string PathPrefix = "/api"; + private readonly IntegrationTestContext, LinksDbContext> _testContext; private readonly LinksFakers _fakers = new(); @@ -47,7 +50,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = $"/api/photoAlbums/{album.StringId}"; + string route = $"{PathPrefix}/photoAlbums/{album.StringId}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -55,17 +58,25 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.Self.Should().Be(route); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().BeNull(); responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - responseDocument.Data.SingleValue.Should().NotBeNull(); - responseDocument.Data.SingleValue.Links.Self.Should().Be(route); - responseDocument.Data.SingleValue.Relationships["photos"].Links.Self.Should().Be($"{route}/relationships/photos"); - responseDocument.Data.SingleValue.Relationships["photos"].Links.Related.Should().Be($"{route}/photos"); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.Self.Should().Be($"{HostPrefix}{route}"); + + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photos").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{HostPrefix}{route}/relationships/photos"); + value.Links.Related.Should().Be($"{HostPrefix}{route}/photos"); + }); } [Fact] @@ -82,7 +93,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - const string route = "/api/photoAlbums?include=photos"; + string route = $"{PathPrefix}/photoAlbums?include=photos"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -90,26 +101,49 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.Self.Should().Be(route); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); - responseDocument.Links.First.Should().Be(route); - responseDocument.Links.Last.Should().Be(route); + responseDocument.Links.First.Should().Be(responseDocument.Links.Self); + responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - string albumLink = $"/api/photoAlbums/{album.StringId}"; + responseDocument.Data.ManyValue.ShouldHaveCount(1); + + responseDocument.Data.ManyValue[0].With(resource => + { + string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{album.StringId}"; + + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(albumLink); + + resource.Relationships.ShouldContainKey("photos").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); + value.Links.Related.Should().Be($"{albumLink}/photos"); + }); + }); + + responseDocument.Included.ShouldHaveCount(1); - responseDocument.Data.ManyValue.Should().HaveCount(1); - responseDocument.Data.ManyValue[0].Links.Self.Should().Be(albumLink); - responseDocument.Data.ManyValue[0].Relationships["photos"].Links.Self.Should().Be($"{albumLink}/relationships/photos"); - responseDocument.Data.ManyValue[0].Relationships["photos"].Links.Related.Should().Be($"{albumLink}/photos"); + responseDocument.Included[0].With(resource => + { + string photoLink = $"{HostPrefix}{PathPrefix}/photos/{album.Photos.ElementAt(0).StringId}"; - string photoLink = $"/api/photos/{album.Photos.ElementAt(0).StringId}"; + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(photoLink); - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Links.Self.Should().Be(photoLink); - responseDocument.Included[0].Relationships["album"].Links.Self.Should().Be($"{photoLink}/relationships/album"); - responseDocument.Included[0].Relationships["album"].Links.Related.Should().Be($"{photoLink}/album"); + resource.Relationships.ShouldContainKey("album").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{photoLink}/relationships/album"); + value.Links.Related.Should().Be($"{photoLink}/album"); + }); + }); } [Fact] @@ -125,7 +159,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = $"/api/photos/{photo.StringId}/album"; + string route = $"{PathPrefix}/photos/{photo.StringId}/album"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -133,19 +167,27 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.Self.Should().Be(route); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().BeNull(); responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - string albumLink = $"/api/photoAlbums/{photo.Album.StringId}"; + string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{photo.Album.StringId}"; - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); responseDocument.Data.SingleValue.Links.Self.Should().Be(albumLink); - responseDocument.Data.SingleValue.Relationships["photos"].Links.Self.Should().Be($"{albumLink}/relationships/photos"); - responseDocument.Data.SingleValue.Relationships["photos"].Links.Related.Should().Be($"{albumLink}/photos"); + + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photos").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); + value.Links.Related.Should().Be($"{albumLink}/photos"); + }); } [Fact] @@ -161,7 +203,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = $"/api/photoAlbums/{album.StringId}/photos"; + string route = $"{PathPrefix}/photoAlbums/{album.StringId}/photos"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -169,19 +211,31 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.Self.Should().Be(route); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); - responseDocument.Links.First.Should().Be(route); - responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.First.Should().Be(responseDocument.Links.Self); + responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - string photoLink = $"/api/photos/{album.Photos.ElementAt(0).StringId}"; + responseDocument.Data.ManyValue.ShouldHaveCount(1); + + responseDocument.Data.ManyValue[0].With(resource => + { + string photoLink = $"{HostPrefix}{PathPrefix}/photos/{album.Photos.ElementAt(0).StringId}"; - responseDocument.Data.ManyValue.Should().HaveCount(1); - responseDocument.Data.ManyValue[0].Links.Self.Should().Be(photoLink); - responseDocument.Data.ManyValue[0].Relationships["album"].Links.Self.Should().Be($"{photoLink}/relationships/album"); - responseDocument.Data.ManyValue[0].Relationships["album"].Links.Related.Should().Be($"{photoLink}/album"); + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(photoLink); + + resource.Relationships.ShouldContainKey("album").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{photoLink}/relationships/album"); + value.Links.Related.Should().Be($"{photoLink}/album"); + }); + }); } [Fact] @@ -197,7 +251,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = $"/api/photos/{photo.StringId}/relationships/album"; + string route = $"{PathPrefix}/photos/{photo.StringId}/relationships/album"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -205,14 +259,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.Self.Should().Be(route); - responseDocument.Links.Related.Should().Be($"/api/photos/{photo.StringId}/album"); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); + responseDocument.Links.Related.Should().Be($"{HostPrefix}{PathPrefix}/photos/{photo.StringId}/album"); responseDocument.Links.First.Should().BeNull(); responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Links.Should().BeNull(); responseDocument.Data.SingleValue.Relationships.Should().BeNull(); } @@ -230,7 +285,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = $"/api/photoAlbums/{album.StringId}/relationships/photos"; + string route = $"{PathPrefix}/photoAlbums/{album.StringId}/relationships/photos"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -238,14 +293,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.Self.Should().Be(route); - responseDocument.Links.Related.Should().Be($"/api/photoAlbums/{album.StringId}/photos"); - responseDocument.Links.First.Should().Be(route); - responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); + responseDocument.Links.Related.Should().Be($"{HostPrefix}{PathPrefix}/photoAlbums/{album.StringId}/photos"); + responseDocument.Links.First.Should().Be(responseDocument.Links.Self); + responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue.ShouldHaveCount(1); responseDocument.Data.ManyValue[0].Links.Should().BeNull(); responseDocument.Data.ManyValue[0].Relationships.Should().BeNull(); } @@ -256,6 +312,8 @@ public async Task Create_resource_with_side_effects_and_include_returns_relative // Arrange Photo existingPhoto = _fakers.Photo.Generate(); + string newAlbumName = _fakers.PhotoAlbum.Generate().Name; + await _testContext.RunOnDatabaseAsync(async dbContext => { dbContext.Photos.Add(existingPhoto); @@ -267,6 +325,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = new { type = "photoAlbums", + attributes = new + { + name = newAlbumName + }, relationships = new { photos = new @@ -284,7 +346,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - const string route = "/api/photoAlbums?include=photos"; + string route = $"{PathPrefix}/photoAlbums?include=photos"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); @@ -292,25 +354,46 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Links.Self.Should().Be(route); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().BeNull(); responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - string albumLink = $"/api/photoAlbums/{responseDocument.Data.SingleValue.Id}"; + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); + + string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{responseDocument.Data.SingleValue.Id}"; responseDocument.Data.SingleValue.Links.Self.Should().Be(albumLink); - responseDocument.Data.SingleValue.Relationships["photos"].Links.Self.Should().Be($"{albumLink}/relationships/photos"); - responseDocument.Data.SingleValue.Relationships["photos"].Links.Related.Should().Be($"{albumLink}/photos"); - string photoLink = $"/api/photos/{existingPhoto.StringId}"; + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photos").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); + value.Links.Related.Should().Be($"{albumLink}/photos"); + }); + + responseDocument.Included.ShouldHaveCount(1); + + responseDocument.Included[0].With(resource => + { + string photoLink = $"{HostPrefix}{PathPrefix}/photos/{existingPhoto.StringId}"; + + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(photoLink); - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Links.Self.Should().Be(photoLink); - responseDocument.Included[0].Relationships["album"].Links.Self.Should().Be($"{photoLink}/relationships/album"); - responseDocument.Included[0].Relationships["album"].Links.Related.Should().Be($"{photoLink}/album"); + resource.Relationships.ShouldContainKey("album").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{photoLink}/relationships/album"); + value.Links.Related.Should().Be($"{photoLink}/album"); + }); + }); } [Fact] @@ -346,7 +429,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - string route = $"/api/photos/{existingPhoto.StringId}?include=album"; + string route = $"{PathPrefix}/photos/{existingPhoto.StringId}?include=album"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -354,26 +437,45 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.Self.Should().Be(route); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().BeNull(); responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - string photoLink = $"/api/photos/{existingPhoto.StringId}"; + string photoLink = $"{HostPrefix}{PathPrefix}/photos/{existingPhoto.StringId}"; - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); responseDocument.Data.SingleValue.Links.Self.Should().Be(photoLink); - responseDocument.Data.SingleValue.Relationships["album"].Links.Self.Should().Be($"{photoLink}/relationships/album"); - responseDocument.Data.SingleValue.Relationships["album"].Links.Related.Should().Be($"{photoLink}/album"); - string albumLink = $"/api/photoAlbums/{existingAlbum.StringId}"; + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("album").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{photoLink}/relationships/album"); + value.Links.Related.Should().Be($"{photoLink}/album"); + }); + + responseDocument.Included.ShouldHaveCount(1); - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Links.Self.Should().Be(albumLink); - responseDocument.Included[0].Relationships["photos"].Links.Self.Should().Be($"{albumLink}/relationships/photos"); - responseDocument.Included[0].Relationships["photos"].Links.Related.Should().Be($"{albumLink}/photos"); + responseDocument.Included[0].With(resource => + { + string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{existingAlbum.StringId}"; + + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(albumLink); + + resource.Relationships.ShouldContainKey("photos").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); + value.Links.Related.Should().Be($"{albumLink}/photos"); + }); + }); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithoutNamespaceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithoutNamespaceTests.cs index 6256adf66d..7fb86278c8 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithoutNamespaceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithoutNamespaceTests.cs @@ -16,6 +16,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Links public sealed class RelativeLinksWithoutNamespaceTests : IClassFixture, LinksDbContext>> { + private const string HostPrefix = ""; + private const string PathPrefix = ""; + private readonly IntegrationTestContext, LinksDbContext> _testContext; private readonly LinksFakers _fakers = new(); @@ -47,7 +50,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = $"/photoAlbums/{album.StringId}"; + string route = $"{PathPrefix}/photoAlbums/{album.StringId}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -55,17 +58,25 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.Self.Should().Be(route); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().BeNull(); responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - responseDocument.Data.SingleValue.Should().NotBeNull(); - responseDocument.Data.SingleValue.Links.Self.Should().Be(route); - responseDocument.Data.SingleValue.Relationships["photos"].Links.Self.Should().Be($"{route}/relationships/photos"); - responseDocument.Data.SingleValue.Relationships["photos"].Links.Related.Should().Be($"{route}/photos"); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.Self.Should().Be($"{HostPrefix}{route}"); + + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photos").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{HostPrefix}{route}/relationships/photos"); + value.Links.Related.Should().Be($"{HostPrefix}{route}/photos"); + }); } [Fact] @@ -82,7 +93,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - const string route = "/photoAlbums?include=photos"; + string route = $"{PathPrefix}/photoAlbums?include=photos"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -90,26 +101,49 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.Self.Should().Be(route); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); - responseDocument.Links.First.Should().Be(route); - responseDocument.Links.Last.Should().Be(route); + responseDocument.Links.First.Should().Be(responseDocument.Links.Self); + responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - string albumLink = $"/photoAlbums/{album.StringId}"; + responseDocument.Data.ManyValue.ShouldHaveCount(1); + + responseDocument.Data.ManyValue[0].With(resource => + { + string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{album.StringId}"; + + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(albumLink); + + resource.Relationships.ShouldContainKey("photos").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); + value.Links.Related.Should().Be($"{albumLink}/photos"); + }); + }); + + responseDocument.Included.ShouldHaveCount(1); - responseDocument.Data.ManyValue.Should().HaveCount(1); - responseDocument.Data.ManyValue[0].Links.Self.Should().Be(albumLink); - responseDocument.Data.ManyValue[0].Relationships["photos"].Links.Self.Should().Be($"{albumLink}/relationships/photos"); - responseDocument.Data.ManyValue[0].Relationships["photos"].Links.Related.Should().Be($"{albumLink}/photos"); + responseDocument.Included[0].With(resource => + { + string photoLink = $"{HostPrefix}{PathPrefix}/photos/{album.Photos.ElementAt(0).StringId}"; - string photoLink = $"/photos/{album.Photos.ElementAt(0).StringId}"; + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(photoLink); - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Links.Self.Should().Be(photoLink); - responseDocument.Included[0].Relationships["album"].Links.Self.Should().Be($"{photoLink}/relationships/album"); - responseDocument.Included[0].Relationships["album"].Links.Related.Should().Be($"{photoLink}/album"); + resource.Relationships.ShouldContainKey("album").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{photoLink}/relationships/album"); + value.Links.Related.Should().Be($"{photoLink}/album"); + }); + }); } [Fact] @@ -125,7 +159,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = $"/photos/{photo.StringId}/album"; + string route = $"{PathPrefix}/photos/{photo.StringId}/album"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -133,19 +167,27 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.Self.Should().Be(route); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().BeNull(); responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - string albumLink = $"/photoAlbums/{photo.Album.StringId}"; + string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{photo.Album.StringId}"; - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); responseDocument.Data.SingleValue.Links.Self.Should().Be(albumLink); - responseDocument.Data.SingleValue.Relationships["photos"].Links.Self.Should().Be($"{albumLink}/relationships/photos"); - responseDocument.Data.SingleValue.Relationships["photos"].Links.Related.Should().Be($"{albumLink}/photos"); + + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photos").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); + value.Links.Related.Should().Be($"{albumLink}/photos"); + }); } [Fact] @@ -161,7 +203,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = $"/photoAlbums/{album.StringId}/photos"; + string route = $"{PathPrefix}/photoAlbums/{album.StringId}/photos"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -169,19 +211,31 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.Self.Should().Be(route); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); - responseDocument.Links.First.Should().Be(route); - responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.First.Should().Be(responseDocument.Links.Self); + responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - string photoLink = $"/photos/{album.Photos.ElementAt(0).StringId}"; + responseDocument.Data.ManyValue.ShouldHaveCount(1); + + responseDocument.Data.ManyValue[0].With(resource => + { + string photoLink = $"{HostPrefix}{PathPrefix}/photos/{album.Photos.ElementAt(0).StringId}"; - responseDocument.Data.ManyValue.Should().HaveCount(1); - responseDocument.Data.ManyValue[0].Links.Self.Should().Be(photoLink); - responseDocument.Data.ManyValue[0].Relationships["album"].Links.Self.Should().Be($"{photoLink}/relationships/album"); - responseDocument.Data.ManyValue[0].Relationships["album"].Links.Related.Should().Be($"{photoLink}/album"); + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(photoLink); + + resource.Relationships.ShouldContainKey("album").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{photoLink}/relationships/album"); + value.Links.Related.Should().Be($"{photoLink}/album"); + }); + }); } [Fact] @@ -197,7 +251,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = $"/photos/{photo.StringId}/relationships/album"; + string route = $"{PathPrefix}/photos/{photo.StringId}/relationships/album"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -205,14 +259,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.Self.Should().Be(route); - responseDocument.Links.Related.Should().Be($"/photos/{photo.StringId}/album"); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); + responseDocument.Links.Related.Should().Be($"{HostPrefix}{PathPrefix}/photos/{photo.StringId}/album"); responseDocument.Links.First.Should().BeNull(); responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Links.Should().BeNull(); responseDocument.Data.SingleValue.Relationships.Should().BeNull(); } @@ -230,7 +285,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = $"/photoAlbums/{album.StringId}/relationships/photos"; + string route = $"{PathPrefix}/photoAlbums/{album.StringId}/relationships/photos"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -238,14 +293,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.Self.Should().Be(route); - responseDocument.Links.Related.Should().Be($"/photoAlbums/{album.StringId}/photos"); - responseDocument.Links.First.Should().Be(route); - responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); + responseDocument.Links.Related.Should().Be($"{HostPrefix}{PathPrefix}/photoAlbums/{album.StringId}/photos"); + responseDocument.Links.First.Should().Be(responseDocument.Links.Self); + responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue.ShouldHaveCount(1); responseDocument.Data.ManyValue[0].Links.Should().BeNull(); responseDocument.Data.ManyValue[0].Relationships.Should().BeNull(); } @@ -256,6 +312,8 @@ public async Task Create_resource_with_side_effects_and_include_returns_relative // Arrange Photo existingPhoto = _fakers.Photo.Generate(); + string newAlbumName = _fakers.PhotoAlbum.Generate().Name; + await _testContext.RunOnDatabaseAsync(async dbContext => { dbContext.Photos.Add(existingPhoto); @@ -267,6 +325,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = new { type = "photoAlbums", + attributes = new + { + name = newAlbumName + }, relationships = new { photos = new @@ -284,7 +346,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - const string route = "/photoAlbums?include=photos"; + string route = $"{PathPrefix}/photoAlbums?include=photos"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); @@ -292,25 +354,46 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Links.Self.Should().Be(route); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().BeNull(); responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - string albumLink = $"/photoAlbums/{responseDocument.Data.SingleValue.Id}"; + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); + + string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{responseDocument.Data.SingleValue.Id}"; responseDocument.Data.SingleValue.Links.Self.Should().Be(albumLink); - responseDocument.Data.SingleValue.Relationships["photos"].Links.Self.Should().Be($"{albumLink}/relationships/photos"); - responseDocument.Data.SingleValue.Relationships["photos"].Links.Related.Should().Be($"{albumLink}/photos"); - string photoLink = $"/photos/{existingPhoto.StringId}"; + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photos").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); + value.Links.Related.Should().Be($"{albumLink}/photos"); + }); + + responseDocument.Included.ShouldHaveCount(1); + + responseDocument.Included[0].With(resource => + { + string photoLink = $"{HostPrefix}{PathPrefix}/photos/{existingPhoto.StringId}"; + + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(photoLink); - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Links.Self.Should().Be(photoLink); - responseDocument.Included[0].Relationships["album"].Links.Self.Should().Be($"{photoLink}/relationships/album"); - responseDocument.Included[0].Relationships["album"].Links.Related.Should().Be($"{photoLink}/album"); + resource.Relationships.ShouldContainKey("album").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{photoLink}/relationships/album"); + value.Links.Related.Should().Be($"{photoLink}/album"); + }); + }); } [Fact] @@ -346,7 +429,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - string route = $"/photos/{existingPhoto.StringId}?include=album"; + string route = $"{PathPrefix}/photos/{existingPhoto.StringId}?include=album"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -354,26 +437,45 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.Self.Should().Be(route); + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().BeNull(); responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - string photoLink = $"/photos/{existingPhoto.StringId}"; + string photoLink = $"{HostPrefix}{PathPrefix}/photos/{existingPhoto.StringId}"; - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); responseDocument.Data.SingleValue.Links.Self.Should().Be(photoLink); - responseDocument.Data.SingleValue.Relationships["album"].Links.Self.Should().Be($"{photoLink}/relationships/album"); - responseDocument.Data.SingleValue.Relationships["album"].Links.Related.Should().Be($"{photoLink}/album"); - string albumLink = $"/photoAlbums/{existingAlbum.StringId}"; + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("album").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{photoLink}/relationships/album"); + value.Links.Related.Should().Be($"{photoLink}/album"); + }); + + responseDocument.Included.ShouldHaveCount(1); - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Links.Self.Should().Be(albumLink); - responseDocument.Included[0].Relationships["photos"].Links.Self.Should().Be($"{albumLink}/relationships/photos"); - responseDocument.Included[0].Relationships["photos"].Links.Related.Should().Be($"{albumLink}/photos"); + responseDocument.Included[0].With(resource => + { + string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{existingAlbum.StringId}"; + + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(albumLink); + + resource.Relationships.ShouldContainKey("photos").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); + value.Links.Related.Should().Be($"{albumLink}/photos"); + }); + }); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/AuditEntriesController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/AuditEntriesController.cs index 2432334e6b..d3104eb45b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/AuditEntriesController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/AuditEntriesController.cs @@ -5,10 +5,11 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Logging { - public sealed class AuditEntriesController : JsonApiController + public sealed class AuditEntriesController : JsonApiController { - public AuditEntriesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public AuditEntriesController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/AuditEntry.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/AuditEntry.cs index f7dc6dac53..00aaa5c6b7 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/AuditEntry.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/AuditEntry.cs @@ -6,10 +6,10 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Logging { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class AuditEntry : Identifiable + public sealed class AuditEntry : Identifiable { [Attr] - public string UserName { get; set; } + public string UserName { get; set; } = null!; [Attr] public DateTimeOffset CreatedAt { get; set; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/AuditDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingDbContext.cs similarity index 55% rename from test/JsonApiDotNetCoreTests/IntegrationTests/Logging/AuditDbContext.cs rename to test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingDbContext.cs index 670b97dcbc..d99bf62f8a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/AuditDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingDbContext.cs @@ -4,11 +4,11 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Logging { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class AuditDbContext : DbContext + public sealed class LoggingDbContext : DbContext { - public DbSet AuditEntries { get; set; } + public DbSet AuditEntries => Set(); - public AuditDbContext(DbContextOptions options) + public LoggingDbContext(DbContextOptions options) : base(options) { } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/AuditFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingFakers.cs similarity index 92% rename from test/JsonApiDotNetCoreTests/IntegrationTests/Logging/AuditFakers.cs rename to test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingFakers.cs index ff8fc1b2a8..f2e17e5494 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/AuditFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingFakers.cs @@ -7,7 +7,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Logging { - internal sealed class AuditFakers : FakerContainer + internal sealed class LoggingFakers : FakerContainer { private readonly Lazy> _lazyAuditEntryFaker = new(() => new Faker() diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingTests.cs index d9858db4f3..0ac74170db 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingTests.cs @@ -10,12 +10,12 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Logging { - public sealed class LoggingTests : IClassFixture, AuditDbContext>> + public sealed class LoggingTests : IClassFixture, LoggingDbContext>> { - private readonly IntegrationTestContext, AuditDbContext> _testContext; - private readonly AuditFakers _fakers = new(); + private readonly IntegrationTestContext, LoggingDbContext> _testContext; + private readonly LoggingFakers _fakers = new(); - public LoggingTests(IntegrationTestContext, AuditDbContext> testContext) + public LoggingTests(IntegrationTestContext, LoggingDbContext> testContext) { _testContext = testContext; @@ -67,7 +67,7 @@ public async Task Logs_request_body_at_Trace_level() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - loggerFactory.Logger.Messages.Should().NotBeEmpty(); + loggerFactory.Logger.Messages.ShouldNotBeEmpty(); loggerFactory.Logger.Messages.Should().ContainSingle(message => message.LogLevel == LogLevel.Trace && message.Text.StartsWith("Received POST request at 'http://localhost/auditEntries' with body: <<", StringComparison.Ordinal)); @@ -89,7 +89,7 @@ public async Task Logs_response_body_at_Trace_level() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - loggerFactory.Logger.Messages.Should().NotBeEmpty(); + loggerFactory.Logger.Messages.ShouldNotBeEmpty(); loggerFactory.Logger.Messages.Should().ContainSingle(message => message.LogLevel == LogLevel.Trace && message.Text.StartsWith("Sending 200 response for GET request at 'http://localhost/auditEntries' with body: <<", StringComparison.Ordinal)); @@ -113,7 +113,7 @@ public async Task Logs_invalid_request_body_error_at_Information_level() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - loggerFactory.Logger.Messages.Should().NotBeEmpty(); + loggerFactory.Logger.Messages.ShouldNotBeEmpty(); loggerFactory.Logger.Messages.Should().ContainSingle(message => message.LogLevel == LogLevel.Information && message.Text.Contains("Failed to deserialize request body.")); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/MetaDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/MetaDbContext.cs new file mode 100644 index 0000000000..9c46c51709 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/MetaDbContext.cs @@ -0,0 +1,17 @@ +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreTests.IntegrationTests.Meta +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class MetaDbContext : DbContext + { + public DbSet ProductFamilies => Set(); + public DbSet SupportTickets => Set(); + + public MetaDbContext(DbContextOptions options) + : base(options) + { + } + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/MetaFakers.cs similarity index 94% rename from test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportFakers.cs rename to test/JsonApiDotNetCoreTests/IntegrationTests/Meta/MetaFakers.cs index d20016a8d6..3deae16930 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/MetaFakers.cs @@ -7,7 +7,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Meta { - internal sealed class SupportFakers : FakerContainer + internal sealed class MetaFakers : FakerContainer { private readonly Lazy> _lazyProductFamilyFaker = new(() => new Faker() diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ProductFamiliesController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ProductFamiliesController.cs index d5ddc0327c..408d6a3414 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ProductFamiliesController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ProductFamiliesController.cs @@ -5,10 +5,11 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Meta { - public sealed class ProductFamiliesController : JsonApiController + public sealed class ProductFamiliesController : JsonApiController { - public ProductFamiliesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public ProductFamiliesController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ProductFamily.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ProductFamily.cs index 23d6656282..feb2d9358a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ProductFamily.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ProductFamily.cs @@ -6,12 +6,12 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Meta { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class ProductFamily : Identifiable + public sealed class ProductFamily : Identifiable { [Attr] - public string Name { get; set; } + public string Name { get; set; } = null!; [HasMany] - public IList Tickets { get; set; } + public IList Tickets { get; set; } = new List(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ResourceMetaTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ResourceMetaTests.cs index fbd9892f9a..dacfe150df 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ResourceMetaTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ResourceMetaTests.cs @@ -11,12 +11,12 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Meta { - public sealed class ResourceMetaTests : IClassFixture, SupportDbContext>> + public sealed class ResourceMetaTests : IClassFixture, MetaDbContext>> { - private readonly IntegrationTestContext, SupportDbContext> _testContext; - private readonly SupportFakers _fakers = new(); + private readonly IntegrationTestContext, MetaDbContext> _testContext; + private readonly MetaFakers _fakers = new(); - public ResourceMetaTests(IntegrationTestContext, SupportDbContext> testContext) + public ResourceMetaTests(IntegrationTestContext, MetaDbContext> testContext) { _testContext = testContext; @@ -58,16 +58,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(3); - responseDocument.Data.ManyValue[0].Meta.Should().ContainKey("hasHighPriority"); + responseDocument.Data.ManyValue.ShouldHaveCount(3); + responseDocument.Data.ManyValue[0].Meta.ShouldContainKey("hasHighPriority"); responseDocument.Data.ManyValue[1].Meta.Should().BeNull(); - responseDocument.Data.ManyValue[2].Meta.Should().ContainKey("hasHighPriority"); + responseDocument.Data.ManyValue[2].Meta.ShouldContainKey("hasHighPriority"); hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(SupportTicket), ResourceDefinitionHitCounter.ExtensibilityPoint.GetMeta), - (typeof(SupportTicket), ResourceDefinitionHitCounter.ExtensibilityPoint.GetMeta), - (typeof(SupportTicket), ResourceDefinitionHitCounter.ExtensibilityPoint.GetMeta) + (typeof(SupportTicket), ResourceDefinitionExtensibilityPoints.GetMeta), + (typeof(SupportTicket), ResourceDefinitionExtensibilityPoints.GetMeta), + (typeof(SupportTicket), ResourceDefinitionExtensibilityPoints.GetMeta) }, options => options.WithStrictOrdering()); } @@ -96,13 +96,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Meta.Should().ContainKey("hasHighPriority"); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included[0].Meta.ShouldContainKey("hasHighPriority"); hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(SupportTicket), ResourceDefinitionHitCounter.ExtensibilityPoint.GetMeta) + (typeof(SupportTicket), ResourceDefinitionExtensibilityPoints.GetMeta) }, options => options.WithStrictOrdering()); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ResponseMetaTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ResponseMetaTests.cs index 0c698644da..d416eac792 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ResponseMetaTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ResponseMetaTests.cs @@ -3,18 +3,18 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Serialization; +using JsonApiDotNetCore.Serialization.Response; using Microsoft.Extensions.DependencyInjection; using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreTests.IntegrationTests.Meta { - public sealed class ResponseMetaTests : IClassFixture, SupportDbContext>> + public sealed class ResponseMetaTests : IClassFixture, MetaDbContext>> { - private readonly IntegrationTestContext, SupportDbContext> _testContext; + private readonly IntegrationTestContext, MetaDbContext> _testContext; - public ResponseMetaTests(IntegrationTestContext, SupportDbContext> testContext) + public ResponseMetaTests(IntegrationTestContext, MetaDbContext> testContext) { _testContext = testContext; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportDbContext.cs deleted file mode 100644 index 0db9f2cc95..0000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportDbContext.cs +++ /dev/null @@ -1,17 +0,0 @@ -using JetBrains.Annotations; -using Microsoft.EntityFrameworkCore; - -namespace JsonApiDotNetCoreTests.IntegrationTests.Meta -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class SupportDbContext : DbContext - { - public DbSet ProductFamilies { get; set; } - public DbSet SupportTickets { get; set; } - - public SupportDbContext(DbContextOptions options) - : base(options) - { - } - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportResponseMeta.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportResponseMeta.cs index dccf338f22..7bb94a52c8 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportResponseMeta.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportResponseMeta.cs @@ -1,13 +1,13 @@ using System.Collections.Generic; -using JsonApiDotNetCore.Serialization; +using JsonApiDotNetCore.Serialization.Response; namespace JsonApiDotNetCoreTests.IntegrationTests.Meta { public sealed class SupportResponseMeta : IResponseMeta { - public IReadOnlyDictionary GetMeta() + public IReadOnlyDictionary GetMeta() { - return new Dictionary + return new Dictionary { ["license"] = "MIT", ["projectUrl"] = "https://github.com/json-api-dotnet/JsonApiDotNetCore/", diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportTicket.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportTicket.cs index fe8c6dfd1e..603b6790fd 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportTicket.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportTicket.cs @@ -5,9 +5,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Meta { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class SupportTicket : Identifiable + public sealed class SupportTicket : Identifiable { [Attr] - public string Description { get; set; } + public string Description { get; set; } = null!; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportTicketDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportTicketDefinition.cs index 99d7b0af77..0c355abce6 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportTicketDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportTicketDefinition.cs @@ -2,34 +2,32 @@ using System.Collections.Generic; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Resources; namespace JsonApiDotNetCoreTests.IntegrationTests.Meta { [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class SupportTicketDefinition : JsonApiResourceDefinition + public sealed class SupportTicketDefinition : HitCountingResourceDefinition { - private readonly ResourceDefinitionHitCounter _hitCounter; + protected override ResourceDefinitionExtensibilityPoints ExtensibilityPointsToTrack => ResourceDefinitionExtensibilityPoints.GetMeta; public SupportTicketDefinition(IResourceGraph resourceGraph, ResourceDefinitionHitCounter hitCounter) - : base(resourceGraph) + : base(resourceGraph, hitCounter) { - _hitCounter = hitCounter; } - public override IDictionary GetMeta(SupportTicket resource) + public override IDictionary? GetMeta(SupportTicket resource) { - _hitCounter.TrackInvocation(ResourceDefinitionHitCounter.ExtensibilityPoint.GetMeta); + base.GetMeta(resource); - if (resource.Description != null && resource.Description.StartsWith("Critical:", StringComparison.Ordinal)) + if (!string.IsNullOrEmpty(resource.Description) && resource.Description.StartsWith("Critical:", StringComparison.Ordinal)) { - return new Dictionary + return new Dictionary { ["hasHighPriority"] = true }; } - return base.GetMeta(resource); + return null; } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportTicketsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportTicketsController.cs index 7f787b8b77..9e5b6da653 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportTicketsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportTicketsController.cs @@ -5,10 +5,11 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Meta { - public sealed class SupportTicketsController : JsonApiController + public sealed class SupportTicketsController : JsonApiController { - public SupportTicketsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public SupportTicketsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs index a449e1799b..60604b91f0 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs @@ -12,12 +12,12 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Meta { - public sealed class TopLevelCountTests : IClassFixture, SupportDbContext>> + public sealed class TopLevelCountTests : IClassFixture, MetaDbContext>> { - private readonly IntegrationTestContext, SupportDbContext> _testContext; - private readonly SupportFakers _fakers = new(); + private readonly IntegrationTestContext, MetaDbContext> _testContext; + private readonly MetaFakers _fakers = new(); - public TopLevelCountTests(IntegrationTestContext, SupportDbContext> testContext) + public TopLevelCountTests(IntegrationTestContext, MetaDbContext> testContext) { _testContext = testContext; @@ -54,8 +54,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Meta.Should().NotBeNull(); - ((JsonElement)responseDocument.Meta["total"]).GetInt32().Should().Be(1); + responseDocument.Meta.ShouldNotBeNull(); + + responseDocument.Meta.ShouldContainKey("total").With(value => + { + JsonElement element = value.Should().BeOfType().Subject; + element.GetInt32().Should().Be(1); + }); } [Fact] @@ -75,8 +80,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Meta.Should().NotBeNull(); - ((JsonElement)responseDocument.Meta["total"]).GetInt32().Should().Be(0); + responseDocument.Meta.ShouldNotBeNull(); + + responseDocument.Meta.ShouldContainKey("total").With(value => + { + JsonElement element = value.Should().BeOfType().Subject; + element.GetInt32().Should().Be(0); + }); } [Fact] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/DomainGroup.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/DomainGroup.cs index 1d7b797884..f8028b650e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/DomainGroup.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/DomainGroup.cs @@ -10,9 +10,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices public sealed class DomainGroup : Identifiable { [Attr] - public string Name { get; set; } + public string Name { get; set; } = null!; [HasMany] - public ISet Users { get; set; } + public ISet Users { get; set; } = new HashSet(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/DomainGroupsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/DomainGroupsController.cs index a175e8773e..cb1bbc6a32 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/DomainGroupsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/DomainGroupsController.cs @@ -8,8 +8,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices { public sealed class DomainGroupsController : JsonApiController { - public DomainGroupsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public DomainGroupsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/DomainUser.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/DomainUser.cs index a9bfd19c15..6bc4af7483 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/DomainUser.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/DomainUser.cs @@ -1,5 +1,4 @@ using System; -using System.ComponentModel.DataAnnotations; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; @@ -10,13 +9,12 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices public sealed class DomainUser : Identifiable { [Attr] - [Required] - public string LoginName { get; set; } + public string LoginName { get; set; } = null!; [Attr] - public string DisplayName { get; set; } + public string? DisplayName { get; set; } [HasOne] - public DomainGroup Group { get; set; } + public DomainGroup? Group { get; set; } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/DomainUsersController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/DomainUsersController.cs index 1908da2667..3890ced6cb 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/DomainUsersController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/DomainUsersController.cs @@ -8,8 +8,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices { public sealed class DomainUsersController : JsonApiController { - public DomainUsersController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public DomainUsersController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetDbContext.cs index f2ab748564..c778390d39 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetDbContext.cs @@ -6,8 +6,8 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.FireAndForgetDel [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class FireForgetDbContext : DbContext { - public DbSet Users { get; set; } - public DbSet Groups { get; set; } + public DbSet Users => Set(); + public DbSet Groups => Set(); public FireForgetDbContext(DbContextOptions options) : base(options) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetGroupDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetGroupDefinition.cs index e8abb064dd..23c17aa47f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetGroupDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetGroupDefinition.cs @@ -12,20 +12,18 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.FireAndForgetDel public sealed class FireForgetGroupDefinition : MessagingGroupDefinition { private readonly MessageBroker _messageBroker; - private readonly ResourceDefinitionHitCounter _hitCounter; - private DomainGroup _groupToDelete; + private DomainGroup? _groupToDelete; public FireForgetGroupDefinition(IResourceGraph resourceGraph, FireForgetDbContext dbContext, MessageBroker messageBroker, ResourceDefinitionHitCounter hitCounter) : base(resourceGraph, dbContext.Users, dbContext.Groups, hitCounter) { _messageBroker = messageBroker; - _hitCounter = hitCounter; } public override async Task OnWritingAsync(DomainGroup group, WriteOperationKind writeOperation, CancellationToken cancellationToken) { - _hitCounter.TrackInvocation(ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync); + await base.OnWritingAsync(group, writeOperation, cancellationToken); if (writeOperation == WriteOperationKind.DeleteResource) { @@ -33,11 +31,11 @@ public override async Task OnWritingAsync(DomainGroup group, WriteOperationKind } } - public override Task OnWriteSucceededAsync(DomainGroup group, WriteOperationKind writeOperation, CancellationToken cancellationToken) + public override async Task OnWriteSucceededAsync(DomainGroup group, WriteOperationKind writeOperation, CancellationToken cancellationToken) { - _hitCounter.TrackInvocation(ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync); + await base.OnWriteSucceededAsync(group, writeOperation, cancellationToken); - return FinishWriteAsync(group, writeOperation, cancellationToken); + await FinishWriteAsync(group, writeOperation, cancellationToken); } protected override Task FlushMessageAsync(OutgoingMessage message, CancellationToken cancellationToken) @@ -45,7 +43,7 @@ protected override Task FlushMessageAsync(OutgoingMessage message, CancellationT return _messageBroker.PostMessageAsync(message, cancellationToken); } - protected override Task GetGroupToDeleteAsync(Guid groupId, CancellationToken cancellationToken) + protected override Task GetGroupToDeleteAsync(Guid groupId, CancellationToken cancellationToken) { return Task.FromResult(_groupToDelete); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.Group.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.Group.cs index bf762c857b..31a01feb99 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.Group.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.Group.cs @@ -43,19 +43,19 @@ public async Task Create_group_sends_messages() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.Should().NotBeNull(); - responseDocument.Data.SingleValue.Attributes["name"].Should().Be(newGroupName); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newGroupName)); hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.Should().HaveCount(1); + messageBroker.SentMessages.ShouldHaveCount(1); - Guid newGroupId = Guid.Parse(responseDocument.Data.SingleValue.Id); + Guid newGroupId = Guid.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); var content = messageBroker.SentMessages[0].GetContentAs(); content.GroupId.Should().Be(newGroupId); @@ -121,20 +121,20 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.Should().NotBeNull(); - responseDocument.Data.SingleValue.Attributes["name"].Should().Be(newGroupName); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newGroupName)); hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSetToManyRelationshipAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnSetToManyRelationshipAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.Should().HaveCount(3); + messageBroker.SentMessages.ShouldHaveCount(3); - Guid newGroupId = Guid.Parse(responseDocument.Data.SingleValue.Id); + Guid newGroupId = Guid.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); var content1 = messageBroker.SentMessages[0].GetContentAs(); content1.GroupId.Should().Be(newGroupId); @@ -192,12 +192,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.Should().HaveCount(1); + messageBroker.SentMessages.ShouldHaveCount(1); var content = messageBroker.SentMessages[0].GetContentAs(); content.GroupId.Should().Be(existingGroup.StringId); @@ -276,13 +276,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSetToManyRelationshipAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnSetToManyRelationshipAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.Should().HaveCount(3); + messageBroker.SentMessages.ShouldHaveCount(3); var content1 = messageBroker.SentMessages[0].GetContentAs(); content1.UserId.Should().Be(existingUserWithoutGroup.Id); @@ -325,11 +325,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.Should().HaveCount(1); + messageBroker.SentMessages.ShouldHaveCount(1); var content = messageBroker.SentMessages[0].GetContentAs(); content.GroupId.Should().Be(existingGroup.StringId); @@ -363,11 +363,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.Should().HaveCount(2); + messageBroker.SentMessages.ShouldHaveCount(2); var content1 = messageBroker.SentMessages[0].GetContentAs(); content1.UserId.Should().Be(existingGroup.Users.ElementAt(0).Id); @@ -437,13 +437,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSetToManyRelationshipAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnSetToManyRelationshipAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.Should().HaveCount(3); + messageBroker.SentMessages.ShouldHaveCount(3); var content1 = messageBroker.SentMessages[0].GetContentAs(); content1.UserId.Should().Be(existingUserWithoutGroup.Id); @@ -511,12 +511,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnAddToRelationshipAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnAddToRelationshipAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.Should().HaveCount(2); + messageBroker.SentMessages.ShouldHaveCount(2); var content1 = messageBroker.SentMessages[0].GetContentAs(); content1.UserId.Should().Be(existingUserWithoutGroup.Id); @@ -573,12 +573,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnRemoveFromRelationshipAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnRemoveFromRelationshipAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.Should().HaveCount(1); + messageBroker.SentMessages.ShouldHaveCount(1); var content = messageBroker.SentMessages[0].GetContentAs(); content.UserId.Should().Be(existingUserWithSameGroup2.Id); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.User.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.User.cs index fe40df6188..b6014d9fb0 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.User.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.User.cs @@ -21,7 +21,7 @@ public async Task Create_user_sends_messages() var messageBroker = _testContext.Factory.Services.GetRequiredService(); string newLoginName = _fakers.DomainUser.Generate().LoginName; - string newDisplayName = _fakers.DomainUser.Generate().DisplayName; + string newDisplayName = _fakers.DomainUser.Generate().DisplayName!; var requestBody = new { @@ -44,20 +44,20 @@ public async Task Create_user_sends_messages() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.Should().NotBeNull(); - responseDocument.Data.SingleValue.Attributes["loginName"].Should().Be(newLoginName); - responseDocument.Data.SingleValue.Attributes["displayName"].Should().Be(newDisplayName); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("loginName").With(value => value.Should().Be(newLoginName)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("displayName").With(value => value.Should().Be(newDisplayName)); hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.Should().HaveCount(1); + messageBroker.SentMessages.ShouldHaveCount(1); - Guid newUserId = Guid.Parse(responseDocument.Data.SingleValue.Id); + Guid newUserId = Guid.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); var content = messageBroker.SentMessages[0].GetContentAs(); content.UserId.Should().Be(newUserId); @@ -113,21 +113,21 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.Should().NotBeNull(); - responseDocument.Data.SingleValue.Attributes["loginName"].Should().Be(newLoginName); - responseDocument.Data.SingleValue.Attributes["displayName"].Should().BeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("loginName").With(value => value.Should().Be(newLoginName)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("displayName").With(value => value.Should().BeNull()); hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSetToOneRelationshipAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnSetToOneRelationshipAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.Should().HaveCount(2); + messageBroker.SentMessages.ShouldHaveCount(2); - Guid newUserId = Guid.Parse(responseDocument.Data.SingleValue.Id); + Guid newUserId = Guid.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); var content1 = messageBroker.SentMessages[0].GetContentAs(); content1.UserId.Should().Be(newUserId); @@ -149,7 +149,7 @@ public async Task Update_user_sends_messages() DomainUser existingUser = _fakers.DomainUser.Generate(); string newLoginName = _fakers.DomainUser.Generate().LoginName; - string newDisplayName = _fakers.DomainUser.Generate().DisplayName; + string newDisplayName = _fakers.DomainUser.Generate().DisplayName!; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -183,12 +183,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.Should().HaveCount(2); + messageBroker.SentMessages.ShouldHaveCount(2); var content1 = messageBroker.SentMessages[0].GetContentAs(); content1.UserId.Should().Be(existingUser.Id); @@ -211,7 +211,7 @@ public async Task Update_user_clear_group_sends_messages() DomainUser existingUser = _fakers.DomainUser.Generate(); existingUser.Group = _fakers.DomainGroup.Generate(); - string newDisplayName = _fakers.DomainUser.Generate().DisplayName; + string newDisplayName = _fakers.DomainUser.Generate().DisplayName!; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -233,7 +233,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { group = new { - data = (object)null + data = (object?)null } } } @@ -251,13 +251,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSetToOneRelationshipAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnSetToOneRelationshipAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.Should().HaveCount(2); + messageBroker.SentMessages.ShouldHaveCount(2); var content1 = messageBroker.SentMessages[0].GetContentAs(); content1.UserId.Should().Be(existingUser.Id); @@ -279,7 +279,7 @@ public async Task Update_user_add_to_group_sends_messages() DomainUser existingUser = _fakers.DomainUser.Generate(); DomainGroup existingGroup = _fakers.DomainGroup.Generate(); - string newDisplayName = _fakers.DomainUser.Generate().DisplayName; + string newDisplayName = _fakers.DomainUser.Generate().DisplayName!; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -323,13 +323,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSetToOneRelationshipAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnSetToOneRelationshipAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.Should().HaveCount(2); + messageBroker.SentMessages.ShouldHaveCount(2); var content1 = messageBroker.SentMessages[0].GetContentAs(); content1.UserId.Should().Be(existingUser.Id); @@ -353,7 +353,7 @@ public async Task Update_user_move_to_group_sends_messages() DomainGroup existingGroup = _fakers.DomainGroup.Generate(); - string newDisplayName = _fakers.DomainUser.Generate().DisplayName; + string newDisplayName = _fakers.DomainUser.Generate().DisplayName!; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -397,13 +397,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSetToOneRelationshipAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnSetToOneRelationshipAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.Should().HaveCount(2); + messageBroker.SentMessages.ShouldHaveCount(2); var content1 = messageBroker.SentMessages[0].GetContentAs(); content1.UserId.Should().Be(existingUser.Id); @@ -443,11 +443,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.Should().HaveCount(1); + messageBroker.SentMessages.ShouldHaveCount(1); var content = messageBroker.SentMessages[0].GetContentAs(); content.UserId.Should().Be(existingUser.Id); @@ -481,11 +481,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.Should().HaveCount(2); + messageBroker.SentMessages.ShouldHaveCount(2); var content1 = messageBroker.SentMessages[0].GetContentAs(); content1.UserId.Should().Be(existingUser.Id); @@ -513,7 +513,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var requestBody = new { - data = (object)null + data = (object?)null }; string route = $"/domainUsers/{existingUser.StringId}/relationships/group"; @@ -528,13 +528,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSetToOneRelationshipAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnSetToOneRelationshipAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.Should().HaveCount(1); + messageBroker.SentMessages.ShouldHaveCount(1); var content = messageBroker.SentMessages[0].GetContentAs(); content.UserId.Should().Be(existingUser.Id); @@ -578,13 +578,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSetToOneRelationshipAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnSetToOneRelationshipAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.Should().HaveCount(1); + messageBroker.SentMessages.ShouldHaveCount(1); var content = messageBroker.SentMessages[0].GetContentAs(); content.UserId.Should().Be(existingUser.Id); @@ -630,13 +630,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSetToOneRelationshipAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnSetToOneRelationshipAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.Should().HaveCount(1); + messageBroker.SentMessages.ShouldHaveCount(1); var content = messageBroker.SentMessages[0].GetContentAs(); content.UserId.Should().Be(existingUser.Id); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.cs index 391ff96781..93932d1acb 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.cs @@ -56,7 +56,7 @@ public async Task Does_not_send_message_on_write_error() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); @@ -65,7 +65,7 @@ public async Task Does_not_send_message_on_write_error() hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync) + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync) }, options => options.WithStrictOrdering()); messageBroker.SentMessages.Should().BeEmpty(); @@ -97,7 +97,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.ServiceUnavailable); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.ServiceUnavailable); @@ -106,15 +106,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.Should().HaveCount(1); + messageBroker.SentMessages.ShouldHaveCount(1); await _testContext.RunOnDatabaseAsync(async dbContext => { - DomainUser user = await dbContext.Users.FirstWithIdOrDefaultAsync(existingUser.Id); + DomainUser? user = await dbContext.Users.FirstWithIdOrDefaultAsync(existingUser.Id); user.Should().BeNull(); }); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetUserDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetUserDefinition.cs index aaa5414f35..512c340222 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetUserDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetUserDefinition.cs @@ -12,20 +12,18 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.FireAndForgetDel public sealed class FireForgetUserDefinition : MessagingUserDefinition { private readonly MessageBroker _messageBroker; - private readonly ResourceDefinitionHitCounter _hitCounter; - private DomainUser _userToDelete; + private DomainUser? _userToDelete; public FireForgetUserDefinition(IResourceGraph resourceGraph, FireForgetDbContext dbContext, MessageBroker messageBroker, ResourceDefinitionHitCounter hitCounter) : base(resourceGraph, dbContext.Users, hitCounter) { _messageBroker = messageBroker; - _hitCounter = hitCounter; } public override async Task OnWritingAsync(DomainUser user, WriteOperationKind writeOperation, CancellationToken cancellationToken) { - _hitCounter.TrackInvocation(ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync); + await base.OnWritingAsync(user, writeOperation, cancellationToken); if (writeOperation == WriteOperationKind.DeleteResource) { @@ -33,11 +31,11 @@ public override async Task OnWritingAsync(DomainUser user, WriteOperationKind wr } } - public override Task OnWriteSucceededAsync(DomainUser user, WriteOperationKind writeOperation, CancellationToken cancellationToken) + public override async Task OnWriteSucceededAsync(DomainUser user, WriteOperationKind writeOperation, CancellationToken cancellationToken) { - _hitCounter.TrackInvocation(ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync); + await base.OnWriteSucceededAsync(user, writeOperation, cancellationToken); - return FinishWriteAsync(user, writeOperation, cancellationToken); + await FinishWriteAsync(user, writeOperation, cancellationToken); } protected override Task FlushMessageAsync(OutgoingMessage message, CancellationToken cancellationToken) @@ -45,7 +43,7 @@ protected override Task FlushMessageAsync(OutgoingMessage message, CancellationT return _messageBroker.PostMessageAsync(message, cancellationToken); } - protected override Task GetUserToDeleteAsync(Guid userId, CancellationToken cancellationToken) + protected override Task GetUserToDeleteAsync(Guid userId, CancellationToken cancellationToken) { return Task.FromResult(_userToDelete); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/GroupCreatedContent.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/GroupCreatedContent.cs index 11541b5133..8b13860f7a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/GroupCreatedContent.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/GroupCreatedContent.cs @@ -8,7 +8,13 @@ public sealed class GroupCreatedContent : IMessageContent { public int FormatVersion => 1; - public Guid GroupId { get; set; } - public string GroupName { get; set; } + public Guid GroupId { get; } + public string GroupName { get; } + + public GroupCreatedContent(Guid groupId, string groupName) + { + GroupId = groupId; + GroupName = groupName; + } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/GroupDeletedContent.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/GroupDeletedContent.cs index d3bb447513..7dc9d4e93f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/GroupDeletedContent.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/GroupDeletedContent.cs @@ -8,6 +8,11 @@ public sealed class GroupDeletedContent : IMessageContent { public int FormatVersion => 1; - public Guid GroupId { get; set; } + public Guid GroupId { get; } + + public GroupDeletedContent(Guid groupId) + { + GroupId = groupId; + } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/GroupRenamedContent.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/GroupRenamedContent.cs index 21044b4bcf..068c1dabdd 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/GroupRenamedContent.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/GroupRenamedContent.cs @@ -8,8 +8,15 @@ public sealed class GroupRenamedContent : IMessageContent { public int FormatVersion => 1; - public Guid GroupId { get; set; } - public string BeforeGroupName { get; set; } - public string AfterGroupName { get; set; } + public Guid GroupId { get; } + public string BeforeGroupName { get; } + public string AfterGroupName { get; } + + public GroupRenamedContent(Guid groupId, string beforeGroupName, string afterGroupName) + { + GroupId = groupId; + BeforeGroupName = beforeGroupName; + AfterGroupName = afterGroupName; + } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/OutgoingMessage.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/OutgoingMessage.cs index fccf23a8ef..eb797263a8 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/OutgoingMessage.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/OutgoingMessage.cs @@ -11,23 +11,26 @@ public sealed class OutgoingMessage public int FormatVersion { get; set; } public string Content { get; set; } + private OutgoingMessage(string type, int formatVersion, string content) + { + Type = type; + FormatVersion = formatVersion; + Content = content; + } + public T GetContentAs() where T : IMessageContent { - string namespacePrefix = typeof(IMessageContent).Namespace; - var contentType = System.Type.GetType($"{namespacePrefix}.{Type}", true); + string namespacePrefix = typeof(IMessageContent).Namespace!; + var contentType = System.Type.GetType($"{namespacePrefix}.{Type}", true)!; - return (T)JsonSerializer.Deserialize(Content, contentType); + return (T)JsonSerializer.Deserialize(Content, contentType)!; } public static OutgoingMessage CreateFromContent(IMessageContent content) { - return new() - { - Type = content.GetType().Name, - FormatVersion = content.FormatVersion, - Content = JsonSerializer.Serialize(content, content.GetType()) - }; + string value = JsonSerializer.Serialize(content, content.GetType()); + return new OutgoingMessage(content.GetType().Name, content.FormatVersion, value); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserAddedToGroupContent.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserAddedToGroupContent.cs index 0dd40a8ecc..e4cf0d0864 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserAddedToGroupContent.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserAddedToGroupContent.cs @@ -8,7 +8,13 @@ public sealed class UserAddedToGroupContent : IMessageContent { public int FormatVersion => 1; - public Guid UserId { get; set; } - public Guid GroupId { get; set; } + public Guid UserId { get; } + public Guid GroupId { get; } + + public UserAddedToGroupContent(Guid userId, Guid groupId) + { + UserId = userId; + GroupId = groupId; + } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserCreatedContent.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserCreatedContent.cs index eff26c683f..c6de505362 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserCreatedContent.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserCreatedContent.cs @@ -8,8 +8,15 @@ public sealed class UserCreatedContent : IMessageContent { public int FormatVersion => 1; - public Guid UserId { get; set; } - public string UserLoginName { get; set; } - public string UserDisplayName { get; set; } + public Guid UserId { get; } + public string UserLoginName { get; } + public string? UserDisplayName { get; } + + public UserCreatedContent(Guid userId, string userLoginName, string? userDisplayName) + { + UserId = userId; + UserLoginName = userLoginName; + UserDisplayName = userDisplayName; + } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserDeletedContent.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserDeletedContent.cs index d48fd1dedd..21d5789b25 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserDeletedContent.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserDeletedContent.cs @@ -8,6 +8,11 @@ public sealed class UserDeletedContent : IMessageContent { public int FormatVersion => 1; - public Guid UserId { get; set; } + public Guid UserId { get; } + + public UserDeletedContent(Guid userId) + { + UserId = userId; + } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserDisplayNameChangedContent.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserDisplayNameChangedContent.cs index d9f00f533a..64be5883ab 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserDisplayNameChangedContent.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserDisplayNameChangedContent.cs @@ -8,8 +8,15 @@ public sealed class UserDisplayNameChangedContent : IMessageContent { public int FormatVersion => 1; - public Guid UserId { get; set; } - public string BeforeUserDisplayName { get; set; } - public string AfterUserDisplayName { get; set; } + public Guid UserId { get; } + public string? BeforeUserDisplayName { get; } + public string? AfterUserDisplayName { get; } + + public UserDisplayNameChangedContent(Guid userId, string? beforeUserDisplayName, string? afterUserDisplayName) + { + UserId = userId; + BeforeUserDisplayName = beforeUserDisplayName; + AfterUserDisplayName = afterUserDisplayName; + } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserLoginNameChangedContent.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserLoginNameChangedContent.cs index 56015fbe13..8adc213fa2 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserLoginNameChangedContent.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserLoginNameChangedContent.cs @@ -8,8 +8,15 @@ public sealed class UserLoginNameChangedContent : IMessageContent { public int FormatVersion => 1; - public Guid UserId { get; set; } - public string BeforeUserLoginName { get; set; } - public string AfterUserLoginName { get; set; } + public Guid UserId { get; } + public string BeforeUserLoginName { get; } + public string AfterUserLoginName { get; } + + public UserLoginNameChangedContent(Guid userId, string beforeUserLoginName, string afterUserLoginName) + { + UserId = userId; + BeforeUserLoginName = beforeUserLoginName; + AfterUserLoginName = afterUserLoginName; + } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserMovedToGroupContent.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserMovedToGroupContent.cs index 29ed680283..2f1234734e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserMovedToGroupContent.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserMovedToGroupContent.cs @@ -8,8 +8,15 @@ public sealed class UserMovedToGroupContent : IMessageContent { public int FormatVersion => 1; - public Guid UserId { get; set; } - public Guid BeforeGroupId { get; set; } - public Guid AfterGroupId { get; set; } + public Guid UserId { get; } + public Guid BeforeGroupId { get; } + public Guid AfterGroupId { get; } + + public UserMovedToGroupContent(Guid userId, Guid beforeGroupId, Guid afterGroupId) + { + UserId = userId; + BeforeGroupId = beforeGroupId; + AfterGroupId = afterGroupId; + } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserRemovedFromGroupContent.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserRemovedFromGroupContent.cs index 8f2599e8ae..8bc1805942 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserRemovedFromGroupContent.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserRemovedFromGroupContent.cs @@ -8,7 +8,13 @@ public sealed class UserRemovedFromGroupContent : IMessageContent { public int FormatVersion => 1; - public Guid UserId { get; set; } - public Guid GroupId { get; set; } + public Guid UserId { get; } + public Guid GroupId { get; } + + public UserRemovedFromGroupContent(Guid userId, Guid groupId) + { + UserId = userId; + GroupId = groupId; + } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/MessagingGroupDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/MessagingGroupDefinition.cs index 7c85223bd2..e08f8398e8 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/MessagingGroupDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/MessagingGroupDefinition.cs @@ -12,27 +12,27 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices { - public abstract class MessagingGroupDefinition : JsonApiResourceDefinition + public abstract class MessagingGroupDefinition : HitCountingResourceDefinition { private readonly DbSet _userSet; private readonly DbSet _groupSet; - private readonly ResourceDefinitionHitCounter _hitCounter; private readonly List _pendingMessages = new(); - private string _beforeGroupName; + private string? _beforeGroupName; + + protected override ResourceDefinitionExtensibilityPoints ExtensibilityPointsToTrack => ResourceDefinitionExtensibilityPoints.Writing; protected MessagingGroupDefinition(IResourceGraph resourceGraph, DbSet userSet, DbSet groupSet, ResourceDefinitionHitCounter hitCounter) - : base(resourceGraph) + : base(resourceGraph, hitCounter) { _userSet = userSet; _groupSet = groupSet; - _hitCounter = hitCounter; } - public override Task OnPrepareWriteAsync(DomainGroup group, WriteOperationKind writeOperation, CancellationToken cancellationToken) + public override async Task OnPrepareWriteAsync(DomainGroup group, WriteOperationKind writeOperation, CancellationToken cancellationToken) { - _hitCounter.TrackInvocation(ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync); + await base.OnPrepareWriteAsync(group, writeOperation, cancellationToken); if (writeOperation == WriteOperationKind.CreateResource) { @@ -42,14 +42,12 @@ public override Task OnPrepareWriteAsync(DomainGroup group, WriteOperationKind w { _beforeGroupName = group.Name; } - - return Task.CompletedTask; } public override async Task OnSetToManyRelationshipAsync(DomainGroup group, HasManyAttribute hasManyRelationship, ISet rightResourceIds, WriteOperationKind writeOperation, CancellationToken cancellationToken) { - _hitCounter.TrackInvocation(ResourceDefinitionHitCounter.ExtensibilityPoint.OnSetToManyRelationshipAsync); + await base.OnSetToManyRelationshipAsync(group, hasManyRelationship, rightResourceIds, writeOperation, cancellationToken); if (hasManyRelationship.Property.Name == nameof(DomainGroup.Users)) { @@ -60,44 +58,29 @@ public override async Task OnSetToManyRelationshipAsync(DomainGroup group, HasMa foreach (DomainUser beforeUser in beforeUsers) { - IMessageContent content = null; + IMessageContent? content = null; if (beforeUser.Group == null) { - content = new UserAddedToGroupContent - { - UserId = beforeUser.Id, - GroupId = group.Id - }; + content = new UserAddedToGroupContent(beforeUser.Id, group.Id); } else if (beforeUser.Group != null && beforeUser.Group.Id != group.Id) { - content = new UserMovedToGroupContent - { - UserId = beforeUser.Id, - BeforeGroupId = beforeUser.Group.Id, - AfterGroupId = group.Id - }; + content = new UserMovedToGroupContent(beforeUser.Id, beforeUser.Group.Id, group.Id); } if (content != null) { - _pendingMessages.Add(OutgoingMessage.CreateFromContent(content)); + var message = OutgoingMessage.CreateFromContent(content); + _pendingMessages.Add(message); } } - if (group.Users != null) + foreach (DomainUser userToRemoveFromGroup in group.Users.Where(user => !rightUserIds.Contains(user.Id))) { - foreach (DomainUser userToRemoveFromGroup in group.Users.Where(user => !rightUserIds.Contains(user.Id))) - { - var message = OutgoingMessage.CreateFromContent(new UserRemovedFromGroupContent - { - UserId = userToRemoveFromGroup.Id, - GroupId = group.Id - }); - - _pendingMessages.Add(message); - } + var content = new UserRemovedFromGroupContent(userToRemoveFromGroup.Id, group.Id); + var message = OutgoingMessage.CreateFromContent(content); + _pendingMessages.Add(message); } } } @@ -105,7 +88,7 @@ public override async Task OnSetToManyRelationshipAsync(DomainGroup group, HasMa public override async Task OnAddToRelationshipAsync(Guid groupId, HasManyAttribute hasManyRelationship, ISet rightResourceIds, CancellationToken cancellationToken) { - _hitCounter.TrackInvocation(ResourceDefinitionHitCounter.ExtensibilityPoint.OnAddToRelationshipAsync); + await base.OnAddToRelationshipAsync(groupId, hasManyRelationship, rightResourceIds, cancellationToken); if (hasManyRelationship.Property.Name == nameof(DomainGroup.Users)) { @@ -116,38 +99,30 @@ public override async Task OnAddToRelationshipAsync(Guid groupId, HasManyAttribu foreach (DomainUser beforeUser in beforeUsers) { - IMessageContent content = null; + IMessageContent? content = null; if (beforeUser.Group == null) { - content = new UserAddedToGroupContent - { - UserId = beforeUser.Id, - GroupId = groupId - }; + content = new UserAddedToGroupContent(beforeUser.Id, groupId); } else if (beforeUser.Group != null && beforeUser.Group.Id != groupId) { - content = new UserMovedToGroupContent - { - UserId = beforeUser.Id, - BeforeGroupId = beforeUser.Group.Id, - AfterGroupId = groupId - }; + content = new UserMovedToGroupContent(beforeUser.Id, beforeUser.Group.Id, groupId); } if (content != null) { - _pendingMessages.Add(OutgoingMessage.CreateFromContent(content)); + var message = OutgoingMessage.CreateFromContent(content); + _pendingMessages.Add(message); } } } } - public override Task OnRemoveFromRelationshipAsync(DomainGroup group, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + public override async Task OnRemoveFromRelationshipAsync(DomainGroup group, HasManyAttribute hasManyRelationship, ISet rightResourceIds, CancellationToken cancellationToken) { - _hitCounter.TrackInvocation(ResourceDefinitionHitCounter.ExtensibilityPoint.OnRemoveFromRelationshipAsync); + await base.OnRemoveFromRelationshipAsync(group, hasManyRelationship, rightResourceIds, cancellationToken); if (hasManyRelationship.Property.Name == nameof(DomainGroup.Users)) { @@ -155,68 +130,42 @@ public override Task OnRemoveFromRelationshipAsync(DomainGroup group, HasManyAtt foreach (DomainUser userToRemoveFromGroup in group.Users.Where(user => rightUserIds.Contains(user.Id))) { - var message = OutgoingMessage.CreateFromContent(new UserRemovedFromGroupContent - { - UserId = userToRemoveFromGroup.Id, - GroupId = group.Id - }); - + var content = new UserRemovedFromGroupContent(userToRemoveFromGroup.Id, group.Id); + var message = OutgoingMessage.CreateFromContent(content); _pendingMessages.Add(message); } } - - return Task.CompletedTask; } protected async Task FinishWriteAsync(DomainGroup group, WriteOperationKind writeOperation, CancellationToken cancellationToken) { if (writeOperation == WriteOperationKind.CreateResource) { - var message = OutgoingMessage.CreateFromContent(new GroupCreatedContent - { - GroupId = group.Id, - GroupName = group.Name - }); - + var message = OutgoingMessage.CreateFromContent(new GroupCreatedContent(group.Id, group.Name)); await FlushMessageAsync(message, cancellationToken); } else if (writeOperation == WriteOperationKind.UpdateResource) { if (_beforeGroupName != group.Name) { - var message = OutgoingMessage.CreateFromContent(new GroupRenamedContent - { - GroupId = group.Id, - BeforeGroupName = _beforeGroupName, - AfterGroupName = group.Name - }); - + var message = OutgoingMessage.CreateFromContent(new GroupRenamedContent(group.Id, _beforeGroupName!, group.Name)); await FlushMessageAsync(message, cancellationToken); } } else if (writeOperation == WriteOperationKind.DeleteResource) { - DomainGroup groupToDelete = await GetGroupToDeleteAsync(group.Id, cancellationToken); + DomainGroup? groupToDelete = await GetGroupToDeleteAsync(group.Id, cancellationToken); if (groupToDelete != null) { foreach (DomainUser user in groupToDelete.Users) { - var removeMessage = OutgoingMessage.CreateFromContent(new UserRemovedFromGroupContent - { - UserId = user.Id, - GroupId = group.Id - }); - + var removeMessage = OutgoingMessage.CreateFromContent(new UserRemovedFromGroupContent(user.Id, group.Id)); await FlushMessageAsync(removeMessage, cancellationToken); } } - var deleteMessage = OutgoingMessage.CreateFromContent(new GroupDeletedContent - { - GroupId = group.Id - }); - + var deleteMessage = OutgoingMessage.CreateFromContent(new GroupDeletedContent(group.Id)); await FlushMessageAsync(deleteMessage, cancellationToken); } @@ -228,7 +177,7 @@ protected async Task FinishWriteAsync(DomainGroup group, WriteOperationKind writ protected abstract Task FlushMessageAsync(OutgoingMessage message, CancellationToken cancellationToken); - protected virtual async Task GetGroupToDeleteAsync(Guid groupId, CancellationToken cancellationToken) + protected virtual async Task GetGroupToDeleteAsync(Guid groupId, CancellationToken cancellationToken) { return await _groupSet.Include(group => group.Users).FirstOrDefaultAsync(group => group.Id == groupId, cancellationToken); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/MessagingUserDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/MessagingUserDefinition.cs index bea8286624..4af5076cca 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/MessagingUserDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/MessagingUserDefinition.cs @@ -11,25 +11,25 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices { - public abstract class MessagingUserDefinition : JsonApiResourceDefinition + public abstract class MessagingUserDefinition : HitCountingResourceDefinition { private readonly DbSet _userSet; - private readonly ResourceDefinitionHitCounter _hitCounter; private readonly List _pendingMessages = new(); - private string _beforeLoginName; - private string _beforeDisplayName; + private string? _beforeLoginName; + private string? _beforeDisplayName; + + protected override ResourceDefinitionExtensibilityPoints ExtensibilityPointsToTrack => ResourceDefinitionExtensibilityPoints.Writing; protected MessagingUserDefinition(IResourceGraph resourceGraph, DbSet userSet, ResourceDefinitionHitCounter hitCounter) - : base(resourceGraph) + : base(resourceGraph, hitCounter) { _userSet = userSet; - _hitCounter = hitCounter; } - public override Task OnPrepareWriteAsync(DomainUser user, WriteOperationKind writeOperation, CancellationToken cancellationToken) + public override async Task OnPrepareWriteAsync(DomainUser user, WriteOperationKind writeOperation, CancellationToken cancellationToken) { - _hitCounter.TrackInvocation(ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync); + await base.OnPrepareWriteAsync(user, writeOperation, cancellationToken); if (writeOperation == WriteOperationKind.CreateResource) { @@ -40,44 +40,29 @@ public override Task OnPrepareWriteAsync(DomainUser user, WriteOperationKind wri _beforeLoginName = user.LoginName; _beforeDisplayName = user.DisplayName; } - - return Task.CompletedTask; } - public override Task OnSetToOneRelationshipAsync(DomainUser user, HasOneAttribute hasOneRelationship, IIdentifiable rightResourceId, - WriteOperationKind writeOperation, CancellationToken cancellationToken) + public override async Task OnSetToOneRelationshipAsync(DomainUser user, HasOneAttribute hasOneRelationship, + IIdentifiable? rightResourceId, WriteOperationKind writeOperation, CancellationToken cancellationToken) { - _hitCounter.TrackInvocation(ResourceDefinitionHitCounter.ExtensibilityPoint.OnSetToOneRelationshipAsync); + await base.OnSetToOneRelationshipAsync(user, hasOneRelationship, rightResourceId, writeOperation, cancellationToken); if (hasOneRelationship.Property.Name == nameof(DomainUser.Group)) { var afterGroupId = (Guid?)rightResourceId?.GetTypedId(); - IMessageContent content = null; + IMessageContent? content = null; if (user.Group != null && afterGroupId == null) { - content = new UserRemovedFromGroupContent - { - UserId = user.Id, - GroupId = user.Group.Id - }; + content = new UserRemovedFromGroupContent(user.Id, user.Group.Id); } else if (user.Group == null && afterGroupId != null) { - content = new UserAddedToGroupContent - { - UserId = user.Id, - GroupId = afterGroupId.Value - }; + content = new UserAddedToGroupContent(user.Id, afterGroupId.Value); } else if (user.Group != null && afterGroupId != null && user.Group.Id != afterGroupId) { - content = new UserMovedToGroupContent - { - UserId = user.Id, - BeforeGroupId = user.Group.Id, - AfterGroupId = afterGroupId.Value - }; + content = new UserMovedToGroupContent(user.Id, user.Group.Id, afterGroupId.Value); } if (content != null) @@ -87,68 +72,45 @@ public override Task OnSetToOneRelationshipAsync(DomainUser user, } } - return Task.FromResult(rightResourceId); + return rightResourceId; } protected async Task FinishWriteAsync(DomainUser user, WriteOperationKind writeOperation, CancellationToken cancellationToken) { if (writeOperation == WriteOperationKind.CreateResource) { - var message = OutgoingMessage.CreateFromContent(new UserCreatedContent - { - UserId = user.Id, - UserLoginName = user.LoginName, - UserDisplayName = user.DisplayName - }); - + var content = new UserCreatedContent(user.Id, user.LoginName, user.DisplayName); + var message = OutgoingMessage.CreateFromContent(content); await FlushMessageAsync(message, cancellationToken); } else if (writeOperation == WriteOperationKind.UpdateResource) { if (_beforeLoginName != user.LoginName) { - var message = OutgoingMessage.CreateFromContent(new UserLoginNameChangedContent - { - UserId = user.Id, - BeforeUserLoginName = _beforeLoginName, - AfterUserLoginName = user.LoginName - }); - + var content = new UserLoginNameChangedContent(user.Id, _beforeLoginName!, user.LoginName); + var message = OutgoingMessage.CreateFromContent(content); await FlushMessageAsync(message, cancellationToken); } if (_beforeDisplayName != user.DisplayName) { - var message = OutgoingMessage.CreateFromContent(new UserDisplayNameChangedContent - { - UserId = user.Id, - BeforeUserDisplayName = _beforeDisplayName, - AfterUserDisplayName = user.DisplayName - }); - + var content = new UserDisplayNameChangedContent(user.Id, _beforeDisplayName!, user.DisplayName); + var message = OutgoingMessage.CreateFromContent(content); await FlushMessageAsync(message, cancellationToken); } } else if (writeOperation == WriteOperationKind.DeleteResource) { - DomainUser userToDelete = await GetUserToDeleteAsync(user.Id, cancellationToken); + DomainUser? userToDelete = await GetUserToDeleteAsync(user.Id, cancellationToken); if (userToDelete?.Group != null) { - var removeMessage = OutgoingMessage.CreateFromContent(new UserRemovedFromGroupContent - { - UserId = user.Id, - GroupId = userToDelete.Group.Id - }); - - await FlushMessageAsync(removeMessage, cancellationToken); + var content = new UserRemovedFromGroupContent(user.Id, userToDelete.Group.Id); + var message = OutgoingMessage.CreateFromContent(content); + await FlushMessageAsync(message, cancellationToken); } - var deleteMessage = OutgoingMessage.CreateFromContent(new UserDeletedContent - { - UserId = user.Id - }); - + var deleteMessage = OutgoingMessage.CreateFromContent(new UserDeletedContent(user.Id)); await FlushMessageAsync(deleteMessage, cancellationToken); } @@ -160,7 +122,7 @@ protected async Task FinishWriteAsync(DomainUser user, WriteOperationKind writeO protected abstract Task FlushMessageAsync(OutgoingMessage message, CancellationToken cancellationToken); - protected virtual async Task GetUserToDeleteAsync(Guid userId, CancellationToken cancellationToken) + protected virtual async Task GetUserToDeleteAsync(Guid userId, CancellationToken cancellationToken) { return await _userSet.Include(domainUser => domainUser.Group).FirstOrDefaultAsync(domainUser => domainUser.Id == userId, cancellationToken); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxDbContext.cs index 42e96e1d00..87e8d63707 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxDbContext.cs @@ -7,9 +7,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.TransactionalOut [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class OutboxDbContext : DbContext { - public DbSet Users { get; set; } - public DbSet Groups { get; set; } - public DbSet OutboxMessages { get; set; } + public DbSet Users => Set(); + public DbSet Groups => Set(); + public DbSet OutboxMessages => Set(); public OutboxDbContext(DbContextOptions options) : base(options) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxGroupDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxGroupDefinition.cs index 19fa2e72f6..f79bce5e00 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxGroupDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxGroupDefinition.cs @@ -11,21 +11,19 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.TransactionalOut [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] public sealed class OutboxGroupDefinition : MessagingGroupDefinition { - private readonly ResourceDefinitionHitCounter _hitCounter; private readonly DbSet _outboxMessageSet; public OutboxGroupDefinition(IResourceGraph resourceGraph, OutboxDbContext dbContext, ResourceDefinitionHitCounter hitCounter) : base(resourceGraph, dbContext.Users, dbContext.Groups, hitCounter) { - _hitCounter = hitCounter; _outboxMessageSet = dbContext.OutboxMessages; } - public override Task OnWritingAsync(DomainGroup group, WriteOperationKind writeOperation, CancellationToken cancellationToken) + public override async Task OnWritingAsync(DomainGroup group, WriteOperationKind writeOperation, CancellationToken cancellationToken) { - _hitCounter.TrackInvocation(ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync); + await base.OnWritingAsync(group, writeOperation, cancellationToken); - return FinishWriteAsync(group, writeOperation, cancellationToken); + await FinishWriteAsync(group, writeOperation, cancellationToken); } protected override async Task FlushMessageAsync(OutgoingMessage message, CancellationToken cancellationToken) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.Group.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.Group.cs index eda25094b2..4bbe4e4aa7 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.Group.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.Group.cs @@ -49,21 +49,22 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.Should().NotBeNull(); - responseDocument.Data.SingleValue.Attributes["name"].Should().Be(newGroupName); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newGroupName)); hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync) + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - Guid newGroupId = Guid.Parse(responseDocument.Data.SingleValue.Id); + Guid newGroupId = Guid.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(1); + messages.ShouldHaveCount(1); var content = messages[0].GetContentAs(); content.GroupId.Should().Be(newGroupId); @@ -130,22 +131,23 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.Should().NotBeNull(); - responseDocument.Data.SingleValue.Attributes["name"].Should().Be(newGroupName); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newGroupName)); hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSetToManyRelationshipAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync) + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnSetToManyRelationshipAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - Guid newGroupId = Guid.Parse(responseDocument.Data.SingleValue.Id); + Guid newGroupId = Guid.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(3); + messages.ShouldHaveCount(3); var content1 = messages[0].GetContentAs(); content1.GroupId.Should().Be(newGroupId); @@ -204,14 +206,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync) + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); await _testContext.RunOnDatabaseAsync(async dbContext => { List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(1); + messages.ShouldHaveCount(1); var content = messages[0].GetContentAs(); content.GroupId.Should().Be(existingGroup.StringId); @@ -291,15 +294,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSetToManyRelationshipAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync) + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnSetToManyRelationshipAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); await _testContext.RunOnDatabaseAsync(async dbContext => { List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(3); + messages.ShouldHaveCount(3); var content1 = messages[0].GetContentAs(); content1.UserId.Should().Be(existingUserWithoutGroup.Id); @@ -343,13 +347,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync) + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); await _testContext.RunOnDatabaseAsync(async dbContext => { List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(1); + messages.ShouldHaveCount(1); var content = messages[0].GetContentAs(); content.GroupId.Should().Be(existingGroup.StringId); @@ -384,13 +389,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync) + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); await _testContext.RunOnDatabaseAsync(async dbContext => { List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(2); + messages.ShouldHaveCount(2); var content1 = messages[0].GetContentAs(); content1.UserId.Should().Be(existingGroup.Users.ElementAt(0).Id); @@ -461,15 +467,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSetToManyRelationshipAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync) + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnSetToManyRelationshipAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); await _testContext.RunOnDatabaseAsync(async dbContext => { List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(3); + messages.ShouldHaveCount(3); var content1 = messages[0].GetContentAs(); content1.UserId.Should().Be(existingUserWithoutGroup.Id); @@ -538,14 +545,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnAddToRelationshipAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync) + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnAddToRelationshipAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); await _testContext.RunOnDatabaseAsync(async dbContext => { List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(2); + messages.ShouldHaveCount(2); var content1 = messages[0].GetContentAs(); content1.UserId.Should().Be(existingUserWithoutGroup.Id); @@ -603,14 +611,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnRemoveFromRelationshipAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync) + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnRemoveFromRelationshipAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); await _testContext.RunOnDatabaseAsync(async dbContext => { List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(1); + messages.ShouldHaveCount(1); var content = messages[0].GetContentAs(); content.UserId.Should().Be(existingUserWithSameGroup2.Id); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.User.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.User.cs index 219da42053..8b233b272c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.User.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.User.cs @@ -23,7 +23,7 @@ public async Task Create_user_writes_to_outbox() var hitCounter = _testContext.Factory.Services.GetRequiredService(); string newLoginName = _fakers.DomainUser.Generate().LoginName; - string newDisplayName = _fakers.DomainUser.Generate().DisplayName; + string newDisplayName = _fakers.DomainUser.Generate().DisplayName!; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -51,22 +51,23 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.Should().NotBeNull(); - responseDocument.Data.SingleValue.Attributes["loginName"].Should().Be(newLoginName); - responseDocument.Data.SingleValue.Attributes["displayName"].Should().Be(newDisplayName); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("loginName").With(value => value.Should().Be(newLoginName)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("displayName").With(value => value.Should().Be(newDisplayName)); hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync) + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - Guid newUserId = Guid.Parse(responseDocument.Data.SingleValue.Id); + Guid newUserId = Guid.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(1); + messages.ShouldHaveCount(1); var content = messages[0].GetContentAs(); content.UserId.Should().Be(newUserId); @@ -123,23 +124,24 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.Should().NotBeNull(); - responseDocument.Data.SingleValue.Attributes["loginName"].Should().Be(newLoginName); - responseDocument.Data.SingleValue.Attributes["displayName"].Should().BeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("loginName").With(value => value.Should().Be(newLoginName)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("displayName").With(value => value.Should().BeNull()); hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSetToOneRelationshipAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync) + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnSetToOneRelationshipAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - Guid newUserId = Guid.Parse(responseDocument.Data.SingleValue.Id); + Guid newUserId = Guid.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(2); + messages.ShouldHaveCount(2); var content1 = messages[0].GetContentAs(); content1.UserId.Should().Be(newUserId); @@ -161,7 +163,7 @@ public async Task Update_user_writes_to_outbox() DomainUser existingUser = _fakers.DomainUser.Generate(); string newLoginName = _fakers.DomainUser.Generate().LoginName; - string newDisplayName = _fakers.DomainUser.Generate().DisplayName; + string newDisplayName = _fakers.DomainUser.Generate().DisplayName!; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -196,14 +198,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync) + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); await _testContext.RunOnDatabaseAsync(async dbContext => { List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(2); + messages.ShouldHaveCount(2); var content1 = messages[0].GetContentAs(); content1.UserId.Should().Be(existingUser.Id); @@ -226,7 +229,7 @@ public async Task Update_user_clear_group_writes_to_outbox() DomainUser existingUser = _fakers.DomainUser.Generate(); existingUser.Group = _fakers.DomainGroup.Generate(); - string newDisplayName = _fakers.DomainUser.Generate().DisplayName; + string newDisplayName = _fakers.DomainUser.Generate().DisplayName!; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -249,7 +252,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { group = new { - data = (object)null + data = (object?)null } } } @@ -267,15 +270,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSetToOneRelationshipAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync) + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnSetToOneRelationshipAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); await _testContext.RunOnDatabaseAsync(async dbContext => { List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(2); + messages.ShouldHaveCount(2); var content1 = messages[0].GetContentAs(); content1.UserId.Should().Be(existingUser.Id); @@ -297,7 +301,7 @@ public async Task Update_user_add_to_group_writes_to_outbox() DomainUser existingUser = _fakers.DomainUser.Generate(); DomainGroup existingGroup = _fakers.DomainGroup.Generate(); - string newDisplayName = _fakers.DomainUser.Generate().DisplayName; + string newDisplayName = _fakers.DomainUser.Generate().DisplayName!; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -342,15 +346,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSetToOneRelationshipAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync) + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnSetToOneRelationshipAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); await _testContext.RunOnDatabaseAsync(async dbContext => { List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(2); + messages.ShouldHaveCount(2); var content1 = messages[0].GetContentAs(); content1.UserId.Should().Be(existingUser.Id); @@ -374,7 +379,7 @@ public async Task Update_user_move_to_group_writes_to_outbox() DomainGroup existingGroup = _fakers.DomainGroup.Generate(); - string newDisplayName = _fakers.DomainUser.Generate().DisplayName; + string newDisplayName = _fakers.DomainUser.Generate().DisplayName!; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -419,15 +424,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSetToOneRelationshipAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync) + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnSetToOneRelationshipAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); await _testContext.RunOnDatabaseAsync(async dbContext => { List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(2); + messages.ShouldHaveCount(2); var content1 = messages[0].GetContentAs(); content1.UserId.Should().Be(existingUser.Id); @@ -468,13 +474,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync) + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); await _testContext.RunOnDatabaseAsync(async dbContext => { List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(1); + messages.ShouldHaveCount(1); var content = messages[0].GetContentAs(); content.UserId.Should().Be(existingUser.Id); @@ -509,13 +516,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync) + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); await _testContext.RunOnDatabaseAsync(async dbContext => { List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(2); + messages.ShouldHaveCount(2); var content1 = messages[0].GetContentAs(); content1.UserId.Should().Be(existingUser.Id); @@ -544,7 +552,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var requestBody = new { - data = (object)null + data = (object?)null }; string route = $"/domainUsers/{existingUser.StringId}/relationships/group"; @@ -559,15 +567,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSetToOneRelationshipAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync) + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnSetToOneRelationshipAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); await _testContext.RunOnDatabaseAsync(async dbContext => { List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(1); + messages.ShouldHaveCount(1); var content = messages[0].GetContentAs(); content.UserId.Should().Be(existingUser.Id); @@ -612,15 +621,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSetToOneRelationshipAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync) + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnSetToOneRelationshipAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); await _testContext.RunOnDatabaseAsync(async dbContext => { List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(1); + messages.ShouldHaveCount(1); var content = messages[0].GetContentAs(); content.UserId.Should().Be(existingUser.Id); @@ -667,15 +677,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSetToOneRelationshipAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync) + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnSetToOneRelationshipAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWritingAsync), + (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); await _testContext.RunOnDatabaseAsync(async dbContext => { List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(1); + messages.ShouldHaveCount(1); var content = messages[0].GetContentAs(); content.UserId.Should().Be(existingUser.Id); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.cs index 68af373b9d..5c4a353a1b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.cs @@ -67,7 +67,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => new { type = "domainUsers", - id = existingUser.StringId + id = existingUser.StringId! }, new { @@ -85,7 +85,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); @@ -94,8 +94,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnAddToRelationshipAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync) + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnAddToRelationshipAsync), + (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync) }, options => options.WithStrictOrdering()); await _testContext.RunOnDatabaseAsync(async dbContext => diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxUserDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxUserDefinition.cs index 82d02736b2..c071842d09 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxUserDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxUserDefinition.cs @@ -11,21 +11,19 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.TransactionalOut [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] public sealed class OutboxUserDefinition : MessagingUserDefinition { - private readonly ResourceDefinitionHitCounter _hitCounter; private readonly DbSet _outboxMessageSet; public OutboxUserDefinition(IResourceGraph resourceGraph, OutboxDbContext dbContext, ResourceDefinitionHitCounter hitCounter) : base(resourceGraph, dbContext.Users, hitCounter) { - _hitCounter = hitCounter; _outboxMessageSet = dbContext.OutboxMessages; } - public override Task OnWritingAsync(DomainUser user, WriteOperationKind writeOperation, CancellationToken cancellationToken) + public override async Task OnWritingAsync(DomainUser user, WriteOperationKind writeOperation, CancellationToken cancellationToken) { - _hitCounter.TrackInvocation(ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync); + await base.OnWritingAsync(user, writeOperation, cancellationToken); - return FinishWriteAsync(user, writeOperation, cancellationToken); + await FinishWriteAsync(user, writeOperation, cancellationToken); } protected override async Task FlushMessageAsync(OutgoingMessage message, CancellationToken cancellationToken) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenancyDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenancyDbContext.cs index fed46351e1..59bc69faff 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenancyDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenancyDbContext.cs @@ -10,8 +10,8 @@ public sealed class MultiTenancyDbContext : DbContext { private readonly ITenantProvider _tenantProvider; - public DbSet WebShops { get; set; } - public DbSet WebProducts { get; set; } + public DbSet WebShops => Set(); + public DbSet WebProducts => Set(); public MultiTenancyDbContext(DbContextOptions options, ITenantProvider tenantProvider) : base(options) @@ -23,8 +23,7 @@ protected override void OnModelCreating(ModelBuilder builder) { builder.Entity() .HasMany(webShop => webShop.Products) - .WithOne(webProduct => webProduct.Shop) - .IsRequired(); + .WithOne(webProduct => webProduct.Shop); builder.Entity() .HasQueryFilter(webShop => webShop.TenantId == _tenantProvider.TenantId); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenancyTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenancyTests.cs index 11c4af8983..992b19c40e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenancyTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenancyTests.cs @@ -37,12 +37,13 @@ public MultiTenancyTests(IntegrationTestContext { - services.AddResourceService>(); - services.AddResourceService>(); + services.AddResourceService>(); + services.AddResourceService>(); }); var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); options.UseRelativeLinks = true; + options.IncludeTotalResourceCount = true; } [Fact] @@ -68,7 +69,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue.ShouldHaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(shops[1].StringId); } @@ -98,7 +99,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue.ShouldHaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(shops[1].StringId); } @@ -128,11 +129,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue.ShouldHaveCount(1); responseDocument.Data.ManyValue[0].Type.Should().Be("webShops"); responseDocument.Data.ManyValue[0].Id.Should().Be(shops[1].StringId); - responseDocument.Included.Should().HaveCount(1); + responseDocument.Included.ShouldHaveCount(1); responseDocument.Included[0].Type.Should().Be("webProducts"); responseDocument.Included[0].Id.Should().Be(shops[1].Products[0].StringId); } @@ -158,7 +159,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); @@ -188,7 +189,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); @@ -218,7 +219,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); @@ -248,7 +249,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); @@ -278,7 +279,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); @@ -312,11 +313,11 @@ public async Task Can_create_resource() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.Should().NotBeNull(); - responseDocument.Data.SingleValue.Attributes["url"].Should().Be(newShopUrl); - responseDocument.Data.SingleValue.Relationships.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("url").With(value => value.Should().Be(newShopUrl)); + responseDocument.Data.SingleValue.Relationships.ShouldNotBeNull(); - int newShopId = int.Parse(responseDocument.Data.SingleValue.Id); + int newShopId = int.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -377,7 +378,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); @@ -431,7 +432,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); @@ -524,7 +525,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); @@ -580,7 +581,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); @@ -633,7 +634,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); @@ -668,7 +669,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); @@ -713,7 +714,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); @@ -737,7 +738,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var requestBody = new { - data = (object)null + data = (object?)null }; string route = $"/nld/products/{existingProduct.StringId}/relationships/shop"; @@ -748,7 +749,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); @@ -790,7 +791,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); @@ -835,7 +836,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); @@ -879,7 +880,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); @@ -921,7 +922,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); @@ -955,7 +956,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - WebProduct productInDatabase = await dbContext.WebProducts.IgnoreQueryFilters().FirstWithIdOrDefaultAsync(existingProduct.Id); + WebProduct? productInDatabase = await dbContext.WebProducts.IgnoreQueryFilters().FirstWithIdOrDefaultAsync(existingProduct.Id); productInDatabase.Should().BeNull(); }); @@ -983,7 +984,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); @@ -1014,26 +1015,49 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.Links.ShouldNotBeNull(); responseDocument.Links.Self.Should().Be(route); responseDocument.Links.Related.Should().BeNull(); - responseDocument.Links.First.Should().Be(route); - responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.First.Should().Be(responseDocument.Links.Self); + responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - string shopLink = $"/nld/shops/{shop.StringId}"; + responseDocument.Data.ManyValue.ShouldHaveCount(1); - responseDocument.Data.ManyValue.Should().HaveCount(1); - responseDocument.Data.ManyValue[0].Links.Self.Should().Be(shopLink); - responseDocument.Data.ManyValue[0].Relationships["products"].Links.Self.Should().Be($"{shopLink}/relationships/products"); - responseDocument.Data.ManyValue[0].Relationships["products"].Links.Related.Should().Be($"{shopLink}/products"); + responseDocument.Data.ManyValue[0].With(resource => + { + string shopLink = $"/nld/shops/{shop.StringId}"; + + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(shopLink); + + resource.Relationships.ShouldContainKey("products").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{shopLink}/relationships/products"); + value.Links.Related.Should().Be($"{shopLink}/products"); + }); + }); - string productLink = $"/nld/products/{shop.Products[0].StringId}"; + responseDocument.Included.ShouldHaveCount(1); - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Links.Self.Should().Be(productLink); - responseDocument.Included[0].Relationships["shop"].Links.Self.Should().Be($"{productLink}/relationships/shop"); - responseDocument.Included[0].Relationships["shop"].Links.Related.Should().Be($"{productLink}/shop"); + responseDocument.Included[0].With(resource => + { + string productLink = $"/nld/products/{shop.Products[0].StringId}"; + + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be(productLink); + + resource.Relationships.ShouldContainKey("shop").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{productLink}/relationships/shop"); + value.Links.Related.Should().Be($"{productLink}/shop"); + }); + }); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenantResourceService.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenantResourceService.cs index 01dbe88bcf..2d3086a236 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenantResourceService.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenantResourceService.cs @@ -46,21 +46,21 @@ protected override async Task InitializeResourceAsync(TResource resourceForDatab // To optimize performance, the default resource service does not always fetch all resources on write operations. // We do that here, to assure everything belongs to the active tenant. On mismatch, a 404 error is thrown. - public override async Task CreateAsync(TResource resource, CancellationToken cancellationToken) + public override async Task CreateAsync(TResource resource, CancellationToken cancellationToken) { await AssertResourcesToAssignInRelationshipsExistAsync(resource, cancellationToken); return await base.CreateAsync(resource, cancellationToken); } - public override async Task UpdateAsync(TId id, TResource resource, CancellationToken cancellationToken) + public override async Task UpdateAsync(TId id, TResource resource, CancellationToken cancellationToken) { await AssertResourcesToAssignInRelationshipsExistAsync(resource, cancellationToken); return await base.UpdateAsync(id, resource, cancellationToken); } - public override async Task SetRelationshipAsync(TId leftId, string relationshipName, object rightValue, CancellationToken cancellationToken) + public override async Task SetRelationshipAsync(TId leftId, string relationshipName, object? rightValue, CancellationToken cancellationToken) { await AssertRightResourcesExistAsync(rightValue, cancellationToken); @@ -83,17 +83,4 @@ public override async Task DeleteAsync(TId id, CancellationToken cancellationTok await base.DeleteAsync(id, cancellationToken); } } - - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public class MultiTenantResourceService : MultiTenantResourceService, IResourceService - where TResource : class, IIdentifiable - { - public MultiTenantResourceService(ITenantProvider tenantProvider, IResourceRepositoryAccessor repositoryAccessor, - IQueryLayerComposer queryLayerComposer, IPaginationContext paginationContext, IJsonApiOptions options, ILoggerFactory loggerFactory, - IJsonApiRequest request, IResourceChangeTracker resourceChangeTracker, IResourceDefinitionAccessor resourceDefinitionAccessor) - : base(tenantProvider, repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, resourceChangeTracker, - resourceDefinitionAccessor) - { - } - } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/RouteTenantProvider.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/RouteTenantProvider.cs index e373e1bcb6..560944e8cb 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/RouteTenantProvider.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/RouteTenantProvider.cs @@ -24,8 +24,8 @@ public Guid TenantId throw new InvalidOperationException(); } - string countryCode = (string)_httpContextAccessor.HttpContext.Request.RouteValues["countryCode"]; - return countryCode != null && TenantRegistry.ContainsKey(countryCode) ? TenantRegistry[countryCode] : Guid.Empty; + string? countryCode = (string?)_httpContextAccessor.HttpContext.Request.RouteValues["countryCode"]; + return countryCode != null && TenantRegistry.TryGetValue(countryCode, out Guid tenantId) ? tenantId : Guid.Empty; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebProduct.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebProduct.cs index f7e65c2315..310aad6a8b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebProduct.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebProduct.cs @@ -5,15 +5,15 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.MultiTenancy { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class WebProduct : Identifiable + public sealed class WebProduct : Identifiable { [Attr] - public string Name { get; set; } + public string Name { get; set; } = null!; [Attr] public decimal Price { get; set; } [HasOne] - public WebShop Shop { get; set; } + public WebShop Shop { get; set; } = null!; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebProductsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebProductsController.cs index 3985fc3805..53a460376b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebProductsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebProductsController.cs @@ -9,10 +9,11 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.MultiTenancy { [DisableRoutingConvention] [Route("{countryCode}/products")] - public sealed class WebProductsController : JsonApiController + public sealed class WebProductsController : JsonApiController { - public WebProductsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public WebProductsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebShop.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebShop.cs index ddddace8fa..c5830276d8 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebShop.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebShop.cs @@ -7,14 +7,14 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.MultiTenancy { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class WebShop : Identifiable, IHasTenant + public sealed class WebShop : Identifiable, IHasTenant { [Attr] - public string Url { get; set; } + public string Url { get; set; } = null!; public Guid TenantId { get; set; } [HasMany] - public IList Products { get; set; } + public IList Products { get; set; } = new List(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebShopsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebShopsController.cs index 1c7c65ae4d..0907c67d25 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebShopsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebShopsController.cs @@ -9,10 +9,11 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.MultiTenancy { [DisableRoutingConvention] [Route("{countryCode}/shops")] - public sealed class WebShopsController : JsonApiController + public sealed class WebShopsController : JsonApiController { - public WebShopsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public WebShopsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/DivingBoard.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/DivingBoard.cs index 30dd4d2cd9..fd84f2231b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/DivingBoard.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/DivingBoard.cs @@ -6,10 +6,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.NamingConventions { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class DivingBoard : Identifiable + public sealed class DivingBoard : Identifiable { [Attr] - [Required] [Range(1, 20)] public decimal HeightInMeters { get; set; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/DivingBoardsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/DivingBoardsController.cs index 65e5f0b3e3..673ddca0c8 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/DivingBoardsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/DivingBoardsController.cs @@ -5,10 +5,11 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.NamingConventions { - public sealed class DivingBoardsController : JsonApiController + public sealed class DivingBoardsController : JsonApiController { - public DivingBoardsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public DivingBoardsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/KebabCasingConventionStartup.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/KebabCasingConventionStartup.cs index 952ffd7a14..6114b514cb 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/KebabCasingConventionStartup.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/KebabCasingConventionStartup.cs @@ -16,7 +16,6 @@ protected override void SetJsonApiOptions(JsonApiOptions options) options.Namespace = "public-api"; options.UseRelativeLinks = true; options.IncludeTotalResourceCount = true; - options.ValidateModelState = true; options.SerializerOptions.PropertyNamingPolicy = JsonKebabCaseNamingPolicy.Instance; options.SerializerOptions.DictionaryKeyPolicy = JsonKebabCaseNamingPolicy.Instance; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/KebabCasingTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/KebabCasingTests.cs index de91ad49fb..d2be3f1c74 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/KebabCasingTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/KebabCasingTests.cs @@ -10,12 +10,12 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.NamingConventions { - public sealed class KebabCasingTests : IClassFixture, SwimmingDbContext>> + public sealed class KebabCasingTests : IClassFixture, NamingDbContext>> { - private readonly IntegrationTestContext, SwimmingDbContext> _testContext; - private readonly SwimmingFakers _fakers = new(); + private readonly IntegrationTestContext, NamingDbContext> _testContext; + private readonly NamingFakers _fakers = new(); - public KebabCasingTests(IntegrationTestContext, SwimmingDbContext> testContext) + public KebabCasingTests(IntegrationTestContext, NamingDbContext> testContext) { _testContext = testContext; @@ -45,20 +45,26 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(2); + responseDocument.Data.ManyValue.ShouldHaveCount(2); responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Type == "swimming-pools"); - responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Attributes.ContainsKey("is-indoor")); - responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Relationships.ContainsKey("water-slides")); - responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Relationships.ContainsKey("diving-boards")); + responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Attributes.ShouldContainKey("is-indoor") != null); + responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Relationships.ShouldContainKey("water-slides") != null); + responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Relationships.ShouldContainKey("diving-boards") != null); - responseDocument.Included.Should().HaveCount(1); + decimal height = pools[1].DivingBoards[0].HeightInMeters; + + responseDocument.Included.ShouldHaveCount(1); responseDocument.Included[0].Type.Should().Be("diving-boards"); responseDocument.Included[0].Id.Should().Be(pools[1].DivingBoards[0].StringId); - responseDocument.Included[0].Attributes["height-in-meters"].As().Should().BeApproximately(pools[1].DivingBoards[0].HeightInMeters); + responseDocument.Included[0].Attributes.ShouldContainKey("height-in-meters").With(value => value.As().Should().BeApproximately(height)); responseDocument.Included[0].Relationships.Should().BeNull(); - responseDocument.Included[0].Links.Self.Should().Be($"/public-api/diving-boards/{pools[1].DivingBoards[0].StringId}"); + responseDocument.Included[0].Links.ShouldNotBeNull().Self.Should().Be($"/public-api/diving-boards/{pools[1].DivingBoards[0].StringId}"); - ((JsonElement)responseDocument.Meta["total"]).GetInt32().Should().Be(2); + responseDocument.Meta.ShouldContainKey("total").With(value => + { + JsonElement element = value.Should().BeOfType().Subject; + element.GetInt32().Should().Be(2); + }); } [Fact] @@ -85,10 +91,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue.ShouldHaveCount(1); responseDocument.Data.ManyValue[0].Type.Should().Be("water-slides"); responseDocument.Data.ManyValue[0].Id.Should().Be(pool.WaterSlides[1].StringId); - responseDocument.Data.ManyValue[0].Attributes.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.ShouldHaveCount(1); } [Fact] @@ -117,18 +123,28 @@ public async Task Can_create_resource() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Type.Should().Be("swimming-pools"); - responseDocument.Data.SingleValue.Attributes["is-indoor"].Should().Be(newPool.IsIndoor); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("is-indoor").With(value => value.Should().Be(newPool.IsIndoor)); - int newPoolId = int.Parse(responseDocument.Data.SingleValue.Id); + int newPoolId = int.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); string poolLink = $"{route}/{newPoolId}"; - responseDocument.Data.SingleValue.Relationships.Should().NotBeEmpty(); - responseDocument.Data.SingleValue.Relationships["water-slides"].Links.Self.Should().Be($"{poolLink}/relationships/water-slides"); - responseDocument.Data.SingleValue.Relationships["water-slides"].Links.Related.Should().Be($"{poolLink}/water-slides"); - responseDocument.Data.SingleValue.Relationships["diving-boards"].Links.Self.Should().Be($"{poolLink}/relationships/diving-boards"); - responseDocument.Data.SingleValue.Relationships["diving-boards"].Links.Related.Should().Be($"{poolLink}/diving-boards"); + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("water-slides").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{poolLink}/relationships/water-slides"); + value.Links.Related.Should().Be($"{poolLink}/water-slides"); + }); + + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("diving-boards").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{poolLink}/relationships/diving-boards"); + value.Links.Related.Should().Be($"{poolLink}/diving-boards"); + }); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -152,12 +168,12 @@ public async Task Applies_casing_convention_on_error_stack_trace() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body."); - error.Meta.Should().ContainKey("stack-trace"); + error.Meta.ShouldContainKey("stack-trace"); } [Fact] @@ -193,12 +209,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Input validation failed."); error.Detail.Should().Be("The field HeightInMeters must be between 1 and 20."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/data/attributes/height-in-meters"); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/NamingDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/NamingDbContext.cs new file mode 100644 index 0000000000..120c28ff72 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/NamingDbContext.cs @@ -0,0 +1,18 @@ +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreTests.IntegrationTests.NamingConventions +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class NamingDbContext : DbContext + { + public DbSet SwimmingPools => Set(); + public DbSet WaterSlides => Set(); + public DbSet DivingBoards => Set(); + + public NamingDbContext(DbContextOptions options) + : base(options) + { + } + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/SwimmingFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/NamingFakers.cs similarity index 95% rename from test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/SwimmingFakers.cs rename to test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/NamingFakers.cs index 7733a120b8..fa387cf3bf 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/SwimmingFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/NamingFakers.cs @@ -7,7 +7,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.NamingConventions { - internal sealed class SwimmingFakers : FakerContainer + internal sealed class NamingFakers : FakerContainer { private readonly Lazy> _lazySwimmingPoolFaker = new(() => new Faker() diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/PascalCasingConventionStartup.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/PascalCasingConventionStartup.cs index 65deafe3bc..0f863df251 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/PascalCasingConventionStartup.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/PascalCasingConventionStartup.cs @@ -16,7 +16,6 @@ protected override void SetJsonApiOptions(JsonApiOptions options) options.Namespace = "PublicApi"; options.UseRelativeLinks = true; options.IncludeTotalResourceCount = true; - options.ValidateModelState = true; options.SerializerOptions.PropertyNamingPolicy = null; options.SerializerOptions.DictionaryKeyPolicy = null; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/PascalCasingTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/PascalCasingTests.cs index d1c511bda9..6692bf337e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/PascalCasingTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/PascalCasingTests.cs @@ -10,12 +10,12 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.NamingConventions { - public sealed class PascalCasingTests : IClassFixture, SwimmingDbContext>> + public sealed class PascalCasingTests : IClassFixture, NamingDbContext>> { - private readonly IntegrationTestContext, SwimmingDbContext> _testContext; - private readonly SwimmingFakers _fakers = new(); + private readonly IntegrationTestContext, NamingDbContext> _testContext; + private readonly NamingFakers _fakers = new(); - public PascalCasingTests(IntegrationTestContext, SwimmingDbContext> testContext) + public PascalCasingTests(IntegrationTestContext, NamingDbContext> testContext) { _testContext = testContext; @@ -45,20 +45,26 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(2); + responseDocument.Data.ManyValue.ShouldHaveCount(2); responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Type == "SwimmingPools"); - responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Attributes.ContainsKey("IsIndoor")); - responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Relationships.ContainsKey("WaterSlides")); - responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Relationships.ContainsKey("DivingBoards")); + responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Attributes.ShouldContainKey("IsIndoor") != null); + responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Relationships.ShouldContainKey("WaterSlides") != null); + responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Relationships.ShouldContainKey("DivingBoards") != null); - responseDocument.Included.Should().HaveCount(1); + decimal height = pools[1].DivingBoards[0].HeightInMeters; + + responseDocument.Included.ShouldHaveCount(1); responseDocument.Included[0].Type.Should().Be("DivingBoards"); responseDocument.Included[0].Id.Should().Be(pools[1].DivingBoards[0].StringId); - responseDocument.Included[0].Attributes["HeightInMeters"].As().Should().BeApproximately(pools[1].DivingBoards[0].HeightInMeters); + responseDocument.Included[0].Attributes.ShouldContainKey("HeightInMeters").With(value => value.As().Should().BeApproximately(height)); responseDocument.Included[0].Relationships.Should().BeNull(); - responseDocument.Included[0].Links.Self.Should().Be($"/PublicApi/DivingBoards/{pools[1].DivingBoards[0].StringId}"); + responseDocument.Included[0].Links.ShouldNotBeNull().Self.Should().Be($"/PublicApi/DivingBoards/{pools[1].DivingBoards[0].StringId}"); - ((JsonElement)responseDocument.Meta["Total"]).GetInt32().Should().Be(2); + responseDocument.Meta.ShouldContainKey("Total").With(value => + { + JsonElement element = value.Should().BeOfType().Subject; + element.GetInt32().Should().Be(2); + }); } [Fact] @@ -85,10 +91,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue.ShouldHaveCount(1); responseDocument.Data.ManyValue[0].Type.Should().Be("WaterSlides"); responseDocument.Data.ManyValue[0].Id.Should().Be(pool.WaterSlides[1].StringId); - responseDocument.Data.ManyValue[0].Attributes.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.ShouldHaveCount(1); } [Fact] @@ -117,18 +123,28 @@ public async Task Can_create_resource() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Type.Should().Be("SwimmingPools"); - responseDocument.Data.SingleValue.Attributes["IsIndoor"].Should().Be(newPool.IsIndoor); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("IsIndoor").With(value => value.Should().Be(newPool.IsIndoor)); - int newPoolId = int.Parse(responseDocument.Data.SingleValue.Id); + int newPoolId = int.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); string poolLink = $"{route}/{newPoolId}"; - responseDocument.Data.SingleValue.Relationships.Should().NotBeEmpty(); - responseDocument.Data.SingleValue.Relationships["WaterSlides"].Links.Self.Should().Be($"{poolLink}/relationships/WaterSlides"); - responseDocument.Data.SingleValue.Relationships["WaterSlides"].Links.Related.Should().Be($"{poolLink}/WaterSlides"); - responseDocument.Data.SingleValue.Relationships["DivingBoards"].Links.Self.Should().Be($"{poolLink}/relationships/DivingBoards"); - responseDocument.Data.SingleValue.Relationships["DivingBoards"].Links.Related.Should().Be($"{poolLink}/DivingBoards"); + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("WaterSlides").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{poolLink}/relationships/WaterSlides"); + value.Links.Related.Should().Be($"{poolLink}/WaterSlides"); + }); + + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("DivingBoards").With(value => + { + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.Should().Be($"{poolLink}/relationships/DivingBoards"); + value.Links.Related.Should().Be($"{poolLink}/DivingBoards"); + }); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -152,12 +168,12 @@ public async Task Applies_casing_convention_on_error_stack_trace() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body."); - error.Meta.Should().ContainKey("StackTrace"); + error.Meta.ShouldContainKey("StackTrace"); } [Fact] @@ -193,12 +209,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Input validation failed."); error.Detail.Should().Be("The field HeightInMeters must be between 1 and 20."); + error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/data/attributes/HeightInMeters"); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/SwimmingDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/SwimmingDbContext.cs deleted file mode 100644 index c250e90f22..0000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/SwimmingDbContext.cs +++ /dev/null @@ -1,18 +0,0 @@ -using JetBrains.Annotations; -using Microsoft.EntityFrameworkCore; - -namespace JsonApiDotNetCoreTests.IntegrationTests.NamingConventions -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class SwimmingDbContext : DbContext - { - public DbSet SwimmingPools { get; set; } - public DbSet WaterSlides { get; set; } - public DbSet DivingBoards { get; set; } - - public SwimmingDbContext(DbContextOptions options) - : base(options) - { - } - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/SwimmingPool.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/SwimmingPool.cs index ae3b1ef04f..fa05fec5ed 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/SwimmingPool.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/SwimmingPool.cs @@ -6,15 +6,15 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.NamingConventions { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class SwimmingPool : Identifiable + public sealed class SwimmingPool : Identifiable { [Attr] public bool IsIndoor { get; set; } [HasMany] - public IList WaterSlides { get; set; } + public IList WaterSlides { get; set; } = new List(); [HasMany] - public IList DivingBoards { get; set; } + public IList DivingBoards { get; set; } = new List(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/SwimmingPoolsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/SwimmingPoolsController.cs index 2d0783bbb3..7147413c51 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/SwimmingPoolsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/SwimmingPoolsController.cs @@ -5,10 +5,11 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.NamingConventions { - public sealed class SwimmingPoolsController : JsonApiController + public sealed class SwimmingPoolsController : JsonApiController { - public SwimmingPoolsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public SwimmingPoolsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/WaterSlide.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/WaterSlide.cs index b74f2fcfe6..7c436bb5e4 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/WaterSlide.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/WaterSlide.cs @@ -5,7 +5,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.NamingConventions { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class WaterSlide : Identifiable + public sealed class WaterSlide : Identifiable { [Attr] public decimal LengthInMeters { get; set; } 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/NonJsonApiControllers/NonJsonApiControllerTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/NonJsonApiControllerTests.cs index 1b6fa12d8f..1aa854b281 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/NonJsonApiControllerTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/NonJsonApiControllerTests.cs @@ -32,7 +32,8 @@ public async Task Get_skips_middleware_and_formatters() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - httpResponse.Content.Headers.ContentType.Should().NotBeNull().And.Subject.ToString().Should().Be("application/json; charset=utf-8"); + httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be("application/json; charset=utf-8"); string responseText = await httpResponse.Content.ReadAsStringAsync(); responseText.Should().Be("[\"Welcome!\"]"); @@ -60,7 +61,8 @@ public async Task Post_skips_middleware_and_formatters() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - httpResponse.Content.Headers.ContentType.Should().NotBeNull().And.Subject.ToString().Should().Be("text/plain; charset=utf-8"); + httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be("text/plain; charset=utf-8"); string responseText = await httpResponse.Content.ReadAsStringAsync(); responseText.Should().Be("Hello, Jack"); @@ -79,7 +81,8 @@ public async Task Post_skips_error_handler() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - httpResponse.Content.Headers.ContentType.Should().NotBeNull().And.Subject.ToString().Should().Be("text/plain; charset=utf-8"); + httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be("text/plain; charset=utf-8"); string responseText = await httpResponse.Content.ReadAsStringAsync(); responseText.Should().Be("Please send your name."); @@ -107,7 +110,8 @@ public async Task Put_skips_middleware_and_formatters() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - httpResponse.Content.Headers.ContentType.Should().NotBeNull().And.Subject.ToString().Should().Be("text/plain; charset=utf-8"); + httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be("text/plain; charset=utf-8"); string responseText = await httpResponse.Content.ReadAsStringAsync(); responseText.Should().Be("Hi, Jane"); @@ -126,7 +130,8 @@ public async Task Patch_skips_middleware_and_formatters() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - httpResponse.Content.Headers.ContentType.Should().NotBeNull().And.Subject.ToString().Should().Be("text/plain; charset=utf-8"); + httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be("text/plain; charset=utf-8"); string responseText = await httpResponse.Content.ReadAsStringAsync(); responseText.Should().Be("Good day, Janice"); @@ -145,7 +150,8 @@ public async Task Delete_skips_middleware_and_formatters() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - httpResponse.Content.Headers.ContentType.Should().NotBeNull().And.Subject.ToString().Should().Be("text/plain; charset=utf-8"); + httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be("text/plain; charset=utf-8"); string responseText = await httpResponse.Content.ReadAsStringAsync(); responseText.Should().Be("Bye."); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/UnknownResource.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/UnknownResource.cs new file mode 100644 index 0000000000..5dc9f2f6ce --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/UnknownResource.cs @@ -0,0 +1,11 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCoreTests.IntegrationTests.NonJsonApiControllers +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class UnknownResource : Identifiable + { + public string? Value { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/UnknownResourceControllerTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/UnknownResourceControllerTests.cs new file mode 100644 index 0000000000..d2803c195c --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/UnknownResourceControllerTests.cs @@ -0,0 +1,32 @@ +using System; +using FluentAssertions; +using JsonApiDotNetCore.Errors; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.NonJsonApiControllers +{ + public sealed class UnknownResourceControllerTests : IntegrationTestContext, NonJsonApiDbContext> + { + public UnknownResourceControllerTests() + { + UseController(); + } + + [Fact] + public void Fails_at_startup_when_using_controller_for_resource_type_that_is_not_registered_in_resource_graph() + { + // Act + Action action = () => _ = Factory; + + // Assert + action.Should().ThrowExactly().WithMessage($"Controller '{typeof(UnknownResourcesController)}' " + + $"depends on resource type '{typeof(UnknownResource)}', which does not exist in the resource graph."); + } + + public override void Dispose() + { + // Prevents crash when test cleanup tries to access lazily constructed Factory. + } + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/UnknownResourcesController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/UnknownResourcesController.cs new file mode 100644 index 0000000000..82bb597266 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/UnknownResourcesController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreTests.IntegrationTests.NonJsonApiControllers +{ + public sealed class UnknownResourcesController : JsonApiController + { + public UnknownResourcesController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/AccountPreferences.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/AccountPreferences.cs index 640fff74a9..17b0d478e8 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/AccountPreferences.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/AccountPreferences.cs @@ -5,7 +5,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class AccountPreferences : Identifiable + public sealed class AccountPreferences : Identifiable { [Attr] public bool UseDarkTheme { get; set; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Appointment.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Appointment.cs index 7221f0815c..0e6371e356 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Appointment.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Appointment.cs @@ -6,10 +6,13 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Appointment : Identifiable + public sealed class Appointment : Identifiable { [Attr] - public string Title { get; set; } + public string Title { get; set; } = null!; + + [Attr] + public string? Description { get; set; } [Attr] public DateTimeOffset StartTime { get; set; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Blog.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Blog.cs index e13dde1faa..f421a0d67c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Blog.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Blog.cs @@ -7,21 +7,21 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Blog : Identifiable + public sealed class Blog : Identifiable { [Attr] - public string Title { get; set; } + public string Title { get; set; } = null!; [Attr] - public string PlatformName { get; set; } + public string PlatformName { get; set; } = null!; [Attr(Capabilities = AttrCapabilities.All & ~(AttrCapabilities.AllowCreate | AttrCapabilities.AllowChange))] public bool ShowAdvertisements => PlatformName.EndsWith("(using free account)", StringComparison.Ordinal); [HasMany] - public IList Posts { get; set; } + public IList Posts { get; set; } = new List(); [HasOne] - public WebAccount Owner { get; set; } + public WebAccount? Owner { get; set; } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/BlogPost.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/BlogPost.cs index d62291379b..2e809d651e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/BlogPost.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/BlogPost.cs @@ -6,27 +6,27 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class BlogPost : Identifiable + public sealed class BlogPost : Identifiable { [Attr] - public string Caption { get; set; } + public string Caption { get; set; } = null!; [Attr] - public string Url { get; set; } + public string Url { get; set; } = null!; [HasOne] - public WebAccount Author { get; set; } + public WebAccount? Author { get; set; } [HasOne] - public WebAccount Reviewer { get; set; } + public WebAccount? Reviewer { get; set; } [HasMany] - public ISet