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 @@ <?xml version="1.0" encoding="utf-8"?> <cSharpGuidelinesAnalyzerSettings> <setting rule="AV1561" name="MaxParameterCount" value="6" /> - <setting rule="AV1561" name="MaxConstructorParameterCount" value="12" /> + <setting rule="AV1561" name="MaxConstructorParameterCount" value="13" /> </cSharpGuidelinesAnalyzerSettings> 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 @@ <EFCoreVersion>5.0.*</EFCoreVersion> <NpgsqlPostgreSQLVersion>5.0.*</NpgsqlPostgreSQLVersion> <SwashbuckleVersion>6.2.*</SwashbuckleVersion> - <JsonApiDotNetCoreVersionPrefix>4.2.0</JsonApiDotNetCoreVersionPrefix> + <JsonApiDotNetCoreVersionPrefix>5.0.0</JsonApiDotNetCoreVersionPrefix> <CodeAnalysisRuleSet>$(MSBuildThisFileDirectory)CodingGuidelines.ruleset</CodeAnalysisRuleSet> <WarningLevel>9999</WarningLevel> + <Nullable>enable</Nullable> </PropertyGroup> <ItemGroup> - <PackageReference Include="JetBrains.Annotations" Version="2021.1.0" PrivateAssets="All" /> + <PackageReference Include="JetBrains.Annotations" Version="2021.3.0" PrivateAssets="All" /> <PackageReference Include="CSharpGuidelinesAnalyzer" Version="3.7.0" PrivateAssets="All" /> <AdditionalFiles Include="$(MSBuildThisFileDirectory)CSharpGuidelinesAnalyzer.config" Visible="False" /> </ItemGroup> @@ -26,9 +27,9 @@ <PropertyGroup> <BogusVersion>33.1.1</BogusVersion> <CoverletVersion>3.1.0</CoverletVersion> - <FluentAssertionsVersion>6.1.0</FluentAssertionsVersion> + <FluentAssertionsVersion>6.2.0</FluentAssertionsVersion> <MoqVersion>4.16.1</MoqVersion> <XUnitVersion>2.4.*</XUnitVersion> - <TestSdkVersion>16.11.0</TestSdkVersion> + <TestSdkVersion>17.0.0</TestSdkVersion> </PropertyGroup> </Project> 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<int> { [Attr] - public string Name { get; set; } + public string Name { get; set; } = null!; } ``` ### Controllers ```c# -public class ArticlesController : JsonApiController<Article> +public class ArticlesController : JsonApiController<Article, int> { - public ArticlesController(IJsonApiOptions options, ILoggerFactory loggerFactory, - IResourceService<Article> resourceService,) - : base(options, loggerFactory, resourceService) + public ArticlesController(IJsonApiOptions options, IResourceGraph resourceGraph, + ILoggerFactory loggerFactory, IResourceService<Article, int> 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 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="BenchmarkDotNet" Version="0.13.0" /> + <PackageReference Include="BenchmarkDotNet" Version="0.13.1" /> <PackageReference Include="Moq" Version="$(MoqVersion)" /> </ItemGroup> </Project> 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<BenchmarkResource>(BenchmarkResourcePublicNames.Type); - builder.Add<SubResource>(); - - 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<IncomingResource, int>().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<IncomingResource, int>), + new JsonApiResourceDefinition<IncomingResource, int>(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<int> + { + [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<IncomingResource> Multi1 { get; set; } = null!; + + [HasMany] + public ISet<IncomingResource> Multi2 { get; set; } = null!; + + [HasMany] + public ISet<IncomingResource> Multi3 { get; set; } = null!; + + [HasMany] + public ISet<IncomingResource> Multi4 { get; set; } = null!; + + [HasMany] + public ISet<IncomingResource> 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<Document>(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<Document>(RequestBody, SerializerReadOptions)!; + return DocumentAdapter.Convert(document); + } + + protected override JsonApiRequest CreateJsonApiRequest(IResourceGraph resourceGraph) + { + return new JsonApiRequest + { + Kind = EndpointKind.Primary, + PrimaryResourceType = resourceGraph.GetResourceType<IncomingResource>(), + 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<char> resourceNameSpan = resourceName.AsSpan(); - ReadOnlySpan<char> pathSpan = path.AsSpan(); - - for (int index = 0; index < pathSpan.Length; index++) - { - if (pathSpan[index].Equals(PathDelimiter)) - { - if (pathSpan.Length > index + resourceNameSpan.Length) - { - ReadOnlySpan<char> 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<QueryableResource, int>("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<SortQueryStringParameterReader> 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<IQueryStringParameterReader>(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<int> + { + [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<BenchmarkResource>), new JsonApiResourceDefinition<BenchmarkResource>(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<IMetaBuilder>().Object; - ILinkBuilder linkBuilder = new Mock<ILinkBuilder>().Object; - IIncludedResourceObjectBuilder includeBuilder = new Mock<IIncludedResourceObjectBuilder>().Object; - - var resourceObjectBuilder = new ResourceObjectBuilder(resourceGraph, options); - - IResourceDefinitionAccessor resourceDefinitionAccessor = new Mock<IResourceDefinitionAccessor>().Object; - - _jsonApiSerializer = new ResponseSerializer<BenchmarkResource>(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<IResourceDefinitionAccessor>().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<OperationContainer> _responseOperations; + + public OperationsSerializationBenchmarks() + { + // ReSharper disable once VirtualMemberCallInConstructor + JsonApiRequest request = CreateJsonApiRequest(ResourceGraph); + + _responseOperations = CreateResponseOperations(request); + } + + private static IEnumerable<OperationContainer> 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<OperationContainer> + { + 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<OutgoingResource>() + }; + } + + 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<OutgoingResource>() + }; + } + + protected override IEvaluatedIncludeCache CreateEvaluatedIncludeCache(IResourceGraph resourceGraph) + { + ResourceType resourceAType = resourceGraph.GetResourceType<OutgoingResource>(); + + 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<ResourceFieldAttribute> chain = ImmutableArray.Create<ResourceFieldAttribute>(single2, single3, multi4, multi5); + IEnumerable<ResourceFieldChainExpression> 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<OutgoingResource, int>().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<IQueryConstraintProvider>(); + 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<int> + { + [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<OutgoingResource> Multi1 { get; set; } = null!; + + [HasMany] + public ISet<OutgoingResource> Multi2 { get; set; } = null!; + + [HasMany] + public ISet<OutgoingResource> Multi3 { get; set; } = null!; + + [HasMany] + public ISet<OutgoingResource> Multi4 { get; set; } = null!; + + [HasMany] + public ISet<OutgoingResource> Multi5 { get; set; } = null!; + } + + private sealed class FakeResourceDefinitionAccessor : IResourceDefinitionAccessor + { + public IImmutableSet<IncludeElementExpression> OnApplyIncludes(ResourceType resourceType, IImmutableSet<IncludeElementExpression> 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<string, object?>? GetMeta(ResourceType resourceType, IIdentifiable resourceInstance) + { + return null; + } + + public Task OnPrepareWriteAsync<TResource>(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + return Task.CompletedTask; + } + + public Task<IIdentifiable?> OnSetToOneRelationshipAsync<TResource>(TResource leftResource, HasOneAttribute hasOneRelationship, + IIdentifiable? rightResourceId, WriteOperationKind writeOperation, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + return Task.FromResult(rightResourceId); + } + + public Task OnSetToManyRelationshipAsync<TResource>(TResource leftResource, HasManyAttribute hasManyRelationship, + ISet<IIdentifiable> rightResourceIds, WriteOperationKind writeOperation, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + return Task.CompletedTask; + } + + public Task OnAddToRelationshipAsync<TResource, TId>(TId leftResourceId, HasManyAttribute hasManyRelationship, ISet<IIdentifiable> rightResourceIds, + CancellationToken cancellationToken) + where TResource : class, IIdentifiable<TId> + { + return Task.CompletedTask; + } + + public Task OnRemoveFromRelationshipAsync<TResource>(TResource leftResource, HasManyAttribute hasManyRelationship, + ISet<IIdentifiable> rightResourceIds, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + return Task.CompletedTask; + } + + public Task OnWritingAsync<TResource>(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + return Task.CompletedTask; + } + + public Task OnWriteSucceededAsync<TResource>(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<string, object?> values) + { + } + + public IDictionary<string, object?>? 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<TResource>`](JsonApiDotNetCore.Resources.JsonApiResourceDefinition-1.yml) +- [`JsonApiResourceDefinition<TResource, TId>`](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<TId>`. -The easiest way to do this is to inherit from `Identifiable` +The easiest way to do this is to inherit from `Identifiable<TId>`. ```c# -public class Person : Identifiable +#nullable enable + +public class Person : Identifiable<int> { [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<Person> People => Set<Person>(); + public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { } - - public DbSet<Person> People { get; set; } } ``` ### Define Controllers -You need to create controllers that inherit from `JsonApiController<TResource>` or `JsonApiController<TResource, TId>` -where `TResource` is the model that inherits from `Identifiable<TId>` +You need to create controllers that inherit from `JsonApiController<TResource, TId>` +where `TResource` is the model that inherits from `Identifiable<TId>`. ```c# -public class PeopleController : JsonApiController<Person> +public class PeopleController : JsonApiController<Person, int> { - public PeopleController(IJsonApiOptions options, ILoggerFactory loggerFactory, - IResourceService<Person> resourceService) - : base(options, loggerFactory, resourceService) + public PeopleController(IJsonApiOptions options, IResourceGraph resourceGraph, + ILoggerFactory loggerFactory, IResourceService<Person, int> 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 @@ <h2>Example usage</h2> <div class="icon"><i class='bx bx-detail'></i></div> <h4 class="title">Resource</h4> <pre> -<code>public class Article : Identifiable +<code>#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>(); }</code> </pre> </div> @@ -179,7 +183,7 @@ <h4 class="title">Resource</h4> <h4 class="title">Request</h4> <pre> <code> -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 </code> </pre> </div> @@ -197,9 +201,9 @@ <h4 class="title">Response</h4> "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<ErrorObject> 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<TResource>` - -```c# -public class ArticlesController : JsonApiController<Article> -{ - public ArticlesController(IJsonApiOptions options, ILoggerFactory loggerFactory, - IResourceService<Article> 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<TResource, TId>` ```c# public class ArticlesController : JsonApiController<Article, Guid> -//---------------------------------------------------------- ^^^^ { - public ArticlesController(IJsonApiOptions options, ILoggerFactory loggerFactory, - IResourceService<Article, Guid> resourceService) - //----------------------- ^^^^ - : base(options, loggerFactory, resourceService) + public ArticlesController(IJsonApiOptions options, IResourceGraph resourceGraph, + ILoggerFactory loggerFactory, IResourceService<Article, Guid> resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } ``` +If you want to setup routes yourself, you can instead inherit from `BaseJsonApiController<TResource, TId>` 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<Article> +public class ReportsController : JsonApiController<Report, int> { - public ArticlesController(IJsonApiOptions options, ILoggerFactory loggerFactory, - IResourceService<Article> resourceService) - : base(options, loggerFactory, resourceService) - { - } - - [HttpGet] - public override async Task<IActionResult> GetAsync(CancellationToken cancellationToken) + public ReportsController(IJsonApiOptions options, IResourceGraph resourceGraph, + ILoggerFactory loggerFactory, IGetAllService<Report, int> getAllService) + : base(options, resourceGraph, loggerFactory, getAll: getAllService) { - return await base.GetAsync(cancellationToken); - } - - [HttpGet("{id}")] - public override async Task<IActionResult> 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<Article> -{ - public ArticlesController(IJsonApiOptions options, ILoggerFactory loggerFactory, - IResourceService<Article> 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<Report> +```json { - public ReportsController(IJsonApiOptions options, ILoggerFactory loggerFactory, - IGetAllService<Report> getAllService) - : base(options, loggerFactory, getAllService) - { - } - - [HttpGet] - public override async Task<IActionResult> 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<IResourceRepository<Article>, ArticleRepository>(); - services.AddScoped<IResourceReadRepository<Article>, ArticleRepository>(); - services.AddScoped<IResourceWriteRepository<Article>, ArticleRepository>(); + services.AddScoped<IResourceRepository<Article, int>, ArticleRepository>(); + services.AddScoped<IResourceReadRepository<Article, int>, ArticleRepository>(); + services.AddScoped<IResourceWriteRepository<Article, int>, 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<TResource>`, so this is a good method to apply filters such as user or tenant authorization. ```c# -public class ArticleRepository : EntityFrameworkCoreRepository<Article> +public class ArticleRepository : EntityFrameworkCoreRepository<Article, int> { 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<IQueryConstraintProvider> 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<TResource> : EntityFrameworkCoreRepository<TResource> - where TResource : class, IIdentifiable<int> +public class DbContextARepository<TResource, TId> : EntityFrameworkCoreRepository<TResource, TId> + where TResource : class, IIdentifiable<TId> { public DbContextARepository(ITargetedFields targetedFields, - DbContextResolver<DbContextA> contextResolver, + DbContextResolver<DbContextA> dbContextResolver, // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - IResourceGraph resourceGraph, IGenericServiceFactory genericServiceFactory, - IResourceFactory resourceFactory, + IResourceGraph resourceGraph, IResourceFactory resourceFactory, IEnumerable<IQueryConstraintProvider> 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<ResourceDefinition<Product>, ProductResource>(); +services.AddScoped<ResourceDefinition<Product, int>, ProductDefinition>(); ``` ## Customizing queries @@ -31,7 +31,7 @@ services.AddScoped<ResourceDefinition<Product>, ProductResource>(); _since v4.0_ For various reasons (see examples below) you may need to change parts of the query, depending on resource type. -`JsonApiResourceDefinition<TResource>` (which is an empty implementation of `IResourceDefinition<TResource>`) provides overridable methods that pass you the result of query string parameter parsing. +`JsonApiResourceDefinition<TResource, TId>` (which is an empty implementation of `IResourceDefinition<TResource, TId>`) 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<User> +public class UserDefinition : JsonApiResourceDefinition<User, int> { 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<Account> +public class AccountDefinition : JsonApiResourceDefinition<Account, int> { public AccountDefinition(IResourceGraph resourceGraph) : base(resourceGraph) @@ -132,7 +132,7 @@ public class AccountDefinition : JsonApiResourceDefinition<Account> You may want to enforce pagination on large database tables. ```c# -public class AccessLogDefinition : JsonApiResourceDefinition<AccessLog> +public class AccessLogDefinition : JsonApiResourceDefinition<AccessLog, int> { public AccessLogDefinition(IResourceGraph resourceGraph) : base(resourceGraph) @@ -163,7 +163,7 @@ public class AccessLogDefinition : JsonApiResourceDefinition<AccessLog> The next example filters out `Account` resources that are suspended. ```c# -public class AccountDefinition : JsonApiResourceDefinition<Account> +public class AccountDefinition : JsonApiResourceDefinition<Account, int> { public AccountDefinition(IResourceGraph resourceGraph) : base(resourceGraph) @@ -172,11 +172,8 @@ public class AccountDefinition : JsonApiResourceDefinition<Account> public override FilterExpression OnApplyFilter(FilterExpression existingFilter) { - var resourceContext = ResourceGraph.GetResourceContext<Account>(); - - 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<Account> In the example below, an error is returned when a user tries to include the manager of an employee. ```c# -public class EmployeeDefinition : JsonApiResourceDefinition<Employee> +public class EmployeeDefinition : JsonApiResourceDefinition<Employee, int> { 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<Item> +public class ItemDefinition : JsonApiResourceDefinition<Item, int> { 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<TResource>` and override the existing methods. +If you don't need to alter the underlying mechanisms, you can inherit from `JsonApiResourceService<TResource, TId>` 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<TodoItem> +public class TodoItemService : JsonApiResourceService<TodoItem, int> { private readonly INotificationService _notificationService; @@ -19,7 +19,8 @@ public class TodoItemService : JsonApiResourceService<TodoItem> IQueryLayerComposer queryLayerComposer, IPaginationContext paginationContext, IJsonApiOptions options, ILoggerFactory loggerFactory, IJsonApiRequest request, IResourceChangeTracker<TodoItem> resourceChangeTracker, - IResourceDefinitionAccessor resourceDefinitionAccessor) + IResourceDefinitionAccessor resourceDefinitionAccessor, + INotificationService notificationService) : base(repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, resourceChangeTracker, resourceDefinitionAccessor) { @@ -43,21 +44,21 @@ public class TodoItemService : JsonApiResourceService<TodoItem> ## 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<TResource>` implementation. +If you'd like to use another ORM that does not provide what JsonApiResourceService depends upon, you can use a custom `IResourceService<TResource, TId>` implementation. ```c# // Startup.cs public void ConfigureServices(IServiceCollection services) { // add the service override for Product - services.AddScoped<IResourceService<Product>, ProductService>(); + services.AddScoped<IResourceService<Product, int>, ProductService>(); // add your own Data Access Object services.AddScoped<IProductDao, ProductDao>(); } // ProductService.cs -public class ProductService : IResourceService<Product> +public class ProductService : IResourceService<Product, int> { 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<Article>, IDeleteService<Article> +public class ArticleService : ICreateService<Article, int>, IDeleteService<Article, int> { // ... } @@ -130,8 +131,8 @@ public class Startup { public void ConfigureServices(IServiceCollection services) { - services.AddScoped<ICreateService<Article>, ArticleService>(); - services.AddScoped<IDeleteService<Article>, ArticleService>(); + services.AddScoped<ICreateService<Article, int>, ArticleService>(); + services.AddScoped<IDeleteService<Article, int>, 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<Article> +public class ArticlesController : JsonApiController<Article, int> { - public ArticlesController(IJsonApiOptions options, ILoggerFactory loggerFactory, - ICreateService<Article, int> create, IDeleteService<Article, int> delete) - : base(options, loggerFactory, create: create, delete: delete) + public ArticlesController(IJsonApiOptions options, IResourceGraph resourceGraph, + ILoggerFactory loggerFactory, ICreateService<Article, int> create, + IDeleteService<Article, int> delete) + : base(options, resourceGraph, loggerFactory, create: create, delete: delete) { } - - [HttpPost] - public override async Task<IActionResult> PostAsync([FromBody] Article resource, - CancellationToken cancellationToken) - { - return await base.PostAsync(resource, cancellationToken); - } - - [HttpDelete("{id}")] - public override async Task<IActionResult>DeleteAsync(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<IResponseMeta, CopyrightResponseMeta>(); public sealed class CopyrightResponseMeta : IResponseMeta { - public IReadOnlyDictionary<string, object> GetMeta() + public IReadOnlyDictionary<string, object?> GetMeta() { - return new Dictionary<string, object> + return new Dictionary<string, object?> { ["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<TResource, TId>.GetMeta` (or overriding it on `JsonApiResourceDefinition`): +Resource-specific metadata can be added by implementing `IResourceDefinition<TResource, TId>.GetMeta` (or overriding it on `JsonApiResourceDefinition<TResource, TId>`): ```c# -public class PersonDefinition : JsonApiResourceDefinition<Person> +#nullable enable + +public class PersonDefinition : JsonApiResourceDefinition<Person, int> { public PersonDefinition(IResourceGraph resourceGraph) : base(resourceGraph) { } - public override IReadOnlyDictionary<string, object> GetMeta(Person person) + public override IReadOnlyDictionary<string, object?>? GetMeta(Person person) { if (person.IsEmployee) { - return new Dictionary<string, object> + return new Dictionary<string, object?> { ["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<int> { [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<Person>(); + builder.Add<Person, int>(); }); } ``` @@ -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<Person>(publicName: "people"); + builder.Add<Person, int>(publicName: "people"); }); ``` 2. The model is decorated with a `ResourceAttribute` ```c# [Resource("myResources")] -public class MyModel : Identifiable +public class MyModel : Identifiable<int> { } ``` @@ -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<int> { } ``` 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<int> { [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<int> { [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<int> { [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<int> { [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<int> { [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<int> { [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<int> { [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<int> { - [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<Guid> } ``` -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<int> -{ -} -``` - -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<int> { [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<int> +{ + [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<int> +{ + [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<TodoItem> 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<int> +{ + [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<TodoItem> TodoItems { get; set; } = new HashSet<TodoItem>(); +} +``` 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<int> { [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<int> +{ + [HasOne] + public Engine Engine { get; set; } = null!; +} + +public sealed class Engine : Identifiable<int> +{ + [HasOne] + public Car? Car { get; set; } +} + +public sealed class AppDbContext : DbContext +{ + protected override void OnModelCreating(ModelBuilder builder) + { + builder.Entity<Car>() + .HasOne(car => car.Engine) + .WithOne(engine => engine.Car) + .HasForeignKey<Car>() + .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<Car>() + .HasOne(car => car.Engine) + .WithOne(engine => engine.Car) + .HasForeignKey<Car>("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<int> { [HasMany] - public ICollection<TodoItem> TodoItems { get; set; } + public ICollection<TodoItem> TodoItems { get; set; } = new HashSet<TodoItem>(); } ``` @@ -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<int> { // 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<int> { [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<int> { [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<int> { [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<int> { } -public class OrderLineController : JsonApiController<OrderLine> +public class OrderLineController : JsonApiController<OrderLine, int> { - public OrderLineController(IJsonApiOptions options, ILoggerFactory loggerFactory, - IResourceService<OrderLine> resourceService) - : base(options, loggerFactory, resourceService) + public OrderLineController(IJsonApiOptions options, IResourceGraph resourceGraph, + ILoggerFactory loggerFactory, IResourceService<OrderLine, int> 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<TResource>`, 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<TResource, TId>`, 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<OrderLine> +public class OrderLineController : JsonApiController<OrderLine, int> { - public OrderLineController(IJsonApiOptions options, ILoggerFactory loggerFactory, - IResourceService<OrderLine> resourceService) - : base(options, loggerFactory, resourceService) + public OrderLineController(IJsonApiOptions options, IResourceGraph resourceGraph, + ILoggerFactory loggerFactory, IResourceService<OrderLine, int> 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<Book> + public sealed class BooksController : JsonApiController<Book, int> { - public BooksController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<Book> resourceService) - : base(options, loggerFactory, resourceService) + public BooksController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IResourceService<Book, int> 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<Person> + public sealed class PeopleController : JsonApiController<Person, int> { - public PeopleController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<Person> resourceService) - : base(options, loggerFactory, resourceService) + public PeopleController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<Person, int> 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<Book> Books { get; set; } + public DbSet<Book> Books => Set<Book>(); public SampleDbContext(DbContextOptions<SampleDbContext> options) : base(options) { } - - protected override void OnModelCreating(ModelBuilder builder) - { - builder.Entity<Person>(); - } } } 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<int> { [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<int> { [Attr] - public string Name { get; set; } + public string Name { get; set; } = null!; [HasMany] - public ICollection<Book> Books { get; set; } + public ICollection<Book> Books { get; set; } = new List<Book>(); } } 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<Person> + public sealed class PeopleController : JsonApiController<Person, int> { - public PeopleController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<Person> resourceService) - : base(options, loggerFactory, resourceService) + public PeopleController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<Person, int> 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<Tag> + public sealed class TagsController : JsonApiController<Tag, int> { - public TagsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<Tag> resourceService) - : base(options, loggerFactory, resourceService) + public TagsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IResourceService<Tag, int> 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<TodoItem> + public sealed class TodoItemsController : JsonApiController<TodoItem, int> { - public TodoItemsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<TodoItem> resourceService) - : base(options, loggerFactory, resourceService) + public TodoItemsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<TodoItem, int> 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<TodoItem> TodoItems { get; set; } + public DbSet<TodoItem> TodoItems => Set<TodoItem>(); public AppDbContext(DbContextOptions<AppDbContext> 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<Person>() .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<TodoItem>() .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<TodoItem> + public sealed class TodoItemDefinition : JsonApiResourceDefinition<TodoItem, int> { 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<int> { [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<TodoItem> AssignedTodoItems { get; set; } + public ISet<TodoItem> AssignedTodoItems { get; set; } = new HashSet<TodoItem>(); } } 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<int> { - [Required] - [MinLength(1)] [Attr] - public string Name { get; set; } + [MinLength(1)] + public string Name { get; set; } = null!; [HasMany] - public ISet<TodoItem> TodoItems { get; set; } + public ISet<TodoItem> TodoItems { get; set; } = new HashSet<TodoItem>(); } } 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<int> { [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<Tag> Tags { get; set; } + public ISet<Tag> Tags { get; set; } = new HashSet<Tag>(); } } 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<Startup> logger = loggerFactory.CreateLogger<Startup>(); using (CodeTimingSessionManager.Current.Measure("Initialize other (startup)")) { - using (IServiceScope scope = app.ApplicationServices.CreateScope()) - { - var appDbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>(); - 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<ResourceA> + public sealed class ResourceAsController : JsonApiController<ResourceA, int> { - public ResourceAsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<ResourceA> resourceService) - : base(options, loggerFactory, resourceService) + public ResourceAsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<ResourceA, int> 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<ResourceB> + public sealed class ResourceBsController : JsonApiController<ResourceB, int> { - public ResourceBsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<ResourceB> resourceService) - : base(options, loggerFactory, resourceService) + public ResourceBsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<ResourceB, int> 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<ResourceA> ResourceAs { get; set; } + public DbSet<ResourceA> ResourceAs => Set<ResourceA>(); public DbContextA(DbContextOptions<DbContextA> 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<ResourceB> ResourceBs { get; set; } + public DbSet<ResourceB> ResourceBs => Set<ResourceB>(); public DbContextB(DbContextOptions<DbContextB> 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<int> { [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<int> { [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<TResource> : EntityFrameworkCoreRepository<TResource> + public sealed class DbContextARepository<TResource> : EntityFrameworkCoreRepository<TResource, int> where TResource : class, IIdentifiable<int> { - public DbContextARepository(ITargetedFields targetedFields, DbContextResolver<DbContextA> contextResolver, IResourceGraph resourceGraph, + public DbContextARepository(ITargetedFields targetedFields, DbContextResolver<DbContextA> dbContextResolver, IResourceGraph resourceGraph, IResourceFactory resourceFactory, IEnumerable<IQueryConstraintProvider> 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<TResource> : EntityFrameworkCoreRepository<TResource> + public sealed class DbContextBRepository<TResource> : EntityFrameworkCoreRepository<TResource, int> where TResource : class, IIdentifiable<int> { - public DbContextBRepository(ITargetedFields targetedFields, DbContextResolver<DbContextB> contextResolver, IResourceGraph resourceGraph, + public DbContextBRepository(ITargetedFields targetedFields, DbContextResolver<DbContextB> dbContextResolver, IResourceGraph resourceGraph, IResourceFactory resourceFactory, IEnumerable<IQueryConstraintProvider> 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<WorkItem> + public sealed class WorkItemsController : JsonApiController<WorkItem, int> { - public WorkItemsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<WorkItem> resourceService) - : base(options, loggerFactory, resourceService) + public WorkItemsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<WorkItem, int> 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<WorkItem> WorkItems { get; set; } + public DbSet<WorkItem> WorkItems => Set<WorkItem>(); public AppDbContext(DbContextOptions<AppDbContext> 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<int> { [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<WorkItem> + public sealed class WorkItemService : IResourceService<WorkItem, int> { private readonly string _connectionString; @@ -46,17 +46,17 @@ public async Task<WorkItem> GetAsync(int id, CancellationToken cancellationToken return workItems.Single(); } - public Task<object> GetSecondaryAsync(int id, string relationshipName, CancellationToken cancellationToken) + public Task<object?> GetSecondaryAsync(int id, string relationshipName, CancellationToken cancellationToken) { throw new NotImplementedException(); } - public Task<object> GetRelationshipAsync(int id, string relationshipName, CancellationToken cancellationToken) + public Task<object?> GetRelationshipAsync(int id, string relationshipName, CancellationToken cancellationToken) { throw new NotImplementedException(); } - public async Task<WorkItem> CreateAsync(WorkItem resource, CancellationToken cancellationToken) + public async Task<WorkItem?> 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<WorkItem> UpdateAsync(int id, WorkItem resource, CancellationToken cancellationToken) + public Task<WorkItem?> 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<WorkItem>("workItems")); + services.AddJsonApi(options => options.Namespace = "api/v1", resources: builder => builder.Add<WorkItem, int>("workItems")); - services.AddScoped<IResourceService<WorkItem>, WorkItemService>(); + services.AddResourceService<WorkItemService>(); services.AddDbContext<AppDbContext>(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<Report> + public class ReportsController : BaseJsonApiController<Report, int> { - public ReportsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IGetAllService<Report> getAllService) - : base(options, loggerFactory, getAllService) + public ReportsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IGetAllService<Report, int> 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<int> { [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<Report> + public class ReportService : IGetAllService<Report, int> { private readonly ILogger<ReportService> _logger; @@ -33,6 +33,7 @@ private IReadOnlyCollection<Report> 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<T>([CanBeNull] [NoEnumeration] T value, [NotNull] [InvokerParameterName] string name) + public static void NotNull<T>([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 /// <c>using</c> statement, so the registrations are cleaned up after executing the request. /// </returns> IDisposable RegisterAttributesForRequestDocument<TRequestDocument, TAttributesObject>(TRequestDocument requestDocument, - params Expression<Func<TAttributesObject, object>>[] alwaysIncludedAttributeSelectors) + params Expression<Func<TAttributesObject, object?>>[] 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) /// <inheritdoc /> public IDisposable RegisterAttributesForRequestDocument<TRequestDocument, TAttributesObject>(TRequestDocument requestDocument, - params Expression<Func<TAttributesObject, object>>[] alwaysIncludedAttributeSelectors) + params Expression<Func<TAttributesObject, object?>>[] alwaysIncludedAttributeSelectors) where TRequestDocument : class { ArgumentGuard.NotNull(requestDocument, nameof(requestDocument)); var attributeNames = new HashSet<string>(); - foreach (Expression<Func<TAttributesObject, object>> selector in alwaysIncludedAttributeSelectors) + foreach (Expression<Func<TAttributesObject, object?>> 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<TFilterMetaData>(this ActionDescriptor descriptor) + public static TFilterMetaData? GetFilterMetadata<TFilterMetaData>(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<ActionDescriptor> AddJsonApiMetadataToAction(ActionDescriptor endpoint, IJsonApiEndpointMetadata jsonApiEndpointMetadata) + private static IEnumerable<ActionDescriptor> AddJsonApiMetadataToAction(ActionDescriptor endpoint, IJsonApiEndpointMetadata? jsonApiEndpointMetadata) { switch (jsonApiEndpointMetadata) { case PrimaryResponseMetadata primaryMetadata: { - UpdateProducesResponseTypeAttribute(endpoint, primaryMetadata.Type); + UpdateProducesResponseTypeAttribute(endpoint, primaryMetadata.DocumentType); return Array.Empty<ActionDescriptor>(); } case PrimaryRequestMetadata primaryMetadata: { - UpdateBodyParameterDescriptor(endpoint, primaryMetadata.Type); + UpdateBodyParameterDescriptor(endpoint, primaryMetadata.DocumentType); return Array.Empty<ActionDescriptor>(); } - 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<ActionDescriptor> 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<ProducesResponseTypeAttribute>(); - 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<ProducesAttribute>(); return produces != null && produces.ContentTypes.Any(contentType => contentType == HeaderConstants.MediaType); } - private static IList<ActionDescriptor> Expand(ActionDescriptor genericEndpoint, ExpansibleEndpointMetadata metadata, + private static IEnumerable<ActionDescriptor> Expand(ActionDescriptor genericEndpoint, NonPrimaryEndpointMetadata metadata, Action<ActionDescriptor, Type, string> expansionCallback) { var expansion = new List<ActionDescriptor>(); - 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<ActionDescriptor> 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<FilterDescriptor>(); 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<HttpMethodAttribute>().FirstOrDefault(); + HttpMethodAttribute? method = controllerAction.GetCustomAttributes(true).OfType<HttpMethodAttribute>().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<string, Type> 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 /// </summary> 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 /// </summary> 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<RelationshipAttribute> relationships, + bool ignoreHasOneRelationships) { - IEnumerable<RelationshipAttribute> relationships = _resourceGraph.GetResourceContext(primaryResourceType).Relationships; + IEnumerable<RelationshipAttribute> relationshipsOfEndpoint = ignoreHasOneRelationships ? relationships.OfType<HasManyAttribute>() : relationships; - if (ignoreHasOneRelationships) - { - relationships = relationships.OfType<HasManyAttribute>(); - } + IDictionary<string, Type> requestDocumentTypesByRelationshipName = relationshipsOfEndpoint.ToDictionary(relationship => relationship.PublicName, + NonPrimaryDocumentTypeFactory.Instance.GetForRelationshipRequest); - IDictionary<string, Type> 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<RelationshipAttribute> relationships) { - IDictionary<string, Type> responseTypesByRelationshipName = GetMetadataByRelationshipName(primaryResourceType, relationship => - { - Type documentType = relationship is HasManyAttribute - ? typeof(ResourceCollectionResponseDocument<>) - : typeof(SecondaryResourceResponseDocument<>); - - return documentType.MakeGenericType(relationship.RightType); - }); + IDictionary<string, Type> responseDocumentTypesByRelationshipName = relationships.ToDictionary(relationship => relationship.PublicName, + NonPrimaryDocumentTypeFactory.Instance.GetForSecondaryResponse); - return new SecondaryResponseMetadata - { - ResponseTypesByRelationshipName = responseTypesByRelationshipName - }; + return new SecondaryResponseMetadata(responseDocumentTypesByRelationshipName); } - private IDictionary<string, Type> GetMetadataByRelationshipName(Type primaryResourceType, - Func<RelationshipAttribute, Type> extractRelationshipMetadataCallback) + private static RelationshipResponseMetadata GetRelationshipResponseMetadata(IEnumerable<RelationshipAttribute> relationships) { - IReadOnlyCollection<RelationshipAttribute> relationships = _resourceGraph.GetResourceContext(primaryResourceType).Relationships; + IDictionary<string, Type> responseDocumentTypesByRelationshipName = relationships.ToDictionary(relationship => relationship.PublicName, + NonPrimaryDocumentTypeFactory.Instance.GetForRelationshipResponse); - return relationships.ToDictionary(relationship => relationship.PublicName, extractRelationshipMetadataCallback); - } - - private RelationshipResponseMetadata GetRelationshipResponseMetadata(Type primaryResourceType) - { - IDictionary<string, Type> 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<string, Type> DocumentTypesByRelationshipName { get; } + + protected NonPrimaryEndpointMetadata(IDictionary<string, Type> 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<string, Type> RequestBodyTypeByRelationshipName { get; init; } - - public override IDictionary<string, Type> ExpansionElements => RequestBodyTypeByRelationshipName; + public RelationshipRequestMetadata(IDictionary<string, Type> 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<string, Type> ResponseTypesByRelationshipName { get; init; } - - public override IDictionary<string, Type> ExpansionElements => ResponseTypesByRelationshipName; + public RelationshipResponseMetadata(IDictionary<string, Type> 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<string, Type> ResponseTypesByRelationshipName { get; init; } - - public override IDictionary<string, Type> ExpansionElements => ResponseTypesByRelationshipName; + public SecondaryResponseMetadata(IDictionary<string, Type> 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<TResource> : NullableSingleData<ResourceIdentifierObject<TResource>> + where TResource : IIdentifiable + { + public IDictionary<string, object> 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<TResource> : NullableSingleData<ResourceResponseObject<TResource>> + where TResource : IIdentifiable + { + public IDictionary<string, object> 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<TResource> : SingleData<ResourceResponseObject<TResource>> where TResource : IIdentifiable { - public IDictionary<string, object> Meta { get; set; } + public IDictionary<string, object> 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<TResource> : ManyData<ResourceResponseObject<TResource>> where TResource : IIdentifiable { - public IDictionary<string, object> Meta { get; set; } + public IDictionary<string, object> 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<TResource> : ManyData<ResourceIdentifierObject<TResource>> where TResource : IIdentifiable { - public IDictionary<string, object> Meta { get; set; } + public IDictionary<string, object> 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<TResource> : SingleData<ResourceIdentifierObject<TResource>> where TResource : IIdentifiable { - public IDictionary<string, object> Meta { get; set; } + public IDictionary<string, object> 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<TResource> : SingleData<ResourceResponseObject<TResource>> where TResource : IIdentifiable { - public IDictionary<string, object> Meta { get; set; } + public IDictionary<string, object> 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<string> Ext { get; set; } + public ICollection<string> Ext { get; set; } = null!; - public ICollection<string> Profile { get; set; } + public ICollection<string> Profile { get; set; } = null!; - public IDictionary<string, object> Meta { get; set; } + public IDictionary<string, object> 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<TData> where TData : ResourceIdentifierObject { [Required] - public ICollection<TData> Data { get; set; } + public ICollection<TData> 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<TData> + 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<TResource> : NullableSingleData<ResourceIdentifierObject<TResource>> + 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<TResource> : NullableSingleData<ResourceIdentifierObject<TResource>> + where TResource : IIdentifiable + { + [Required] + public LinksInRelationshipObject Links { get; set; } = null!; + + public IDictionary<string, object> 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<TResource> : ManyData<Resou where TResource : IIdentifiable { [Required] - public LinksInRelationshipObject Links { get; set; } + public LinksInRelationshipObject Links { get; set; } = null!; - public IDictionary<string, object> Meta { get; set; } + public IDictionary<string, object> 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<TResource> : SingleData<Reso where TResource : IIdentifiable { [Required] - public LinksInRelationshipObject Links { get; set; } + public LinksInRelationshipObject Links { get; set; } = null!; - public IDictionary<string, object> Meta { get; set; } + public IDictionary<string, object> 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<TResource> : 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<TResource> : ResourceIdentifierObject<TResource> where TResource : IIdentifiable { - public IDictionary<string, object> Attributes { get; set; } + public IDictionary<string, object> Attributes { get; set; } = null!; - public IDictionary<string, object> Relationships { get; set; } + public IDictionary<string, object> 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<TResource> : ResourceObject<TResour where TResource : IIdentifiable { [Required] - public LinksInResourceObject Links { get; set; } + public LinksInResourceObject Links { get; set; } = null!; - public IDictionary<string, object> Meta { get; set; } + public IDictionary<string, object> 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<TData> 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<ProducesResponseTypeAttribute>(); + 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<MethodInfo> MemberwiseCloneMethod = new(() => - typeof(object).GetMethod(nameof(MemberwiseClone), BindingFlags.Instance | BindingFlags.NonPublic), LazyThreadSafetyMode.ExecutionAndPublication); + private static readonly Lazy<MethodInfo> 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 /// </summary> 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<RelationshipAttribute> 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<FieldInfo> NameField = new(() => - typeof(ParameterInfo).GetField("NameImpl", BindingFlags.Instance | BindingFlags.NonPublic), LazyThreadSafetyMode.ExecutionAndPublication); + private static readonly Lazy<FieldInfo> NameField = + new(() => typeof(ParameterInfo).GetField("NameImpl", BindingFlags.Instance | BindingFlags.NonPublic)!, + LazyThreadSafetyMode.ExecutionAndPublication); - private static readonly Lazy<FieldInfo> ParameterTypeField = new(() => - typeof(ParameterInfo).GetField("ClassImpl", BindingFlags.Instance | BindingFlags.NonPublic), LazyThreadSafetyMode.ExecutionAndPublication); + private static readonly Lazy<FieldInfo> 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<RequiredAttribute>(); + + 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 /// <summary> /// Adds the OpenAPI integration to JsonApiDotNetCore by configuring Swashbuckle. /// </summary> - public static void AddOpenApi(this IServiceCollection services, IMvcCoreBuilder mvcBuilder, Action<SwaggerGenOptions> setupSwaggerGenAction = null) + public static void AddOpenApi(this IServiceCollection services, IMvcCoreBuilder mvcBuilder, Action<SwaggerGenOptions>? 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<IApiDescriptionGroupCollectionProvider>(provider => { - var resourceGraph = provider.GetRequiredService<IResourceGraph>(); var controllerResourceMapping = provider.GetRequiredService<IControllerResourceMapping>(); var actionDescriptorCollectionProvider = provider.GetRequiredService<IActionDescriptorCollectionProvider>(); var apiDescriptionProviders = provider.GetRequiredService<IEnumerable<IApiDescriptionProvider>>(); 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<SwaggerGenOptions> setupSwaggerGenAction) + private static void AddSwaggerGenerator(IServiceScope scope, IServiceCollection services, Action<SwaggerGenOptions>? setupSwaggerGenAction) { var controllerResourceMapping = scope.ServiceProvider.GetRequiredService<IControllerResourceMapping>(); var resourceGraph = scope.ServiceProvider.GetRequiredService<IResourceGraph>(); var jsonApiOptions = scope.ServiceProvider.GetRequiredService<IJsonApiOptions>(); - 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<EndpointOrderingFilter>(); 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<string> GetOperationTags(ApiDescription description, IControllerResourceMapping controllerResourceMapping, - IResourceGraph resourceGraph) + private static IList<string> 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<IResourceGraph>(); var controllerResourceMapping = scope.ServiceProvider.GetRequiredService<IControllerResourceMapping>(); - 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<DataProperty> replacementProperties = null; + IList<DataProperty>? 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<DataProperty> GetDataPropertiesThatExistInResourceContext(Type resourceType, DataContract dataContract) + private IList<DataProperty> GetDataPropertiesThatExistInResourceClrType(Type resourceClrType, DataContract dataContract) { - ResourceContext resourceContext = _resourceGraph.GetResourceContext(resourceType); + ResourceType resourceType = _resourceGraph.GetResourceType(resourceClrType); var dataProperties = new List<DataProperty>(); foreach (DataProperty property in dataContract.ObjectProperties) { - if (property.MemberInfo.Name == nameof(Identifiable.Id)) + if (property.MemberInfo.Name == nameof(Identifiable<object>.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<DataProperty> 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 -{ - /// <summary> - /// Removes unwanted nullability of entries in schemas of JSON:API documents. - /// </summary> - /// <remarks> - /// 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. - /// </remarks> - 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>(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<string, OpenApiSchema> 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<AttrAttribute>(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<RequiredAttribute>() != null; + if (field is HasManyAttribute || _resourceTypeInfo.ResourceObjectOpenType != typeof(ResourcePostRequestObject<>)) + { + return false; + } + + TypeCategory fieldTypeCategory = field.Property.GetTypeCategory(); + bool hasRequiredAttribute = field.Property.HasAttribute<RequiredAttribute>(); + + 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<RelationshipAttribute>(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<ResourceTypeInfo, ResourceFieldObjectSchemaBuilder> 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<TResourceFieldAttribute>(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<Type, OpenApiSchema> _resourceTypeSchemaCache = new(); + private readonly Dictionary<Type, OpenApiSchema> _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<IOpenApiAny> { - 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<T>([CanBeNull] [NoEnumeration] T value, [NotNull] [InvokerParameterName] string name) + public static void NotNull<T>([NoEnumeration] [SysNotNull] T? value, [InvokerParameterName] string name) where T : class { if (value is null) @@ -21,9 +21,7 @@ public static void NotNull<T>([CanBeNull] [NoEnumeration] T value, [NotNull] [In } [AssertionMethod] - [ContractAnnotation("value: null => halt")] - public static void NotNullNorEmpty<T>([CanBeNull] IEnumerable<T> value, [NotNull] [InvokerParameterName] string name, - [CanBeNull] string collectionName = null) + public static void NotNullNorEmpty<T>([SysNotNull] IEnumerable<T>? value, [InvokerParameterName] string name, string? collectionName = null) { NotNull(value, name); @@ -34,8 +32,7 @@ public static void NotNullNorEmpty<T>([CanBeNull] IEnumerable<T> 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 { /// <summary> @@ -13,16 +15,16 @@ public interface ILocalIdTracker /// <summary> /// Declares a local ID without assigning a server-generated value. /// </summary> - void Declare(string localId, string resourceType); + void Declare(string localId, ResourceType resourceType); /// <summary> /// Assigns a server-generated ID value to a previously declared local ID. /// </summary> - void Assign(string localId, string resourceType, string stringId); + void Assign(string localId, ResourceType resourceType, string stringId); /// <summary> /// Gets the server-assigned ID for the specified local ID. /// </summary> - 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 /// <summary> /// Invokes <see cref="IOperationProcessor.ProcessAsync" /> on a processor compatible with the operation kind. /// </summary> - Task<OperationContainer> ProcessAsync(OperationContainer operation, CancellationToken cancellationToken); + Task<OperationContainer?> 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 /// <summary> /// Processes the list of specified operations. /// </summary> - Task<IList<OperationContainer>> ProcessAsync(IList<OperationContainer> operations, CancellationToken cancellationToken); + Task<IList<OperationContainer?>> ProcessAsync(IList<OperationContainer> 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() } /// <inheritdoc /> - 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); } } /// <inheritdoc /> - 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) } /// <inheritdoc /> - 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<OperationContainer> 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; } /// <inheritdoc /> - public Task<OperationContainer> ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) + public Task<OperationContainer?> ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) { ArgumentGuard.NotNull(operation, nameof(operation)); @@ -37,10 +34,10 @@ public Task<OperationContainer> 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); } /// <inheritdoc /> - public virtual async Task<IList<OperationContainer>> ProcessAsync(IList<OperationContainer> operations, CancellationToken cancellationToken) + public virtual async Task<IList<OperationContainer?>> ProcessAsync(IList<OperationContainer> operations, CancellationToken cancellationToken) { ArgumentGuard.NotNull(operations, nameof(operations)); _localIdValidator.Validate(operations); _localIdTracker.Reset(); - var results = new List<OperationContainer>(); + var results = new List<OperationContainer?>(); 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<IList<OperationContainer>> ProcessAsync(IList<Operatio catch (Exception exception) #pragma warning restore AV1210 // Catch a specific exception instead of Exception, SystemException or ApplicationException { - throw new JsonApiException(new ErrorObject(HttpStatusCode.InternalServerError) - { - Title = "An unhandled error occurred while processing an operation in this request.", - Detail = exception.Message, - Source = new ErrorSource - { - Pointer = $"/atomic:operations[{results.Count}]" - } - }, exception); + throw new FailedOperationException(results.Count, exception); } return results; } - protected virtual async Task<OperationContainer> ProcessOperationAsync(OperationContainer operation, CancellationToken cancellationToken) + protected virtual async Task<OperationContainer?> 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<OperationContainer> 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<TResource, TId> serv } /// <inheritdoc /> - public virtual async Task<OperationContainer> ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) + public virtual async Task<OperationContainer?> ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) { ArgumentGuard.NotNull(operation, nameof(operation)); var leftId = (TId)operation.Resource.GetTypedId(); ISet<IIdentifiable> 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<TResource, TId> : ICreateProcessor<TResource, TId> { private readonly ICreateService<TResource, TId> _service; private readonly ILocalIdTracker _localIdTracker; - private readonly IResourceGraph _resourceGraph; - public CreateProcessor(ICreateService<TResource, TId> service, ILocalIdTracker localIdTracker, IResourceGraph resourceGraph) + public CreateProcessor(ICreateService<TResource, TId> service, ILocalIdTracker localIdTracker) { ArgumentGuard.NotNull(service, nameof(service)); ArgumentGuard.NotNull(localIdTracker, nameof(localIdTracker)); - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); _service = service; _localIdTracker = localIdTracker; - _resourceGraph = resourceGraph; } /// <inheritdoc /> - public virtual async Task<OperationContainer> ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) + public virtual async Task<OperationContainer?> 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<TResource>(); - - _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<TResource, TId> service) } /// <inheritdoc /> - public virtual async Task<OperationContainer> ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) + public virtual async Task<OperationContainer?> 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 /// <summary> /// Processes the specified operation. /// </summary> - Task<OperationContainer> ProcessAsync(OperationContainer operation, CancellationToken cancellationToken); + Task<OperationContainer?> 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<TResource, } /// <inheritdoc /> - public virtual async Task<OperationContainer> ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) + public virtual async Task<OperationContainer?> ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) { ArgumentGuard.NotNull(operation, nameof(operation)); var leftId = (TId)operation.Resource.GetTypedId(); ISet<IIdentifiable> 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<TResource, TId> service) } /// <inheritdoc /> - public virtual async Task<OperationContainer> ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) + public virtual async Task<OperationContainer?> 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<TResource, TId> service) } /// <inheritdoc /> - public virtual async Task<OperationContainer> ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) + public virtual async Task<OperationContainer?> 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 +{ + /// <summary> + /// Copies the current request state into a backup, which is restored on dispose. + /// </summary> + 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) /// <summary> /// Returns a collection that contains zero, one or multiple resources, depending on the specified value. /// </summary> - public ICollection<IIdentifiable> ExtractResources(object value) + public ICollection<IIdentifiable> ExtractResources(object? value) { if (value is ICollection<IIdentifiable> resourceCollection) { @@ -92,13 +92,13 @@ public ICollection<IIdentifiable> ExtractResources(object value) /// <summary> /// Returns the element type if the specified type is a generic collection, for example: IList{string} -> string or IList -> null. /// </summary> - 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<IEnumerable>()) { return type.GenericTypeArguments[0]; } @@ -114,6 +114,8 @@ public Type TryGetCollectionElementType(Type type) /// </summary> 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<T>(this IEnumerable<T> source) + public static bool IsNullOrEmpty<T>([NotNullWhen(false)] this IEnumerable<T>? source) { if (source == null) { @@ -35,10 +35,10 @@ public static int FindIndex<T>(this IReadOnlyList<T> source, Predicate<T> match) return -1; } - public static bool DictionaryEqual<TKey, TValue>(this IReadOnlyDictionary<TKey, TValue> first, IReadOnlyDictionary<TKey, TValue> second, - IEqualityComparer<TValue> valueComparer = null) + public static bool DictionaryEqual<TKey, TValue>(this IReadOnlyDictionary<TKey, TValue>? first, IReadOnlyDictionary<TKey, TValue>? second, + IEqualityComparer<TValue>? valueComparer = null) { - if (first == second) + if (ReferenceEquals(first, second)) { return true; } @@ -57,7 +57,7 @@ public static bool DictionaryEqual<TKey, TValue>(this IReadOnlyDictionary<TKey, foreach ((TKey firstKey, TValue firstValue) in first) { - if (!second.TryGetValue(firstKey, out TValue secondValue)) + if (!second.TryGetValue(firstKey, out TValue? secondValue)) { return false; } @@ -71,6 +71,18 @@ public static bool DictionaryEqual<TKey, TValue>(this IReadOnlyDictionary<TKey, return true; } + public static IEnumerable<T> EmptyIfNull<T>(this IEnumerable<T>? source) + { + return source ?? Enumerable.Empty<T>(); + } + + public static IEnumerable<T> WhereNotNull<T>(this IEnumerable<T?> 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<T>(this ICollection<T> source, IEnumerable<T> 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 { /// <summary> /// Responsible for populating <see cref="RelationshipAttribute.InverseNavigationProperty" />. 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 /// <see cref="RelationshipAttribute.InverseNavigationProperty" /> explicitly. /// </summary> [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<MvcOptions> ConfigureMvcOptions { set; } + public Action<MvcOptions>? 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 /// <example> /// <code>options.Namespace = "api/v1";</code> /// </example> - string Namespace { get; } + string? Namespace { get; } /// <summary> /// Specifies the default query string capabilities that can be used on exposed JSON:API attributes. Defaults to <see cref="AttrCapabilities.All" />. @@ -30,10 +30,15 @@ public interface IJsonApiOptions bool IncludeJsonApiVersion { get; } /// <summary> - /// Whether or not <see cref="Exception" /> stack traces should be serialized in <see cref="ErrorObject.Meta" />. False by default. + /// Whether or not <see cref="Exception" /> stack traces should be included in <see cref="ErrorObject.Meta" />. False by default. /// </summary> bool IncludeExceptionStackTraceInErrors { get; } + /// <summary> + /// Whether or not the request body should be included in <see cref="Document.Meta" /> when it is invalid. False by default. + /// </summary> + bool IncludeRequestBodyInErrors { get; } + /// <summary> /// Use relative links for all resources. False by default. /// </summary> @@ -85,20 +90,20 @@ public interface IJsonApiOptions /// <summary> /// The page size (10 by default) that is used when not specified in query string. Set to <c>null</c> to not use paging by default. /// </summary> - PageSize DefaultPageSize { get; } + PageSize? DefaultPageSize { get; } /// <summary> /// The maximum page size that can be used, or <c>null</c> for unconstrained (default). /// </summary> - PageSize MaximumPageSize { get; } + PageSize? MaximumPageSize { get; } /// <summary> /// The maximum page number that can be used, or <c>null</c> for unconstrained (default). /// </summary> - PageNumber MaximumPageNumber { get; } + PageNumber? MaximumPageNumber { get; } /// <summary> - /// 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. /// </summary> bool ValidateModelState { get; } @@ -113,6 +118,11 @@ public interface IJsonApiOptions /// </summary> bool AllowUnknownQueryStringParameters { get; } + /// <summary> + /// Whether or not to produce an error on unknown attribute and relationship keys in request bodies. False by default. + /// </summary> + bool AllowUnknownFieldsInRequestBody { get; } + /// <summary> /// Determines whether legacy filter notation in query strings, such as =eq:, =like:, and =in: is enabled. False by default. /// </summary> 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 -{ - /// <summary> - /// An interface used to separate the registration of the global <see cref="IServiceProvider" /> 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). - /// </summary> - 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 /// <summary> /// Gets the metadata for all registered resources. /// </summary> - IReadOnlySet<ResourceContext> GetResourceContexts(); + IReadOnlySet<ResourceType> GetResourceTypes(); /// <summary> - /// Gets the resource metadata for the resource that is publicly exposed by the specified name. Throws an <see cref="InvalidOperationException" /> when - /// not found. + /// Gets the metadata for the resource that is publicly exposed by the specified name. Throws an <see cref="InvalidOperationException" /> when not found. /// </summary> - ResourceContext GetResourceContext(string publicName); + ResourceType GetResourceType(string publicName); /// <summary> - /// Gets the resource metadata for the specified resource type. Throws an <see cref="InvalidOperationException" /> when not found. + /// Gets the metadata for the resource of the specified CLR type. Throws an <see cref="InvalidOperationException" /> when not found. /// </summary> - ResourceContext GetResourceContext(Type resourceType); + ResourceType GetResourceType(Type resourceClrType); /// <summary> - /// Gets the resource metadata for the specified resource type. Throws an <see cref="InvalidOperationException" /> when not found. + /// Gets the metadata for the resource of the specified CLR type. Throws an <see cref="InvalidOperationException" /> when not found. /// </summary> - ResourceContext GetResourceContext<TResource>() + ResourceType GetResourceType<TResource>() where TResource : class, IIdentifiable; /// <summary> - /// Attempts to get the resource metadata for the resource that is publicly exposed by the specified name. Returns <c>null</c> when not found. + /// Attempts to get the metadata for the resource that is publicly exposed by the specified name. Returns <c>null</c> when not found. /// </summary> - ResourceContext TryGetResourceContext(string publicName); + ResourceType? FindResourceType(string publicName); /// <summary> - /// Attempts to get the resource metadata for the specified resource type. Returns <c>null</c> when not found. + /// Attempts to get metadata for the resource of the specified CLR type. Returns <c>null</c> when not found. /// </summary> - ResourceContext TryGetResourceContext(Type resourceType); + ResourceType? FindResourceType(Type resourceClrType); /// <summary> /// Gets the fields (attributes and relationships) for <typeparamref name="TResource" /> that are targeted by the selector. /// </summary> /// <typeparam name="TResource"> - /// The resource type for which to retrieve fields. + /// The resource CLR type for which to retrieve fields. /// </typeparam> /// <param name="selector"> - /// Should be of the form: (TResource r) => new { r.Field1, r.Field2 } + /// Should be of the form: <![CDATA[ + /// (TResource resource) => new { resource.Attribute1, resource.Relationship2 } + /// ]]> /// </param> - IReadOnlyCollection<ResourceFieldAttribute> GetFields<TResource>(Expression<Func<TResource, dynamic>> selector) + IReadOnlyCollection<ResourceFieldAttribute> GetFields<TResource>(Expression<Func<TResource, dynamic?>> selector) where TResource : class, IIdentifiable; /// <summary> /// Gets the attributes for <typeparamref name="TResource" /> that are targeted by the selector. /// </summary> /// <typeparam name="TResource"> - /// The resource type for which to retrieve attributes. + /// The resource CLR type for which to retrieve attributes. /// </typeparam> /// <param name="selector"> - /// Should be of the form: (TResource r) => new { r.Attribute1, r.Attribute2 } + /// Should be of the form: <![CDATA[ + /// (TResource resource) => new { resource.attribute1, resource.Attribute2 } + /// ]]> /// </param> - IReadOnlyCollection<AttrAttribute> GetAttributes<TResource>(Expression<Func<TResource, dynamic>> selector) + IReadOnlyCollection<AttrAttribute> GetAttributes<TResource>(Expression<Func<TResource, dynamic?>> selector) where TResource : class, IIdentifiable; /// <summary> /// Gets the relationships for <typeparamref name="TResource" /> that are targeted by the selector. /// </summary> /// <typeparam name="TResource"> - /// The resource type for which to retrieve relationships. + /// The resource CLR type for which to retrieve relationships. /// </typeparam> /// <param name="selector"> - /// Should be of the form: (TResource r) => new { r.Relationship1, r.Relationship2 } + /// Should be of the form: <![CDATA[ + /// (TResource resource) => new { resource.Relationship1, resource.Relationship2 } + /// ]]> /// </param> - IReadOnlyCollection<RelationshipAttribute> GetRelationships<TResource>(Expression<Func<TResource, dynamic>> selector) + IReadOnlyCollection<RelationshipAttribute> GetRelationships<TResource>(Expression<Func<TResource, dynamic?>> 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<string, INavigationBase> navigationMap = GetNavigations(entityType); - ResolveRelationships(resourceContext.Relationships, navigationMap); + ResolveRelationships(resourceType.Relationships, navigationMap); } } } @@ -64,7 +64,7 @@ private void ResolveRelationships(IReadOnlyCollection<RelationshipAttribute> 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<MvcOptions> ConfigureMvcOptions { get; set; } + public Action<MvcOptions>? ConfigureMvcOptions { get; set; } public JsonApiApplicationBuilder(IServiceCollection services, IMvcCoreBuilder mvcBuilder) { @@ -59,7 +58,7 @@ public JsonApiApplicationBuilder(IServiceCollection services, IMvcCoreBuilder mv /// <summary> /// Executes the action provided by the user to configure <see cref="JsonApiOptions" />. /// </summary> - public void ConfigureJsonApiOptions(Action<JsonApiOptions> configureOptions) + public void ConfigureJsonApiOptions(Action<JsonApiOptions>? configureOptions) { configureOptions?.Invoke(_options); } @@ -67,7 +66,7 @@ public void ConfigureJsonApiOptions(Action<JsonApiOptions> configureOptions) /// <summary> /// Executes the action provided by the user to configure <see cref="ServiceDiscoveryFacade" />. /// </summary> - public void ConfigureAutoDiscovery(Action<ServiceDiscoveryFacade> configureAutoDiscovery) + public void ConfigureAutoDiscovery(Action<ServiceDiscoveryFacade>? configureAutoDiscovery) { configureAutoDiscovery?.Invoke(_serviceDiscoveryFacade); } @@ -75,14 +74,16 @@ public void ConfigureAutoDiscovery(Action<ServiceDiscoveryFacade> configureAutoD /// <summary> /// Configures and builds the resource graph with resources from the provided sources and adds it to the DI container. /// </summary> - public void AddResourceGraph(ICollection<Type> dbContextTypes, Action<ResourceGraphBuilder> configureResourceGraph) + public void ConfigureResourceGraph(ICollection<Type> dbContextTypes, Action<ResourceGraphBuilder>? 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<Type> dbContextTypes, Action<ResourceGr } /// <summary> - /// 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. /// </summary> public void ConfigureMvc() { @@ -128,14 +129,16 @@ public void DiscoverInjectables() /// </summary> public void ConfigureServiceContainer(ICollection<Type> 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<IOperationsTransactionFactory, EntityFrameworkCoreTransactionFactory>(); @@ -156,6 +159,7 @@ public void ConfigureServiceContainer(ICollection<Type> dbContextTypes) _services.AddScoped(typeof(IResourceChangeTracker<>), typeof(ResourceChangeTracker<>)); _services.AddScoped<IPaginationContext, PaginationContext>(); _services.AddScoped<IEvaluatedIncludeCache, EvaluatedIncludeCache>(); + _services.AddScoped<ISparseFieldSetCache, SparseFieldSetCache>(); _services.AddScoped<IQueryLayerComposer, QueryLayerComposer>(); _services.AddScoped<IInverseNavigationResolver, InverseNavigationResolver>(); } @@ -173,18 +177,15 @@ private void AddMiddlewareLayer() _services.AddSingleton<IJsonApiRoutingConvention, JsonApiRoutingConvention>(); _services.AddSingleton<IControllerResourceMapping>(sp => sp.GetRequiredService<IJsonApiRoutingConvention>()); _services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>(); - _services.AddSingleton<IRequestScopedServiceProvider, RequestScopedServiceProvider>(); _services.AddScoped<IJsonApiRequest, JsonApiRequest>(); _services.AddScoped<IJsonApiWriter, JsonApiWriter>(); _services.AddScoped<IJsonApiReader, JsonApiReader>(); _services.AddScoped<ITargetedFields, TargetedFields>(); - _services.AddScoped<IFieldsToSerialize, FieldsToSerialize>(); } private void AddResourceLayer() { - RegisterImplementationForOpenInterfaces(ServiceDiscoveryFacade.ResourceDefinitionInterfaces, typeof(JsonApiResourceDefinition<>), - typeof(JsonApiResourceDefinition<,>)); + RegisterImplementationForOpenInterfaces(ServiceDiscoveryFacade.ResourceDefinitionInterfaces, typeof(JsonApiResourceDefinition<,>)); _services.AddScoped<IResourceDefinitionAccessor, ResourceDefinitionAccessor>(); _services.AddScoped<IResourceFactory, ResourceFactory>(); @@ -192,24 +193,20 @@ private void AddResourceLayer() private void AddRepositoryLayer() { - RegisterImplementationForOpenInterfaces(ServiceDiscoveryFacade.RepositoryInterfaces, typeof(EntityFrameworkCoreRepository<>), - typeof(EntityFrameworkCoreRepository<,>)); + RegisterImplementationForOpenInterfaces(ServiceDiscoveryFacade.RepositoryInterfaces, typeof(EntityFrameworkCoreRepository<,>)); _services.AddScoped<IResourceRepositoryAccessor, ResourceRepositoryAccessor>(); } private void AddServiceLayer() { - RegisterImplementationForOpenInterfaces(ServiceDiscoveryFacade.ServiceInterfaces, typeof(JsonApiResourceService<>), - typeof(JsonApiResourceService<,>)); + RegisterImplementationForOpenInterfaces(ServiceDiscoveryFacade.ServiceInterfaces, typeof(JsonApiResourceService<,>)); } - private void RegisterImplementationForOpenInterfaces(HashSet<Type> openGenericInterfaces, Type intImplementation, Type implementation) + private void RegisterImplementationForOpenInterfaces(HashSet<Type> 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<TCollectionElement, TElementToAdd>() private void AddSerializationLayer() { - _services.AddScoped<IIncludedResourceObjectBuilder, IncludedResourceObjectBuilder>(); - _services.AddScoped<IJsonApiDeserializer, RequestDeserializer>(); - _services.AddScoped<IJsonApiSerializerFactory, ResponseSerializerFactory>(); + _services.AddScoped<IResourceIdentifierObjectAdapter, ResourceIdentifierObjectAdapter>(); + _services.AddScoped<IRelationshipDataAdapter, RelationshipDataAdapter>(); + _services.AddScoped<IResourceObjectAdapter, ResourceObjectAdapter>(); + _services.AddScoped<IResourceDataAdapter, ResourceDataAdapter>(); + _services.AddScoped<IAtomicReferenceAdapter, AtomicReferenceAdapter>(); + _services.AddScoped<IResourceDataInOperationsRequestAdapter, ResourceDataInOperationsRequestAdapter>(); + _services.AddScoped<IAtomicOperationObjectAdapter, AtomicOperationObjectAdapter>(); + _services.AddScoped<IDocumentInResourceOrRelationshipRequestAdapter, DocumentInResourceOrRelationshipRequestAdapter>(); + _services.AddScoped<IDocumentInOperationsRequestAdapter, DocumentInOperationsRequestAdapter>(); + _services.AddScoped<IDocumentAdapter, DocumentAdapter>(); + _services.AddScoped<ILinkBuilder, LinkBuilder>(); _services.AddScoped<IResponseMeta, EmptyResponseMeta>(); _services.AddScoped<IMetaBuilder, MetaBuilder>(); - _services.AddScoped(typeof(ResponseSerializer<>)); - _services.AddScoped(typeof(AtomicOperationsResponseSerializer)); - _services.AddScoped(sp => sp.GetRequiredService<IJsonApiSerializerFactory>().GetSerializer()); - _services.AddScoped<IResourceObjectBuilder, ResponseResourceObjectBuilder>(); _services.AddSingleton<IFingerprintGenerator, FingerprintGenerator>(); _services.AddSingleton<IETagGenerator, ETagGenerator>(); + _services.AddScoped<IResponseModelAdapter, ResponseModelAdapter>(); } private void AddOperationsLayer() @@ -278,24 +280,6 @@ private void AddOperationsLayer() _services.AddScoped<ILocalIdTracker, LocalIdTracker>(); } - 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; /// <inheritdoc /> - public JsonApiModelMetadataProvider(ICompositeMetadataDetailsProvider detailsProvider, IRequestScopedServiceProvider serviceProvider) + public JsonApiModelMetadataProvider(ICompositeMetadataDetailsProvider detailsProvider, IHttpContextAccessor httpContextAccessor) : base(detailsProvider) { - _jsonApiValidationFilter = new JsonApiValidationFilter(serviceProvider); + _jsonApiValidationFilter = new JsonApiValidationFilter(httpContextAccessor); } /// <inheritdoc /> public JsonApiModelMetadataProvider(ICompositeMetadataDetailsProvider detailsProvider, IOptions<MvcOptions> optionsAccessor, - IRequestScopedServiceProvider serviceProvider) + IHttpContextAccessor httpContextAccessor) : base(detailsProvider, optionsAccessor) { - _jsonApiValidationFilter = new JsonApiValidationFilter(serviceProvider); + _jsonApiValidationFilter = new JsonApiValidationFilter(httpContextAccessor); } /// <inheritdoc /> 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; } /// <inheritdoc /> - public string Namespace { get; set; } + public string? Namespace { get; set; } /// <inheritdoc /> public AttrCapabilities DefaultAttrCapabilities { get; set; } = AttrCapabilities.All; @@ -38,6 +38,9 @@ public sealed class JsonApiOptions : IJsonApiOptions /// <inheritdoc /> public bool IncludeExceptionStackTraceInErrors { get; set; } + /// <inheritdoc /> + public bool IncludeRequestBodyInErrors { get; set; } + /// <inheritdoc /> public bool UseRelativeLinks { get; set; } @@ -54,16 +57,16 @@ public sealed class JsonApiOptions : IJsonApiOptions public bool IncludeTotalResourceCount { get; set; } /// <inheritdoc /> - public PageSize DefaultPageSize { get; set; } = new(10); + public PageSize? DefaultPageSize { get; set; } = new(10); /// <inheritdoc /> - public PageSize MaximumPageSize { get; set; } + public PageSize? MaximumPageSize { get; set; } /// <inheritdoc /> - public PageNumber MaximumPageNumber { get; set; } + public PageNumber? MaximumPageNumber { get; set; } /// <inheritdoc /> - public bool ValidateModelState { get; set; } + public bool ValidateModelState { get; set; } = true; /// <inheritdoc /> public bool AllowClientGeneratedIds { get; set; } @@ -71,6 +74,9 @@ public sealed class JsonApiOptions : IJsonApiOptions /// <inheritdoc /> public bool AllowUnknownQueryStringParameters { get; set; } + /// <inheritdoc /> + public bool AllowUnknownFieldsInRequestBody { get; set; } + /// <inheritdoc /> 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 { /// <summary> - /// 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. /// </summary> 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; } /// <inheritdoc /> public bool ShouldValidateEntry(ValidationEntry entry, ValidationEntry parentEntry) { - var request = _serviceProvider.GetRequiredService<IJsonApiRequest>(); + IServiceProvider serviceProvider = GetScopedServiceProvider(); + + var request = serviceProvider.GetRequiredService<IJsonApiRequest>(); if (IsId(entry.Key)) { @@ -39,30 +41,41 @@ public bool ShouldValidateEntry(ValidationEntry entry, ValidationEntry parentEnt return false; } - var httpContextAccessor = _serviceProvider.GetRequiredService<IHttpContextAccessor>(); - - if (httpContextAccessor.HttpContext!.Request.Method == HttpMethods.Patch || request.WriteOperation == WriteOperationKind.UpdateResource) + if (request.WriteOperation == WriteOperationKind.UpdateResource) { - var targetedFields = _serviceProvider.GetRequiredService<ITargetedFields>(); + var targetedFields = serviceProvider.GetRequiredService<ITargetedFields>(); 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<object>.Id) || key.EndsWith($".{nameof(Identifiable<object>.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 -{ - /// <inheritdoc /> - public sealed class RequestScopedServiceProvider : IRequestScopedServiceProvider - { - private readonly IHttpContextAccessor _httpContextAccessor; - - public RequestScopedServiceProvider(IHttpContextAccessor httpContextAccessor) - { - ArgumentGuard.NotNull(httpContextAccessor, nameof(httpContextAccessor)); - - _httpContextAccessor = httpContextAccessor; - } - - /// <inheritdoc /> - 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<Assembly, IReadOnlyCollection<ResourceDescriptor>> _resourceDescriptorsPerAssembly = new(); + private readonly Dictionary<Assembly, IReadOnlyCollection<ResourceDescriptor>?> _resourceDescriptorsPerAssembly = new(); public void RegisterAssembly(Assembly assembly) { @@ -25,7 +25,7 @@ public IReadOnlyCollection<ResourceDescriptor> GetResourceDescriptors() { EnsureAssembliesScanned(); - return _resourceDescriptorsPerAssembly.SelectMany(pair => pair.Value).ToArray(); + return _resourceDescriptorsPerAssembly.SelectMany(pair => pair.Value!).ToArray(); } public IReadOnlyCollection<Assembly> GetAssemblies() @@ -47,7 +47,7 @@ private IEnumerable<ResourceDescriptor> 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<ResourceContext> _resourceContextSet; - private readonly Dictionary<Type, ResourceContext> _resourceContextsByType = new(); - private readonly Dictionary<string, ResourceContext> _resourceContextsByPublicName = new(); + private readonly IReadOnlySet<ResourceType> _resourceTypeSet; + private readonly Dictionary<Type, ResourceType> _resourceTypesByClrType = new(); + private readonly Dictionary<string, ResourceType> _resourceTypesByPublicName = new(); - public ResourceGraph(IReadOnlySet<ResourceContext> resourceContexts) + public ResourceGraph(IReadOnlySet<ResourceType> 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); } } /// <inheritdoc /> - public IReadOnlySet<ResourceContext> GetResourceContexts() + public IReadOnlySet<ResourceType> GetResourceTypes() { - return _resourceContextSet; + return _resourceTypeSet; } /// <inheritdoc /> - 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; } /// <inheritdoc /> - 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; } /// <inheritdoc /> - 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; } /// <inheritdoc /> - 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; } /// <inheritdoc /> - public ResourceContext GetResourceContext<TResource>() + public ResourceType GetResourceType<TResource>() where TResource : class, IIdentifiable { - return GetResourceContext(typeof(TResource)); + return GetResourceType(typeof(TResource)); } /// <inheritdoc /> - public IReadOnlyCollection<ResourceFieldAttribute> GetFields<TResource>(Expression<Func<TResource, dynamic>> selector) + public IReadOnlyCollection<ResourceFieldAttribute> GetFields<TResource>(Expression<Func<TResource, dynamic?>> selector) where TResource : class, IIdentifiable { ArgumentGuard.NotNull(selector, nameof(selector)); @@ -103,7 +103,7 @@ public IReadOnlyCollection<ResourceFieldAttribute> GetFields<TResource>(Expressi } /// <inheritdoc /> - public IReadOnlyCollection<AttrAttribute> GetAttributes<TResource>(Expression<Func<TResource, dynamic>> selector) + public IReadOnlyCollection<AttrAttribute> GetAttributes<TResource>(Expression<Func<TResource, dynamic?>> selector) where TResource : class, IIdentifiable { ArgumentGuard.NotNull(selector, nameof(selector)); @@ -112,7 +112,7 @@ public IReadOnlyCollection<AttrAttribute> GetAttributes<TResource>(Expression<Fu } /// <inheritdoc /> - public IReadOnlyCollection<RelationshipAttribute> GetRelationships<TResource>(Expression<Func<TResource, dynamic>> selector) + public IReadOnlyCollection<RelationshipAttribute> GetRelationships<TResource>(Expression<Func<TResource, dynamic?>> selector) where TResource : class, IIdentifiable { ArgumentGuard.NotNull(selector, nameof(selector)); @@ -120,7 +120,7 @@ public IReadOnlyCollection<RelationshipAttribute> GetRelationships<TResource>(Ex return FilterFields<TResource, RelationshipAttribute>(selector); } - private IReadOnlyCollection<TField> FilterFields<TResource, TField>(Expression<Func<TResource, dynamic>> selector) + private IReadOnlyCollection<TField> FilterFields<TResource, TField>(Expression<Func<TResource, dynamic?>> selector) where TResource : class, IIdentifiable where TField : ResourceFieldAttribute { @@ -129,7 +129,7 @@ private IReadOnlyCollection<TField> FilterFields<TResource, TField>(Expression<F foreach (string memberName in ToMemberNames(selector)) { - TField matchingField = source.FirstOrDefault(field => field.Property.Name == memberName); + TField? matchingField = source.FirstOrDefault(field => field.Property.Name == memberName); if (matchingField == null) { @@ -145,22 +145,22 @@ private IReadOnlyCollection<TField> FilterFields<TResource, TField>(Expression<F private IReadOnlyCollection<TKind> GetFieldsOfType<TResource, TKind>() where TKind : ResourceFieldAttribute { - ResourceContext resourceContext = GetResourceContext(typeof(TResource)); + ResourceType resourceType = GetResourceType(typeof(TResource)); if (typeof(TKind) == typeof(AttrAttribute)) { - return (IReadOnlyCollection<TKind>)resourceContext.Attributes; + return (IReadOnlyCollection<TKind>)resourceType.Attributes; } if (typeof(TKind) == typeof(RelationshipAttribute)) { - return (IReadOnlyCollection<TKind>)resourceContext.Relationships; + return (IReadOnlyCollection<TKind>)resourceType.Relationships; } - return (IReadOnlyCollection<TKind>)resourceContext.Fields; + return (IReadOnlyCollection<TKind>)resourceType.Fields; } - private IEnumerable<string> ToMemberNames<TResource>(Expression<Func<TResource, dynamic>> selector) + private IEnumerable<string> ToMemberNames<TResource>(Expression<Func<TResource, dynamic?>> 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<ResourceGraphBuilder> _logger; - private readonly HashSet<ResourceContext> _resourceContexts = new(); + private readonly Dictionary<Type, ResourceType> _resourceTypesByClrType = new(); private readonly TypeLocator _typeLocator = new(); public ResourceGraphBuilder(IJsonApiOptions options, ILoggerFactory loggerFactory) @@ -34,39 +38,68 @@ public ResourceGraphBuilder(IJsonApiOptions options, ILoggerFactory loggerFactor /// </summary> public IResourceGraph Build() { - return new ResourceGraph(_resourceContexts); + HashSet<ResourceType> 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; } - /// <summary> - /// Adds a JSON:API resource with <code>int</code> as the identifier type. - /// </summary> - /// <typeparam name="TResource"> - /// The resource model type. - /// </typeparam> - /// <param name="publicName"> - /// The name under which the resource is publicly exposed by the API. If nothing is specified, the configured naming convention formatter will be - /// applied. - /// </param> - public ResourceGraphBuilder Add<TResource>(string publicName = null) - where TResource : class, IIdentifiable<int> + public ResourceGraphBuilder Add(DbContext dbContext) { - return Add<TResource, int>(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. } /// <summary> /// Adds a JSON:API resource. /// </summary> /// <typeparam name="TResource"> - /// The resource model type. + /// The resource CLR type. /// </typeparam> /// <typeparam name="TId"> - /// The resource model identifier type. + /// The resource identifier CLR type. /// </typeparam> /// <param name="publicName"> - /// 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. /// </param> - public ResourceGraphBuilder Add<TResource, TId>(string publicName = null) + public ResourceGraphBuilder Add<TResource, TId>(string? publicName = null) where TResource : class, IIdentifiable<TId> { return Add(typeof(TResource), typeof(TId), publicName); @@ -75,67 +108,75 @@ public ResourceGraphBuilder Add<TResource, TId>(string publicName = null) /// <summary> /// Adds a JSON:API resource. /// </summary> - /// <param name="resourceType"> - /// The resource model type. + /// <param name="resourceClrType"> + /// The resource CLR type. /// </param> - /// <param name="idType"> - /// The resource model identifier type. + /// <param name="idClrType"> + /// The resource identifier CLR type. /// </param> /// <param name="publicName"> - /// 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. /// </param> - 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<IIdentifiable>()) { - 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<TId>'."); + } + + 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<AttrAttribute> attributes = GetAttributes(resourceType); - IReadOnlyCollection<RelationshipAttribute> relationships = GetRelationships(resourceType); - IReadOnlyCollection<EagerLoadAttribute> eagerLoads = GetEagerLoads(resourceType); + IReadOnlyCollection<AttrAttribute> attributes = GetAttributes(resourceClrType); + IReadOnlyCollection<RelationshipAttribute> relationships = GetRelationships(resourceClrType); + IReadOnlyCollection<EagerLoadAttribute> 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<AttrAttribute> GetAttributes(Type resourceType) + private IReadOnlyCollection<AttrAttribute> GetAttributes(Type resourceClrType) { - var attributes = new List<AttrAttribute>(); + var attributesByName = new Dictionary<string, AttrAttribute>(); - 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<object>.Id)) { var idAttr = new AttrAttribute { @@ -144,16 +185,18 @@ private IReadOnlyCollection<AttrAttribute> 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<AttrAttribute> 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<RelationshipAttribute> GetRelationships(Type resourceType) + private IReadOnlyCollection<RelationshipAttribute> GetRelationships(Type resourceClrType) { - var attributes = new List<RelationshipAttribute>(); - PropertyInfo[] properties = resourceType.GetProperties(); + var relationshipsByName = new Dictionary<string, RelationshipAttribute>(); + 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<EagerLoadAttribute> GetEagerLoads(Type resourceType, int recursionDepth = 0) + private IReadOnlyCollection<EagerLoadAttribute> GetEagerLoads(Type resourceClrType, int recursionDepth = 0) { AssertNoInfiniteRecursion(recursionDepth); var attributes = new List<EagerLoadAttribute>(); - 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<TField>(Dictionary<string, TField> 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<AttrAttribute> attributes, IReadOnlyCollection<RelationshipAttribute> 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; } /// <summary> - /// 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. /// </summary> - 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. /// </summary> [PublicAPI] - public sealed class ResourceContext + public sealed class ResourceType { private readonly Dictionary<string, ResourceFieldAttribute> _fieldsByPublicName = new(); private readonly Dictionary<string, ResourceFieldAttribute> _fieldsByPropertyName = new(); @@ -23,12 +23,12 @@ public sealed class ResourceContext /// <summary> /// The CLR type of the resource. /// </summary> - public Type ResourceType { get; } + public Type ClrType { get; } /// <summary> - /// The identity type of the resource. + /// The CLR type of the resource identity. /// </summary> - public Type IdentityType { get; } + public Type IdentityClrType { get; } /// <summary> /// Exposed resource attributes and relationships. See https://jsonapi.org/format/#document-resource-object-fields. @@ -78,28 +78,25 @@ public sealed class ResourceContext /// </remarks> public LinkTypes RelationshipLinks { get; } - public ResourceContext(string publicName, Type resourceType, Type identityType, IReadOnlyCollection<AttrAttribute> attributes, - IReadOnlyCollection<RelationshipAttribute> relationships, IReadOnlyCollection<EagerLoadAttribute> eagerLoads, + public ResourceType(string publicName, Type clrType, Type identityClrType, IReadOnlyCollection<AttrAttribute>? attributes = null, + IReadOnlyCollection<RelationshipAttribute>? relationships = null, IReadOnlyCollection<EagerLoadAttribute>? 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<ResourceFieldAttribute>().Concat(relationships).ToArray(); - Attributes = attributes; - Relationships = relationships; - EagerLoads = eagerLoads; + ClrType = clrType; + IdentityClrType = identityClrType; + Attributes = attributes ?? Array.Empty<AttrAttribute>(); + Relationships = relationships ?? Array.Empty<RelationshipAttribute>(); + EagerLoads = eagerLoads ?? Array.Empty<EagerLoadAttribute>(); TopLevelLinks = topLevelLinks; ResourceLinks = resourceLinks; RelationshipLinks = relationshipLinks; + Fields = Attributes.Cast<ResourceFieldAttribute>().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 /// <summary> /// Configures JsonApiDotNetCore by registering resources manually. /// </summary> - public static IServiceCollection AddJsonApi(this IServiceCollection services, Action<JsonApiOptions> options = null, - Action<ServiceDiscoveryFacade> discovery = null, Action<ResourceGraphBuilder> resources = null, IMvcCoreBuilder mvcBuilder = null, - ICollection<Type> dbContextTypes = null) + public static IServiceCollection AddJsonApi(this IServiceCollection services, Action<JsonApiOptions>? options = null, + Action<ServiceDiscoveryFacade>? discovery = null, Action<ResourceGraphBuilder>? resources = null, IMvcCoreBuilder? mvcBuilder = null, + ICollection<Type>? dbContextTypes = null) { ArgumentGuard.NotNull(services, nameof(services)); @@ -34,30 +33,30 @@ public static IServiceCollection AddJsonApi(this IServiceCollection services, Ac /// <summary> /// Configures JsonApiDotNetCore by registering resources from an Entity Framework Core model. /// </summary> - public static IServiceCollection AddJsonApi<TDbContext>(this IServiceCollection services, Action<JsonApiOptions> options = null, - Action<ServiceDiscoveryFacade> discovery = null, Action<ResourceGraphBuilder> resources = null, IMvcCoreBuilder mvcBuilder = null) + public static IServiceCollection AddJsonApi<TDbContext>(this IServiceCollection services, Action<JsonApiOptions>? options = null, + Action<ServiceDiscoveryFacade>? discovery = null, Action<ResourceGraphBuilder>? 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<JsonApiOptions> configureOptions, - Action<ServiceDiscoveryFacade> configureAutoDiscovery, Action<ResourceGraphBuilder> configureResourceGraph, IMvcCoreBuilder mvcBuilder, + private static void SetupApplicationBuilder(IServiceCollection services, Action<JsonApiOptions>? configureOptions, + Action<ServiceDiscoveryFacade>? configureAutoDiscovery, Action<ResourceGraphBuilder>? configureResources, IMvcCoreBuilder? mvcBuilder, ICollection<Type> 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); } /// <summary> - /// Adds IoC container registrations for the various JsonApiDotNetCore resource service interfaces, such as <see cref="IGetAllService{TResource}" />, - /// <see cref="ICreateService{TResource}" /> and the various others. + /// Adds IoC container registrations for the various JsonApiDotNetCore resource service interfaces, such as <see cref="IGetAllService{TResource,TId}" />, + /// <see cref="ICreateService{TResource, TId}" /> and the various others. /// </summary> public static IServiceCollection AddResourceService<TService>(this IServiceCollection services) { @@ -70,7 +69,7 @@ public static IServiceCollection AddResourceService<TService>(this IServiceColle /// <summary> /// Adds IoC container registrations for the various JsonApiDotNetCore resource repository interfaces, such as - /// <see cref="IResourceReadRepository{TResource}" /> and <see cref="IResourceWriteRepository{TResource}" />. + /// <see cref="IResourceReadRepository{TResource,TId}" /> and <see cref="IResourceWriteRepository{TResource, TId}" />. /// </summary> public static IServiceCollection AddResourceRepository<TRepository>(this IServiceCollection services) { @@ -83,7 +82,7 @@ public static IServiceCollection AddResourceRepository<TRepository>(this IServic /// <summary> /// Adds IoC container registrations for the various JsonApiDotNetCore resource definition interfaces, such as - /// <see cref="IResourceDefinition{TResource}" /> and <see cref="IResourceDefinition{TResource,TId}" />. + /// <see cref="IResourceDefinition{TResource,TId}" />. /// </summary> public static IServiceCollection AddResourceDefinition<TResourceDefinition>(this IServiceCollection services) { @@ -97,25 +96,13 @@ public static IServiceCollection AddResourceDefinition<TResourceDefinition>(this private static void RegisterForConstructedType(IServiceCollection services, Type implementationType, IEnumerable<Type> 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<TResource> is the shorthand for IResourceService<TResource, TId> - 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<Type> 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<Type> RepositoryInterfaces = new() { - typeof(IResourceRepository<>), typeof(IResourceRepository<,>), - typeof(IResourceWriteRepository<>), typeof(IResourceWriteRepository<,>), - typeof(IResourceReadRepository<>), typeof(IResourceReadRepository<,>) }; internal static readonly HashSet<Type> 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 /// <summary> /// Attempts to lookup the ID type of the specified resource type. Returns <c>null</c> if it does not implement <see cref="IIdentifiable{TId}" />. /// </summary> - 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) /// <summary> /// Attempts to get a descriptor for the specified resource type. /// </summary> - public ResourceDescriptor TryGetResourceDescriptor(Type type) + public ResourceDescriptor? ResolveResourceDescriptor(Type? type) { - if (type.IsOrImplementsInterface(typeof(IIdentifiable))) + if (type != null && type.IsOrImplementsInterface<IIdentifiable>()) { - Type idType = TryGetIdType(type); + Type? idType = LookupIdType(type); if (idType != null) { @@ -126,6 +126,10 @@ private static (Type implementation, Type registrationInterface)? FindGenericInt /// </example> public IReadOnlyCollection<Type> 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<Type> GetDerivedGenericTypes(Assembly assembly, Type /// </example> public IEnumerable<Type> 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 { /// <summary> - /// 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. /// </summary> /// <example><![CDATA[ /// [DisableQueryString(JsonApiQueryStringParameters.Sort | JsonApiQueryStringParameters.Page)] diff --git a/src/JsonApiDotNetCore/Controllers/Annotations/DisableRoutingConventionAttribute.cs b/src/JsonApiDotNetCore/Controllers/Annotations/DisableRoutingConventionAttribute.cs index 5cd68b2e6f..34dc0c97f4 100644 --- a/src/JsonApiDotNetCore/Controllers/Annotations/DisableRoutingConventionAttribute.cs +++ b/src/JsonApiDotNetCore/Controllers/Annotations/DisableRoutingConventionAttribute.cs @@ -4,7 +4,7 @@ namespace JsonApiDotNetCore.Controllers.Annotations { /// <summary> - /// 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. /// </summary> /// <example><![CDATA[ /// [DisableRoutingConvention, Route("some/custom/route/to/customers")] diff --git a/src/JsonApiDotNetCore/Controllers/Annotations/HttpReadOnlyAttribute.cs b/src/JsonApiDotNetCore/Controllers/Annotations/HttpReadOnlyAttribute.cs deleted file mode 100644 index b4c0fb7675..0000000000 --- a/src/JsonApiDotNetCore/Controllers/Annotations/HttpReadOnlyAttribute.cs +++ /dev/null @@ -1,24 +0,0 @@ -using JetBrains.Annotations; - -namespace JsonApiDotNetCore.Controllers.Annotations -{ - /// <summary> - /// Used on an ASP.NET Core controller class to indicate write actions must be blocked. - /// </summary> - /// <example><![CDATA[ - /// [HttpReadOnly] - /// public class ArticlesController : BaseJsonApiController<Article> - /// { - /// } - /// ]]></example> - [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 -{ - /// <summary> - /// Used on an ASP.NET Core controller class to indicate the DELETE verb must be blocked. - /// </summary> - /// <example><![CDATA[ - /// [NoHttpDelete] - /// public class ArticlesController : BaseJsonApiController<Article> - /// { - /// } - /// ]]></example> - [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 -{ - /// <summary> - /// Used on an ASP.NET Core controller class to indicate the PATCH verb must be blocked. - /// </summary> - /// <example><![CDATA[ - /// [NoHttpPatch] - /// public class ArticlesController : BaseJsonApiController<Article> - /// { - /// } - /// ]]></example> - [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 -{ - /// <summary> - /// Used on an ASP.NET Core controller class to indicate the POST verb must be blocked. - /// </summary> - /// <example><![CDATA[ - /// [NoHttpost] - /// public class ArticlesController : BaseJsonApiController<Article> - /// { - /// } - /// ]]></example> - [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 { /// <summary> - /// 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. /// </summary> /// <typeparam name="TResource"> /// The resource type. @@ -25,50 +25,54 @@ public abstract class BaseJsonApiController<TResource, TId> : CoreJsonApiControl where TResource : class, IIdentifiable<TId> { private readonly IJsonApiOptions _options; - private readonly IGetAllService<TResource, TId> _getAll; - private readonly IGetByIdService<TResource, TId> _getById; - private readonly IGetSecondaryService<TResource, TId> _getSecondary; - private readonly IGetRelationshipService<TResource, TId> _getRelationship; - private readonly ICreateService<TResource, TId> _create; - private readonly IAddToRelationshipService<TResource, TId> _addToRelationship; - private readonly IUpdateService<TResource, TId> _update; - private readonly ISetRelationshipService<TResource, TId> _setRelationship; - private readonly IDeleteService<TResource, TId> _delete; - private readonly IRemoveFromRelationshipService<TResource, TId> _removeFromRelationship; + private readonly IResourceGraph _resourceGraph; + private readonly IGetAllService<TResource, TId>? _getAll; + private readonly IGetByIdService<TResource, TId>? _getById; + private readonly IGetSecondaryService<TResource, TId>? _getSecondary; + private readonly IGetRelationshipService<TResource, TId>? _getRelationship; + private readonly ICreateService<TResource, TId>? _create; + private readonly IAddToRelationshipService<TResource, TId>? _addToRelationship; + private readonly IUpdateService<TResource, TId>? _update; + private readonly ISetRelationshipService<TResource, TId>? _setRelationship; + private readonly IDeleteService<TResource, TId>? _delete; + private readonly IRemoveFromRelationshipService<TResource, TId>? _removeFromRelationship; private readonly TraceLogWriter<BaseJsonApiController<TResource, TId>> _traceWriter; /// <summary> /// Creates an instance from a read/write service. /// </summary> - protected BaseJsonApiController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<TResource, TId> resourceService) - : this(options, loggerFactory, resourceService, resourceService) + protected BaseJsonApiController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<TResource, TId> resourceService) + : this(options, resourceGraph, loggerFactory, resourceService, resourceService) { } /// <summary> /// Creates an instance from separate services for reading and writing. /// </summary> - protected BaseJsonApiController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceQueryService<TResource, TId> queryService = null, - IResourceCommandService<TResource, TId> commandService = null) - : this(options, loggerFactory, queryService, queryService, queryService, queryService, commandService, commandService, commandService, - commandService, commandService, commandService) + protected BaseJsonApiController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceQueryService<TResource, TId>? queryService = null, IResourceCommandService<TResource, TId>? commandService = null) + : this(options, resourceGraph, loggerFactory, queryService, queryService, queryService, queryService, commandService, commandService, + commandService, commandService, commandService, commandService) { } /// <summary> /// Creates an instance from separate services for the various individual read and write methods. /// </summary> - protected BaseJsonApiController(IJsonApiOptions options, ILoggerFactory loggerFactory, IGetAllService<TResource, TId> getAll = null, - IGetByIdService<TResource, TId> getById = null, IGetSecondaryService<TResource, TId> getSecondary = null, - IGetRelationshipService<TResource, TId> getRelationship = null, ICreateService<TResource, TId> create = null, - IAddToRelationshipService<TResource, TId> addToRelationship = null, IUpdateService<TResource, TId> update = null, - ISetRelationshipService<TResource, TId> setRelationship = null, IDeleteService<TResource, TId> delete = null, - IRemoveFromRelationshipService<TResource, TId> removeFromRelationship = null) + protected BaseJsonApiController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IGetAllService<TResource, TId>? getAll = null, IGetByIdService<TResource, TId>? getById = null, + IGetSecondaryService<TResource, TId>? getSecondary = null, IGetRelationshipService<TResource, TId>? getRelationship = null, + ICreateService<TResource, TId>? create = null, IAddToRelationshipService<TResource, TId>? addToRelationship = null, + IUpdateService<TResource, TId>? update = null, ISetRelationshipService<TResource, TId>? setRelationship = null, + IDeleteService<TResource, TId>? delete = null, IRemoveFromRelationshipService<TResource, TId>? removeFromRelationship = null) { ArgumentGuard.NotNull(options, nameof(options)); + ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); ArgumentGuard.NotNull(loggerFactory, nameof(loggerFactory)); _options = options; + _resourceGraph = resourceGraph; _traceWriter = new TraceLogWriter<BaseJsonApiController<TResource, TId>>(loggerFactory); _getAll = getAll; _getById = getById; @@ -83,7 +87,9 @@ protected BaseJsonApiController(IJsonApiOptions options, ILoggerFactory loggerFa } /// <summary> - /// Gets a collection of top-level (non-nested) resources. Example: GET /articles HTTP/1.1 + /// Gets a collection of primary resources. Example: <code><![CDATA[ + /// GET /articles HTTP/1.1 + /// ]]></code> /// </summary> public virtual async Task<IActionResult> GetAsync(CancellationToken cancellationToken) { @@ -91,7 +97,7 @@ public virtual async Task<IActionResult> GetAsync(CancellationToken cancellation if (_getAll == null) { - throw new RequestMethodNotAllowedException(HttpMethod.Get); + throw new RouteNotAvailableException(HttpMethod.Get, Request.Path); } IReadOnlyCollection<TResource> resources = await _getAll.GetAsync(cancellationToken); @@ -100,7 +106,9 @@ public virtual async Task<IActionResult> GetAsync(CancellationToken cancellation } /// <summary> - /// Gets a single top-level (non-nested) resource by ID. Example: /articles/1 + /// Gets a single primary resource by ID. Example: <code><![CDATA[ + /// GET /articles/1 HTTP/1.1 + /// ]]></code> /// </summary> public virtual async Task<IActionResult> GetAsync(TId id, CancellationToken cancellationToken) { @@ -111,7 +119,7 @@ public virtual async Task<IActionResult> 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<IActionResult> GetAsync(TId id, CancellationToken canc } /// <summary> - /// 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: <code><![CDATA[ + /// GET /articles/1/author HTTP/1.1 + /// ]]></code> Example: + /// <code><![CDATA[ + /// GET /articles/1/revisions HTTP/1.1 + /// ]]></code> /// </summary> public virtual async Task<IActionResult> GetSecondaryAsync(TId id, string relationshipName, CancellationToken cancellationToken) { @@ -134,16 +147,22 @@ public virtual async Task<IActionResult> 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); } /// <summary> - /// 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 <c>null</c>, a single object or a collection. Example: + /// <code><![CDATA[ + /// GET /articles/1/relationships/author HTTP/1.1 + /// ]]></code> Example: + /// <code><![CDATA[ + /// GET /articles/1/relationships/revisions HTTP/1.1 + /// ]]></code> /// </summary> public virtual async Task<IActionResult> GetRelationshipAsync(TId id, string relationshipName, CancellationToken cancellationToken) { @@ -157,16 +176,18 @@ public virtual async Task<IActionResult> 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); } /// <summary> - /// 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: <code><![CDATA[ + /// POST /articles HTTP/1.1 + /// ]]></code> /// </summary> public virtual async Task<IActionResult> PostAsync([FromBody] TResource resource, CancellationToken cancellationToken) { @@ -179,23 +200,17 @@ public virtual async Task<IActionResult> 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<IActionResult> PostAsync([FromBody] TResource resource } /// <summary> - /// Adds resources to a to-many relationship. Example: POST /articles/1/revisions HTTP/1.1 + /// Adds resources to a to-many relationship. Example: <code><![CDATA[ + /// POST /articles/1/revisions HTTP/1.1 + /// ]]></code> /// </summary> /// <param name="id"> /// Identifies the left side of the relationship. @@ -237,7 +254,7 @@ public virtual async Task<IActionResult> 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<IActionResult> PostRelationshipAsync(TId id, string re /// <summary> /// 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: <code><![CDATA[ + /// PATCH /articles/1 HTTP/1.1 + /// ]]></code> /// </summary> public virtual async Task<IActionResult> PatchAsync(TId id, [FromBody] TResource resource, CancellationToken cancellationToken) { @@ -261,22 +280,27 @@ public virtual async Task<IActionResult> 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); } /// <summary> - /// 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: + /// <code><![CDATA[ + /// PATCH /articles/1/relationships/author HTTP/1.1 + /// ]]></code> Example: + /// <code><![CDATA[ + /// PATCH /articles/1/relationships/revisions HTTP/1.1 + /// ]]></code> /// </summary> /// <param name="id"> /// Identifies the left side of the relationship. @@ -290,7 +314,7 @@ public virtual async Task<IActionResult> PatchAsync(TId id, [FromBody] TResource /// <param name="cancellationToken"> /// Propagates notification that request handling should be canceled. /// </param> - public virtual async Task<IActionResult> PatchRelationshipAsync(TId id, string relationshipName, [FromBody] object rightValue, + public virtual async Task<IActionResult> PatchRelationshipAsync(TId id, string relationshipName, [FromBody] object? rightValue, CancellationToken cancellationToken) { _traceWriter.LogMethodStart(new @@ -304,7 +328,7 @@ public virtual async Task<IActionResult> 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<IActionResult> PatchRelationshipAsync(TId id, string r } /// <summary> - /// Deletes an existing resource. Example: DELETE /articles/1 HTTP/1.1 + /// Deletes an existing resource. Example: <code><![CDATA[ + /// DELETE /articles/1 HTTP/1.1 + /// ]]></code> /// </summary> public virtual async Task<IActionResult> DeleteAsync(TId id, CancellationToken cancellationToken) { @@ -324,7 +350,7 @@ public virtual async Task<IActionResult> 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<IActionResult> DeleteAsync(TId id, CancellationToken c } /// <summary> - /// Removes resources from a to-many relationship. Example: DELETE /articles/1/relationships/revisions HTTP/1.1 + /// Removes resources from a to-many relationship. Example: <code><![CDATA[ + /// DELETE /articles/1/relationships/revisions HTTP/1.1 + /// ]]></code> /// </summary> /// <param name="id"> /// Identifies the left side of the relationship. @@ -362,7 +390,7 @@ public virtual async Task<IActionResult> 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<IActionResult> DeleteRelationshipAsync(TId id, string return NoContent(); } } - - /// <inheritdoc /> - public abstract class BaseJsonApiController<TResource> : BaseJsonApiController<TResource, int> - where TResource : class, IIdentifiable<int> - { - /// <inheritdoc /> - protected BaseJsonApiController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<TResource, int> resourceService) - : base(options, loggerFactory, resourceService, resourceService) - { - } - - /// <inheritdoc /> - protected BaseJsonApiController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceQueryService<TResource, int> queryService = null, - IResourceCommandService<TResource, int> commandService = null) - : base(options, loggerFactory, queryService, commandService) - { - } - - /// <inheritdoc /> - protected BaseJsonApiController(IJsonApiOptions options, ILoggerFactory loggerFactory, IGetAllService<TResource, int> getAll = null, - IGetByIdService<TResource, int> getById = null, IGetSecondaryService<TResource, int> getSecondary = null, - IGetRelationshipService<TResource, int> getRelationship = null, ICreateService<TResource, int> create = null, - IAddToRelationshipService<TResource, int> addToRelationship = null, IUpdateService<TResource, int> update = null, - ISetRelationshipService<TResource, int> setRelationship = null, IDeleteService<TResource, int> delete = null, - IRemoveFromRelationshipService<TResource, int> 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 { /// <summary> - /// 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 <see cref="IOperationsProcessor" />. /// </summary> [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<BaseJsonApiOperationsController> _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<IActionResult> PostOperationsAsync([FromBody] IList<Op ArgumentGuard.NotNull(operations, nameof(operations)); - ValidateClientGeneratedIds(operations); - if (_options.ValidateModelState) { ValidateModelState(operations); } - IList<OperationContainer> results = await _processor.ProcessAsync(operations, cancellationToken); + IList<OperationContainer?> results = await _processor.ProcessAsync(operations, cancellationToken); return results.Any(result => result != null) ? Ok(results) : NoContent(); } - protected virtual void ValidateClientGeneratedIds(IEnumerable<OperationContainer> 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<OperationContainer> operations) + protected virtual void ValidateModelState(IList<OperationContainer> 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<ModelStateViolation>(); + 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<ModelStateViolation> violations) - { - foreach ((string propertyName, ModelStateEntry entry) in modelState) + if (requestModelState.Any()) { - AddValidationErrors(entry, propertyName, resourceType, operationIndex, violations); + Dictionary<string, ModelStateEntry> modelStateDictionary = requestModelState.ToDictionary(tuple => tuple.key, tuple => tuple.entry); + + throw new InvalidModelStateException(modelStateDictionary, typeof(IList<OperationContainer>), _options.IncludeExceptionStackTraceInErrors, + _resourceGraph, + (collectionType, index) => collectionType == typeof(IList<OperationContainer>) ? operations[index].Resource.GetType() : null); } } - private static void AddValidationErrors(ModelStateEntry entry, string propertyName, Type resourceType, int operationIndex, - List<ModelStateViolation> 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<ErrorObject> errors) { - ArgumentGuard.NotNull(errors, nameof(errors)); + IReadOnlyList<ErrorObject>? 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<ErrorObject>? ToErrorList(IEnumerable<ErrorObject>? 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 { /// <summary> - /// The base class to derive resource-specific write-only controllers from. This class delegates all work to - /// <see cref="BaseJsonApiController{TResource, TId}" /> 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. /// </summary> /// <typeparam name="TResource"> /// The resource type. @@ -20,70 +15,16 @@ namespace JsonApiDotNetCore.Controllers /// <typeparam name="TId"> /// The resource identifier type. /// </typeparam> - public abstract class JsonApiCommandController<TResource, TId> : BaseJsonApiController<TResource, TId> + public abstract class JsonApiCommandController<TResource, TId> : JsonApiController<TResource, TId> where TResource : class, IIdentifiable<TId> { /// <summary> /// Creates an instance from a write-only service. /// </summary> - protected JsonApiCommandController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceCommandService<TResource, TId> commandService) - : base(options, loggerFactory, null, commandService) - { - } - - /// <inheritdoc /> - [HttpPost] - public override async Task<IActionResult> PostAsync([FromBody] TResource resource, CancellationToken cancellationToken) - { - return await base.PostAsync(resource, cancellationToken); - } - - /// <inheritdoc /> - [HttpPost("{id}/relationships/{relationshipName}")] - public override async Task<IActionResult> PostRelationshipAsync(TId id, string relationshipName, [FromBody] ISet<IIdentifiable> rightResourceIds, - CancellationToken cancellationToken) - { - return await base.PostRelationshipAsync(id, relationshipName, rightResourceIds, cancellationToken); - } - - /// <inheritdoc /> - [HttpPatch("{id}")] - public override async Task<IActionResult> PatchAsync(TId id, [FromBody] TResource resource, CancellationToken cancellationToken) - { - return await base.PatchAsync(id, resource, cancellationToken); - } - - /// <inheritdoc /> - [HttpPatch("{id}/relationships/{relationshipName}")] - public override async Task<IActionResult> PatchRelationshipAsync(TId id, string relationshipName, [FromBody] object rightValue, - CancellationToken cancellationToken) - { - return await base.PatchRelationshipAsync(id, relationshipName, rightValue, cancellationToken); - } - - /// <inheritdoc /> - [HttpDelete("{id}")] - public override async Task<IActionResult> DeleteAsync(TId id, CancellationToken cancellationToken) - { - return await base.DeleteAsync(id, cancellationToken); - } - - /// <inheritdoc /> - [HttpDelete("{id}/relationships/{relationshipName}")] - public override async Task<IActionResult> DeleteRelationshipAsync(TId id, string relationshipName, [FromBody] ISet<IIdentifiable> rightResourceIds, - CancellationToken cancellationToken) - { - return await base.DeleteRelationshipAsync(id, relationshipName, rightResourceIds, cancellationToken); - } - } - - /// <inheritdoc /> - public abstract class JsonApiCommandController<TResource> : JsonApiCommandController<TResource, int> - where TResource : class, IIdentifiable<int> - { - /// <inheritdoc /> - protected JsonApiCommandController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceCommandService<TResource, int> commandService) - : base(options, loggerFactory, commandService) + protected JsonApiCommandController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceCommandService<TResource, TId> 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<TResource, TId> : BaseJsonApiController< where TResource : class, IIdentifiable<TId> { /// <inheritdoc /> - protected JsonApiController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<TResource, TId> resourceService) - : base(options, loggerFactory, resourceService) + protected JsonApiController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<TResource, TId> resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } /// <inheritdoc /> - protected JsonApiController(IJsonApiOptions options, ILoggerFactory loggerFactory, IGetAllService<TResource, TId> getAll = null, - IGetByIdService<TResource, TId> getById = null, IGetSecondaryService<TResource, TId> getSecondary = null, - IGetRelationshipService<TResource, TId> getRelationship = null, ICreateService<TResource, TId> create = null, - IAddToRelationshipService<TResource, TId> addToRelationship = null, IUpdateService<TResource, TId> update = null, - ISetRelationshipService<TResource, TId> setRelationship = null, IDeleteService<TResource, TId> delete = null, - IRemoveFromRelationshipService<TResource, TId> removeFromRelationship = null) - : base(options, loggerFactory, getAll, getById, getSecondary, getRelationship, create, addToRelationship, update, setRelationship, delete, - removeFromRelationship) + protected JsonApiController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IGetAllService<TResource, TId>? getAll = null, IGetByIdService<TResource, TId>? getById = null, + IGetSecondaryService<TResource, TId>? getSecondary = null, IGetRelationshipService<TResource, TId>? getRelationship = null, + ICreateService<TResource, TId>? create = null, IAddToRelationshipService<TResource, TId>? addToRelationship = null, + IUpdateService<TResource, TId>? update = null, ISetRelationshipService<TResource, TId>? setRelationship = null, + IDeleteService<TResource, TId>? delete = null, IRemoveFromRelationshipService<TResource, TId>? removeFromRelationship = null) + : base(options, resourceGraph, loggerFactory, getAll, getById, getSecondary, getRelationship, create, addToRelationship, update, setRelationship, + delete, removeFromRelationship) { } @@ -96,7 +97,7 @@ public override async Task<IActionResult> PatchAsync(TId id, [FromBody] TResourc /// <inheritdoc /> [HttpPatch("{id}/relationships/{relationshipName}")] - public override async Task<IActionResult> PatchRelationshipAsync(TId id, string relationshipName, [FromBody] object rightValue, + public override async Task<IActionResult> 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<IActionResult> DeleteRelationshipAsync(TId id, string return await base.DeleteRelationshipAsync(id, relationshipName, rightResourceIds, cancellationToken); } } - - /// <inheritdoc /> - public abstract class JsonApiController<TResource> : JsonApiController<TResource, int> - where TResource : class, IIdentifiable<int> - { - /// <inheritdoc /> - protected JsonApiController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<TResource, int> resourceService) - : base(options, loggerFactory, resourceService) - { - } - - /// <inheritdoc /> - protected JsonApiController(IJsonApiOptions options, ILoggerFactory loggerFactory, IGetAllService<TResource, int> getAll = null, - IGetByIdService<TResource, int> getById = null, IGetSecondaryService<TResource, int> getSecondary = null, - IGetRelationshipService<TResource, int> getRelationship = null, ICreateService<TResource, int> create = null, - IAddToRelationshipService<TResource, int> addToRelationship = null, IUpdateService<TResource, int> update = null, - ISetRelationshipService<TResource, int> setRelationship = null, IDeleteService<TResource, int> delete = null, - IRemoveFromRelationshipService<TResource, int> 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 /// </summary> 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 { /// <summary> - /// The base class to derive resource-specific read-only controllers from. This class delegates all work to - /// <see cref="BaseJsonApiController{TResource, TId}" /> 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. /// </summary> /// <typeparam name="TResource"> /// The resource type. @@ -19,53 +15,15 @@ namespace JsonApiDotNetCore.Controllers /// <typeparam name="TId"> /// The resource identifier type. /// </typeparam> - public abstract class JsonApiQueryController<TResource, TId> : BaseJsonApiController<TResource, TId> + public abstract class JsonApiQueryController<TResource, TId> : JsonApiController<TResource, TId> where TResource : class, IIdentifiable<TId> { /// <summary> /// Creates an instance from a read-only service. /// </summary> - protected JsonApiQueryController(IJsonApiOptions context, ILoggerFactory loggerFactory, IResourceQueryService<TResource, TId> queryService) - : base(context, loggerFactory, queryService) - { - } - - /// <inheritdoc /> - [HttpGet] - public override async Task<IActionResult> GetAsync(CancellationToken cancellationToken) - { - return await base.GetAsync(cancellationToken); - } - - /// <inheritdoc /> - [HttpGet("{id}")] - public override async Task<IActionResult> GetAsync(TId id, CancellationToken cancellationToken) - { - return await base.GetAsync(id, cancellationToken); - } - - /// <inheritdoc /> - [HttpGet("{id}/{relationshipName}")] - public override async Task<IActionResult> GetSecondaryAsync(TId id, string relationshipName, CancellationToken cancellationToken) - { - return await base.GetSecondaryAsync(id, relationshipName, cancellationToken); - } - - /// <inheritdoc /> - [HttpGet("{id}/relationships/{relationshipName}")] - public override async Task<IActionResult> GetRelationshipAsync(TId id, string relationshipName, CancellationToken cancellationToken) - { - return await base.GetRelationshipAsync(id, relationshipName, cancellationToken); - } - } - - /// <inheritdoc /> - public abstract class JsonApiQueryController<TResource> : JsonApiQueryController<TResource, int> - where TResource : class, IIdentifiable<int> - { - /// <inheritdoc /> - protected JsonApiQueryController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceQueryService<TResource, int> queryService) - : base(options, loggerFactory, queryService) + protected JsonApiQueryController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceQueryService<TResource, TId> 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 -{ - /// <summary> - /// Represents the violation of a model state validation rule. - /// </summary> - [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 /// </summary> public sealed class DefaultCodeTimerSession : ICodeTimerSession { - private readonly AsyncLocal<ICodeTimer> _codeTimerInContext = new(); + private readonly AsyncLocal<ICodeTimer?> _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 +{ + /// <summary> + /// The error that is thrown when assigning a local ID that was already assigned in an earlier operation. + /// </summary> + [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 +{ + /// <summary> + /// The error that is thrown when an operation in an atomic:operations request failed to be processed for unknown reasons. + /// </summary> + [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 +{ + /// <summary> + /// The error that is thrown when referencing a local ID that was assigned to a different resource type. + /// </summary> + [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 { /// <summary> - /// The error that is thrown when model state validation fails. + /// The error that is thrown when ASP.NET ModelState validation fails. /// </summary> [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<string, ModelStateEntry> modelState, Type modelType, bool includeExceptionStackTraceInErrors, + IResourceGraph resourceGraph, Func<Type, int, Type?>? getCollectionElementTypeCallback = null) + : base(FromModelStateDictionary(modelState, modelType, resourceGraph, includeExceptionStackTraceInErrors, getCollectionElementTypeCallback)) { } - public InvalidModelStateException(IEnumerable<ModelStateViolation> violations, bool includeExceptionStackTraceInErrors, JsonNamingPolicy namingPolicy) - : base(FromModelStateViolations(violations, includeExceptionStackTraceInErrors, namingPolicy)) - { - } - - private static IEnumerable<ModelStateViolation> FromModelStateDictionary(ModelStateDictionary modelState, Type resourceType) + private static IEnumerable<ErrorObject> FromModelStateDictionary(IReadOnlyDictionary<string, ModelStateEntry> modelState, Type modelType, + IResourceGraph resourceGraph, bool includeExceptionStackTraceInErrors, Func<Type, int, Type?>? 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<ModelStateViolation>(); + List<ErrorObject> 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<ModelStateViolation> violations) + private static IEnumerable<(ModelStateEntry entry, string? sourcePointer)> ResolveSourcePointers( + IReadOnlyDictionary<string, ModelStateEntry> modelState, Type modelType, IResourceGraph resourceGraph, + Func<Type, int, Type?>? 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<ErrorObject> FromModelStateViolations(IEnumerable<ModelStateViolation> 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<ErrorObject> 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<OperationContainer>)) { - 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>(); + 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<object>.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<ErrorObject> 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<string, object>(); - error.Meta["StackTrace"] = stackTraceLines; + if (stackTraceLines.Any()) + { + error.Meta ??= new Dictionary<string, object?>(); + error.Meta["StackTrace"] = stackTraceLines; + } } return error; } + + /// <summary> + /// Base type that represents a segment in a ModelState key. + /// </summary> + 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<Type, int, Type?>? 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<Type, int, Type?>? 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<Type, int, Type?>? 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<Type, int, Type?>? 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); + } + } + + /// <summary> + /// Represents an array indexer in a ModelState key, such as "1" in "Customer.Orders[1].Amount". + /// </summary> + 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<Type, int, Type?>? 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; + } + } + + /// <summary> + /// Represents a property in a ModelState key, such as "Orders" in "Customer.Orders[1].Amount". + /// </summary> + private sealed class PropertySegment : ModelStateKeySegment + { + public string PropertyName { get; } + + public PropertySegment(string propertyName, Type modelType, bool isInComplexType, string nextKey, string? sourcePointer, + ModelStateKeySegment? parent, Func<Type, int, Type?>? 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<string, object?> + { + ["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<ErrorObject> 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<ErrorObject> errors, Exception innerException = null) + public JsonApiException(IEnumerable<ErrorObject> errors, Exception? innerException = null) : base(null, innerException) { - List<ErrorObject> errorList = errors?.ToList(); + IReadOnlyList<ErrorObject>? errorList = ToErrorList(errors); ArgumentGuard.NotNullNorEmpty(errorList, nameof(errors)); Errors = errorList; } + + private static IReadOnlyList<ErrorObject>? ToErrorList(IEnumerable<ErrorObject>? 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 +{ + /// <summary> + /// The error that is thrown when assigning and referencing a local ID within the same operation. + /// </summary> + [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 -{ - /// <summary> - /// The error that is thrown when a request is received that contains an unsupported HTTP verb. - /// </summary> - [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 -{ - /// <summary> - /// The error that is thrown when a resource creation request or operation is received that contains a client-generated ID. - /// </summary> - [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 -{ - /// <summary> - /// The error that is thrown when the resource ID in the request body does not match the ID in the current endpoint URL. - /// </summary> - [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 -{ - /// <summary> - /// The error that is thrown when the resource type in the request body does not match the type expected at the current endpoint URL. - /// </summary> - [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<MissingResourceInRe private static ErrorObject CreateError(MissingResourceInRelationship missingResourceInRelationship) { - return new(HttpStatusCode.NotFound) + return new ErrorObject(HttpStatusCode.NotFound) { Title = "A related resource does not exist.", Detail = $"Related resource of type '{missingResourceInRelationship.ResourceType}' with ID '{missingResourceInRelationship.ResourceId}' " + diff --git a/src/JsonApiDotNetCore/Errors/RouteNotAvailableException.cs b/src/JsonApiDotNetCore/Errors/RouteNotAvailableException.cs new file mode 100644 index 0000000000..ed0797efba --- /dev/null +++ b/src/JsonApiDotNetCore/Errors/RouteNotAvailableException.cs @@ -0,0 +1,26 @@ +using System.Net; +using System.Net.Http; +using JetBrains.Annotations; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Errors +{ + /// <summary> + /// The error that is thrown when a request is received for an HTTP route that is not exposed. + /// </summary> + [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 -{ - /// <summary> - /// The error that is thrown when an attempt is made to update a to-one relationship from a to-many relationship endpoint. - /// </summary> - [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 +{ + /// <summary> + /// The error that is thrown when referencing a local ID that hasn't been assigned. + /// </summary> + [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 @@ <PropertyGroup> <PackageTags>jsonapidotnetcore;jsonapi;json:api;dotnet;asp.net</PackageTags> - <Description>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.</Description> + <Description>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.</Description> <Authors>json-api-dotnet</Authors> <PackageProjectUrl>https://www.jsonapi.net/</PackageProjectUrl> <PackageLicenseExpression>MIT</PackageLicenseExpression> <PackageRequireLicenseAcceptance>false</PackageRequireLicenseAcceptance> + <PackageReleaseNotes>See https://github.com/json-api-dotnet/JsonApiDotNetCore/releases.</PackageReleaseNotes> + <PackageIcon>logo.png</PackageIcon> <PublishRepositoryUrl>true</PublishRepositoryUrl> <EmbedUntrackedSources>true</EmbedUntrackedSources> <DebugType>embedded</DebugType> </PropertyGroup> + <ItemGroup> + <None Include="..\..\logo.png"> + <Pack>True</Pack> + <PackagePath></PackagePath> + </None> + </ItemGroup> + <ItemGroup> <FrameworkReference Include="Microsoft.AspNetCore.App" /> </ItemGroup> 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<ErrorObject> 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<ExceptionHandler>(); } - public Document HandleException(Exception exception) + public IReadOnlyList<ErrorObject> 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<ErrorObject> 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<ErrorObject> 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<string, object>(); - error.Meta["StackTrace"] = stackTraceLines; + foreach (ErrorObject error in errors) + { + error.Meta ??= new Dictionary<string, object?>(); + 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<IFeatureCollection, IHttpRequestFeature> NullRequestFeature = _ => null; + private static readonly Func<IFeatureCollection, IHttpRequestFeature?> NullRequestFeature = _ => null; private FeatureReferences<IHttpRequestFeature> _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)!; /// <inheritdoc /> + [AllowNull] public IQueryCollection Query { get @@ -38,7 +40,7 @@ public IQueryCollection Query { _original = current; - Dictionary<string, StringValues> result = FixedQueryHelpers.ParseNullableQuery(current); + Dictionary<string, StringValues>? 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 /// <returns> /// A collection of parsed keys and values, null if there are no entries. /// </returns> - public static Dictionary<string, StringValues> ParseNullableQuery(string queryString) + public static Dictionary<string, StringValues>? 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 /// <summary> /// Gets the associated resource type for the provided controller type. /// </summary> - Type GetResourceTypeForController(Type controllerType); + ResourceType? GetResourceTypeForController(Type? controllerType); /// <summary> /// Gets the associated controller name for the provided resource type. /// </summary> - 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 /// </summary> public interface IExceptionHandler { - Document HandleException(Exception exception); + IReadOnlyList<ErrorObject> 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; } /// <summary> - /// 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 <c>null</c> in "/blogs", "123" in "/blogs/123" or "/blogs/123/author". This is + /// <c>null</c> before and after processing operations in an atomic:operations request. /// </summary> - string PrimaryId { get; } + string? PrimaryId { get; } /// <summary> - /// 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 <c>null</c> before and + /// after processing operations in an atomic:operations request. /// </summary> - ResourceContext PrimaryResource { get; } + ResourceType? PrimaryResourceType { get; } /// <summary> - /// 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 <c>null</c> in "/blogs", "/blogs/123" and "/blogs/123/unknownResource" or "people" in + /// "/blogs/123/author" and "/blogs/123/relationships/author". This is <c>null</c> before and after processing operations in an atomic:operations + /// request. /// </summary> - ResourceContext SecondaryResource { get; } + ResourceType? SecondaryResourceType { get; } /// <summary> - /// 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 <c>null</c> in "/blogs", "/blogs/123" and "/blogs/123/unknownResource" or "author" in + /// "/blogs/123/author" and "/blogs/123/relationships/author". This is <c>null</c> before and after processing operations in an atomic:operations + /// request. /// </summary> - RelationshipAttribute Relationship { get; } + RelationshipAttribute? Relationship { get; } /// <summary> /// Indicates whether this request targets a single resource or a collection of resources. @@ -41,19 +45,20 @@ public interface IJsonApiRequest bool IsCollection { get; } /// <summary> - /// 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. /// </summary> bool IsReadOnly { get; } /// <summary> - /// 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 <c>null</c> when processing a + /// read-only operation, and before and after processing operations in an atomic:operations request. /// </summary> WriteOperationKind? WriteOperation { get; } /// <summary> /// In case of an atomic:operations request, identifies the overarching transaction. /// </summary> - string TransactionId { get; } + string? TransactionId { get; } /// <summary> /// 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<InputFormatterResult> ReadAsync(InputFormatterContext context) ArgumentGuard.NotNull(context, nameof(context)); var reader = context.HttpContext.RequestServices.GetRequiredService<IJsonApiReader>(); - 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<JsonApiMiddleware> logger) + IJsonApiRequest request, ILogger<JsonApiMiddleware> 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<IQueryFeature>(new FixedQueryFeature(httpContext.Features)); using (CodeTimingSessionManager.Current.Measure("Subsequent middleware")) @@ -119,30 +121,20 @@ private async Task<bool> 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<ControllerActionDescriptor>(); - 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<bool> 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<bool> 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<IJsonApiWriter>(); - 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; } /// <inheritdoc /> - public string PrimaryId { get; set; } + public string? PrimaryId { get; set; } /// <inheritdoc /> - public ResourceContext PrimaryResource { get; set; } + public ResourceType? PrimaryResourceType { get; set; } /// <inheritdoc /> - public ResourceContext SecondaryResource { get; set; } + public ResourceType? SecondaryResourceType { get; set; } /// <inheritdoc /> - public RelationshipAttribute Relationship { get; set; } + public RelationshipAttribute? Relationship { get; set; } /// <inheritdoc /> public bool IsCollection { get; set; } @@ -33,7 +33,7 @@ public sealed class JsonApiRequest : IJsonApiRequest public WriteOperationKind? WriteOperation { get; set; } /// <inheritdoc /> - public string TransactionId { get; set; } + public string? TransactionId { get; set; } /// <inheritdoc /> 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<string, string> _registeredControllerNameByTemplate = new(); - private readonly Dictionary<Type, ResourceContext> _resourceContextPerControllerTypeMap = new(); - private readonly Dictionary<ResourceContext, ControllerModel> _controllerPerResourceContextMap = new(); + private readonly Dictionary<Type, ResourceType> _resourceTypePerControllerTypeMap = new(); + private readonly Dictionary<ResourceType, ControllerModel> _controllerPerResourceTypeMap = new(); public JsonApiRoutingConvention(IJsonApiOptions options, IResourceGraph resourceGraph) { @@ -47,32 +47,19 @@ public JsonApiRoutingConvention(IJsonApiOptions options, IResourceGraph resource } /// <inheritdoc /> - 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; } /// <inheritdoc /> - 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; } /// <inheritdoc /> @@ -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) /// <summary> /// Derives a template from the resource type, and checks if this template was already registered. /// </summary> - 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) /// <summary> /// Determines the resource associated to a controller by inspecting generic arguments in its inheritance tree. /// </summary> - 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<IIdentifiable>()); - 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<string> 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<Type>()); + MethodInfo? toStringMethod = type.GetMethod("ToString", Array.Empty<Type>()); 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<T>(this T element) public static List<T> AsList<T>(this T element) { - return new() + return new List<T> { element }; @@ -29,7 +29,7 @@ public static List<T> AsList<T>(this T element) public static HashSet<T> AsHashSet<T>(this T element) { - return new() + return new HashSet<T> { 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<ResourceFieldChainEx { ArgumentGuard.NotNull(chains, nameof(chains)); - IImmutableList<IncludeElementExpression> elements = ConvertChainsToElements(chains); + IImmutableSet<IncludeElementExpression> elements = ConvertChainsToElements(chains); return elements.Any() ? new IncludeExpression(elements) : IncludeExpression.Empty; } - private static IImmutableList<IncludeElementExpression> ConvertChainsToElements(IEnumerable<ResourceFieldChainExpression> chains) + private static IImmutableSet<IncludeElementExpression> ConvertChainsToElements(IEnumerable<ResourceFieldChainExpression> 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<object, object> + private sealed class IncludeToChainsConverter : QueryExpressionVisitor<object?, object?> { private readonly Stack<RelationshipAttribute> _parentRelationshipStack = new(); public List<ResourceFieldChainExpression> 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<IncludeElementExpression> elementChildren = Children.Values.Select(child => child.ToExpression()).ToImmutableArray(); + IImmutableSet<IncludeElementExpression> 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<IncludeElementExpression> Children { get; } + public IImmutableSet<IncludeElementExpression> Children { get; } public IncludeElementExpression(RelationshipAttribute relationship) - : this(relationship, ImmutableArray<IncludeElementExpression>.Empty) + : this(relationship, ImmutableHashSet<IncludeElementExpression>.Empty) { } - public IncludeElementExpression(RelationshipAttribute relationship, IImmutableList<IncludeElementExpression> children) + public IncludeElementExpression(RelationshipAttribute relationship, IImmutableSet<IncludeElementExpression> 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<IncludeElementExpression> Elements { get; } + public IImmutableSet<IncludeElementExpression> Elements { get; } - public IncludeExpression(IImmutableList<IncludeElementExpression> elements) + public IncludeExpression(IImmutableSet<IncludeElementExpression> elements) { ArgumentGuard.NotNullNorEmpty(elements, nameof(elements)); @@ -27,7 +27,7 @@ public IncludeExpression(IImmutableList<IncludeElementExpression> elements) private IncludeExpression() { - Elements = ImmutableArray<IncludeElementExpression>.Empty; + Elements = ImmutableHashSet<IncludeElementExpression>.Empty; } public override TResult Accept<TArgument, TResult>(QueryExpressionVisitor<TArgument, TResult> visitor, TArgument argument) @@ -38,10 +38,10 @@ public override TResult Accept<TArgument, TResult>(QueryExpressionVisitor<TArgum public override string ToString() { IReadOnlyCollection<ResourceFieldChainExpression> 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<FilterExpress Terms = terms; } + public static FilterExpression? Compose(LogicalOperator @operator, params FilterExpression?[] filters) + { + ArgumentGuard.NotNull(filters, nameof(filters)); + + ImmutableArray<FilterExpression> terms = filters.WhereNotNull().ToImmutableArray(); + + return terms.Length > 1 ? new LogicalExpression(@operator, terms) : terms.FirstOrDefault(); + } + public override TResult Accept<TArgument, TResult>(QueryExpressionVisitor<TArgument, TResult> 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<TArgument, TResult>(QueryExpressionVisitor<TArgument, TResult> 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 <see cref="QueryExpression" /> trees. It walks through nested expressions and updates parent on changes. /// </summary> [PublicAPI] - public class QueryExpressionRewriter<TArgument> : QueryExpressionVisitor<TArgument, QueryExpression> + public class QueryExpressionRewriter<TArgument> : QueryExpressionVisitor<TArgument, QueryExpression?> { - 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<FilterExpression> newTerms = VisitList(expression.Terms, argument); + IImmutableList<FilterExpression> 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<SortElementExpression> newElements = VisitList(expression.Elements, argument); + IImmutableList<SortElementExpression> 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<LiteralConstantExpression> newConstants = VisitSet(expression.Constants, argument); + var newTargetAttribute = Visit(expression.TargetAttribute, argument) as ResourceFieldChainExpression; + IImmutableSet<LiteralConstantExpression> 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<ResourceContext, SparseFieldSetExpression>.Builder newTable = - ImmutableDictionary.CreateBuilder<ResourceContext, SparseFieldSetExpression>(); + ImmutableDictionary<ResourceType, SparseFieldSetExpression>.Builder newTable = + ImmutableDictionary.CreateBuilder<ResourceType, SparseFieldSetExpression>(); - 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<PaginationElementQueryStringValueExpression> newElements = VisitList(expression.Elements, argument); + IImmutableList<PaginationElementQueryStringValueExpression> 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<IncludeElementExpression> newElements = VisitList(expression.Elements, argument); + IImmutableSet<IncludeElementExpression> 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<IncludeElementExpression> newElements = VisitList(expression.Children, argument); + IImmutableSet<IncludeElementExpression> 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<TResource>(this SparseFieldSetExpression sparseFieldSet, - Expression<Func<TResource, dynamic>> fieldSelector, IResourceGraph resourceGraph) + public static SparseFieldSetExpression? Including<TResource>(this SparseFieldSetExpression? sparseFieldSet, + Expression<Func<TResource, dynamic?>> 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<TResource>(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<TResource>(this SparseFieldSetExpression sparseFieldSet, - Expression<Func<TResource, dynamic>> fieldSelector, IResourceGraph resourceGraph) + public static SparseFieldSetExpression? Excluding<TResource>(this SparseFieldSetExpression? sparseFieldSet, + Expression<Func<TResource, dynamic?>> 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<TResource>(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<ResourceContext, SparseFieldSetExpression> Table { get; } + public IImmutableDictionary<ResourceType, SparseFieldSetExpression> Table { get; } - public SparseFieldTableExpression(IImmutableDictionary<ResourceContext, SparseFieldSetExpression> table) + public SparseFieldTableExpression(IImmutableDictionary<ResourceType, SparseFieldSetExpression> 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 <c>null</c>, which means no paging. Cannot be higher than /// options.MaximumPageSize. /// </summary> - PageSize PageSize { get; set; } + PageSize? PageSize { get; set; } /// <summary> /// Indicates whether the number of resources on the current page equals the page size. When <c>true</c>, 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 { /// <summary> - /// 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. /// </summary> - FilterExpression GetTopFilterFromConstraints(ResourceContext resourceContext); + FilterExpression? GetPrimaryFilterFromConstraints(ResourceType primaryResourceType); + + /// <summary> + /// Builds a filter from constraints, used to determine total resource count on a secondary collection endpoint. + /// </summary> + FilterExpression? GetSecondaryFilterFromConstraints<TId>(TId primaryId, HasManyAttribute hasManyRelationship); /// <summary> /// Collects constraints and builds a <see cref="QueryLayer" /> out of them, used to retrieve the actual resources. /// </summary> - QueryLayer ComposeFromConstraints(ResourceContext requestResource); + QueryLayer ComposeFromConstraints(ResourceType requestResourceType); /// <summary> /// Collects constraints and builds a <see cref="QueryLayer" /> out of them, used to retrieve one resource. /// </summary> - QueryLayer ComposeForGetById<TId>(TId id, ResourceContext resourceContext, TopFieldSelection fieldSelection); + QueryLayer ComposeForGetById<TId>(TId id, ResourceType primaryResourceType, TopFieldSelection fieldSelection); /// <summary> /// Collects constraints and builds the secondary layer for a relationship endpoint. /// </summary> - QueryLayer ComposeSecondaryLayerForRelationship(ResourceContext secondaryResourceContext); + QueryLayer ComposeSecondaryLayerForRelationship(ResourceType secondaryResourceType); /// <summary> /// Wraps a layer for a secondary endpoint into a primary layer, rewriting top-level includes. /// </summary> - QueryLayer WrapLayerForSecondaryEndpoint<TId>(QueryLayer secondaryLayer, ResourceContext primaryResourceContext, TId primaryId, - RelationshipAttribute secondaryRelationship); + QueryLayer WrapLayerForSecondaryEndpoint<TId>(QueryLayer secondaryLayer, ResourceType primaryResourceType, TId primaryId, + RelationshipAttribute relationship); /// <summary> /// Builds a query that retrieves the primary resource, including all of its attributes and all targeted relationships, during a create/update/delete /// request. /// </summary> - QueryLayer ComposeForUpdate<TId>(TId id, ResourceContext primaryResource); + QueryLayer ComposeForUpdate<TId>(TId id, ResourceType primaryResourceType); /// <summary> /// 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 /// <inheritdoc /> internal sealed class EvaluatedIncludeCache : IEvaluatedIncludeCache { - private IncludeExpression _include; + private IncludeExpression? _include; /// <inheritdoc /> public void Set(IncludeExpression include) { + ArgumentGuard.NotNull(include, nameof(include)); + _include = include; } /// <inheritdoc /> - 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 /// <summary> /// Gets the evaluated inclusion tree that was stored earlier. /// </summary> - 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 +{ + /// <summary> + /// Takes sparse fieldsets from <see cref="IQueryConstraintProvider" />s and invokes + /// <see cref="IResourceDefinition{TResource,TId}.OnApplySparseFieldSet" /> on them. + /// </summary> + /// <remarks> + /// 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. + /// </remarks> + public interface ISparseFieldSetCache + { + /// <summary> + /// Gets the set of sparse fields to retrieve from the underlying data store. Returns an empty set to retrieve all fields. + /// </summary> + IImmutableSet<ResourceFieldAttribute> GetSparseFieldSetForQuery(ResourceType resourceType); + + /// <summary> + /// 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. + /// </summary> + IImmutableSet<AttrAttribute> GetIdAttributeSetForRelationshipQuery(ResourceType resourceType); + + /// <summary> + /// Gets the evaluated set of sparse fields to serialize into the response body. + /// </summary> + IImmutableSet<ResourceFieldAttribute> GetSparseFieldSetForSerializer(ResourceType resourceType); + + /// <summary> + /// Resets the cached results from resource definition callbacks. + /// </summary> + 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<ResourceFieldAttribute, ResourceContext, string> _validateSingleFieldCallback; - private ResourceContext _resourceContextInScope; + private readonly Action<ResourceFieldAttribute, ResourceType, string>? _validateSingleFieldCallback; + private ResourceType? _resourceTypeInScope; - public FilterParser(IResourceGraph resourceGraph, IResourceFactory resourceFactory, - Action<ResourceFieldAttribute, ResourceContext, string> validateSingleFieldCallback = null) - : base(resourceGraph) + public FilterParser(IResourceFactory resourceFactory, Action<ResourceFieldAttribute, ResourceType, string>? 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<object>.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<object>.Id)) { constantSet = DeObfuscateIdConstants(constantSet, targetAttributeProperty); } @@ -235,7 +230,7 @@ private IImmutableSet<LiteralConstantExpression> 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<ResourceFieldAttribute> 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<RelationshipAttribute, ResourceContext, string> _validateSingleRelationshipCallback; - private ResourceContext _resourceContextInScope; + private readonly Action<RelationshipAttribute, ResourceType, string>? _validateSingleRelationshipCallback; + private ResourceType? _resourceTypeInScope; - public IncludeParser(IResourceGraph resourceGraph, Action<RelationshipAttribute, ResourceContext, string> validateSingleRelationshipCallback = null) - : base(resourceGraph) + public IncludeParser(Action<RelationshipAttribute, ResourceType, string>? 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<R protected override IImmutableList<ResourceFieldAttribute> 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<ResourceFieldAttribute, ResourceContext, string> _validateSingleFieldCallback; - private ResourceContext _resourceContextInScope; + private readonly Action<ResourceFieldAttribute, ResourceType, string>? _validateSingleFieldCallback; + private ResourceType? _resourceTypeInScope; - public PaginationParser(IResourceGraph resourceGraph, Action<ResourceFieldAttribute, ResourceContext, string> validateSingleFieldCallback = null) - : base(resourceGraph) + public PaginationParser(Action<ResourceFieldAttribute, ResourceType, string>? 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<ResourceFieldAttribute> 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<Token> TokenStack { get; private set; } - private protected ResourceFieldChainResolver ChainResolver { get; } - - protected QueryExpressionParser(IResourceGraph resourceGraph) - { - ChainResolver = new ResourceFieldChainResolver(resourceGraph); - } + protected Stack<Token> TokenStack { get; private set; } = null!; + private protected ResourceFieldChainResolver ChainResolver { get; } = new(); /// <summary> /// 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<Token>(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<ResourceFieldAttribute> chain = OnResolveFieldChain(token.Value, chainRequirements); + IImmutableList<ResourceFieldAttribute> 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<ResourceFieldAttribute, ResourceContext, string> _validateSingleFieldCallback; - private ResourceContext _resourceContextInScope; + private readonly Action<ResourceFieldAttribute, ResourceType, string>? _validateSingleFieldCallback; + private ResourceType? _resourceTypeInScope; - public QueryStringParameterScopeParser(IResourceGraph resourceGraph, FieldChainRequirements chainRequirements, - Action<ResourceFieldAttribute, ResourceContext, string> validateSingleFieldCallback = null) - : base(resourceGraph) + public QueryStringParameterScopeParser(FieldChainRequirements chainRequirements, + Action<ResourceFieldAttribute, ResourceType, string>? 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<ResourceFieldAttribute> 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<Token> EnumerateTokens() _isInQuotedSection = false; - Token literalToken = ProduceTokenFromTextBuffer(true); + Token literalToken = ProduceTokenFromTextBuffer(true)!; yield return literalToken; } else @@ -76,7 +76,7 @@ public IEnumerable<Token> EnumerateTokens() if (singleCharacterTokenKind != null && !IsMinusInsideText(singleCharacterTokenKind.Value)) { - Token identifierToken = ProduceTokenFromTextBuffer(false); + Token? identifierToken = ProduceTokenFromTextBuffer(false); if (identifierToken != null) { @@ -104,7 +104,7 @@ public IEnumerable<Token> 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 /// </summary> internal sealed class ResourceFieldChainResolver { - private readonly IResourceGraph _resourceGraph; - - public ResourceFieldChainResolver(IResourceGraph resourceGraph) - { - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); - - _resourceGraph = resourceGraph; - } - /// <summary> /// Resolves a chain of relationships that ends in a to-many relationship, for example: blogs.owner.articles.comments /// </summary> - public IImmutableList<ResourceFieldAttribute> ResolveToManyChain(ResourceContext resourceContext, string path, - Action<ResourceFieldAttribute, ResourceContext, string> validateCallback = null) + public IImmutableList<ResourceFieldAttribute> ResolveToManyChain(ResourceType resourceType, string path, + Action<ResourceFieldAttribute, ResourceType, string>? validateCallback = null) { ImmutableArray<ResourceFieldAttribute>.Builder chainBuilder = ImmutableArray.CreateBuilder<ResourceFieldAttribute>(); 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<ResourceFieldAttribute> ResolveToManyChain(ResourceContext /// articles.revisions.author /// </example> /// </summary> - public IImmutableList<ResourceFieldAttribute> ResolveRelationshipChain(ResourceContext resourceContext, string path, - Action<RelationshipAttribute, ResourceContext, string> validateCallback = null) + public IImmutableList<ResourceFieldAttribute> ResolveRelationshipChain(ResourceType resourceType, string path, + Action<RelationshipAttribute, ResourceType, string>? validateCallback = null) { ImmutableArray<ResourceFieldAttribute>.Builder chainBuilder = ImmutableArray.CreateBuilder<ResourceFieldAttribute>(); - 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<ResourceFieldAttribute> ResolveRelationshipChain(ResourceC /// </example> /// <example>name</example> /// </summary> - public IImmutableList<ResourceFieldAttribute> ResolveToOneChainEndingInAttribute(ResourceContext resourceContext, string path, - Action<ResourceFieldAttribute, ResourceContext, string> validateCallback = null) + public IImmutableList<ResourceFieldAttribute> ResolveToOneChainEndingInAttribute(ResourceType resourceType, string path, + Action<ResourceFieldAttribute, ResourceType, string>? validateCallback = null) { ImmutableArray<ResourceFieldAttribute>.Builder chainBuilder = ImmutableArray.CreateBuilder<ResourceFieldAttribute>(); 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<ResourceFieldAttribute> ResolveToOneChainEndingInAttribute /// comments /// </example> /// </summary> - public IImmutableList<ResourceFieldAttribute> ResolveToOneChainEndingInToMany(ResourceContext resourceContext, string path, - Action<ResourceFieldAttribute, ResourceContext, string> validateCallback = null) + public IImmutableList<ResourceFieldAttribute> ResolveToOneChainEndingInToMany(ResourceType resourceType, string path, + Action<ResourceFieldAttribute, ResourceType, string>? validateCallback = null) { ImmutableArray<ResourceFieldAttribute>.Builder chainBuilder = ImmutableArray.CreateBuilder<ResourceFieldAttribute>(); 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<ResourceFieldAttribute> ResolveToOneChainEndingInToMany(Re /// author.address /// </example> /// </summary> - public IImmutableList<ResourceFieldAttribute> ResolveToOneChainEndingInAttributeOrToOne(ResourceContext resourceContext, string path, - Action<ResourceFieldAttribute, ResourceContext, string> validateCallback = null) + public IImmutableList<ResourceFieldAttribute> ResolveToOneChainEndingInAttributeOrToOne(ResourceType resourceType, string path, + Action<ResourceFieldAttribute, ResourceType, string>? validateCallback = null) { ImmutableArray<ResourceFieldAttribute>.Builder chainBuilder = ImmutableArray.CreateBuilder<ResourceFieldAttribute>(); 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<ResourceFieldAttribute, ResourceContext, string> _validateSingleFieldCallback; - private ResourceContext _resourceContextInScope; + private readonly Action<ResourceFieldAttribute, ResourceType, string>? _validateSingleFieldCallback; + private ResourceType? _resourceTypeInScope; - public SortParser(IResourceGraph resourceGraph, Action<ResourceFieldAttribute, ResourceContext, string> validateSingleFieldCallback = null) - : base(resourceGraph) + public SortParser(Action<ResourceFieldAttribute, ResourceType, string>? 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<ResourceFieldAttribute> 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<ResourceFieldAttribute, ResourceContext, string> _validateSingleFieldCallback; - private ResourceContext _resourceContext; + private readonly Action<ResourceFieldAttribute, ResourceType, string>? _validateSingleFieldCallback; + private ResourceType? _resourceType; - public SparseFieldSetParser(IResourceGraph resourceGraph, Action<ResourceFieldAttribute, ResourceContext, string> validateSingleFieldCallback = null) - : base(resourceGraph) + public SparseFieldSetParser(Action<ResourceFieldAttribute, ResourceType, string>? 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<ResourceFieldAttribute>.Builder fieldSetBuilder = ImmutableHashSet.CreateBuilder<ResourceFieldAttribute>(); @@ -56,9 +55,9 @@ protected SparseFieldSetExpression ParseSparseFieldSet() protected override IImmutableList<ResourceFieldAttribute> 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<ResourceFieldAttribute> 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<IQueryConstraintProvider> _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<IQueryConstraintProvider> constraintProviders, IResourceGraph resourceGraph, - IResourceDefinitionAccessor resourceDefinitionAccessor, IJsonApiOptions options, IPaginationContext paginationContext, - ITargetedFields targetedFields, IEvaluatedIncludeCache evaluatedIncludeCache) + public QueryLayerComposer(IEnumerable<IQueryConstraintProvider> 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; } /// <inheritdoc /> - 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); } /// <inheritdoc /> - public QueryLayer ComposeFromConstraints(ResourceContext requestResource) + public FilterExpression? GetSecondaryFilterFromConstraints<TId>(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<FilterExpression>() + .ToArray(); + + // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_chained_method_calls restore + + FilterExpression? primaryFilter = GetFilter(Array.Empty<QueryExpression>(), 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>(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>(TId primaryId, HasManyAttribute relationship, + HasOneAttribute inverseRelationship) + { + AttrAttribute idAttribute = GetIdAttribute(relationship.LeftType); + var idChain = new ResourceFieldChainExpression(ImmutableArray.Create<ResourceFieldAttribute>(inverseRelationship, idAttribute)); + + return new ComparisonExpression(ComparisonOperator.Equals, idChain, new LiteralConstantExpression(primaryId!.ToString()!)); + } + + private static FilterExpression GetInverseHasManyRelationshipFilter<TId>(TId primaryId, HasManyAttribute relationship, + HasManyAttribute inverseRelationship) + { + AttrAttribute idAttribute = GetIdAttribute(relationship.LeftType); + var idChain = new ResourceFieldChainExpression(ImmutableArray.Create<ResourceFieldAttribute>(idAttribute)); + var idComparison = new ComparisonExpression(ComparisonOperator.Equals, idChain, new LiteralConstantExpression(primaryId!.ToString()!)); + + return new HasExpression(new ResourceFieldChainExpression(inverseRelationship), idComparison); + } + + /// <inheritdoc /> + 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<ExpressionInScope> constraints, ResourceContext resourceContext) + private QueryLayer ComposeTopLayer(IEnumerable<ExpressionInScope> constraints, ResourceType resourceType) { using IDisposable _ = CodeTimingSessionManager.Current.Measure("Top-level query composition"); @@ -97,20 +164,16 @@ private QueryLayer ComposeTopLayer(IEnumerable<ExpressionInScope> 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<Expre // @formatter:keep_existing_linebreaks restore // @formatter:wrap_chained_method_calls restore - IImmutableList<IncludeElementExpression> includeElements = + IImmutableSet<IncludeElementExpression> includeElements = ProcessIncludeSet(include.Elements, topLayer, new List<RelationshipAttribute>(), constraints); return !ReferenceEquals(includeElements, include.Elements) @@ -138,17 +201,16 @@ private IncludeExpression ComposeChildren(QueryLayer topLayer, ICollection<Expre : include; } - private IImmutableList<IncludeElementExpression> ProcessIncludeSet(IImmutableList<IncludeElementExpression> includeElements, QueryLayer parentLayer, + private IImmutableSet<IncludeElementExpression> ProcessIncludeSet(IImmutableSet<IncludeElementExpression> includeElements, QueryLayer parentLayer, ICollection<RelationshipAttribute> parentRelationshipChain, ICollection<ExpressionInScope> constraints) { - IImmutableList<IncludeElementExpression> includeElementsEvaluated = - GetIncludeElements(includeElements, parentLayer.ResourceContext) ?? ImmutableArray<IncludeElementExpression>.Empty; + IImmutableSet<IncludeElementExpression> includeElementsEvaluated = GetIncludeElements(includeElements, parentLayer.ResourceType); - var updatesInChildren = new Dictionary<IncludeElementExpression, IImmutableList<IncludeElementExpression>>(); + var updatesInChildren = new Dictionary<IncludeElementExpression, IImmutableSet<IncludeElementExpression>>(); foreach (IncludeElementExpression includeElement in includeElementsEvaluated) { - parentLayer.Projection ??= new Dictionary<ResourceFieldAttribute, QueryLayer>(); + parentLayer.Projection ??= new Dictionary<ResourceFieldAttribute, QueryLayer?>(); if (!parentLayer.Projection.ContainsKey(includeElement.Relationship)) { @@ -169,30 +231,26 @@ private IImmutableList<IncludeElementExpression> 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<IncludeElementExpression> updatedChildren = - ProcessIncludeSet(includeElement.Children, child, relationshipChain, constraints); + IImmutableSet<IncludeElementExpression> 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<IncludeElementExpression> ProcessIncludeSet(IImmutableLis return !updatesInChildren.Any() ? includeElementsEvaluated : ApplyIncludeElementUpdates(includeElementsEvaluated, updatesInChildren); } - private static IImmutableList<IncludeElementExpression> ApplyIncludeElementUpdates(IImmutableList<IncludeElementExpression> includeElements, - IDictionary<IncludeElementExpression, IImmutableList<IncludeElementExpression>> updatesInChildren) + private static IImmutableSet<IncludeElementExpression> ApplyIncludeElementUpdates(IImmutableSet<IncludeElementExpression> includeElements, + IDictionary<IncludeElementExpression, IImmutableSet<IncludeElementExpression>> updatesInChildren) { - ImmutableArray<IncludeElementExpression>.Builder newElementsBuilder = ImmutableArray.CreateBuilder<IncludeElementExpression>(includeElements.Count); + ImmutableHashSet<IncludeElementExpression>.Builder newElementsBuilder = ImmutableHashSet.CreateBuilder<IncludeElementExpression>(); newElementsBuilder.AddRange(includeElements); - foreach ((IncludeElementExpression existingElement, IImmutableList<IncludeElementExpression> updatedChildren) in updatesInChildren) + foreach ((IncludeElementExpression existingElement, IImmutableSet<IncludeElementExpression> 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(); } /// <inheritdoc /> - public QueryLayer ComposeForGetById<TId>(TId id, ResourceContext resourceContext, TopFieldSelection fieldSelection) + public QueryLayer ComposeForGetById<TId>(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<ResourceFieldAttribute, QueryLayer> + queryLayer.Projection = new Dictionary<ResourceFieldAttribute, QueryLayer?> { [idAttribute] = null }; @@ -247,93 +305,93 @@ public QueryLayer ComposeForGetById<TId>(TId id, ResourceContext resourceContext } /// <inheritdoc /> - 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<ResourceFieldAttribute, QueryLayer> GetProjectionForRelationship(ResourceContext secondaryResourceContext) + private IDictionary<ResourceFieldAttribute, QueryLayer?> GetProjectionForRelationship(ResourceType secondaryResourceType) { - IImmutableSet<AttrAttribute> secondaryAttributeSet = _sparseFieldSetCache.GetIdAttributeSetForRelationshipQuery(secondaryResourceContext); + IImmutableSet<AttrAttribute> secondaryAttributeSet = _sparseFieldSetCache.GetIdAttributeSetForRelationshipQuery(secondaryResourceType); - return secondaryAttributeSet.ToDictionary(key => (ResourceFieldAttribute)key, _ => (QueryLayer)null); + return secondaryAttributeSet.ToDictionary(key => (ResourceFieldAttribute)key, _ => (QueryLayer?)null); } /// <inheritdoc /> - public QueryLayer WrapLayerForSecondaryEndpoint<TId>(QueryLayer secondaryLayer, ResourceContext primaryResourceContext, TId primaryId, - RelationshipAttribute secondaryRelationship) + public QueryLayer WrapLayerForSecondaryEndpoint<TId>(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<AttrAttribute> primaryAttributeSet = _sparseFieldSetCache.GetIdAttributeSetForRelationshipQuery(primaryResourceContext); + IImmutableSet<AttrAttribute> primaryAttributeSet = _sparseFieldSetCache.GetIdAttributeSetForRelationshipQuery(primaryResourceType); - Dictionary<ResourceFieldAttribute, QueryLayer> primaryProjection = - primaryAttributeSet.ToDictionary(key => (ResourceFieldAttribute)key, _ => (QueryLayer)null); + Dictionary<ResourceFieldAttribute, QueryLayer?> primaryProjection = + primaryAttributeSet.ToDictionary(key => (ResourceFieldAttribute)key, _ => (QueryLayer?)null); - primaryProjection[secondaryRelationship] = secondaryLayer; + primaryProjection[relationship] = secondaryLayer; - FilterExpression primaryFilter = GetFilter(Array.Empty<QueryExpression>(), primaryResourceContext); - AttrAttribute primaryIdAttribute = GetIdAttribute(primaryResourceContext); + FilterExpression? primaryFilter = GetFilter(Array.Empty<QueryExpression>(), 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<TId>(IReadOnlyCollection<TId> ids, AttrAttribute idAttribute, FilterExpression existingFilter) + private FilterExpression? CreateFilterByIds<TId>(IReadOnlyCollection<TId> 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<LiteralConstantExpression> constants = ids.Select(id => new LiteralConstantExpression(id.ToString())).ToImmutableHashSet(); + ImmutableHashSet<LiteralConstantExpression> 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); } /// <inheritdoc /> - public QueryLayer ComposeForUpdate<TId>(TId id, ResourceContext primaryResource) + public QueryLayer ComposeForUpdate<TId>(TId id, ResourceType primaryResourceType) { - ArgumentGuard.NotNull(primaryResource, nameof(primaryResource)); + ArgumentGuard.NotNull(primaryResourceType, nameof(primaryResourceType)); - ImmutableArray<IncludeElementExpression> includeElements = _targetedFields.Relationships - .Select(relationship => new IncludeElementExpression(relationship)).ToImmutableArray(); + IImmutableSet<IncludeElementExpression> includeElements = _targetedFields.Relationships + .Select(relationship => new IncludeElementExpression(relationship)).ToImmutableHashSet(); - AttrAttribute primaryIdAttribute = GetIdAttribute(primaryResource); + AttrAttribute primaryIdAttribute = GetIdAttribute(primaryResourceType); - QueryLayer primaryLayer = ComposeTopLayer(Array.Empty<ExpressionInScope>(), primaryResource); + QueryLayer primaryLayer = ComposeTopLayer(Array.Empty<ExpressionInScope>(), primaryResourceType); primaryLayer.Include = includeElements.Any() ? new IncludeExpression(includeElements) : IncludeExpression.Empty; primaryLayer.Sort = null; primaryLayer.Pagination = null; @@ -350,7 +408,7 @@ public QueryLayer ComposeForUpdate<TId>(TId id, ResourceContext primaryResource) foreach (RelationshipAttribute relationship in _targetedFields.Relationships) { - object rightValue = relationship.GetValue(primaryResource); + object? rightValue = relationship.GetValue(primaryResource); ICollection<IIdentifiable> 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<QueryExpression>(), rightResourceContext); - FilterExpression filter = CreateFilterByIds(typedIds, rightIdAttribute, baseFilter); + FilterExpression? baseFilter = GetFilter(Array.Empty<QueryExpression>(), 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<ResourceFieldAttribute, QueryLayer> + Projection = new Dictionary<ResourceFieldAttribute, QueryLayer?> { [rightIdAttribute] = null } @@ -392,26 +449,23 @@ public QueryLayer ComposeForHasMany<TId>(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<ResourceFieldAttribute, QueryLayer> + Projection = new Dictionary<ResourceFieldAttribute, QueryLayer?> { - [hasManyRelationship] = new(rightResourceContext) + [hasManyRelationship] = new(hasManyRelationship.RightType) { Filter = rightFilter, - Projection = new Dictionary<ResourceFieldAttribute, QueryLayer> + Projection = new Dictionary<ResourceFieldAttribute, QueryLayer?> { [rightIdAttribute] = null } @@ -421,37 +475,37 @@ public QueryLayer ComposeForHasMany<TId>(HasManyAttribute hasManyRelationship, T }; } - protected virtual IImmutableList<IncludeElementExpression> GetIncludeElements(IImmutableList<IncludeElementExpression> includeElements, - ResourceContext resourceContext) + protected virtual IImmutableSet<IncludeElementExpression> GetIncludeElements(IImmutableSet<IncludeElementExpression> 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<QueryExpression> expressionsInScope, ResourceContext resourceContext) + protected virtual FilterExpression? GetFilter(IReadOnlyCollection<QueryExpression> expressionsInScope, ResourceType resourceType) { ArgumentGuard.NotNull(expressionsInScope, nameof(expressionsInScope)); - ArgumentGuard.NotNull(resourceContext, nameof(resourceContext)); + ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - ImmutableArray<FilterExpression> filters = expressionsInScope.OfType<FilterExpression>().ToImmutableArray(); - FilterExpression filter = filters.Length > 1 ? new LogicalExpression(LogicalOperator.And, filters) : filters.FirstOrDefault(); + FilterExpression[] filters = expressionsInScope.OfType<FilterExpression>().ToArray(); + FilterExpression? filter = LogicalExpression.Compose(LogicalOperator.And, filters); - return _resourceDefinitionAccessor.OnApplyFilter(resourceContext.ResourceType, filter); + return _resourceDefinitionAccessor.OnApplyFilter(resourceType, filter); } - protected virtual SortExpression GetSort(IReadOnlyCollection<QueryExpression> expressionsInScope, ResourceContext resourceContext) + protected virtual SortExpression GetSort(IReadOnlyCollection<QueryExpression> expressionsInScope, ResourceType resourceType) { ArgumentGuard.NotNull(expressionsInScope, nameof(expressionsInScope)); - ArgumentGuard.NotNull(resourceContext, nameof(resourceContext)); + ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - SortExpression sort = expressionsInScope.OfType<SortExpression>().FirstOrDefault(); + SortExpression? sort = expressionsInScope.OfType<SortExpression>().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<QueryExpression> ex return sort; } - protected virtual PaginationExpression GetPagination(IReadOnlyCollection<QueryExpression> expressionsInScope, ResourceContext resourceContext) + protected virtual PaginationExpression GetPagination(IReadOnlyCollection<QueryExpression> expressionsInScope, ResourceType resourceType) { ArgumentGuard.NotNull(expressionsInScope, nameof(expressionsInScope)); - ArgumentGuard.NotNull(resourceContext, nameof(resourceContext)); + ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - PaginationExpression pagination = expressionsInScope.OfType<PaginationExpression>().FirstOrDefault(); + PaginationExpression? pagination = expressionsInScope.OfType<PaginationExpression>().FirstOrDefault(); - pagination = _resourceDefinitionAccessor.OnApplyPagination(resourceContext.ResourceType, pagination); + pagination = _resourceDefinitionAccessor.OnApplyPagination(resourceType, pagination); pagination ??= new PaginationExpression(PageNumber.ValueOne, _options.DefaultPageSize); return pagination; } - protected virtual IDictionary<ResourceFieldAttribute, QueryLayer> GetProjectionForSparseAttributeSet(ResourceContext resourceContext) + protected virtual IDictionary<ResourceFieldAttribute, QueryLayer?>? GetProjectionForSparseAttributeSet(ResourceType resourceType) { - ArgumentGuard.NotNull(resourceContext, nameof(resourceContext)); + ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - IImmutableSet<ResourceFieldAttribute> fieldSet = _sparseFieldSetCache.GetSparseFieldSetForQuery(resourceContext); + IImmutableSet<ResourceFieldAttribute> fieldSet = _sparseFieldSetCache.GetSparseFieldSetForQuery(resourceType); if (!fieldSet.Any()) { @@ -485,15 +539,15 @@ protected virtual IDictionary<ResourceFieldAttribute, QueryLayer> GetProjectionF } HashSet<AttrAttribute> attributeSet = fieldSet.OfType<AttrAttribute>().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<object>.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 <see cref="IncludeExpression" /> into <see cref="EntityFrameworkQueryableExtensions.Include{TEntity, TProperty}" /> calls. /// </summary> [PublicAPI] - public class IncludeClauseBuilder : QueryClauseBuilder<object> + public class IncludeClauseBuilder : QueryClauseBuilder<object?> { 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<RelationshipAttribute>()) { 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<EagerLoadAttribute> eagerLoads, string pathPrefix) + private Expression ApplyEagerLoads(Expression source, IEnumerable<EagerLoadAttribute> 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 /// <see cref="Queryable.OrderBy{TSource, TKey}(IQueryable{TSource}, System.Linq.Expressions.Expression{System.Func{TSource,TKey}})" /> calls. /// </summary> [PublicAPI] - public class OrderClauseBuilder : QueryClauseBuilder<Expression> + public class OrderClauseBuilder : QueryClauseBuilder<Expression?> { 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<PropertyInfo>(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<PropertyInfo>(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<string> 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<ResourceFieldAttribute, QueryLayer> projection, - ResourceContext resourceContext) + protected virtual Expression ApplyProjection(Expression source, IDictionary<ResourceFieldAttribute, QueryLayer?> 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<object> 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<ResourceFieldAttribute, QueryLayer> selectors, ResourceContext resourceContext) + public Expression ApplySelect(IDictionary<ResourceFieldAttribute, QueryLayer?> selectors, ResourceType resourceType) { ArgumentGuard.NotNull(selectors, nameof(selectors)); @@ -58,17 +55,17 @@ public Expression ApplySelect(IDictionary<ResourceFieldAttribute, QueryLayer> 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<ResourceFieldAttribute, QueryLayer> selectors, ResourceContext resourceContext, + private Expression CreateLambdaBodyInitializer(IDictionary<ResourceFieldAttribute, QueryLayer?> selectors, ResourceType resourceType, LambdaScope lambdaScope, bool lambdaAccessorRequiresTestForNull) { - ICollection<PropertySelector> propertySelectors = ToPropertySelectors(selectors, resourceContext, lambdaScope.Accessor.Type); + ICollection<PropertySelector> propertySelectors = ToPropertySelectors(selectors, resourceType, lambdaScope.Accessor.Type); MemberBinding[] propertyAssignments = propertySelectors.Select(selector => CreatePropertyAssignment(selector, lambdaScope)).Cast<MemberBinding>().ToArray(); @@ -84,44 +81,63 @@ private Expression CreateLambdaBodyInitializer(IDictionary<ResourceFieldAttribut return TestForNull(lambdaScope.Accessor, memberInit); } - private ICollection<PropertySelector> ToPropertySelectors(IDictionary<ResourceFieldAttribute, QueryLayer> resourceFieldSelectors, - ResourceContext resourceContext, Type elementType) + private ICollection<PropertySelector> ToPropertySelectors(IDictionary<ResourceFieldAttribute, QueryLayer?> resourceFieldSelectors, + ResourceType resourceType, Type elementType) { var propertySelectors = new Dictionary<PropertyInfo, PropertySelector>(); - // 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<PropertyInfo, PropertySelector> propertySelectors) + { + IEntityType entityModel = _entityModel.GetEntityTypes().Single(type => type.ClrType == elementType); + IEnumerable<IProperty> entityProperties = entityModel.GetProperties().Where(property => !property.IsShadowProperty()).ToArray(); + + foreach (IProperty entityProperty in entityProperties) { - IEntityType entityModel = _entityModel.GetEntityTypes().Single(type => type.ClrType == elementType); - IEnumerable<IProperty> 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<ResourceFieldAttribute, QueryLayer?> resourceFieldSelectors, + Dictionary<PropertyInfo, PropertySelector> 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<PropertyInfo, PropertySelector> propertySelectors) + { + if (propertySelector.Property.SetMethod != null) + { + propertySelectors[propertySelector.Property] = propertySelector; } + } - foreach (EagerLoadAttribute eagerLoad in resourceContext.EagerLoads) + private static void IncludeEagerLoads(ResourceType resourceType, Dictionary<PropertyInfo, PropertySelector> propertySelectors) + { + foreach (EagerLoadAttribute eagerLoad in resourceType.EagerLoads) { var propertySelector = new PropertySelector(eagerLoad.Property); @@ -131,11 +147,7 @@ private ICollection<PropertySelector> ToPropertySelectors(IDictionary<ResourceFi { propertySelectors[propertySelector.Property] = propertySelector; } - - propertySelectors[propertySelector.Property] = propertySelector; } - - return propertySelectors.Values; } private MemberAssignment CreatePropertyAssignment(PropertySelector selector, LambdaScope lambdaScope) @@ -158,7 +170,7 @@ private MemberAssignment CreatePropertyAssignment(PropertySelector selector, Lam private Expression CreateAssignmentRightHandSideForLayer(QueryLayer layer, LambdaScope outerLambdaScope, MemberExpression propertyAccess, PropertyInfo selectorPropertyInfo, LambdaScopeFactory lambdaScopeFactory) { - Type collectionElementType = CollectionConverter.TryGetCollectionElementType(selectorPropertyInfo.PropertyType); + Type? collectionElementType = CollectionConverter.FindCollectionElementType(selectorPropertyInfo.PropertyType); Type bodyElementType = collectionElementType ?? selectorPropertyInfo.PropertyType; if (collectionElementType != null) @@ -172,7 +184,7 @@ private Expression CreateAssignmentRightHandSideForLayer(QueryLayer layer, Lambd } using LambdaScope scope = lambdaScopeFactory.CreateScope(bodyElementType, propertyAccess); - return CreateLambdaBodyInitializer(layer.Projection, layer.ResourceContext, scope, true); + return CreateLambdaBodyInitializer(layer.Projection, layer.ResourceType, scope, true); } private Expression CreateCollectionInitializer(LambdaScope lambdaScope, PropertyInfo collectionProperty, Type elementType, QueryLayer layer, @@ -180,8 +192,8 @@ private Expression CreateCollectionInitializer(LambdaScope lambdaScope, Property { MemberExpression propertyExpression = Expression.Property(lambdaScope.Accessor, collectionProperty); - var builder = new QueryableBuilder(propertyExpression, elementType, typeof(Enumerable), _nameFactory, _resourceFactory, _resourceGraph, - _entityModel, lambdaScopeFactory); + var builder = new QueryableBuilder(propertyExpression, elementType, typeof(Enumerable), _nameFactory, _resourceFactory, _entityModel, + lambdaScopeFactory); Expression layerExpression = builder.ApplyQuery(layer); @@ -209,9 +221,9 @@ private Expression SelectExtensionMethodCall(Expression source, Type elementType private sealed class PropertySelector { public PropertyInfo Property { get; } - public QueryLayer NextLayer { get; } + public QueryLayer? NextLayer { get; } - public PropertySelector(PropertyInfo property, QueryLayer nextLayer = null) + public PropertySelector(PropertyInfo property, QueryLayer? nextLayer = null) { ArgumentGuard.NotNull(property, nameof(property)); diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SkipTakeClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SkipTakeClauseBuilder.cs index 3b66d20903..ce02edb368 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SkipTakeClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SkipTakeClauseBuilder.cs @@ -10,7 +10,7 @@ namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding /// Transforms <see cref="PaginationExpression" /> into <see cref="Queryable.Skip{TSource}" /> and <see cref="Queryable.Take{TSource}" /> calls. /// </summary> [PublicAPI] - public class SkipTakeClauseBuilder : QueryClauseBuilder<object> + public class SkipTakeClauseBuilder : QueryClauseBuilder<object?> { 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 /// <see cref="Queryable.Where{TSource}(IQueryable{TSource}, System.Linq.Expressions.Expression{System.Func{TSource,bool}})" /> calls. /// </summary> [PublicAPI] - public class WhereClauseBuilder : QueryClauseBuilder<Type> + public class WhereClauseBuilder : QueryClauseBuilder<Type?> { 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>(expression.Terms.Select(filter => Visit(filter, argument))); @@ -169,15 +166,15 @@ private static BinaryExpression Compose(Queue<Expression> argumentQueue, Func<Ex return tempExpression; } - public override Expression VisitNot(NotExpression expression, Type argument) + public override Expression VisitNot(NotExpression expression, Type? argument) { Expression child = Visit(expression.Child, argument); return Expression.Not(child); } - public override Expression VisitComparison(ComparisonExpression expression, Type argument) + public override Expression VisitComparison(ComparisonExpression expression, Type? argument) { - Type commonType = TryResolveCommonType(expression.Left, expression.Right); + Type commonType = ResolveCommonType(expression.Left, expression.Right); Expression left = WrapInConvert(Visit(expression.Left, commonType), commonType); Expression right = WrapInConvert(Visit(expression.Right, commonType), commonType); @@ -209,7 +206,7 @@ public override Expression VisitComparison(ComparisonExpression expression, Type throw new InvalidOperationException($"Unknown comparison operator '{expression.Operator}'."); } - private Type TryResolveCommonType(QueryExpression left, QueryExpression right) + private Type ResolveCommonType(QueryExpression left, QueryExpression right) { Type leftType = ResolveFixedType(left); @@ -223,7 +220,7 @@ private Type TryResolveCommonType(QueryExpression left, QueryExpression right) return typeof(Nullable<>).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 { - /// <summary> - /// Takes sparse fieldsets from <see cref="IQueryConstraintProvider" />s and invokes - /// <see cref="IResourceDefinition{TResource,TId}.OnApplySparseFieldSet" /> on them. - /// </summary> - [PublicAPI] - public sealed class SparseFieldSetCache + /// <inheritdoc /> + public sealed class SparseFieldSetCache : ISparseFieldSetCache { + private static readonly ConcurrentDictionary<ResourceType, SparseFieldSetExpression> ViewableFieldSetCache = new(); + private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; - private readonly Lazy<IDictionary<ResourceContext, IImmutableSet<ResourceFieldAttribute>>> _lazySourceTable; - private readonly IDictionary<ResourceContext, IImmutableSet<ResourceFieldAttribute>> _visitedTable; + private readonly Lazy<IDictionary<ResourceType, IImmutableSet<ResourceFieldAttribute>>> _lazySourceTable; + private readonly IDictionary<ResourceType, IImmutableSet<ResourceFieldAttribute>> _visitedTable; public SparseFieldSetCache(IEnumerable<IQueryConstraintProvider> constraintProviders, IResourceDefinitionAccessor resourceDefinitionAccessor) { @@ -27,17 +25,17 @@ public SparseFieldSetCache(IEnumerable<IQueryConstraintProvider> constraintProvi ArgumentGuard.NotNull(resourceDefinitionAccessor, nameof(resourceDefinitionAccessor)); _resourceDefinitionAccessor = resourceDefinitionAccessor; - _lazySourceTable = new Lazy<IDictionary<ResourceContext, IImmutableSet<ResourceFieldAttribute>>>(() => BuildSourceTable(constraintProviders)); - _visitedTable = new Dictionary<ResourceContext, IImmutableSet<ResourceFieldAttribute>>(); + _lazySourceTable = new Lazy<IDictionary<ResourceType, IImmutableSet<ResourceFieldAttribute>>>(() => BuildSourceTable(constraintProviders)); + _visitedTable = new Dictionary<ResourceType, IImmutableSet<ResourceFieldAttribute>>(); } - private static IDictionary<ResourceContext, IImmutableSet<ResourceFieldAttribute>> BuildSourceTable( + private static IDictionary<ResourceType, IImmutableSet<ResourceFieldAttribute>> BuildSourceTable( IEnumerable<IQueryConstraintProvider> constraintProviders) { // @formatter:wrap_chained_method_calls chop_always // @formatter:keep_existing_linebreaks true - KeyValuePair<ResourceContext, SparseFieldSetExpression>[] sparseFieldTables = constraintProviders + KeyValuePair<ResourceType, SparseFieldSetExpression>[] sparseFieldTables = constraintProviders .SelectMany(provider => provider.GetConstraints()) .Where(constraint => constraint.Scope == null) .Select(constraint => constraint.Expression) @@ -49,16 +47,16 @@ private static IDictionary<ResourceContext, IImmutableSet<ResourceFieldAttribute // @formatter:keep_existing_linebreaks restore // @formatter:wrap_chained_method_calls restore - var mergedTable = new Dictionary<ResourceContext, ImmutableHashSet<ResourceFieldAttribute>.Builder>(); + var mergedTable = new Dictionary<ResourceType, ImmutableHashSet<ResourceFieldAttribute>.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<ResourceFieldAttribute>(); + mergedTable[resourceType] = ImmutableHashSet.CreateBuilder<ResourceFieldAttribute>(); } - AddSparseFieldsToSet(sparseFieldSet.Fields, mergedTable[resourceContext]); + AddSparseFieldsToSet(sparseFieldSet.Fields, mergedTable[resourceType]); } return mergedTable.ToDictionary(pair => pair.Key, pair => (IImmutableSet<ResourceFieldAttribute>)pair.Value.ToImmutable()); @@ -73,37 +71,40 @@ private static void AddSparseFieldsToSet(IImmutableSet<ResourceFieldAttribute> s } } - public IImmutableSet<ResourceFieldAttribute> GetSparseFieldSetForQuery(ResourceContext resourceContext) + /// <inheritdoc /> + public IImmutableSet<ResourceFieldAttribute> 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<ResourceFieldAttribute>? inputFields) + ? new SparseFieldSetExpression(inputFields) + : null; - SparseFieldSetExpression outputExpression = _resourceDefinitionAccessor.OnApplySparseFieldSet(resourceContext.ResourceType, inputExpression); + SparseFieldSetExpression? outputExpression = _resourceDefinitionAccessor.OnApplySparseFieldSet(resourceType, inputExpression); IImmutableSet<ResourceFieldAttribute> outputFields = outputExpression == null ? ImmutableHashSet<ResourceFieldAttribute>.Empty : outputExpression.Fields; - _visitedTable[resourceContext] = outputFields; + _visitedTable[resourceType] = outputFields; } - return _visitedTable[resourceContext]; + return _visitedTable[resourceType]; } - public IImmutableSet<AttrAttribute> GetIdAttributeSetForRelationshipQuery(ResourceContext resourceContext) + /// <inheritdoc /> + public IImmutableSet<AttrAttribute> 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<object>.Id)); var inputExpression = new SparseFieldSetExpression(ImmutableHashSet.Create<ResourceFieldAttribute>(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<AttrAttribute> outputAttributes = outputExpression == null ? ImmutableHashSet<AttrAttribute>.Empty @@ -113,40 +114,52 @@ public IImmutableSet<AttrAttribute> GetIdAttributeSetForRelationshipQuery(Resour return outputAttributes; } - public IImmutableSet<ResourceFieldAttribute> GetSparseFieldSetForSerializer(ResourceContext resourceContext) + /// <inheritdoc /> + public IImmutableSet<ResourceFieldAttribute> GetSparseFieldSetForSerializer(ResourceType resourceType) { - ArgumentGuard.NotNull(resourceContext, nameof(resourceContext)); + ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - if (!_visitedTable.ContainsKey(resourceContext)) + if (!_visitedTable.ContainsKey(resourceType)) { - IImmutableSet<ResourceFieldAttribute> inputFields = _lazySourceTable.Value.ContainsKey(resourceContext) - ? _lazySourceTable.Value[resourceContext] - : GetResourceFields(resourceContext); + SparseFieldSetExpression inputExpression = + _lazySourceTable.Value.TryGetValue(resourceType, out IImmutableSet<ResourceFieldAttribute>? 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<ResourceFieldAttribute> outputFields = - outputExpression == null ? GetResourceFields(resourceContext) : inputFields.Intersect(outputExpression.Fields); + IImmutableSet<ResourceFieldAttribute> 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<ResourceFieldAttribute> GetResourceFields(ResourceContext resourceContext) + private static SparseFieldSetExpression GetCachedViewableFieldSet(ResourceType resourceType) { - ArgumentGuard.NotNull(resourceContext, nameof(resourceContext)); + if (!ViewableFieldSetCache.TryGetValue(resourceType, out SparseFieldSetExpression? fieldSet)) + { + IImmutableSet<ResourceFieldAttribute> viewableFields = GetViewableFields(resourceType); + fieldSet = new SparseFieldSetExpression(viewableFields); + ViewableFieldSetCache[resourceType] = fieldSet; + } + return fieldSet; + } + + private static IImmutableSet<ResourceFieldAttribute> GetViewableFields(ResourceType resourceType) + { ImmutableHashSet<ResourceFieldAttribute>.Builder fieldSetBuilder = ImmutableHashSet.CreateBuilder<ResourceFieldAttribute>(); - 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 { /// <inheritdoc /> - public PageNumber PageNumber { get; set; } + public PageNumber PageNumber { get; set; } = PageNumber.ValueOne; /// <inheritdoc /> - public PageSize PageSize { get; set; } + public PageSize? PageSize { get; set; } /// <inheritdoc /> 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<ResourceFieldAttribute, QueryLayer> 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<ResourceFieldAttribute, QueryLayer?>? 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 { /// <summary> - /// Indicates whether this reader supports empty query string parameter values. Defaults to <c>false</c>. + /// Indicates whether this reader supports empty query string parameter values. /// </summary> - bool AllowEmptyValue => false; + bool AllowEmptyValue { get; } /// <summary> /// Indicates whether usage of this query string parameter is blocked using <see cref="DisableQueryStringAttribute" /> 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 /// <param name="disableQueryStringAttribute"> /// The <see cref="DisableQueryStringAttribute" /> if set on the controller that is targeted by the current request. /// </param> - 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<FilterExpression>.Builder _filtersInGlobalScope = ImmutableArray.CreateBuilder<FilterExpression>(); private readonly Dictionary<ResourceFieldChainExpression, ImmutableArray<FilterExpression>.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); } /// <inheritdoc /> 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<string> 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<string> 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<string> 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<string> 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<string> 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(); } /// <inheritdoc /> @@ -45,7 +47,7 @@ public virtual bool IsEnabled(DisableQueryStringAttribute disableQueryStringAttr /// <inheritdoc /> public virtual bool CanRead(string parameterName) { - return parameterName == PageSizeParameterName || parameterName == PageNumberParameterName; + return parameterName is PageSizeParameterName or PageNumberParameterName; } /// <inheritdoc /> @@ -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 /// <inheritdoc /> public virtual IReadOnlyCollection<ExpressionInScope> GetConstraints() { - var context = new PaginationContext(); + var paginationState = new PaginationState(); foreach (PaginationElementQueryStringValueExpression element in _pageSizeConstraint?.Elements ?? ImmutableArray<PaginationElementQueryStringValueExpression>.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<ExpressionInScope> GetConstraints() foreach (PaginationElementQueryStringValueExpression element in _pageNumberConstraint?.Elements ?? ImmutableArray<PaginationElementQueryStringValueExpression>.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<ResourceFieldChainExpression, MutablePaginationEntry> _nestedScopes = new(); - public MutablePaginationEntry ResolveEntryInScope(ResourceFieldChainExpression scope) + public MutablePaginationEntry ResolveEntryInScope(ResourceFieldChainExpression? scope) { if (scope == null) { @@ -189,21 +191,21 @@ public IReadOnlyCollection<ExpressionInScope> GetExpressionsInScope() private IEnumerable<ExpressionInScope> 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 } /// <inheritdoc /> - 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<ExpressionInScope> _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; } /// <inheritdoc /> 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<ExpressionInScope> _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); } /// <inheritdoc /> 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<ResourceContext, SparseFieldSetExpression>.Builder _sparseFieldTableBuilder = - ImmutableDictionary.CreateBuilder<ResourceContext, SparseFieldSetExpression>(); + private readonly ImmutableDictionary<ResourceType, SparseFieldSetExpression>.Builder _sparseFieldTableBuilder = + ImmutableDictionary.CreateBuilder<ResourceType, SparseFieldSetExpression>(); - private string _lastParameterName; + private string? _lastParameterName; /// <inheritdoc /> 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<object>.Id)); return new SparseFieldSetExpression(ImmutableHashSet.Create<ResourceFieldAttribute>(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 /// <summary> /// Searches the change tracker for an entity that matches the type and ID of <paramref name="identifiable" />. /// </summary> - 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; } /// <summary> 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<TDbContext> : 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<TResource, TId> : IResourceRepository private readonly TraceLogWriter<EntityFrameworkCoreRepository<TResource, TId>> _traceWriter; /// <inheritdoc /> - 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<IQueryConstraintProvider> 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 } /// <inheritdoc /> - public virtual async Task<IReadOnlyCollection<TResource>> GetAsync(QueryLayer layer, CancellationToken cancellationToken) + public virtual async Task<IReadOnlyCollection<TResource>> 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<TResource> query = ApplyQueryLayer(layer); + IQueryable<TResource> query = ApplyQueryLayer(queryLayer); using (CodeTimingSessionManager.Current.Measure("Execute SQL (data)", MeasurementSettings.ExcludeDatabaseInPercentages)) { @@ -84,20 +85,20 @@ public virtual async Task<IReadOnlyCollection<TResource>> GetAsync(QueryLayer la } /// <inheritdoc /> - public virtual async Task<int> CountAsync(FilterExpression topFilter, CancellationToken cancellationToken) + public virtual async Task<int> CountAsync(FilterExpression? filter, CancellationToken cancellationToken) { _traceWriter.LogMethodStart(new { - topFilter + filter }); using (CodeTimingSessionManager.Current.Measure("Repository - Count resources")) { - ResourceContext resourceContext = _resourceGraph.GetResourceContext<TResource>(); + ResourceType resourceType = _resourceGraph.GetResourceType<TResource>(); - var layer = new QueryLayer(resourceContext) + var layer = new QueryLayer(resourceType) { - Filter = topFilter + Filter = filter }; IQueryable<TResource> query = ApplyQueryLayer(layer); @@ -109,14 +110,14 @@ public virtual async Task<int> CountAsync(FilterExpression topFilter, Cancellati } } - protected virtual IQueryable<TResource> ApplyQueryLayer(QueryLayer layer) + protected virtual IQueryable<TResource> 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<TResource> 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<TResource> GetAll() /// <inheritdoc /> public virtual Task<TResource> GetForCreateAsync(TId id, CancellationToken cancellationToken) { + _traceWriter.LogMethodStart(new + { + id + }); + var resource = _resourceFactory.CreateInstance<TResource>(); 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<object> VisitSetRelationshipAsync(TResource leftResource, RelationshipAttribute relationship, object rightValue, + private async Task<object?> 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 } /// <inheritdoc /> - public virtual async Task<TResource> GetForUpdateAsync(QueryLayer queryLayer, CancellationToken cancellationToken) + public virtual async Task<TResource?> 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<TResource> 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<TResource>().PublicName; - throw new CannotClearRequiredRelationshipException(relationship.PublicName, leftResource.StringId, resourceType); + if (isRelationshipRequired && isClearingRelationship) + { + string resourceName = _resourceGraph.GetResourceType<TResource>().PublicName; + throw new CannotClearRequiredRelationshipException(relationship.PublicName, leftResource.StringId!, resourceName); + } } } - private bool IsToManyRelationshipBeingCleared(HasManyAttribute hasManyRelationship, TResource leftResource, object valueToAssign) - { - ICollection<IIdentifiable> newRightResourceIds = _collectionConverter.ExtractResources(valueToAssign); - - object existingRightValue = hasManyRelationship.GetValue(leftResource); - - HashSet<IIdentifiable> existingRightResourceIds = - _collectionConverter.ExtractResources(existingRightValue).ToHashSet(IdentifiableComparer.Instance); - - existingRightResourceIds.ExceptWith(newRightResourceIds); - - return existingRightResourceIds.Any(); - } - /// <inheritdoc /> 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<TResource>().Relationships) + foreach (RelationshipAttribute relationship in _resourceGraph.GetResourceType<TResource>().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 }; } /// <inheritdoc /> - 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<IIdentifiable> 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); } } } - - /// <summary> - /// Implements the foundational Repository layer in the JsonApiDotNetCore architecture that uses Entity Framework Core. - /// </summary> - [PublicAPI] - public class EntityFrameworkCoreRepository<TResource> : EntityFrameworkCoreRepository<TResource, int>, IResourceRepository<TResource> - where TResource : class, IIdentifiable<int> - { - public EntityFrameworkCoreRepository(ITargetedFields targetedFields, IDbContextResolver contextResolver, IResourceGraph resourceGraph, - IResourceFactory resourceFactory, IEnumerable<IQueryConstraintProvider> 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 { /// <summary> - /// Used to indicate that an <see cref="IResourceRepository{TResource}" /> supports execution inside a transaction. + /// Used to indicate that an <see cref="IResourceRepository{TResource, TId}" /> supports execution inside a transaction. /// </summary> [PublicAPI] public interface IRepositorySupportsTransaction @@ -11,6 +11,6 @@ public interface IRepositorySupportsTransaction /// <summary> /// Identifies the currently active transaction. /// </summary> - 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 { - /// <inheritdoc /> - public interface IResourceReadRepository<TResource> : IResourceReadRepository<TResource, int> - where TResource : class, IIdentifiable<int> - { - } - /// <summary> /// Groups read operations. /// </summary> @@ -30,11 +24,11 @@ public interface IResourceReadRepository<TResource, in TId> /// <summary> /// Executes a read query using the specified constraints and returns the collection of matching resources. /// </summary> - Task<IReadOnlyCollection<TResource>> GetAsync(QueryLayer layer, CancellationToken cancellationToken); + Task<IReadOnlyCollection<TResource>> GetAsync(QueryLayer queryLayer, CancellationToken cancellationToken); /// <summary> - /// 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. /// </summary> - Task<int> CountAsync(FilterExpression topFilter, CancellationToken cancellationToken); + Task<int> 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 { - /// <summary> - /// Represents the foundational Resource Repository layer in the JsonApiDotNetCore architecture that provides data access to an underlying store. - /// </summary> - /// <typeparam name="TResource"> - /// The resource type. - /// </typeparam> - [PublicAPI] - public interface IResourceRepository<TResource> - : IResourceRepository<TResource, int>, IResourceReadRepository<TResource>, IResourceWriteRepository<TResource> - where TResource : class, IIdentifiable<int> - { - } - /// <summary> /// Represents the foundational Resource Repository layer in the JsonApiDotNetCore architecture that provides data access to an underlying store. /// </summary> @@ -25,6 +12,7 @@ public interface IResourceRepository<TResource> /// <typeparam name="TId"> /// The resource identifier type. /// </typeparam> + [PublicAPI] public interface IResourceRepository<TResource, in TId> : IResourceReadRepository<TResource, TId>, IResourceWriteRepository<TResource, TId> where TResource : class, IIdentifiable<TId> { 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 /// <summary> /// Invokes <see cref="IResourceReadRepository{TResource,TId}.GetAsync" />. /// </summary> - Task<IReadOnlyCollection<TResource>> GetAsync<TResource>(QueryLayer layer, CancellationToken cancellationToken) + Task<IReadOnlyCollection<TResource>> GetAsync<TResource>(QueryLayer queryLayer, CancellationToken cancellationToken) where TResource : class, IIdentifiable; /// <summary> /// Invokes <see cref="IResourceReadRepository{TResource,TId}.GetAsync" /> for the specified resource type. /// </summary> - Task<IReadOnlyCollection<IIdentifiable>> GetAsync(Type resourceType, QueryLayer layer, CancellationToken cancellationToken); + Task<IReadOnlyCollection<IIdentifiable>> GetAsync(ResourceType resourceType, QueryLayer queryLayer, CancellationToken cancellationToken); /// <summary> /// Invokes <see cref="IResourceReadRepository{TResource,TId}.CountAsync" /> for the specified resource type. /// </summary> - Task<int> CountAsync<TResource>(FilterExpression topFilter, CancellationToken cancellationToken) - where TResource : class, IIdentifiable; + Task<int> CountAsync(ResourceType resourceType, FilterExpression? filter, CancellationToken cancellationToken); /// <summary> /// Invokes <see cref="IResourceWriteRepository{TResource,TId}.GetForCreateAsync" />. @@ -45,7 +44,7 @@ Task CreateAsync<TResource>(TResource resourceFromRequest, TResource resourceFor /// <summary> /// Invokes <see cref="IResourceWriteRepository{TResource,TId}.GetForUpdateAsync" />. /// </summary> - Task<TResource> GetForUpdateAsync<TResource>(QueryLayer queryLayer, CancellationToken cancellationToken) + Task<TResource?> GetForUpdateAsync<TResource>(QueryLayer queryLayer, CancellationToken cancellationToken) where TResource : class, IIdentifiable; /// <summary> @@ -63,7 +62,7 @@ Task DeleteAsync<TResource, TId>(TId id, CancellationToken cancellationToken) /// <summary> /// Invokes <see cref="IResourceWriteRepository{TResource,TId}.SetRelationshipAsync" />. /// </summary> - Task SetRelationshipAsync<TResource>(TResource leftResource, object rightValue, CancellationToken cancellationToken) + Task SetRelationshipAsync<TResource>(TResource leftResource, object? rightValue, CancellationToken cancellationToken) where TResource : class, IIdentifiable; /// <summary> 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 { - /// <inheritdoc /> - public interface IResourceWriteRepository<TResource> : IResourceWriteRepository<TResource, int> - where TResource : class, IIdentifiable<int> - { - } - /// <summary> /// Groups write operations. /// </summary> @@ -42,7 +36,7 @@ public interface IResourceWriteRepository<TResource, in TId> /// <summary> /// Retrieves a resource with all of its attributes, including the set of targeted relationships, in preparation for <see cref="UpdateAsync" />. /// </summary> - Task<TResource> GetForUpdateAsync(QueryLayer queryLayer, CancellationToken cancellationToken); + Task<TResource?> GetForUpdateAsync(QueryLayer queryLayer, CancellationToken cancellationToken); /// <summary> /// Updates the attributes and relationships of an existing resource in the underlying data store. @@ -57,7 +51,7 @@ public interface IResourceWriteRepository<TResource, in TId> /// <summary> /// Performs a complete replacement of the relationship in the underlying data store. /// </summary> - Task SetRelationshipAsync(TResource leftResource, object rightValue, CancellationToken cancellationToken); + Task SetRelationshipAsync(TResource leftResource, object? rightValue, CancellationToken cancellationToken); /// <summary> /// 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 } /// <inheritdoc /> - public async Task<IReadOnlyCollection<TResource>> GetAsync<TResource>(QueryLayer layer, CancellationToken cancellationToken) + public async Task<IReadOnlyCollection<TResource>> GetAsync<TResource>(QueryLayer queryLayer, CancellationToken cancellationToken) where TResource : class, IIdentifiable { dynamic repository = ResolveReadRepository(typeof(TResource)); - return (IReadOnlyCollection<TResource>)await repository.GetAsync(layer, cancellationToken); + return (IReadOnlyCollection<TResource>)await repository.GetAsync(queryLayer, cancellationToken); } /// <inheritdoc /> - public async Task<IReadOnlyCollection<IIdentifiable>> GetAsync(Type resourceType, QueryLayer layer, CancellationToken cancellationToken) + public async Task<IReadOnlyCollection<IIdentifiable>> GetAsync(ResourceType resourceType, QueryLayer queryLayer, CancellationToken cancellationToken) { ArgumentGuard.NotNull(resourceType, nameof(resourceType)); dynamic repository = ResolveReadRepository(resourceType); - return (IReadOnlyCollection<IIdentifiable>)await repository.GetAsync(layer, cancellationToken); + return (IReadOnlyCollection<IIdentifiable>)await repository.GetAsync(queryLayer, cancellationToken); } /// <inheritdoc /> - public async Task<int> CountAsync<TResource>(FilterExpression topFilter, CancellationToken cancellationToken) - where TResource : class, IIdentifiable + public async Task<int> 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); } /// <inheritdoc /> @@ -74,7 +73,7 @@ public async Task CreateAsync<TResource>(TResource resourceFromRequest, TResourc } /// <inheritdoc /> - public async Task<TResource> GetForUpdateAsync<TResource>(QueryLayer queryLayer, CancellationToken cancellationToken) + public async Task<TResource?> GetForUpdateAsync<TResource>(QueryLayer queryLayer, CancellationToken cancellationToken) where TResource : class, IIdentifiable { dynamic repository = GetWriteRepository(typeof(TResource)); @@ -98,7 +97,7 @@ public async Task DeleteAsync<TResource, TId>(TId id, CancellationToken cancella } /// <inheritdoc /> - public async Task SetRelationshipAsync<TResource>(TResource leftResource, object rightValue, CancellationToken cancellationToken) + public async Task SetRelationshipAsync<TResource>(TResource leftResource, object? rightValue, CancellationToken cancellationToken) where TResource : class, IIdentifiable { dynamic repository = GetWriteRepository(typeof(TResource)); @@ -122,35 +121,28 @@ public async Task RemoveFromToManyRelationshipAsync<TResource>(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; } - /// <summary> - /// Get the value of the attribute for the given object. Throws if the attribute does not belong to the provided object. - /// </summary> - 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); - } - - /// <summary> - /// Sets the value of the attribute on the given object. - /// </summary> - 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<EagerLoadAttribute> Children { get; internal set; } + public PropertyInfo Property { get; internal set; } = null!; + + public IReadOnlyCollection<EagerLoadAttribute> 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(); /// <summary> - /// 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. + /// </summary> + internal Type? LeftClrType { get; set; } + + /// <summary> + /// The CLR type this relationship points to. In the case of a <see cref="HasManyAttribute" /> relationship, this value will be the collection element + /// type. + /// </summary> + /// <example> + /// <code><![CDATA[ + /// public ISet<Tag> Tags { get; set; } // RightClrType: typeof(Tag) + /// ]]></code> + /// </example> + internal Type? RightClrType { get; set; } + + /// <summary> + /// The <see cref="PropertyInfo" /> 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. /// </summary> /// <example> /// <code><![CDATA[ /// public class Article : Identifiable /// { - /// [HasOne] // InverseNavigationProperty = Person.Articles + /// [HasOne] // InverseNavigationProperty: Person.Articles /// public Person Owner { get; set; } /// } /// /// public class Person : Identifiable /// { - /// [HasMany] // InverseNavigationProperty = Article.Owner + /// [HasMany] // InverseNavigationProperty: Article.Owner /// public ICollection<Article> Articles { get; set; } /// } /// ]]></code> /// </example> - public PropertyInfo InverseNavigationProperty { get; set; } + public PropertyInfo? InverseNavigationProperty { get; set; } /// <summary> - /// The containing type in which this relationship is declared. + /// The containing resource type in which this relationship is declared. /// </summary> - public Type LeftType { get; internal set; } + public ResourceType LeftType { get; internal set; } = null!; /// <summary> - /// The type this relationship points to. This does not necessarily match the relationship property type. In the case of a - /// <see cref="HasManyAttribute" /> relationship, this value will be the collection element type. + /// The resource type this relationship points to. In the case of a <see cref="HasManyAttribute" /> relationship, this value will be the collection + /// element type. /// </summary> - /// <example> - /// <code><![CDATA[ - /// public List<Tag> Tags { get; set; } // RightType == typeof(Tag) - /// ]]></code> - /// </example> - public Type RightType { get; internal set; } + public ResourceType RightType { get; internal set; } = null!; /// <summary> /// Configures which links to show in the <see cref="Serialization.Objects.RelationshipLinks" /> object for this relationship. Defaults to @@ -67,27 +79,7 @@ public abstract class RelationshipAttribute : ResourceFieldAttribute /// </summary> public bool CanInclude { get; set; } = true; - /// <summary> - /// Gets the value of the resource property this attribute was declared on. - /// </summary> - public object GetValue(object resource) - { - ArgumentGuard.NotNull(resource, nameof(resource)); - - return Property.GetValue(resource); - } - - /// <summary> - /// Sets the value of the resource property this attribute was declared on. - /// </summary> - 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; /// <summary> /// The publicly exposed name of this JSON:API field. When not explicitly assigned, the configured naming convention is applied on the property name. /// </summary> public string PublicName { - get => _publicName; + get => _publicName!; set { if (string.IsNullOrWhiteSpace(value)) @@ -35,14 +37,72 @@ public string PublicName /// <summary> /// The resource property that this attribute is declared on. /// </summary> - public PropertyInfo Property { get; internal set; } + public PropertyInfo Property + { + get => _property!; + internal set + { + ArgumentGuard.NotNull(value, nameof(value)); + _property = value; + } + } + + /// <summary> + /// 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. + /// </summary> + 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); + } + } + + /// <summary> + /// 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. + /// </summary> + 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 { /// <summary> - /// 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 <see cref="IIdentifiable{TId}" />. /// </summary> public interface IIdentifiable { /// <summary> /// The value for element 'id' in a JSON:API request or response. /// </summary> - string StringId { get; set; } + string? StringId { get; set; } /// <summary> /// The value for element 'lid' in a JSON:API request. /// </summary> - string LocalId { get; set; } + string? LocalId { get; set; } } /// <summary> @@ -26,7 +25,7 @@ public interface IIdentifiable public interface IIdentifiable<TId> : IIdentifiable { /// <summary> - /// 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). /// </summary> 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 { - /// <summary> - /// Provides an extensibility point to add business logic that is resource-oriented instead of endpoint-oriented. - /// </summary> - /// <typeparam name="TResource"> - /// The resource type. - /// </typeparam> - [PublicAPI] - public interface IResourceDefinition<TResource> : IResourceDefinition<TResource, int> - where TResource : class, IIdentifiable<int> - { - } - /// <summary> /// Provides an extensibility point to add business logic that is resource-oriented instead of endpoint-oriented. /// </summary> @@ -44,7 +32,7 @@ public interface IResourceDefinition<TResource, in TId> /// <returns> /// The new set of includes. Return an empty collection to remove all inclusions (never return <c>null</c>). /// </returns> - IImmutableList<IncludeElementExpression> OnApplyIncludes(IImmutableList<IncludeElementExpression> existingIncludes); + IImmutableSet<IncludeElementExpression> OnApplyIncludes(IImmutableSet<IncludeElementExpression> existingIncludes); /// <summary> /// 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<TResource, in TId> /// <returns> /// The new filter, or <c>null</c> to disable the existing filter. /// </returns> - FilterExpression OnApplyFilter(FilterExpression existingFilter); + FilterExpression? OnApplyFilter(FilterExpression? existingFilter); /// <summary> /// 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<TResource, in TId> /// <returns> /// The new sort order, or <c>null</c> to disable the existing sort order and sort by ID. /// </returns> - SortExpression OnApplySort(SortExpression existingSort); + SortExpression? OnApplySort(SortExpression? existingSort); /// <summary> /// 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<TResource, in TId> /// The changed pagination, or <c>null</c> to use the first page with default size from options. To disable paging, set /// <see cref="PaginationExpression.PageSize" /> to <c>null</c>. /// </returns> - PaginationExpression OnApplyPagination(PaginationExpression existingPagination); + PaginationExpression? OnApplyPagination(PaginationExpression? existingPagination); /// <summary> /// 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<TResource, in TId> /// <returns> /// The new sparse fieldset, or <c>null</c> to discard the existing sparse fieldset and select all viewable fields. /// </returns> - SparseFieldSetExpression OnApplySparseFieldSet(SparseFieldSetExpression existingSparseFieldSet); + SparseFieldSetExpression? OnApplySparseFieldSet(SparseFieldSetExpression? existingSparseFieldSet); /// <summary> /// Enables to adapt the Entity Framework Core <see cref="IQueryable{T}" /> query, based on custom query string parameters. Note this only works on @@ -125,13 +113,13 @@ public interface IResourceDefinition<TResource, in TId> /// ]]></code> /// </example> #pragma warning disable AV1130 // Return type in method signature should be a collection interface instead of a concrete type - QueryStringParameterHandlers<TResource> OnRegisterQueryableHandlersForQueryStringParameters(); + QueryStringParameterHandlers<TResource>? OnRegisterQueryableHandlersForQueryStringParameters(); #pragma warning restore AV1130 // Return type in method signature should be a collection interface instead of a concrete type /// <summary> /// Enables to add JSON:API meta information, specific to this resource. /// </summary> - IDictionary<string, object> GetMeta(TResource resource); + IDictionary<string, object?>? GetMeta(TResource resource); /// <summary> /// 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<TResource, in TId> /// <returns> /// The replacement resource identifier, or <c>null</c> to clear the relationship. Returns <paramref name="rightResourceId" /> by default. /// </returns> - Task<IIdentifiable> OnSetToOneRelationshipAsync(TResource leftResource, HasOneAttribute hasOneRelationship, IIdentifiable rightResourceId, + Task<IIdentifiable?> OnSetToOneRelationshipAsync(TResource leftResource, HasOneAttribute hasOneRelationship, IIdentifiable? rightResourceId, WriteOperationKind writeOperation, CancellationToken cancellationToken); /// <summary> 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 /// <summary> /// Invokes <see cref="IResourceDefinition{TResource,TId}.OnApplyIncludes" /> for the specified resource type. /// </summary> - IImmutableList<IncludeElementExpression> OnApplyIncludes(Type resourceType, IImmutableList<IncludeElementExpression> existingIncludes); + IImmutableSet<IncludeElementExpression> OnApplyIncludes(ResourceType resourceType, IImmutableSet<IncludeElementExpression> existingIncludes); /// <summary> /// Invokes <see cref="IResourceDefinition{TResource,TId}.OnApplyFilter" /> for the specified resource type. /// </summary> - FilterExpression OnApplyFilter(Type resourceType, FilterExpression existingFilter); + FilterExpression? OnApplyFilter(ResourceType resourceType, FilterExpression? existingFilter); /// <summary> /// Invokes <see cref="IResourceDefinition{TResource,TId}.OnApplySort" /> for the specified resource type. /// </summary> - SortExpression OnApplySort(Type resourceType, SortExpression existingSort); + SortExpression? OnApplySort(ResourceType resourceType, SortExpression? existingSort); /// <summary> /// Invokes <see cref="IResourceDefinition{TResource,TId}.OnApplyPagination" /> for the specified resource type. /// </summary> - PaginationExpression OnApplyPagination(Type resourceType, PaginationExpression existingPagination); + PaginationExpression? OnApplyPagination(ResourceType resourceType, PaginationExpression? existingPagination); /// <summary> /// Invokes <see cref="IResourceDefinition{TResource,TId}.OnApplySparseFieldSet" /> for the specified resource type. /// </summary> - SparseFieldSetExpression OnApplySparseFieldSet(Type resourceType, SparseFieldSetExpression existingSparseFieldSet); + SparseFieldSetExpression? OnApplySparseFieldSet(ResourceType resourceType, SparseFieldSetExpression? existingSparseFieldSet); /// <summary> /// Invokes <see cref="IResourceDefinition{TResource,TId}.OnRegisterQueryableHandlersForQueryStringParameters" /> for the specified resource type, then /// returns the <see cref="IQueryable{T}" /> expression for the specified parameter name. /// </summary> - object GetQueryableHandlerForQueryStringParameter(Type resourceType, string parameterName); + object? GetQueryableHandlerForQueryStringParameter(Type resourceClrType, string parameterName); /// <summary> /// Invokes <see cref="IResourceDefinition{TResource,TId}.GetMeta" /> for the specified resource. /// </summary> - IDictionary<string, object> GetMeta(Type resourceType, IIdentifiable resourceInstance); + IDictionary<string, object?>? GetMeta(ResourceType resourceType, IIdentifiable resourceInstance); /// <summary> /// Invokes <see cref="IResourceDefinition{TResource,TId}.OnPrepareWriteAsync" /> for the specified resource. @@ -60,8 +61,8 @@ Task OnPrepareWriteAsync<TResource>(TResource resource, WriteOperationKind write /// <summary> /// Invokes <see cref="IResourceDefinition{TResource,TId}.OnSetToOneRelationshipAsync" /> for the specified resource. /// </summary> - public Task<IIdentifiable> OnSetToOneRelationshipAsync<TResource>(TResource leftResource, HasOneAttribute hasOneRelationship, - IIdentifiable rightResourceId, WriteOperationKind writeOperation, CancellationToken cancellationToken) + public Task<IIdentifiable?> OnSetToOneRelationshipAsync<TResource>(TResource leftResource, HasOneAttribute hasOneRelationship, + IIdentifiable? rightResourceId, WriteOperationKind writeOperation, CancellationToken cancellationToken) where TResource : class, IIdentifiable; /// <summary> 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 /// <summary> /// Creates a new resource object instance. /// </summary> - public IIdentifiable CreateInstance(Type resourceType); + public IIdentifiable CreateInstance(Type resourceClrType); /// <summary> /// Creates a new resource object instance. @@ -22,6 +22,6 @@ public TResource CreateInstance<TResource>() /// <summary> /// Returns an expression tree that represents creating a new resource object instance. /// </summary> - 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 { /// <summary> - /// 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. /// </summary> public interface ITargetedFields { /// <summary> /// The set of attributes that are targeted by a request. /// </summary> - ISet<AttrAttribute> Attributes { get; set; } + IReadOnlySet<AttrAttribute> Attributes { get; } /// <summary> /// The set of relationships that are targeted by a request. /// </summary> - ISet<RelationshipAttribute> Relationships { get; set; } + IReadOnlySet<RelationshipAttribute> Relationships { get; } + + /// <summary> + /// Performs a shallow copy. + /// </summary> + 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 { - /// <inheritdoc /> - public abstract class Identifiable : Identifiable<int> - { - } - /// <summary> - /// A convenient basic implementation of <see cref="IIdentifiable" /> that provides conversion between <see cref="Id" /> and <see cref="StringId" />. + /// A convenient basic implementation of <see cref="IIdentifiable{TId}" /> that provides conversion between typed <see cref="Id" /> and + /// <see cref="StringId" />. /// </summary> /// <typeparam name="TId"> /// The resource identifier type. @@ -18,11 +14,11 @@ public abstract class Identifiable : Identifiable<int> public abstract class Identifiable<TId> : IIdentifiable<TId> { /// <inheritdoc /> - public virtual TId Id { get; set; } + public virtual TId Id { get; set; } = default!; /// <inheritdoc /> [NotMapped] - public string StringId + public string? StringId { get => GetStringId(Id); set => Id = GetTypedId(value); @@ -30,22 +26,22 @@ public string StringId /// <inheritdoc /> [NotMapped] - public string LocalId { get; set; } + public string? LocalId { get; set; } /// <summary> /// Converts an outgoing typed resource identifier to string format for use in a JSON:API response. /// </summary> - protected virtual string GetStringId(TId value) + protected virtual string? GetStringId(TId value) { - return EqualityComparer<TId>.Default.Equals(value, default) ? null : value.ToString(); + return EqualityComparer<TId>.Default.Equals(value, default) ? null : value!.ToString(); } /// <summary> /// Converts an incoming 'id' element from a JSON:API request to the typed resource identifier. /// </summary> - 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<object>.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 { - /// <summary> - /// 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. - /// </summary> - /// <typeparam name="TResource"> - /// The resource type. - /// </typeparam> - [PublicAPI] - public class JsonApiResourceDefinition<TResource> : JsonApiResourceDefinition<TResource, int>, IResourceDefinition<TResource> - where TResource : class, IIdentifiable<int> - { - public JsonApiResourceDefinition(IResourceGraph resourceGraph) - : base(resourceGraph) - { - } - } - /// <inheritdoc /> [PublicAPI] public class JsonApiResourceDefinition<TResource, TId> : IResourceDefinition<TResource, TId> @@ -41,30 +24,30 @@ public class JsonApiResourceDefinition<TResource, TId> : IResourceDefinition<TRe /// <summary> /// Provides metadata for the resource type <typeparamref name="TResource" />. /// </summary> - protected ResourceContext ResourceContext { get; } + protected ResourceType ResourceType { get; } public JsonApiResourceDefinition(IResourceGraph resourceGraph) { ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); ResourceGraph = resourceGraph; - ResourceContext = resourceGraph.GetResourceContext<TResource>(); + ResourceType = resourceGraph.GetResourceType<TResource>(); } /// <inheritdoc /> - public virtual IImmutableList<IncludeElementExpression> OnApplyIncludes(IImmutableList<IncludeElementExpression> existingIncludes) + public virtual IImmutableSet<IncludeElementExpression> OnApplyIncludes(IImmutableSet<IncludeElementExpression> existingIncludes) { return existingIncludes; } /// <inheritdoc /> - public virtual FilterExpression OnApplyFilter(FilterExpression existingFilter) + public virtual FilterExpression? OnApplyFilter(FilterExpression? existingFilter) { return existingFilter; } /// <inheritdoc /> - public virtual SortExpression OnApplySort(SortExpression existingSort) + public virtual SortExpression? OnApplySort(SortExpression? existingSort) { return existingSort; } @@ -83,11 +66,11 @@ public virtual SortExpression OnApplySort(SortExpression existingSort) /// </example> protected SortExpression CreateSortExpressionFromLambda(PropertySortOrder keySelectors) { - ArgumentGuard.NotNull(keySelectors, nameof(keySelectors)); + ArgumentGuard.NotNullNorEmpty(keySelectors, nameof(keySelectors)); ImmutableArray<SortElementExpression>.Builder elementsBuilder = ImmutableArray.CreateBuilder<SortElementExpression>(keySelectors.Count); - foreach ((Expression<Func<TResource, dynamic>> keySelector, ListSortDirection sortDirection) in keySelectors) + foreach ((Expression<Func<TResource, dynamic?>> 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 } /// <inheritdoc /> - public virtual PaginationExpression OnApplyPagination(PaginationExpression existingPagination) + public virtual PaginationExpression? OnApplyPagination(PaginationExpression? existingPagination) { return existingPagination; } /// <inheritdoc /> - public virtual SparseFieldSetExpression OnApplySparseFieldSet(SparseFieldSetExpression existingSparseFieldSet) + public virtual SparseFieldSetExpression? OnApplySparseFieldSet(SparseFieldSetExpression? existingSparseFieldSet) { return existingSparseFieldSet; } /// <inheritdoc /> - public virtual QueryStringParameterHandlers<TResource> OnRegisterQueryableHandlersForQueryStringParameters() + public virtual QueryStringParameterHandlers<TResource>? OnRegisterQueryableHandlersForQueryStringParameters() { return null; } /// <inheritdoc /> - public virtual IDictionary<string, object> GetMeta(TResource resource) + public virtual IDictionary<string, object?>? GetMeta(TResource resource) { return null; } @@ -130,8 +113,8 @@ public virtual Task OnPrepareWriteAsync(TResource resource, WriteOperationKind w } /// <inheritdoc /> - public virtual Task<IIdentifiable> OnSetToOneRelationshipAsync(TResource leftResource, HasOneAttribute hasOneRelationship, - IIdentifiable rightResourceId, WriteOperationKind writeOperation, CancellationToken cancellationToken) + public virtual Task<IIdentifiable?> 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 <see cref="CreateSortExpressionFromLambda" /> for usage /// details. /// </summary> - public sealed class PropertySortOrder : List<(Expression<Func<TResource, dynamic>> KeySelector, ListSortDirection SortDirection)> + public sealed class PropertySortOrder : List<(Expression<Func<TResource, dynamic?>> 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<IIdentifiable> GetSecondaryResources() @@ -56,7 +54,7 @@ public ISet<IIdentifiable> GetSecondaryResources() private void AddSecondaryResources(RelationshipAttribute relationship, HashSet<IIdentifiable> secondaryResources) { - object rightValue = relationship.GetValue(Resource); + object? rightValue = relationship.GetValue(Resource); ICollection<IIdentifiable> 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<TResource> : IResourceChangeTracker<TResource> where TResource : class, IIdentifiable { - private readonly IResourceGraph _resourceGraph; + private readonly ResourceType _resourceType; private readonly ITargetedFields _targetedFields; - private IDictionary<string, string> _initiallyStoredAttributeValues; - private IDictionary<string, string> _requestAttributeValues; - private IDictionary<string, string> _finallyStoredAttributeValues; + private IDictionary<string, string>? _initiallyStoredAttributeValues; + private IDictionary<string, string>? _requestAttributeValues; + private IDictionary<string, string>? _finallyStoredAttributeValues; public ResourceChangeTracker(IResourceGraph resourceGraph, ITargetedFields targetedFields) { ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); ArgumentGuard.NotNull(targetedFields, nameof(targetedFields)); - _resourceGraph = resourceGraph; + _resourceType = resourceGraph.GetResourceType<TResource>(); _targetedFields = targetedFields; } @@ -32,8 +32,7 @@ public void SetInitiallyStoredAttributeValues(TResource resource) { ArgumentGuard.NotNull(resource, nameof(resource)); - ResourceContext resourceContext = _resourceGraph.GetResourceContext<TResource>(); - _initiallyStoredAttributeValues = CreateAttributeDictionary(resource, resourceContext.Attributes); + _initiallyStoredAttributeValues = CreateAttributeDictionary(resource, _resourceType.Attributes); } /// <inheritdoc /> @@ -49,8 +48,7 @@ public void SetFinallyStoredAttributeValues(TResource resource) { ArgumentGuard.NotNull(resource, nameof(resource)); - ResourceContext resourceContext = _resourceGraph.GetResourceContext<TResource>(); - _finallyStoredAttributeValues = CreateAttributeDictionary(resource, resourceContext.Attributes); + _finallyStoredAttributeValues = CreateAttributeDictionary(resource, _resourceType.Attributes); } private IDictionary<string, string> CreateAttributeDictionary(TResource resource, IEnumerable<AttrAttribute> attributes) @@ -59,7 +57,7 @@ private IDictionary<string, string> 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<string, string> CreateAttributeDictionary(TResource resource /// <inheritdoc /> 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 } /// <inheritdoc /> - public IImmutableList<IncludeElementExpression> OnApplyIncludes(Type resourceType, IImmutableList<IncludeElementExpression> existingIncludes) + public IImmutableSet<IncludeElementExpression> OnApplyIncludes(ResourceType resourceType, IImmutableSet<IncludeElementExpression> existingIncludes) { ArgumentGuard.NotNull(resourceType, nameof(resourceType)); @@ -38,7 +38,7 @@ public IImmutableList<IncludeElementExpression> OnApplyIncludes(Type resourceTyp } /// <inheritdoc /> - 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 } /// <inheritdoc /> - 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 } /// <inheritdoc /> - 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 } /// <inheritdoc /> - 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 } /// <inheritdoc /> - 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; } /// <inheritdoc /> - public IDictionary<string, object> GetMeta(Type resourceType, IIdentifiable resourceInstance) + public IDictionary<string, object?>? GetMeta(ResourceType resourceType, IIdentifiable resourceInstance) { ArgumentGuard.NotNull(resourceType, nameof(resourceType)); @@ -105,8 +113,8 @@ public async Task OnPrepareWriteAsync<TResource>(TResource resource, WriteOperat } /// <inheritdoc /> - public async Task<IIdentifiable> OnSetToOneRelationshipAsync<TResource>(TResource leftResource, HasOneAttribute hasOneRelationship, - IIdentifiable rightResourceId, WriteOperationKind writeOperation, CancellationToken cancellationToken) + public async Task<IIdentifiable?> OnSetToOneRelationshipAsync<TResource>(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) } /// <inheritdoc /> - 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); } /// <inheritdoc /> @@ -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 } /// <inheritdoc /> - 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<Expression>(); - 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 { /// <inheritdoc /> + [PublicAPI] public sealed class TargetedFields : ITargetedFields { - /// <inheritdoc /> - public ISet<AttrAttribute> Attributes { get; set; } = new HashSet<AttrAttribute>(); + IReadOnlySet<AttrAttribute> ITargetedFields.Attributes => Attributes; + IReadOnlySet<RelationshipAttribute> ITargetedFields.Relationships => Relationships; + + public HashSet<AttrAttribute> Attributes { get; } = new(); + public HashSet<RelationshipAttribute> Relationships { get; } = new(); /// <inheritdoc /> - public ISet<RelationshipAttribute> Relationships { get; set; } = new HashSet<RelationshipAttribute>(); + 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 -{ - /// <summary> - /// Server serializer implementation of <see cref="BaseSerializer" /> for atomic:operations responses. - /// </summary> - [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; - - /// <inheritdoc /> - 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; - } - - /// <inheritdoc /> - public string Serialize(object content) - { - if (content is IList<OperationContainer> 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<OperationContainer> 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<string> - { - "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<AttrAttribute> attributes = _fieldsToSerialize.GetAttributes(resourceType); - IReadOnlyCollection<RelationshipAttribute> 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>(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 -{ - /// <summary> - /// Abstract base class for deserialization. Deserializes JSON content into <see cref="Objects.Document" />s and constructs instances of the resource(s) - /// in the document body. - /// </summary> - [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; - } - - /// <summary> - /// This method is called each time a <paramref name="resource" /> is constructed from the serialized content, which is used to do additional processing - /// depending on the type of deserializer. - /// </summary> - /// <remarks> - /// See the implementation of this method in <see cref="RequestDeserializer" /> for usage. - /// </remarks> - /// <param name="resource"> - /// The resource that was constructed from the document's body. - /// </param> - /// <param name="field"> - /// The metadata for the exposed field. - /// </param> - /// <param name="data"> - /// Relationship data for <paramref name="resource" />. Is null when <paramref name="field" /> is not a <see cref="RelationshipAttribute" />. - /// </param> - 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<Document>(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; - } - - /// <summary> - /// Sets the attributes on a parsed resource. - /// </summary> - /// <param name="resource"> - /// The parsed resource. - /// </param> - /// <param name="attributeValues"> - /// Attributes and their values, as in the serialized content. - /// </param> - /// <param name="attributes"> - /// Exposed attributes for <paramref name="resource" />. - /// </param> - private IIdentifiable SetAttributes(IIdentifiable resource, IDictionary<string, object> attributeValues, IReadOnlyCollection<AttrAttribute> 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; - } - - /// <summary> - /// Sets the relationships on a parsed resource. - /// </summary> - /// <param name="resource"> - /// The parsed resource. - /// </param> - /// <param name="relationshipValues"> - /// Relationships and their values, as in the serialized content. - /// </param> - /// <param name="relationshipAttributes"> - /// Exposed relationships for <paramref name="resource" />. - /// </param> - private IIdentifiable SetRelationships(IIdentifiable resource, IDictionary<string, RelationshipObject> relationshipValues, - IReadOnlyCollection<RelationshipAttribute> 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; - } - - /// <summary> - /// Creates an instance of the referenced type in <paramref name="data" /> and sets its attributes and relationships. - /// </summary> - /// <returns> - /// The parsed resource. - /// </returns> - 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; - } - - /// <summary> - /// Sets a HasOne relationship on a parsed resource. - /// </summary> - 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); - } - - /// <summary> - /// Sets a HasMany relationship. - /// </summary> - 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<IIdentifiable> 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 -{ - /// <summary> - /// Abstract base class for serialization. Uses <see cref="IResourceObjectBuilder" /> to convert resources into <see cref="ResourceObject" />s and wraps - /// them in a <see cref="Document" />. - /// </summary> - public abstract class BaseSerializer - { - protected IResourceObjectBuilder ResourceObjectBuilder { get; } - - protected BaseSerializer(IResourceObjectBuilder resourceObjectBuilder) - { - ArgumentGuard.NotNull(resourceObjectBuilder, nameof(resourceObjectBuilder)); - - ResourceObjectBuilder = resourceObjectBuilder; - } - - /// <summary> - /// Builds a <see cref="Document" /> for <paramref name="resource" />. Adds the attributes and relationships that are enlisted in - /// <paramref name="attributes" /> and <paramref name="relationships" />. - /// </summary> - /// <param name="resource"> - /// Resource to build a <see cref="ResourceObject" /> for. - /// </param> - /// <param name="attributes"> - /// Attributes to include in the building process. - /// </param> - /// <param name="relationships"> - /// Relationships to include in the building process. - /// </param> - /// <returns> - /// The resource object that was built. - /// </returns> - protected Document Build(IIdentifiable resource, IReadOnlyCollection<AttrAttribute> attributes, - IReadOnlyCollection<RelationshipAttribute> 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>(resourceObject) - }; - } - - /// <summary> - /// Builds a <see cref="Document" /> for <paramref name="resources" />. Adds the attributes and relationships that are enlisted in - /// <paramref name="attributes" /> and <paramref name="relationships" />. - /// </summary> - /// <param name="resources"> - /// Resource to build a <see cref="ResourceObject" /> for. - /// </param> - /// <param name="attributes"> - /// Attributes to include in the building process. - /// </param> - /// <param name="relationships"> - /// Relationships to include in the building process. - /// </param> - /// <returns> - /// The resource object that was built. - /// </returns> - protected Document Build(IReadOnlyCollection<IIdentifiable> resources, IReadOnlyCollection<AttrAttribute> attributes, - IReadOnlyCollection<RelationshipAttribute> relationships) - { - ArgumentGuard.NotNull(resources, nameof(resources)); - - using IDisposable _ = CodeTimingSessionManager.Current.Measure("Serializer.Build (list)"); - - var resourceObjects = new List<ResourceObject>(); - - foreach (IIdentifiable resource in resources) - { - resourceObjects.Add(ResourceObjectBuilder.Build(resource, attributes, relationships)); - } - - return new Document - { - Data = new SingleOrManyData<ResourceObject>(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 - { - /// <summary> - /// Gets the list of resource objects representing the included resources. - /// </summary> - IList<ResourceObject> Build(); - - /// <summary> - /// Extracts the included resources from <paramref name="rootResource" /> using the (arbitrarily deeply nested) included relationships in - /// <paramref name="inclusionChain" />. - /// </summary> - void IncludeRelationshipChain(IReadOnlyCollection<RelationshipAttribute> 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 -{ - /// <summary> - /// Responsible for converting resources into <see cref="ResourceObject" />s given a collection of attributes and relationships. - /// </summary> - public interface IResourceObjectBuilder - { - /// <summary> - /// Converts <paramref name="resource" /> into a <see cref="ResourceObject" />. Adds the attributes and relationships that are enlisted in - /// <paramref name="attributes" /> and <paramref name="relationships" />. - /// </summary> - /// <param name="resource"> - /// Resource to build a <see cref="ResourceObject" /> for. - /// </param> - /// <param name="attributes"> - /// Attributes to include in the building process. - /// </param> - /// <param name="relationships"> - /// Relationships to include in the building process. - /// </param> - /// <returns> - /// The resource object that was built. - /// </returns> - ResourceObject Build(IIdentifiable resource, IReadOnlyCollection<AttrAttribute> attributes, IReadOnlyCollection<RelationshipAttribute> 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<ResourceObject> _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<IQueryConstraintProvider> 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<ResourceObject>(ResourceIdentityComparer.Instance); - _fieldsToSerialize = fieldsToSerialize; - _linkBuilder = linkBuilder; - _resourceDefinitionAccessor = resourceDefinitionAccessor; - _queryStringAccessor = queryStringAccessor; - _sparseFieldSetCache = new SparseFieldSetCache(constraintProviders, resourceDefinitionAccessor); - } - - /// <inheritdoc /> - public IList<ResourceObject> 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<ResourceObject>() : 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<string, RelationshipObject> PruneRelationshipObjects(ResourceObject resourceObject) - { - Dictionary<string, RelationshipObject> 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<ResourceFieldAttribute> fieldSet = _sparseFieldSetCache.GetSparseFieldSetForSerializer(resourceContext); - return fieldSet.Contains(relationship); - } - - /// <inheritdoc /> - public override ResourceObject Build(IIdentifiable resource, IReadOnlyCollection<AttrAttribute> attributes = null, - IReadOnlyCollection<RelationshipAttribute> relationships = null) - { - ResourceObject resourceObject = base.Build(resource, attributes, relationships); - - resourceObject.Meta = _resourceDefinitionAccessor.GetMeta(resource.GetType(), resource); - - return resourceObject; - } - - /// <inheritdoc /> - public void IncludeRelationshipChain(IReadOnlyCollection<RelationshipAttribute> 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<RelationshipAttribute> chainRemainder = ShiftChain(inclusionChain); - object related = relationship.GetValue(rootResource); - ProcessChain(related, chainRemainder); - } - - private void ProcessChain(object related, IList<RelationshipAttribute> 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<RelationshipAttribute> 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<RelationshipAttribute> chainRemainder = inclusionChain.ToList(); - chainRemainder.RemoveAt(0); - - string nextRelationshipName = nextRelationship.PublicName; - IDictionary<string, RelationshipObject> 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<RelationshipAttribute> ShiftChain(IReadOnlyCollection<RelationshipAttribute> chain) - { - List<RelationshipAttribute> chainRemainder = chain.ToList(); - chainRemainder.RemoveAt(0); - return chainRemainder; - } - - /// <summary> - /// We only need an empty relationship object here. It will be populated in the ProcessRelationships method. - /// </summary> - 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<AttrAttribute> attributes = _fieldsToSerialize.GetAttributes(resourceType); - IReadOnlyCollection<RelationshipAttribute> 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<IResourceIdentity> - { - 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 -{ - /// <inheritdoc /> - [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; - } - - /// <inheritdoc /> - public virtual ResourceObject Build(IIdentifiable resource, IReadOnlyCollection<AttrAttribute> attributes = null, - IReadOnlyCollection<RelationshipAttribute> 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; - } - - /// <summary> - /// Builds a <see cref="RelationshipObject" />. The default behavior is to just construct a resource linkage with the "data" field populated with - /// "single" or "many" data. - /// </summary> - 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) - }; - } - - /// <summary> - /// Gets the value for the data property. - /// </summary> - protected SingleOrManyData<ResourceIdentifierObject> 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); - } - - /// <summary> - /// Builds a <see cref="ResourceIdentifierObject" /> for a HasOne relationship. - /// </summary> - private SingleOrManyData<ResourceIdentifierObject> GetRelatedResourceLinkageForHasOne(HasOneAttribute relationship, IIdentifiable resource) - { - var relatedResource = (IIdentifiable)relationship.GetValue(resource); - ResourceIdentifierObject resourceIdentifierObject = relatedResource != null ? GetResourceIdentifier(relatedResource) : null; - return new SingleOrManyData<ResourceIdentifierObject>(resourceIdentifierObject); - } - - /// <summary> - /// Builds the <see cref="ResourceIdentifierObject" />s for a HasMany relationship. - /// </summary> - private SingleOrManyData<ResourceIdentifierObject> GetRelatedResourceLinkageForHasMany(HasManyAttribute relationship, IIdentifiable resource) - { - object value = relationship.GetValue(resource); - ICollection<IIdentifiable> relatedResources = CollectionConverter.ExtractResources(value); - - var manyData = new List<ResourceIdentifierObject>(); - - if (relatedResources != null) - { - foreach (IIdentifiable relatedResource in relatedResources) - { - manyData.Add(GetResourceIdentifier(relatedResource)); - } - } - - return new SingleOrManyData<ResourceIdentifierObject>(manyData); - } - - /// <summary> - /// Creates a <see cref="ResourceIdentifierObject" /> from <paramref name="resource" />. - /// </summary> - private ResourceIdentifierObject GetResourceIdentifier(IIdentifiable resource) - { - string publicName = ResourceGraph.GetResourceContext(resource.GetType()).PublicName; - - return new ResourceIdentifierObject - { - Type = publicName, - Id = resource.StringId - }; - } - - /// <summary> - /// Puts the relationships of the resource into the resource object. - /// </summary> - private void ProcessRelationships(IIdentifiable resource, IEnumerable<RelationshipAttribute> relationships, ResourceObject ro) - { - foreach (RelationshipAttribute rel in relationships) - { - RelationshipObject relData = GetRelationshipData(rel, resource); - - if (relData != null) - { - (ro.Relationships ??= new Dictionary<string, RelationshipObject>()).Add(rel.PublicName, relData); - } - } - } - - /// <summary> - /// Puts the attributes of the resource into the resource object. - /// </summary> - private void ProcessAttributes(IIdentifiable resource, IEnumerable<AttrAttribute> attributes, ResourceObject ro) - { - ro.Attributes = new Dictionary<string, object>(); - - 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<IQueryConstraintProvider> 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); - } - - /// <inheritdoc /> - public override ResourceObject Build(IIdentifiable resource, IReadOnlyCollection<AttrAttribute> attributes = null, - IReadOnlyCollection<RelationshipAttribute> relationships = null) - { - ResourceObject resourceObject = base.Build(resource, attributes, relationships); - - resourceObject.Meta = _resourceDefinitionAccessor.GetMeta(resource.GetType(), resource); - - return resourceObject; - } - - /// <summary> - /// Builds a <see cref="RelationshipObject" /> 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. - /// </summary> - protected override RelationshipObject GetRelationshipData(RelationshipAttribute relationship, IIdentifiable resource) - { - ArgumentGuard.NotNull(relationship, nameof(relationship)); - ArgumentGuard.NotNull(resource, nameof(resource)); - - RelationshipObject relationshipObject = null; - IReadOnlyCollection<IReadOnlyCollection<RelationshipAttribute>> relationshipChains = GetInclusionChainsStartingWith(relationship); - - if (Equals(relationship, _requestRelationship) || relationshipChains.Any()) - { - relationshipObject = base.GetRelationshipData(relationship, resource); - - if (relationshipChains.Any() && relationshipObject.Data.Value != null) - { - foreach (IReadOnlyCollection<RelationshipAttribute> 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 <see cref="ResourceObject"/>) - return relationshipObject; - } - - private bool IsRelationshipInSparseFieldSet(RelationshipAttribute relationship) - { - ResourceContext resourceContext = ResourceGraph.GetResourceContext(relationship.LeftType); - - IImmutableSet<ResourceFieldAttribute> fieldSet = _sparseFieldSetCache.GetSparseFieldSetForSerializer(resourceContext); - return fieldSet.Contains(relationship); - } - - /// <summary> - /// Inspects the included relationship chains and selects the ones that starts with the specified relationship. - /// </summary> - private IReadOnlyCollection<IReadOnlyCollection<RelationshipAttribute>> GetInclusionChainsStartingWith(RelationshipAttribute relationship) - { - IncludeExpression include = _evaluatedIncludeCache.Get() ?? IncludeExpression.Empty; - IReadOnlyCollection<ResourceFieldChainExpression> chains = IncludeChainConverter.GetRelationshipChains(include); - - var inclusionChains = new List<IReadOnlyCollection<RelationshipAttribute>>(); - - foreach (ResourceFieldChainExpression chain in chains) - { - if (chain.Fields[0].Equals(relationship)) - { - inclusionChains.Add(chain.Fields.Cast<RelationshipAttribute>().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 -{ - /// <inheritdoc /> - [PublicAPI] - public class FieldsToSerialize : IFieldsToSerialize - { - private readonly IResourceGraph _resourceGraph; - private readonly IJsonApiRequest _request; - private readonly SparseFieldSetCache _sparseFieldSetCache; - - /// <inheritdoc /> - public bool ShouldSerialize => _request.Kind != EndpointKind.Relationship; - - public FieldsToSerialize(IResourceGraph resourceGraph, IEnumerable<IQueryConstraintProvider> constraintProviders, - IResourceDefinitionAccessor resourceDefinitionAccessor, IJsonApiRequest request) - { - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); - ArgumentGuard.NotNull(request, nameof(request)); - - _resourceGraph = resourceGraph; - _request = request; - _sparseFieldSetCache = new SparseFieldSetCache(constraintProviders, resourceDefinitionAccessor); - } - - /// <inheritdoc /> - public IReadOnlyCollection<AttrAttribute> GetAttributes(Type resourceType) - { - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - - if (!ShouldSerialize) - { - return Array.Empty<AttrAttribute>(); - } - - ResourceContext resourceContext = _resourceGraph.GetResourceContext(resourceType); - IImmutableSet<ResourceFieldAttribute> fieldSet = _sparseFieldSetCache.GetSparseFieldSetForSerializer(resourceContext); - - return SortAttributesInDeclarationOrder(fieldSet, resourceContext).ToArray(); - } - - private IEnumerable<AttrAttribute> SortAttributesInDeclarationOrder(IImmutableSet<ResourceFieldAttribute> fieldSet, ResourceContext resourceContext) - { - foreach (AttrAttribute attribute in resourceContext.Attributes) - { - if (fieldSet.Contains(attribute)) - { - yield return attribute; - } - } - } - - /// <inheritdoc /> - /// <remarks> - /// 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. - /// </remarks> - public IReadOnlyCollection<RelationshipAttribute> GetRelationships(Type resourceType) - { - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - - if (!ShouldSerialize) - { - return Array.Empty<RelationshipAttribute>(); - } - - ResourceContext resourceContext = _resourceGraph.GetResourceContext(resourceType); - return resourceContext.Relationships; - } - - /// <inheritdoc /> - 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 -{ - /// <summary> - /// 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. - /// </summary> - public interface IFieldsToSerialize - { - /// <summary> - /// Indicates whether attributes and relationships should be serialized, based on the current endpoint. - /// </summary> - bool ShouldSerialize { get; } - - /// <summary> - /// Gets the collection of attributes that are to be serialized for resources of type <paramref name="resourceType" />. - /// </summary> - IReadOnlyCollection<AttrAttribute> GetAttributes(Type resourceType); - - /// <summary> - /// Gets the collection of relationships that are to be serialized for resources of type <paramref name="resourceType" />. - /// </summary> - IReadOnlyCollection<RelationshipAttribute> GetRelationships(Type resourceType); - - /// <summary> - /// Clears internal caches. - /// </summary> - 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 -{ - /// <summary> - /// Deserializer used internally in JsonApiDotNetCore to deserialize requests. - /// </summary> - public interface IJsonApiDeserializer - { - /// <summary> - /// Deserializes JSON into a <see cref="Document" /> and constructs resources from the 'data' element. - /// </summary> - /// <param name="body"> - /// The JSON to be deserialized. - /// </param> - 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 -{ - /// <summary> - /// The deserializer of the body, used in ASP.NET Core internally to process `FromBody`. - /// </summary> - [PublicAPI] - public interface IJsonApiReader - { - Task<InputFormatterResult> 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 -{ - /// <summary> - /// Serializer used internally in JsonApiDotNetCore to serialize responses. - /// </summary> - public interface IJsonApiSerializer - { - /// <summary> - /// Gets the Content-Type HTTP header value. - /// </summary> - string ContentType { get; } - - /// <summary> - /// Serializes a single resource or a collection of resources. - /// </summary> - 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 - { - /// <summary> - /// Instantiates the serializer to process the servers response. - /// </summary> - 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 -{ - /// <inheritdoc /> - [PublicAPI] - public class JsonApiReader : IJsonApiReader - { - private readonly IJsonApiDeserializer _deserializer; - private readonly IJsonApiRequest _request; - private readonly IResourceGraph _resourceGraph; - private readonly TraceLogWriter<JsonApiReader> _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<JsonApiReader>(loggerFactory); - } - - public async Task<InputFormatterResult> 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<string> 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<Type> 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<Type> GetResourceTypesFromRequestBody(object model) - { - if (model is IEnumerable<IIdentifiable> resourceCollection) - { - return resourceCollection.Select(resource => resource.GetType()).Distinct(); - } - - return model == null ? Enumerable.Empty<Type>() : 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); - } - } - } - - /// <summary> - /// Checks if the deserialized request body has an ID included. - /// </summary> - private bool HasMissingId(object model) - { - return TryGetId(model, out string id) && id == null; - } - - /// <summary> - /// Checks if all elements in the deserialized request body have an ID included. - /// </summary> - 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<IIdentifiable>) - { - 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 -{ - /// <summary> - /// The error that is thrown when (de)serialization of a JSON:API body fails. - /// </summary> - [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 -{ - /// <summary> - /// 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. - /// </summary> - [PublicAPI] - public class JsonApiWriter : IJsonApiWriter - { - private readonly IJsonApiSerializer _serializer; - private readonly IExceptionHandler _exceptionHandler; - private readonly IETagGenerator _eTagGenerator; - private readonly TraceLogWriter<JsonApiWriter> _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<JsonApiWriter>(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<ErrorObject> 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<EntityTagHeaderValue> 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<TObject> : JsonConverter<TObject> { - protected static TValue ReadSubTree<TValue>(ref Utf8JsonReader reader, JsonSerializerOptions options) + protected static TValue? ReadSubTree<TValue>(ref Utf8JsonReader reader, JsonSerializerOptions options) { - if (typeof(TValue) != typeof(object) && options?.GetConverter(typeof(TValue)) is JsonConverter<TValue> converter) + if (typeof(TValue) != typeof(object) && options.GetConverter(typeof(TValue)) is JsonConverter<TValue> converter) { return converter.Read(ref reader, typeof(TValue), options); } @@ -17,7 +17,7 @@ protected static TValue ReadSubTree<TValue>(ref Utf8JsonReader reader, JsonSeria protected static void WriteSubTree<TValue>(Utf8JsonWriter writer, TValue value, JsonSerializerOptions options) { - if (typeof(TValue) != typeof(object) && options?.GetConverter(typeof(TValue)) is JsonConverter<TValue> converter) + if (typeof(TValue) != typeof(object) && options.GetConverter(typeof(TValue)) is JsonConverter<TValue> converter) { converter.Write(writer, value, options); } @@ -29,7 +29,7 @@ protected static void WriteSubTree<TValue>(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<ResourceObject public ResourceObjectConverter(IResourceGraph resourceGraph) { + ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); + _resourceGraph = resourceGraph; } @@ -45,10 +48,10 @@ public override ResourceObject Read(ref Utf8JsonReader reader, Type typeToConver { // The 'attributes' element may occur before 'type', but we need to know the resource type before we can deserialize attributes // into their corresponding CLR types. - Type = TryPeekType(ref reader) + Type = PeekType(ref reader) }; - ResourceContext resourceContext = resourceObject.Type != null ? _resourceGraph.TryGetResourceContext(resourceObject.Type) : null; + ResourceType? resourceType = resourceObject.Type != null ? _resourceGraph.FindResourceType(resourceObject.Type) : null; while (reader.Read()) { @@ -60,7 +63,7 @@ public override ResourceObject Read(ref Utf8JsonReader reader, Type typeToConver } case JsonTokenType.PropertyName: { - string propertyName = reader.GetString(); + string? propertyName = reader.GetString(); reader.Read(); switch (propertyName) @@ -85,9 +88,9 @@ public override ResourceObject Read(ref Utf8JsonReader reader, Type typeToConver } case "attributes": { - if (resourceContext != null) + if (resourceType != null) { - resourceObject.Attributes = ReadAttributes(ref reader, options, resourceContext); + resourceObject.Attributes = ReadAttributes(ref reader, options, resourceType); } else { @@ -98,7 +101,7 @@ public override ResourceObject Read(ref Utf8JsonReader reader, Type typeToConver } case "relationships": { - resourceObject.Relationships = ReadSubTree<IDictionary<string, RelationshipObject>>(ref reader, options); + resourceObject.Relationships = ReadSubTree<IDictionary<string, RelationshipObject?>>(ref reader, options); break; } case "links": @@ -108,7 +111,7 @@ public override ResourceObject Read(ref Utf8JsonReader reader, Type typeToConver } case "meta": { - resourceObject.Meta = ReadSubTree<IDictionary<string, object>>(ref reader, options); + resourceObject.Meta = ReadSubTree<IDictionary<string, object?>>(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<string, object> ReadAttributes(ref Utf8JsonReader reader, JsonSerializerOptions options, ResourceContext resourceContext) + private static IDictionary<string, object?> ReadAttributes(ref Utf8JsonReader reader, JsonSerializerOptions options, ResourceType resourceType) { - var attributes = new Dictionary<string, object>(); + var attributes = new Dictionary<string, object?>(); while (reader.Read()) { @@ -170,17 +173,17 @@ private static IDictionary<string, object> 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<object>.Id)) { attributeValue = JsonInvalidAttributeInfo.Id; } @@ -202,10 +205,11 @@ private static IDictionary<string, object> 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<T> : JsonObjectConverter<SingleOrManyData<T>> - where T : class, IResourceIdentity + where T : class, IResourceIdentity, new() { public override SingleOrManyData<T> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions serializerOptions) { - var objects = new List<T>(); + var objects = new List<T?>(); bool isManyData = false; bool hasCompletedToMany = false; @@ -46,6 +46,15 @@ public override SingleOrManyData<T> 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<T>(ref reader, serializerOptions); @@ -61,7 +70,7 @@ public override SingleOrManyData<T> 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<T>(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<string, object> Meta { get; set; } + public IDictionary<string, object?>? 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<string, object> Meta { get; set; } + public IDictionary<string, object?>? 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<AtomicOperationObject> Operations { get; set; } + public IList<AtomicOperationObject?>? Operations { get; set; } [JsonPropertyName("atomic:results")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IList<AtomicResultObject> Results { get; set; } + public IList<AtomicResultObject>? Results { get; set; } [JsonPropertyName("errors")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IList<ErrorObject> Errors { get; set; } + public IList<ErrorObject>? Errors { get; set; } [JsonPropertyName("included")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IList<ResourceObject> Included { get; set; } + public IList<ResourceObject>? Included { get; set; } [JsonPropertyName("meta")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IDictionary<string, object> 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<string, object?>? 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<string, object> Meta { get; set; } + public IDictionary<string, object?>? Meta { get; set; } public ErrorObject(HttpStatusCode statusCode) { StatusCode = statusCode; } + + public static HttpStatusCode GetResponseStatusCode(IReadOnlyList<ErrorObject> 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<string> Ext { get; set; } + public IList<string>? Ext { get; set; } [JsonPropertyName("profile")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IList<string> Profile { get; set; } + public IList<string>? Profile { get; set; } [JsonPropertyName("meta")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IDictionary<string, object> Meta { get; set; } + public IDictionary<string, object?>? 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<string, object> Meta { get; set; } + public IDictionary<string, object?>? 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<string, object> Meta { get; set; } + public IDictionary<string, object?>? 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<string, object> Attributes { get; set; } + public IDictionary<string, object?>? Attributes { get; set; } [JsonPropertyName("relationships")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IDictionary<string, RelationshipObject> Relationships { get; set; } + public IDictionary<string, RelationshipObject?>? 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<string, object> Meta { get; set; } + public IDictionary<string, object?>? 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 /// </summary> [PublicAPI] public readonly struct SingleOrManyData<T> - 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<T> ManyValue { get; } + public IList<T>? 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 +{ + /// <inheritdoc /> + 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; + } + + /// <inheritdoc /> + 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<ResourceObject> 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 +{ + /// <inheritdoc cref="IAtomicReferenceAdapter" /> + [PublicAPI] + public sealed class AtomicReferenceAdapter : ResourceIdentityAdapter, IAtomicReferenceAdapter + { + public AtomicReferenceAdapter(IResourceGraph resourceGraph, IResourceFactory resourceFactory) + : base(resourceGraph, resourceFactory) + { + } + + /// <inheritdoc /> + 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 +{ + /// <summary> + /// The result of validating and converting "ref" in an entry of an atomic:operations request. + /// </summary> + [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 +{ + /// <summary> + /// Contains shared assertions for derived types. + /// </summary> + public abstract class BaseAdapter + { + [AssertionMethod] + protected static void AssertHasData<T>(SingleOrManyData<T> 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<T>(SingleOrManyData<T> 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<T>(SingleOrManyData<T> 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<T>([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 +{ + /// <inheritdoc /> + 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; + } + + /// <inheritdoc /> + 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 +{ + /// <inheritdoc cref="IDocumentInOperationsRequestAdapter" /> + 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; + } + + /// <inheritdoc /> + public IList<OperationContainer> 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<AtomicOperationObject?>? atomicOperationObjects, RequestAdapterState state) + { + if (atomicOperationObjects.IsNullOrEmpty()) + { + throw new ModelConversionException(state.Position, "No operations found.", null); + } + } + + private void AssertMaxOperationsNotExceeded(ICollection<AtomicOperationObject?> 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<OperationContainer> ConvertOperations(IEnumerable<AtomicOperationObject?> atomicOperationObjects, RequestAdapterState state) + { + var operations = new List<OperationContainer>(); + 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 +{ + /// <inheritdoc /> + 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; + } + + /// <inheritdoc /> + 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<IIdentifiable>(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 +{ + /// <summary> + /// Validates and converts a single operation inside an atomic:operations request. + /// </summary> + public interface IAtomicOperationObjectAdapter + { + /// <summary> + /// Validates and converts the specified <paramref name="atomicOperationObject" />. + /// </summary> + 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 +{ + /// <summary> + /// 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. + /// </summary> + public interface IAtomicReferenceAdapter + { + /// <summary> + /// Validates and converts the specified <paramref name="atomicReference" />. + /// </summary> + 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 +{ + /// <summary> + /// The entry point for validating and converting the deserialized <see cref="Document" /> from the request body into a model. The produced models are + /// used in ASP.NET Model Binding. + /// </summary> + public interface IDocumentAdapter + { + /// <summary> + /// Validates and converts the specified <paramref name="document" />. Possible return values: + /// <list type="bullet"> + /// <item> + /// <description> + /// <code><![CDATA[IList<OperationContainer>]]></code> (operations) + /// </description> + /// </item> + /// <item> + /// <description> + /// <code><![CDATA[ISet<IIdentifiable>]]></code> (to-many relationship, unknown relationship) + /// </description> + /// </item> + /// <item> + /// <description> + /// <code><![CDATA[IIdentifiable]]></code> (resource, to-one relationship) + /// </description> + /// </item> + /// <item> + /// <description> + /// <code><![CDATA[null]]></code> (to-one relationship) + /// </description> + /// </item> + /// </list> + /// </summary> + 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 +{ + /// <summary> + /// Validates and converts a <see cref="Document" /> belonging to an atomic:operations request. + /// </summary> + public interface IDocumentInOperationsRequestAdapter + { + /// <summary> + /// Validates and converts the specified <paramref name="document" />. + /// </summary> + IList<OperationContainer> 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 +{ + /// <summary> + /// Validates and converts a <see cref="Document" /> belonging to a resource or relationship request. + /// </summary> + public interface IDocumentInResourceOrRelationshipRequestAdapter + { + /// <summary> + /// Validates and converts the specified <paramref name="document" />. + /// </summary> + 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 +{ + /// <summary> + /// 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. + /// </summary> + public interface IRelationshipDataAdapter + { + /// <summary> + /// Validates and converts the specified <paramref name="data" />. + /// </summary> + object? Convert(SingleOrManyData<ResourceObject> data, RelationshipAttribute relationship, bool useToManyElementType, RequestAdapterState state); + + /// <summary> + /// Validates and converts the specified <paramref name="data" />. + /// </summary> + object? Convert(SingleOrManyData<ResourceIdentifierObject> 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 +{ + /// <summary> + /// Validates and converts the data from a resource in a POST/PATCH resource request. + /// </summary> + public interface IResourceDataAdapter + { + /// <summary> + /// Validates and converts the specified <paramref name="data" />. + /// </summary> + IIdentifiable Convert(SingleOrManyData<ResourceObject> 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 +{ + /// <summary> + /// Validates and converts the data from an entry in an atomic:operations request that creates or updates a resource. + /// </summary> + [PublicAPI] + public interface IResourceDataInOperationsRequestAdapter + { + /// <summary> + /// Validates and converts the specified <paramref name="data" />. + /// </summary> + IIdentifiable Convert(SingleOrManyData<ResourceObject> 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 +{ + /// <summary> + /// Validates and converts a <see cref="ResourceIdentifierObject" />. It appears in the data object(s) of a relationship. + /// </summary> + public interface IResourceIdentifierObjectAdapter + { + /// <summary> + /// Validates and converts the specified <paramref name="resourceIdentifierObject" />. + /// </summary> + 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 +{ + /// <summary> + /// Validates and converts a <see cref="ResourceObject" />. It appears in a POST/PATCH resource request and an entry in an atomic:operations request that + /// creates or updates a resource. + /// </summary> + public interface IResourceObjectAdapter + { + /// <summary> + /// Validates and converts the specified <paramref name="resourceObject" />. + /// </summary> + (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 +{ + /// <summary> + /// Lists constraints for the presence or absence of a JSON element. + /// </summary> + [PublicAPI] + public enum JsonElementConstraint + { + /// <summary> + /// A value for the element is not allowed. + /// </summary> + Forbidden, + + /// <summary> + /// A value for the element is required. + /// </summary> + 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 +{ + /// <inheritdoc cref="IRelationshipDataAdapter" /> + 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; + } + + /// <inheritdoc /> + public object? Convert(SingleOrManyData<ResourceObject> data, RelationshipAttribute relationship, bool useToManyElementType, RequestAdapterState state) + { + SingleOrManyData<ResourceIdentifierObject> identifierData = ToIdentifierData(data); + return Convert(identifierData, relationship, useToManyElementType, state); + } + + private static SingleOrManyData<ResourceIdentifierObject> ToIdentifierData(SingleOrManyData<ResourceObject> 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<ResourceIdentifierObject>(newValue); + } + + /// <inheritdoc /> + public object? Convert(SingleOrManyData<ResourceIdentifierObject> 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<ResourceIdentifierObject> data, ResourceIdentityRequirements requirements, + RequestAdapterState state) + { + AssertDataHasSingleValue(data, true, state); + + return data.SingleValue != null ? _resourceIdentifierObjectAdapter.Convert(data.SingleValue, requirements, state) : null; + } + + private IEnumerable ConvertToManyRelationshipData(SingleOrManyData<ResourceIdentifierObject> data, RelationshipAttribute relationship, + ResourceIdentityRequirements requirements, bool useToManyElementType, RequestAdapterState state) + { + AssertDataHasManyValue(data, state); + + int arrayIndex = 0; + var rightResources = new List<IIdentifiable>(); + + 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<IIdentifiable>(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 +{ + /// <summary> + /// Tracks the location within an object tree when validating and converting a request body. + /// </summary> + [PublicAPI] + public sealed class RequestAdapterPosition + { + private readonly Stack<string> _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<string>(_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 +{ + /// <summary> + /// Tracks state while adapting objects from <see cref="JsonApiDotNetCore.Serialization.Objects" /> into the shape that controller actions accept. + /// </summary> + [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 +{ + /// <inheritdoc cref="IResourceDataAdapter" /> + 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; + } + + /// <inheritdoc /> + public IIdentifiable Convert(SingleOrManyData<ResourceObject> 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<ResourceObject> 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 +{ + /// <inheritdoc cref="IResourceDataInOperationsRequestAdapter" /> + public sealed class ResourceDataInOperationsRequestAdapter : ResourceDataAdapter, IResourceDataInOperationsRequestAdapter + { + public ResourceDataInOperationsRequestAdapter(IResourceDefinitionAccessor resourceDefinitionAccessor, IResourceObjectAdapter resourceObjectAdapter) + : base(resourceDefinitionAccessor, resourceObjectAdapter) + { + } + + protected override (IIdentifiable resource, ResourceType resourceType) ConvertResourceObject(SingleOrManyData<ResourceObject> 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 +{ + /// <inheritdoc cref="IResourceIdentifierObjectAdapter" /> + public sealed class ResourceIdentifierObjectAdapter : ResourceIdentityAdapter, IResourceIdentifierObjectAdapter + { + public ResourceIdentifierObjectAdapter(IResourceGraph resourceGraph, IResourceFactory resourceFactory) + : base(resourceGraph, resourceFactory) + { + } + + /// <inheritdoc /> + 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 +{ + /// <summary> + /// Base class for validating and converting objects that represent an identity. + /// </summary> + 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 +{ + /// <summary> + /// Defines requirements to validate an <see cref="IResourceIdentity" /> instance against. + /// </summary> + [PublicAPI] + public sealed class ResourceIdentityRequirements + { + /// <summary> + /// When not null, indicates that the "type" element must be compatible with the specified resource type. + /// </summary> + public ResourceType? ResourceType { get; init; } + + /// <summary> + /// When not null, indicates the presence or absence of the "id" element. + /// </summary> + public JsonElementConstraint? IdConstraint { get; init; } + + /// <summary> + /// When not null, indicates what the value of the "id" element must be. + /// </summary> + public string? IdValue { get; init; } + + /// <summary> + /// When not null, indicates what the value of the "lid" element must be. + /// </summary> + public string? LidValue { get; init; } + + /// <summary> + /// When not null, indicates the name of the relationship to use in error messages. + /// </summary> + 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 +{ + /// <inheritdoc cref="IResourceObjectAdapter" /> + public sealed class ResourceObjectAdapter : ResourceIdentityAdapter, IResourceObjectAdapter + { + private readonly IJsonApiOptions _options; + private readonly IRelationshipDataAdapter _relationshipDataAdapter; + + public ResourceObjectAdapter(IResourceGraph resourceGraph, IResourceFactory resourceFactory, IJsonApiOptions options, + IRelationshipDataAdapter relationshipDataAdapter) + : base(resourceGraph, resourceFactory) + { + ArgumentGuard.NotNull(options, nameof(options)); + ArgumentGuard.NotNull(relationshipDataAdapter, nameof(relationshipDataAdapter)); + + _options = options; + _relationshipDataAdapter = relationshipDataAdapter; + } + + /// <inheritdoc /> + 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<string, object?>? 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<string, RelationshipObject?>? 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 +{ + /// <summary> + /// Deserializes the incoming JSON:API request body and converts it to models, which are passed to controller actions by ASP.NET on `FromBody` + /// parameters. + /// </summary> + [PublicAPI] + public interface IJsonApiReader + { + /// <summary> + /// Reads an object from the request body. + /// </summary> + Task<object?> 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 +{ + /// <inheritdoc /> + public sealed class JsonApiReader : IJsonApiReader + { + private readonly IJsonApiOptions _options; + private readonly IDocumentAdapter _documentAdapter; + private readonly TraceLogWriter<JsonApiReader> _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<JsonApiReader>(loggerFactory); + } + + /// <inheritdoc /> + public async Task<object?> 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<string> 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<Document>(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 { /// <summary> /// 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 +{ + /// <summary> + /// The error that is thrown when unable to convert a deserialized request body to an ASP.NET model. + /// </summary> + [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 -{ - /// <summary> - /// Server deserializer implementation of the <see cref="BaseDeserializer" />. - /// </summary> - [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; - } - - /// <inheritdoc /> - 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<OperationContainer>(); - 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<IIdentifiable>(); - - 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); - } - } - - /// <summary> - /// Additional processing required for server deserialization. Flags a processed attribute or relationship as updated using - /// <see cref="ITargetedFields" />. - /// </summary> - /// <param name="resource"> - /// The resource that was constructed from the document's body. - /// </param> - /// <param name="field"> - /// The metadata for the exposed field. - /// </param> - /// <param name="data"> - /// Relationship data for <paramref name="resource" />. Is null when <paramref name="field" /> is not a <see cref="RelationshipAttribute" />. - /// </param> - 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 { /// <inheritdoc /> 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 { /// <inheritdoc /> public sealed class EmptyResponseMeta : IResponseMeta { /// <inheritdoc /> - public IReadOnlyDictionary<string, object> GetMeta() + public IReadOnlyDictionary<string, object?>? 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 { /// <inheritdoc /> 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 { /// <summary> /// 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 { /// <summary> /// 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 +{ + /// <summary> + /// Serializes ASP.NET models into the outgoing JSON:API response body. + /// </summary> + [PublicAPI] + public interface IJsonApiWriter + { + /// <summary> + /// Writes an object to the response body. + /// </summary> + 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 { /// <summary> /// Builds resource object links and relationship object links. @@ -12,16 +13,16 @@ public interface ILinkBuilder /// <summary> /// Builds the links object that is included in the top-level of the document. /// </summary> - TopLevelLinks GetTopLevelLinks(); + TopLevelLinks? GetTopLevelLinks(); /// <summary> /// Builds the links object for a returned resource (primary or included). /// </summary> - ResourceLinks GetResourceLinks(string resourceName, string id); + ResourceLinks? GetResourceLinks(ResourceType resourceType, IIdentifiable resource); /// <summary> /// Builds the links object for a relationship inside a returned resource. /// </summary> - 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 { /// <summary> /// 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. /// </summary> - void Add(IReadOnlyDictionary<string, object> values); + void Add(IReadOnlyDictionary<string, object?> values); /// <summary> /// Builds the top-level meta data object. /// </summary> - IDictionary<string, object> Build(); + IDictionary<string, object?>? 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 { /// <summary> /// Provides a method to obtain global JSON:API meta, which is added at top-level to a response <see cref="Document" />. Use @@ -13,6 +13,6 @@ public interface IResponseMeta /// <summary> /// Gets the global top-level JSON:API meta information to add to the response. /// </summary> - IReadOnlyDictionary<string, object> GetMeta(); + IReadOnlyDictionary<string, object?>? 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 +{ + /// <summary> + /// Converts the produced model from an ASP.NET controller action into a <see cref="Document" />, ready to be serialized as the response body. + /// </summary> + public interface IResponseModelAdapter + { + /// <summary> + /// Validates and converts the specified <paramref name="model" />. Supported model types: + /// <list type="bullet"> + /// <item> + /// <description> + /// <code><![CDATA[IEnumerable<IIdentifiable>]]></code> + /// </description> + /// </item> + /// <item> + /// <description> + /// <code><![CDATA[IIdentifiable]]></code> + /// </description> + /// </item> + /// <item> + /// <description> + /// <code><![CDATA[null]]></code> + /// </description> + /// </item> + /// <item> + /// <description> + /// <code><![CDATA[IEnumerable<OperationContainer?>]]></code> + /// </description> + /// </item> + /// <item> + /// <description> + /// <code><![CDATA[IEnumerable<ErrorObject>]]></code> + /// </description> + /// </item> + /// <item> + /// <description> + /// <code><![CDATA[ErrorObject]]></code> + /// </description> + /// </item> + /// </list> + /// </summary> + 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 +{ + /// <inheritdoc /> + 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<JsonApiWriter> _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<JsonApiWriter>(loggerFactory); + } + + /// <inheritdoc /> + 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<ErrorObject> 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<EntityTagHeaderValue>? 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<Identifiable>.GetAsync)); - private static readonly string GetSecondaryControllerActionName = NoAsyncSuffix(nameof(BaseJsonApiController<Identifiable>.GetSecondaryAsync)); - private static readonly string GetRelationshipControllerActionName = NoAsyncSuffix(nameof(BaseJsonApiController<Identifiable>.GetRelationshipAsync)); + private static readonly string GetPrimaryControllerActionName = NoAsyncSuffix(nameof(BaseJsonApiController<Identifiable<int>, int>.GetAsync)); + + private static readonly string GetSecondaryControllerActionName = + NoAsyncSuffix(nameof(BaseJsonApiController<Identifiable<int>, int>.GetSecondaryAsync)); + + private static readonly string GetRelationshipControllerActionName = + NoAsyncSuffix(nameof(BaseJsonApiController<Identifiable<int>, 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) } /// <inheritdoc /> - 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; } /// <summary> - /// Checks if the top-level <paramref name="linkType" /> should be added by first checking configuration on the <see cref="ResourceContext" />, and if - /// not configured, by checking with the global configuration in <see cref="IJsonApiOptions" />. + /// Checks if the top-level <paramref name="linkType" /> should be added by first checking configuration on the <see cref="ResourceType" />, and if not + /// configured, by checking with the global configuration in <see cref="IJsonApiOptions" />. /// </summary> - 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<PaginationElementQueryStringValueExpression> elements = ParsePageSizeExpression(pageSizeParameterValue, requestContext); + IImmutableList<PaginationElementQueryStringValueExpression> 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<PaginationElementQueryStringValueExpression> ParsePageSizeExpression(string pageSizeParameterValue, - ResourceContext requestResource) + private IImmutableList<PaginationElementQueryStringValueExpression> ParsePageSizeExpression(string? pageSizeParameterValue, ResourceType resourceType) { if (pageSizeParameterValue == null) { return ImmutableArray<PaginationElementQueryStringValueExpression>.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<string, string> parameters = - _httpContextAccessor.HttpContext!.Request.Query.ToDictionary(pair => pair.Key, pair => pair.Value.ToString()); + IDictionary<string, string?> 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; } /// <inheritdoc /> - 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; } /// <summary> - /// Checks if the resource object level <paramref name="linkType" /> should be added by first checking configuration on the - /// <see cref="ResourceContext" />, and if not configured, by checking with the global configuration in <see cref="IJsonApiOptions" />. + /// Checks if the resource object level <paramref name="linkType" /> should be added by first checking configuration on the <see cref="ResourceType" />, + /// and if not configured, by checking with the global configuration in <see cref="IJsonApiOptions" />. /// </summary> - 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<string, object> routeValues = GetRouteValues(resourceId, null); + string? controllerName = _controllerResourceMapping.GetControllerNameForResourceType(resourceType); + IDictionary<string, object?> routeValues = GetRouteValues(resource.StringId!, null); return RenderLinkForAction(controllerName, GetPrimaryControllerActionName, routeValues); } /// <inheritdoc /> - 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<string, object> routeValues = GetRouteValues(leftId, relationship.PublicName); + string? controllerName = _controllerResourceMapping.GetControllerNameForResourceType(relationship.LeftType); + IDictionary<string, object?> 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<string, object> routeValues = GetRouteValues(leftId, relationship.PublicName); + string? controllerName = _controllerResourceMapping.GetControllerNameForResourceType(relationship.LeftType); + IDictionary<string, object?> routeValues = GetRouteValues(leftId, relationship.PublicName); return RenderLinkForAction(controllerName, GetSecondaryControllerActionName, routeValues); } - private IDictionary<string, object> GetRouteValues(string primaryId, string relationshipName) + private IDictionary<string, object?> 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<string, object> GetRouteValues(string primaryId, string rela return routeValues; } - protected virtual string RenderLinkForAction(string controllerName, string actionName, IDictionary<string, object> routeValues) + protected virtual string? RenderLinkForAction(string? controllerName, string actionName, IDictionary<string, object?> 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); } /// <summary> @@ -325,16 +327,16 @@ protected virtual string RenderLinkForAction(string controllerName, string actio /// <paramref name="relationship" /> attribute, if not configured by checking <see cref="ResourceLinksAttribute.RelationshipLinks" /> on the resource /// type that contains this relationship, and if not configured by checking with the global configuration in <see cref="IJsonApiOptions" />. /// </summary> - 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 { /// <inheritdoc /> [PublicAPI] @@ -14,7 +14,7 @@ public sealed class MetaBuilder : IMetaBuilder private readonly IJsonApiOptions _options; private readonly IResponseMeta _responseMeta; - private Dictionary<string, object> _meta = new(); + private Dictionary<string, object?> _meta = new(); public MetaBuilder(IPaginationContext paginationContext, IJsonApiOptions options, IResponseMeta responseMeta) { @@ -28,7 +28,7 @@ public MetaBuilder(IPaginationContext paginationContext, IJsonApiOptions options } /// <inheritdoc /> - public void Add(IReadOnlyDictionary<string, object> values) + public void Add(IReadOnlyDictionary<string, object?> values) { ArgumentGuard.NotNull(values, nameof(values)); @@ -36,7 +36,7 @@ public void Add(IReadOnlyDictionary<string, object> values) } /// <inheritdoc /> - public IDictionary<string, object> Build() + public IDictionary<string, object?>? Build() { if (_paginationContext.TotalResourceCount != null) { @@ -49,7 +49,7 @@ public IDictionary<string, object> Build() _meta.Add(key, _paginationContext.TotalResourceCount); } - IReadOnlyDictionary<string, object> extraMeta = _responseMeta.GetMeta(); + IReadOnlyDictionary<string, object?>? 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 +{ + /// <summary> + /// 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. + /// </summary> + internal sealed class ResourceObjectTreeNode : IEquatable<ResourceObjectTreeNode> + { + // 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<ResourceObjectTreeNode>? _directChildren; + + // Related resource objects per relationship. These are emitted in 'included'. + private Dictionary<RelationshipAttribute, HashSet<ResourceObjectTreeNode>>? _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<ResourceObjectTreeNode>(); + _directChildren.Add(treeNode); + } + + public void EnsureHasRelationship(RelationshipAttribute relationship) + { + ArgumentGuard.NotNull(relationship, nameof(relationship)); + + _childrenByRelationship ??= new Dictionary<RelationshipAttribute, HashSet<ResourceObjectTreeNode>>(); + + if (!_childrenByRelationship.ContainsKey(relationship)) + { + _childrenByRelationship[relationship] = new HashSet<ResourceObjectTreeNode>(); + } + } + + 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<ResourceObjectTreeNode> rightNodes = _childrenByRelationship[relationship]; + rightNodes.Add(rightNode); + } + + /// <summary> + /// Recursively walks the tree and returns the set of unique nodes. Uses relationship declaration order. + /// </summary> + public ISet<ResourceObjectTreeNode> GetUniqueNodes() + { + AssertIsTreeRoot(); + + var visited = new HashSet<ResourceObjectTreeNode>(); + + VisitSubtree(this, visited); + + return visited; + } + + private static void VisitSubtree(ResourceObjectTreeNode treeNode, ISet<ResourceObjectTreeNode> 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<ResourceObjectTreeNode> visited) + { + if (treeNode._directChildren != null) + { + foreach (ResourceObjectTreeNode child in treeNode._directChildren) + { + VisitSubtree(child, visited); + } + } + } + + private static void VisitRelationshipChildrenInSubtree(ResourceObjectTreeNode treeNode, ISet<ResourceObjectTreeNode> visited) + { + if (treeNode._childrenByRelationship != null) + { + foreach (RelationshipAttribute relationship in treeNode.ResourceType.Relationships) + { + if (treeNode._childrenByRelationship.TryGetValue(relationship, out HashSet<ResourceObjectTreeNode>? rightNodes)) + { + VisitRelationshipChildInSubtree(rightNodes, visited); + } + } + } + } + + private static void VisitRelationshipChildInSubtree(HashSet<ResourceObjectTreeNode> rightNodes, ISet<ResourceObjectTreeNode> visited) + { + foreach (ResourceObjectTreeNode rightNode in rightNodes) + { + VisitSubtree(rightNode, visited); + } + } + + public ISet<ResourceObjectTreeNode>? GetRightNodesInRelationship(RelationshipAttribute relationship) + { + return _childrenByRelationship != null && _childrenByRelationship.TryGetValue(relationship, out HashSet<ResourceObjectTreeNode>? rightNodes) + ? rightNodes + : null; + } + + /// <summary> + /// Provides the value for 'data' in the response body. Uses relationship declaration order. + /// </summary> + public IList<ResourceObject> GetResponseData() + { + AssertIsTreeRoot(); + + return GetDirectChildren().Select(child => child.ResourceObject).ToArray(); + } + + /// <summary> + /// Provides the value for 'included' in the response body. Uses relationship declaration order. + /// </summary> + public IList<ResourceObject> GetResponseIncluded() + { + AssertIsTreeRoot(); + + var visited = new HashSet<ResourceObjectTreeNode>(); + + foreach (ResourceObjectTreeNode child in GetDirectChildren()) + { + VisitRelationshipChildrenInSubtree(child, visited); + } + + return visited.Select(node => node.ResourceObject).ToArray(); + } + + private IList<ResourceObjectTreeNode> 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<ResourceObjectTreeNode>() : _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<ResourceObject> + { + 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 +{ + /// <inheritdoc /> + [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<IIdentifiable, ResourceObjectTreeNode> _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; + } + + /// <inheritdoc /> + public Document Convert(object? model) + { + _sparseFieldSetCache.Reset(); + _resourceToTreeNodeCache.Clear(); + + var document = new Document(); + + IncludeExpression? include = _evaluatedIncludeCache.Get(); + IImmutableSet<IncludeElementExpression> includeElements = include?.Elements ?? ImmutableHashSet<IncludeElementExpression>.Empty; + + var rootNode = ResourceObjectTreeNode.CreateRoot(); + + if (model is IEnumerable<IIdentifiable> 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<ResourceObject> resourceObjects = rootNode.GetResponseData(); + document.Data = new SingleOrManyData<ResourceObject>(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>(resourceObject); + } + else if (model == null) + { + document.Data = new SingleOrManyData<ResourceObject>(null); + } + else if (model is IEnumerable<OperationContainer?> operations) + { + using var _ = new RevertRequestStateOnDispose(_request, null); + document.Results = operations.Select(operation => ConvertOperation(operation, includeElements)).ToList(); + } + else if (model is IEnumerable<ErrorObject> 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<IncludeElementExpression> 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>(resourceObject) + }; + } + + private void TraverseResource(IIdentifiable resource, ResourceType resourceType, EndpointKind kind, + IImmutableSet<IncludeElementExpression> 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<ResourceFieldAttribute> 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<string, object?>? ConvertAttributes(IIdentifiable resource, ResourceType resourceType, + IImmutableSet<ResourceFieldAttribute> fieldSet) + { + var attrMap = new Dictionary<string, object?>(resourceType.Attributes.Count); + + foreach (AttrAttribute attr in resourceType.Attributes) + { + if (!fieldSet.Contains(attr) || attr.Property.Name == nameof(Identifiable<object>.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<IncludeElementExpression> 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<IIdentifiable> 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<ResourceFieldAttribute> 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<ResourceIdentifierObject> 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<string, RelationshipObject?>(); + treeNode.ResourceObject.Relationships.Add(relationship.PublicName, relationshipObject); + } + } + + private static SingleOrManyData<ResourceIdentifierObject> GetRelationshipData(ResourceObjectTreeNode treeNode, RelationshipAttribute relationship) + { + ISet<ResourceObjectTreeNode>? rightNodes = treeNode.GetRightNodesInRelationship(relationship); + + if (rightNodes != null) + { + IEnumerable<ResourceIdentifierObject> resourceIdentifierObjects = rightNodes.Select(rightNode => new ResourceIdentifierObject + { + Type = rightNode.ResourceType.PublicName, + Id = rightNode.ResourceObject.Id + }); + + return relationship is HasOneAttribute + ? new SingleOrManyData<ResourceIdentifierObject>(resourceIdentifierObjects.SingleOrDefault()) + : new SingleOrManyData<ResourceIdentifierObject>(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<string> + { + "https://jsonapi.org/ext/atomic" + }; + } + + return jsonApiObject; + } + + private IList<ResourceObject>? GetIncluded(ResourceObjectTreeNode rootNode) + { + IList<ResourceObject> resourceObjects = rootNode.GetResponseIncluded(); + + if (resourceObjects.Any()) + { + return resourceObjects; + } + + return _requestQueryStringAccessor.Query.ContainsKey("include") ? Array.Empty<ResourceObject>() : 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 -{ - /// <summary> - /// Server serializer implementation of <see cref="BaseSerializer" /> for resources of a specific type. - /// </summary> - /// <remarks> - /// Because in JsonApiDotNetCore every JSON:API request is associated with exactly one resource (the primary resource, see - /// <see cref="IJsonApiRequest.PrimaryResource" />), the serializer can leverage this information using generics. See - /// <see cref="ResponseSerializerFactory" /> for how this is instantiated. - /// </remarks> - /// <typeparam name="TResource"> - /// Type of the resource associated with the scope of the request for which this serializer is used. - /// </typeparam> - [PublicAPI] - public class ResponseSerializer<TResource> : 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; - - /// <inheritdoc /> - 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); - } - - /// <inheritdoc /> - public string Serialize(object content) - { - if (content == null || content is IIdentifiable) - { - return SerializeSingle((IIdentifiable)content); - } - - if (content is IEnumerable<IIdentifiable> 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); - } - - /// <summary> - /// Converts a single resource into a serialized <see cref="Document" />. - /// </summary> - /// <remarks> - /// This method is internal instead of private for easier testability. - /// </remarks> - internal string SerializeSingle(IIdentifiable resource) - { - if (resource != null && _fieldsToSerialize.ShouldSerialize) - { - _resourceDefinitionAccessor.OnSerialize(resource); - } - - IReadOnlyCollection<AttrAttribute> attributes = _fieldsToSerialize.GetAttributes(_primaryResourceType); - IReadOnlyCollection<RelationshipAttribute> 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); - } - - /// <summary> - /// Converts a collection of resources into a serialized <see cref="Document" />. - /// </summary> - /// <remarks> - /// This method is internal instead of private for easier testability. - /// </remarks> - internal string SerializeMany(IReadOnlyCollection<IIdentifiable> resources) - { - if (_fieldsToSerialize.ShouldSerialize) - { - foreach (IIdentifiable resource in resources) - { - _resourceDefinitionAccessor.OnSerialize(resource); - } - } - - IReadOnlyCollection<AttrAttribute> attributes = _fieldsToSerialize.GetAttributes(_primaryResourceType); - IReadOnlyCollection<RelationshipAttribute> 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); - } - - /// <summary> - /// Adds top-level objects that are only added to a document in the case of server-side serialization. - /// </summary> - 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 -{ - /// <summary> - /// A factory class to abstract away the initialization of the serializer from the ASP.NET Core formatter pipeline. - /// </summary> - [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; - } - - /// <summary> - /// Initializes the server serializer using the <see cref="ResourceContext" /> associated with the current request. - /// </summary> - 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 { - /// <inheritdoc /> - public interface IAddToRelationshipService<TResource> : IAddToRelationshipService<TResource, int> - where TResource : class, IIdentifiable<int> - { - } - /// <summary /> [PublicAPI] public interface IAddToRelationshipService<TResource, in TId> 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 { - /// <inheritdoc /> - public interface ICreateService<TResource> : ICreateService<TResource, int> - where TResource : class, IIdentifiable<int> - { - } - /// <summary /> public interface ICreateService<TResource, in TId> where TResource : class, IIdentifiable<TId> @@ -17,6 +11,6 @@ public interface ICreateService<TResource, in TId> /// <summary> /// Handles a JSON:API request to create a new resource with attributes, relationships or both. /// </summary> - Task<TResource> CreateAsync(TResource resource, CancellationToken cancellationToken); + Task<TResource?> 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 { - /// <inheritdoc /> - public interface IDeleteService<TResource> : IDeleteService<TResource, int> - where TResource : class, IIdentifiable<int> - { - } - /// <summary /> public interface IDeleteService<TResource, in TId> where TResource : class, IIdentifiable<TId> 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 { - /// <inheritdoc /> - public interface IGetAllService<TResource> : IGetAllService<TResource, int> - where TResource : class, IIdentifiable<int> - { - } - /// <summary /> public interface IGetAllService<TResource, in TId> where TResource : class, IIdentifiable<TId> 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 { - /// <inheritdoc /> - public interface IGetByIdService<TResource> : IGetByIdService<TResource, int> - where TResource : class, IIdentifiable<int> - { - } - /// <summary /> public interface IGetByIdService<TResource, in TId> where TResource : class, IIdentifiable<TId> 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 { - /// <inheritdoc /> - public interface IGetRelationshipService<TResource> : IGetRelationshipService<TResource, int> - where TResource : class, IIdentifiable<int> - { - } - /// <summary /> public interface IGetRelationshipService<TResource, in TId> where TResource : class, IIdentifiable<TId> @@ -19,6 +13,6 @@ public interface IGetRelationshipService<TResource, in TId> /// <summary> /// Handles a JSON:API request to retrieve a single relationship. /// </summary> - Task<object> GetRelationshipAsync(TId id, string relationshipName, CancellationToken cancellationToken); + Task<object?> 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 { - /// <inheritdoc /> - public interface IGetSecondaryService<TResource> : IGetSecondaryService<TResource, int> - where TResource : class, IIdentifiable<int> - { - } - /// <summary /> public interface IGetSecondaryService<TResource, in TId> where TResource : class, IIdentifiable<TId> @@ -20,6 +14,6 @@ public interface IGetSecondaryService<TResource, in TId> /// 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. /// </summary> - Task<object> GetSecondaryAsync(TId id, string relationshipName, CancellationToken cancellationToken); + Task<object?> 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 { - /// <inheritdoc /> - public interface IRemoveFromRelationshipService<TResource> : IRemoveFromRelationshipService<TResource, int> - where TResource : class, IIdentifiable<int> - { - } - /// <summary /> public interface IRemoveFromRelationshipService<TResource, in TId> where TResource : class, IIdentifiable<TId> 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 { - /// <summary> - /// Groups write operations. - /// </summary> - /// <typeparam name="TResource"> - /// The resource type. - /// </typeparam> - public interface IResourceCommandService<TResource> - : ICreateService<TResource>, IAddToRelationshipService<TResource>, IUpdateService<TResource>, ISetRelationshipService<TResource>, - IDeleteService<TResource>, IRemoveFromRelationshipService<TResource>, IResourceCommandService<TResource, int> - where TResource : class, IIdentifiable<int> - { - } - /// <summary> /// Groups write operations. /// </summary> 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 { - /// <summary> - /// Groups read operations. - /// </summary> - /// <typeparam name="TResource"> - /// The resource type. - /// </typeparam> - public interface IResourceQueryService<TResource> - : IGetAllService<TResource>, IGetByIdService<TResource>, IGetRelationshipService<TResource>, IGetSecondaryService<TResource>, - IResourceQueryService<TResource, int> - where TResource : class, IIdentifiable<int> - { - } - /// <summary> /// Groups read operations. /// </summary> 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 { - /// <summary> - /// Represents the foundational Resource Service layer in the JsonApiDotNetCore architecture that uses a Resource Repository for data access. - /// </summary> - /// <typeparam name="TResource"> - /// The resource type. - /// </typeparam> - public interface IResourceService<TResource> : IResourceCommandService<TResource>, IResourceQueryService<TResource>, IResourceService<TResource, int> - where TResource : class, IIdentifiable<int> - { - } - /// <summary> /// Represents the foundational Resource Service layer in the JsonApiDotNetCore architecture that uses a Resource Repository for data access. /// </summary> 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 { - /// <inheritdoc /> - public interface ISetRelationshipService<TResource> : ISetRelationshipService<TResource, int> - where TResource : class, IIdentifiable<int> - { - } - /// <summary /> public interface ISetRelationshipService<TResource, in TId> where TResource : class, IIdentifiable<TId> @@ -31,6 +25,6 @@ public interface ISetRelationshipService<TResource, in TId> /// <param name="cancellationToken"> /// Propagates notification that request handling should be canceled. /// </param> - 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 { - /// <inheritdoc /> - public interface IUpdateService<TResource> : IUpdateService<TResource, int> - where TResource : class, IIdentifiable<int> - { - } - /// <summary /> public interface IUpdateService<TResource, in TId> where TResource : class, IIdentifiable<TId> @@ -18,6 +12,6 @@ public interface IUpdateService<TResource, in TId> /// 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. /// </summary> - Task<TResource> UpdateAsync(TId id, TResource resource, CancellationToken cancellationToken); + Task<TResource?> 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<IReadOnlyCollection<TResource>> 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<TResource>(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<IReadOnlyCollection<TResource>> GetAsync(CancellationT } } - QueryLayer queryLayer = _queryLayerComposer.ComposeFromConstraints(_request.PrimaryResource); + QueryLayer queryLayer = _queryLayerComposer.ComposeFromConstraints(_request.PrimaryResourceType); IReadOnlyCollection<TResource> resources = await _repositoryAccessor.GetAsync<TResource>(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<TResource> GetAsync(TId id, CancellationToken cancella } /// <inheritdoc /> - public virtual async Task<object> GetSecondaryAsync(TId id, string relationshipName, CancellationToken cancellationToken) + public virtual async Task<object?> GetSecondaryAsync(TId id, string relationshipName, CancellationToken cancellationToken) { _traceWriter.LogMethodStart(new { @@ -110,17 +113,28 @@ public virtual async Task<object> 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<TResource> primaryResources = await _repositoryAccessor.GetAsync<TResource>(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<object> GetSecondaryAsync(TId id, string relationshipN } /// <inheritdoc /> - public virtual async Task<object> GetRelationshipAsync(TId id, string relationshipName, CancellationToken cancellationToken) + public virtual async Task<object?> GetRelationshipAsync(TId id, string relationshipName, CancellationToken cancellationToken) { _traceWriter.LogMethodStart(new { @@ -143,21 +157,49 @@ public virtual async Task<object> 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<TResource> primaryResources = await _repositoryAccessor.GetAsync<TResource>(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); + } } /// <inheritdoc /> - public virtual async Task<TResource> CreateAsync(TResource resource, CancellationToken cancellationToken) + public virtual async Task<TResource?> CreateAsync(TResource resource, CancellationToken cancellationToken) { _traceWriter.LogMethodStart(new { @@ -165,6 +207,7 @@ public virtual async Task<TResource> 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<TResource> 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<TResource> 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<IIdentifiable> rightResourceIds = _collectionConverter.ExtractResources(rightValue); IAsyncEnumerable<MissingResourceInRelationship> missingResourcesInRelationship = @@ -236,17 +282,17 @@ protected async Task AssertResourcesToAssignInRelationshipsExistAsync(TResource private async IAsyncEnumerable<MissingResourceInRelationship> GetMissingRightResourcesAsync(QueryLayer existingRightResourceIdsQueryLayer, RelationshipAttribute relationship, ICollection<IIdentifiable> rightResourceIds, [EnumeratorCancellation] CancellationToken cancellationToken) { - IReadOnlyCollection<IIdentifiable> existingResources = await _repositoryAccessor.GetAsync( - existingRightResourceIdsQueryLayer.ResourceContext.ResourceType, existingRightResourceIdsQueryLayer, cancellationToken); + IReadOnlyCollection<IIdentifiable> 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<IIdentifiable> 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<IIdentifiable> existingRightResourceIds = _collectionConverter.ExtractResources(rightValue); rightResourceIds.ExceptWith(existingRightResourceIds); @@ -302,16 +350,16 @@ private async Task<TResource> GetForHasManyUpdateAsync(HasManyAttribute hasManyR CancellationToken cancellationToken) { QueryLayer queryLayer = _queryLayerComposer.ComposeForHasMany(hasManyRelationship, leftId, rightResourceIds); - IReadOnlyCollection<TResource> leftResources = await _repositoryAccessor.GetAsync<TResource>(queryLayer, cancellationToken); - - TResource leftResource = leftResources.FirstOrDefault(); + var leftResource = await _repositoryAccessor.GetForUpdateAsync<TResource>(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<IIdentifiable> rightResourceIds = _collectionConverter.ExtractResources(rightValue); if (rightResourceIds.Any()) @@ -329,7 +377,7 @@ protected async Task AssertRightResourcesExistAsync(object rightValue, Cancellat } /// <inheritdoc /> - public virtual async Task<TResource> UpdateAsync(TId id, TResource resource, CancellationToken cancellationToken) + public virtual async Task<TResource?> UpdateAsync(TId id, TResource resource, CancellationToken cancellationToken) { _traceWriter.LogMethodStart(new { @@ -369,7 +417,7 @@ public virtual async Task<TResource> UpdateAsync(TId id, TResource resource, Can } /// <inheritdoc /> - 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<TResource> 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<TResource> TryGetPrimaryResourceByIdAsync(TId id, TopFieldSelection fieldSelection, CancellationToken cancellationToken) + private async Task<TResource?> 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<TResource> primaryResources = await _repositoryAccessor.GetAsync<TResource>(primaryLayer, cancellationToken); return primaryResources.SingleOrDefault(); @@ -464,48 +514,52 @@ private async Task<TResource> TryGetPrimaryResourceByIdAsync(TId id, TopFieldSel protected async Task<TResource> GetPrimaryResourceForUpdateAsync(TId id, CancellationToken cancellationToken) { - QueryLayer queryLayer = _queryLayerComposer.ComposeForUpdate(id, _request.PrimaryResource); - var resource = await _repositoryAccessor.GetForUpdateAsync<TResource>(queryLayer, cancellationToken); + AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); + QueryLayer queryLayer = _queryLayerComposer.ComposeForUpdate(id, _request.PrimaryResourceType); + var resource = await _repositoryAccessor.GetForUpdateAsync<TResource>(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); } } - } - /// <summary> - /// Represents the foundational Resource Service layer in the JsonApiDotNetCore architecture that uses a Resource Repository for data access. - /// </summary> - /// <typeparam name="TResource"> - /// The resource type. - /// </typeparam> - [PublicAPI] - public class JsonApiResourceService<TResource> : JsonApiResourceService<TResource, int>, IResourceService<TResource> - where TResource : class, IIdentifiable<int> - { - public JsonApiResourceService(IResourceRepositoryAccessor repositoryAccessor, IQueryLayerComposer queryLayerComposer, - IPaginationContext paginationContext, IJsonApiOptions options, ILoggerFactory loggerFactory, IJsonApiRequest request, - IResourceChangeTracker<TResource> 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 /// <summary> /// Whether the specified source type implements or equals the specified interface. /// </summary> - public static bool IsOrImplementsInterface(this Type source, Type interfaceType) + public static bool IsOrImplementsInterface<TInterface>(this Type? source) + { + return IsOrImplementsInterface(source, typeof(TInterface)); + } + + /// <summary> + /// Whether the specified source type implements or equals the specified interface. This overload enables to test for an open generic interface. + /// </summary> + 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; } /// <summary> @@ -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<int> { } } 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<TestResource> + public sealed class PrivateResourceDefinition : JsonApiResourceDefinition<PrivateResource, int> { - 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<TestResource> + public sealed class PrivateResourceRepository : EntityFrameworkCoreRepository<PrivateResource, int> { - public TestResourceRepository(ITargetedFields targetedFields, IDbContextResolver contextResolver, IResourceGraph resourceGraph, + public PrivateResourceRepository(ITargetedFields targetedFields, IDbContextResolver dbContextResolver, IResourceGraph resourceGraph, IResourceFactory resourceFactory, IEnumerable<IQueryConstraintProvider> 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<TestResource> + public sealed class PrivateResourceService : JsonApiResourceService<PrivateResource, int> { - public TestResourceService(IResourceRepositoryAccessor repositoryAccessor, IQueryLayerComposer queryLayerComposer, IPaginationContext paginationContext, - IJsonApiOptions options, ILoggerFactory loggerFactory, IJsonApiRequest request, IResourceChangeTracker<TestResource> resourceChangeTracker, - IResourceDefinitionAccessor resourceDefinitionAccessor) + public PrivateResourceService(IResourceRepositoryAccessor repositoryAccessor, IQueryLayerComposer queryLayerComposer, + IPaginationContext paginationContext, IJsonApiOptions options, ILoggerFactory loggerFactory, IJsonApiRequest request, + IResourceChangeTracker<PrivateResource> 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<DbContext>().Object); _services.AddScoped(_ => dbResolverMock.Object); - _services.AddSingleton<IJsonApiOptions>(_options); - _services.AddSingleton<ILoggerFactory>(LoggerFactory); + IJsonApiOptions options = new JsonApiOptions(); + + _services.AddSingleton(options); + _services.AddSingleton(LoggerFactory); _services.AddScoped(_ => new Mock<IJsonApiRequest>().Object); _services.AddScoped(_ => new Mock<ITargetedFields>().Object); _services.AddScoped(_ => new Mock<IResourceGraph>().Object); @@ -40,7 +42,7 @@ public ServiceDiscoveryFacadeTests() _services.AddScoped(_ => new Mock<IResourceRepositoryAccessor>().Object); _services.AddScoped(_ => new Mock<IResourceDefinitionAccessor>().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<IResourceService<TestResource>>(); - resourceService.Should().BeOfType<TestResourceService>(); + var resourceService = services.GetRequiredService<IResourceService<PrivateResource, int>>(); + resourceService.Should().BeOfType<PrivateResourceService>(); } [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<IResourceRepository<TestResource>>(); - resourceRepository.Should().BeOfType<TestResourceRepository>(); + var resourceRepository = services.GetRequiredService<IResourceRepository<PrivateResource, int>>(); + resourceRepository.Should().BeOfType<PrivateResourceRepository>(); } [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<IResourceDefinition<TestResource>>(); - resourceDefinition.Should().BeOfType<TestResourceDefinition>(); + var resourceDefinition = services.GetRequiredService<IResourceDefinition<PrivateResource, int>>(); + resourceDefinition.Should().BeOfType<PrivateResourceDefinition>(); } } } 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<DateTimeOffset?>().Should().BeCloseTo(broadcast.ArchivedAt.GetValueOrDefault()); + + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("archivedAt") + .With(value => value.As<DateTimeOffset?>().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<DateTimeOffset?>().Should().BeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("archivedAt").With(value => value.As<DateTimeOffset?>().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<DateTimeOffset?>().Should().BeNull(); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("archivedAt").With(value => value.As<DateTimeOffset?>().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<DateTimeOffset?>().Should().BeCloseTo(broadcasts[0].ArchivedAt.GetValueOrDefault()); + + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("archivedAt") + .With(value => value.As<DateTimeOffset?>().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<DateTimeOffset?>().Should().BeCloseTo(archivedAt0); + responseDocument.Included[0].Attributes.ShouldContainKey("archivedAt").With(value => value.As<DateTimeOffset?>().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<DateTimeOffset?>().Should().BeCloseTo(archivedAt); + + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("archivedAt") + .With(value => value.As<DateTimeOffset?>().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<DateTimeOffset?>().Should().BeCloseTo(archivedAt0); + + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("archivedAt").With(value => + value.As<DateTimeOffset?>().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<DateTimeOffset?>().Should().BeCloseTo(archivedAt0); + responseDocument.Included[0].Attributes.ShouldContainKey("archivedAt").With(value => value.As<DateTimeOffset?>().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<DateTimeOffset>().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<DateTimeOffset>().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<int> { [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<BroadcastComment> + public sealed class BroadcastCommentsController : JsonApiController<BroadcastComment, int> { - public BroadcastCommentsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<BroadcastComment> resourceService) - : base(options, loggerFactory, resourceService) + public BroadcastCommentsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<BroadcastComment, int> 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<int> { [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<BroadcastComment> Comments { get; set; } + public ISet<BroadcastComment> Comments { get; set; } = new HashSet<BroadcastComment>(); } } 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<TelevisionBroadcast> + public sealed class TelevisionBroadcastDefinition : JsonApiResourceDefinition<TelevisionBroadcast, int> { 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<object> + private sealed class FilterWalker : QueryExpressionRewriter<object?> { 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<TelevisionBroadcast> + public sealed class TelevisionBroadcastsController : JsonApiController<TelevisionBroadcast, int> { - public TelevisionBroadcastsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<TelevisionBroadcast> resourceService) - : base(options, loggerFactory, resourceService) + public TelevisionBroadcastsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<TelevisionBroadcast, int> 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<TelevisionNetwork> Networks { get; set; } - public DbSet<TelevisionStation> Stations { get; set; } - public DbSet<TelevisionBroadcast> Broadcasts { get; set; } - public DbSet<BroadcastComment> Comments { get; set; } + public DbSet<TelevisionNetwork> Networks => Set<TelevisionNetwork>(); + public DbSet<TelevisionStation> Stations => Set<TelevisionStation>(); + public DbSet<TelevisionBroadcast> Broadcasts => Set<TelevisionBroadcast>(); + public DbSet<BroadcastComment> Comments => Set<BroadcastComment>(); public TelevisionDbContext(DbContextOptions<TelevisionDbContext> 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<int> { [Attr] - public string Name { get; set; } + public string Name { get; set; } = null!; [HasMany] - public ISet<TelevisionStation> Stations { get; set; } + public ISet<TelevisionStation> Stations { get; set; } = new HashSet<TelevisionStation>(); } } 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<TelevisionNetwork> + public sealed class TelevisionNetworksController : JsonApiController<TelevisionNetwork, int> { - public TelevisionNetworksController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<TelevisionNetwork> resourceService) - : base(options, loggerFactory, resourceService) + public TelevisionNetworksController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<TelevisionNetwork, int> 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<int> { [Attr] - public string Name { get; set; } + public string Name { get; set; } = null!; [HasMany] - public ISet<TelevisionBroadcast> Broadcasts { get; set; } + public ISet<TelevisionBroadcast> Broadcasts { get; set; } = new HashSet<TelevisionBroadcast>(); } } 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<TelevisionStation> + public sealed class TelevisionStationsController : JsonApiController<TelevisionStation, int> { - public TelevisionStationsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<TelevisionStation> resourceService) - : base(options, loggerFactory, resourceService) + public TelevisionStationsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<TelevisionStation, int> 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<OperationContainer foreach (OperationContainer operation in operations) { - if (operation.Kind != WriteOperationKind.CreateResource || operation.Resource.GetType() != typeof(MusicTrack)) + if (operation.Request.WriteOperation != WriteOperationKind.CreateResource || operation.Resource.GetType() != typeof(MusicTrack)) { throw new JsonApiException(new ErrorObject(HttpStatusCode.UnprocessableEntity) { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs index 93dd55627e..fc963b2fae 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs @@ -6,8 +6,10 @@ using System.Threading.Tasks; using FluentAssertions; using FluentAssertions.Extensions; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; using TestBuildingBlocks; using Xunit; @@ -28,13 +30,16 @@ public AtomicCreateResourceTests(IntegrationTestContext<TestableStartup<Operatio testContext.UseController<LyricsController>(); testContext.UseController<MusicTracksController>(); testContext.UseController<PlaylistsController>(); + + var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService<IJsonApiOptions>(); + 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<DateTimeOffset>().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<DateTimeOffset>().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<decimal?>().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<decimal?>().Should().BeApproximately(newTracks[index].LengthInSeconds); - singleData.Attributes["genre"].Should().Be(newTracks[index].Genre); - singleData.Attributes["releasedAt"].As<DateTimeOffset>().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<DateTimeOffset>().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<MusicTrack> 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<DateTimeOffset>().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<DateTimeOffset>().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<Document>(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<IJsonApiOptions>(); + 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<Document>(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<IJsonApiOptions>(); + 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<Document>(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<Tes testContext.ConfigureServicesAfterStartup(services => { services.AddResourceDefinition<ImplicitlyChangingTextLanguageDefinition>(); + + services.AddSingleton<ResourceDefinitionHitCounter>(); }); var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService<IJsonApiOptions>(); @@ -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<Document>(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<Document>(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<Document>(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<Document>(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<Lyric, long>() + } + } + } + } + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync<Document>(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<Lyric, long>(); 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<Document>(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<Lyric, long>() - } - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync<Document>(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. /// </summary> [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public class ImplicitlyChangingTextLanguageDefinition : JsonApiResourceDefinition<TextLanguage, Guid> + public class ImplicitlyChangingTextLanguageDefinition : HitCountingResourceDefinition<TextLanguage, Guid> { 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<IntegrationTestContext<RelativeLinksInApiNamespaceStartup<OperationsDbContext>, OperationsDbContext>> { private readonly IntegrationTestContext<RelativeLinksInApiNamespaceStartup<OperationsDbContext>, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new(); public AtomicRelativeLinksWithNamespaceTests( IntegrationTestContext<RelativeLinksInApiNamespaceStartup<OperationsDbContext>, 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<DateTimeOffset>().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<DateTimeOffset>().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<long> { [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<Lyric, long> { - public LyricsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<Lyric, long> resourceService) - : base(options, loggerFactory, resourceService) + public LyricsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<Lyric, long> 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<JsonElement>().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<JsonElement>().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<JsonElement>().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<string, object> GetMeta() + public IReadOnlyDictionary<string, object?> GetMeta() { - return new Dictionary<string, object> + return new Dictionary<string, object?> { ["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<TestableStartup<Operations { services.AddResourceDefinition<ImplicitlyChangingTextLanguageDefinition>(); + services.AddSingleton<ResourceDefinitionHitCounter>(); services.AddSingleton<IResponseMeta, AtomicResponseMeta>(); }); } @@ -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<JsonElement>().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<JsonElement>().Subject; + element.GetString().Should().Be("https://github.com/json-api-dotnet/JsonApiDotNetCore/"); + }); + + responseDocument.Meta.ShouldContainKey("versions").With(value => + { + JsonElement element = value.Should().BeOfType<JsonElement>().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<JsonElement>().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<JsonElement>().Subject; + element.GetString().Should().Be("https://github.com/json-api-dotnet/JsonApiDotNetCore/"); + }); + + responseDocument.Meta.ShouldContainKey("versions").With(value => + { + JsonElement element = value.Should().BeOfType<JsonElement>().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<MusicTrack, Guid> + public sealed class MusicTrackMetaDefinition : HitCountingResourceDefinition<MusicTrack, Guid> { - 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<string, object> GetMeta(MusicTrack resource) + public override IDictionary<string, object?> GetMeta(MusicTrack resource) { - _hitCounter.TrackInvocation<MusicTrack>(ResourceDefinitionHitCounter.ExtensibilityPoint.GetMeta); + base.GetMeta(resource); - return new Dictionary<string, object> + return new Dictionary<string, object?> { ["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<string, object> GetMeta(TextLanguage resource) + public override IDictionary<string, object?> GetMeta(TextLanguage resource) { - _hitCounter.TrackInvocation<TextLanguage>(ResourceDefinitionHitCounter.ExtensibilityPoint.GetMeta); + base.GetMeta(resource); - return new Dictionary<string, object> + return new Dictionary<string, object?> { ["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<IntegrationTestContext<TestableStartup<OperationsDbContext>, OperationsDbContext>> + { + private readonly IntegrationTestContext<TestableStartup<OperationsDbContext>, OperationsDbContext> _testContext; + + public AtomicLoggingTests(IntegrationTestContext<TestableStartup<OperationsDbContext>, OperationsDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController<OperationsController>(); + + 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<IOperationsTransactionFactory, ThrowingOperationsTransactionFactory>(); + }); + } + + [Fact] + public async Task Logs_at_error_level_on_unhandled_exception() + { + // Arrange + var loggerFactory = _testContext.Factory.Services.GetRequiredService<FakeLoggerFactory>(); + loggerFactory.Logger.Clear(); + + var transactionFactory = (ThrowingOperationsTransactionFactory)_testContext.Factory.Services.GetRequiredService<IOperationsTransactionFactory>(); + 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<Document>(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<FakeLoggerFactory>(); + loggerFactory.Logger.Clear(); + + var transactionFactory = (ThrowingOperationsTransactionFactory)_testContext.Factory.Services.GetRequiredService<IOperationsTransactionFactory>(); + transactionFactory.ThrowOnOperationStart = false; + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update" + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync<Document>(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<IOperationsTransaction> 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<Document>(route, null); + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync<Document>(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<Performer> performersInDatabase = await dbContext.Performers.ToListAsync(); - performersInDatabase.Should().BeEmpty(); + [Fact] + public async Task Cannot_process_for_null_request_body() + { + // Arrange + const string requestBody = "null"; - List<MusicTrack> tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.Should().BeEmpty(); - }); + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync<Document>(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<Document>(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<Performer> 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<Document>(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.ShouldHaveCount(1); - List<MusicTrack> 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<Performer> performersInDatabase = await dbContext.Performers.ToListAsync(); - performersInDatabase.Should().BeEmpty(); - - List<MusicTrack> 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<IntegrationTestContext<TestableStartup<OperationsDbContext>, OperationsDbContext>> { - private const string JsonDateTimeOffsetFormatSpecifier = "yyyy-MM-ddTHH:mm:ss.FFFFFFFK"; - private readonly IntegrationTestContext<TestableStartup<OperationsDbContext>, OperationsDbContext> _testContext; private readonly OperationsFakers _fakers = new(); @@ -25,11 +22,13 @@ public AtomicSerializationTests(IntegrationTestContext<TestableStartup<Operation testContext.UseController<OperationsController>(); // These routes need to be registered in ASP.NET for rendering links to resource/relationship endpoints. - testContext.UseController<PerformersController>(); + testContext.UseController<TextLanguagesController>(); testContext.ConfigureServicesAfterStartup(services => { - services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); + services.AddResourceDefinition<ImplicitlyChangingTextLanguageDefinition>(); + + services.AddSingleton<ResourceDefinitionHitCounter>(); }); var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService<IJsonApiOptions>(); @@ -39,32 +38,46 @@ public AtomicSerializationTests(IntegrationTestContext<TestableStartup<Operation } [Fact] - public async Task Includes_version_with_ext_on_operations_endpoint() + public async Task Hides_data_for_void_operation() { // Arrange - Performer newPerformer = _fakers.Performer.Generate(); - newPerformer.Id = Unknown.TypedId.Int32; + Performer existingPerformer = _fakers.Performer.Generate(); + + TextLanguage newLanguage = _fakers.TextLanguage.Generate(); + newLanguage.Id = Guid.NewGuid(); await _testContext.RunOnDatabaseAsync(async dbContext => { - await dbContext.ClearTableAsync<Performer>(); + 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<IntegrationTestContext<ModelStateValidationStartup<OperationsDbContext>, OperationsDbContext>> + public sealed class AtomicModelStateValidationTests : IClassFixture<IntegrationTestContext<TestableStartup<OperationsDbContext>, OperationsDbContext>> { - private readonly IntegrationTestContext<ModelStateValidationStartup<OperationsDbContext>, OperationsDbContext> _testContext; + private readonly IntegrationTestContext<TestableStartup<OperationsDbContext>, OperationsDbContext> _testContext; private readonly OperationsFakers _fakers = new(); - public AtomicModelStateValidationTests(IntegrationTestContext<ModelStateValidationStartup<OperationsDbContext>, OperationsDbContext> testContext) + public AtomicModelStateValidationTests(IntegrationTestContext<TestableStartup<OperationsDbContext>, 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<Playlist, long>(), 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<Document>(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<Guid> 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<Performer> Performers { get; set; } + public IList<Performer> Performers { get; set; } = new List<Performer>(); [HasMany] - public IList<Playlist> OccursIn { get; set; } + public IList<Playlist> OccursIn { get; set; } = new List<Playlist>(); } } 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<MusicTrack, Guid> { - public MusicTracksController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<MusicTrack, Guid> resourceService) - : base(options, loggerFactory, resourceService) + public MusicTracksController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<MusicTrack, Guid> 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<Playlist> Playlists { get; set; } - public DbSet<MusicTrack> MusicTracks { get; set; } - public DbSet<Lyric> Lyrics { get; set; } - public DbSet<TextLanguage> TextLanguages { get; set; } - public DbSet<Performer> Performers { get; set; } - public DbSet<RecordCompany> RecordCompanies { get; set; } + public DbSet<Playlist> Playlists => Set<Playlist>(); + public DbSet<MusicTrack> MusicTracks => Set<MusicTrack>(); + public DbSet<Lyric> Lyrics => Set<Lyric>(); + public DbSet<TextLanguage> TextLanguages => Set<TextLanguage>(); + public DbSet<Performer> Performers => Set<Performer>(); + public DbSet<RecordCompany> RecordCompanies => Set<RecordCompany>(); public OperationsDbContext(DbContextOptions<OperationsDbContext> options) : base(options) @@ -24,7 +24,7 @@ protected override void OnModelCreating(ModelBuilder builder) { builder.Entity<MusicTrack>() .HasOne(musicTrack => musicTrack.Lyric) - .WithOne(lyric => lyric.Track) + .WithOne(lyric => lyric!.Track!) .HasForeignKey<MusicTrack>("LyricId"); builder.Entity<MusicTrack>() 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<int> { [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<Performer> + public sealed class PerformersController : JsonApiController<Performer, int> { - public PerformersController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<Performer> resourceService) - : base(options, loggerFactory, resourceService) + public PerformersController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<Performer, int> 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<long> { [Attr] - [Required] - public string Name { get; set; } + public string Name { get; set; } = null!; [NotMapped] [Attr] public bool IsArchived => false; [HasMany] - public IList<MusicTrack> Tracks { get; set; } + public IList<MusicTrack> Tracks { get; set; } = new List<MusicTrack>(); } } 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<Playlist, long> { - public PlaylistsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<Playlist, long> resourceService) - : base(options, loggerFactory, resourceService) + public PlaylistsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<Playlist, long> 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<MusicTrack> OnRegisterQueryableHandlersForQueryStringParameters() { - return new() + return new QueryStringParameterHandlers<MusicTrack> { ["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<RecordCompany, short> { - public RecordCompaniesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<RecordCompany, short> resourceService) - : base(options, loggerFactory, resourceService) + public RecordCompaniesController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<RecordCompany, short> 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<short> { [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<MusicTrack> Tracks { get; set; } + public IList<MusicTrack> Tracks { get; set; } = new List<MusicTrack>(); [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<RecordCompany> 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<RecordCompany> 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<RecordCompany, short> + public sealed class RecordCompanyDefinition : HitCountingResourceDefinition<RecordCompany, short> { - 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<RecordCompany>(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<RecordCompany>(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<Lyric, long> + public sealed class LyricTextDefinition : HitCountingResourceDefinition<Lyric, long> { 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<Lyric>(ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplySparseFieldSet); + base.OnApplySparseFieldSet(existingSparseFieldSet); - return _lyricPermissionProvider.CanViewText - ? base.OnApplySparseFieldSet(existingSparseFieldSet) - : existingSparseFieldSet.Excluding<Lyric>(lyric => lyric.Text, ResourceGraph); + return _lyricPermissionProvider.CanViewText ? existingSparseFieldSet : existingSparseFieldSet.Excluding<Lyric>(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<Guid> { [Attr] - public string IsoCode { get; set; } + public string? IsoCode { get; set; } [Attr(Capabilities = AttrCapabilities.None)] public bool IsRightToLeft { get; set; } [HasMany] - public ICollection<Lyric> Lyrics { get; set; } + public ICollection<Lyric> Lyrics { get; set; } = new List<Lyric>(); } } 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<TextLanguage, Guid> { - public TextLanguagesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<TextLanguage, Guid> resourceService) - : base(options, loggerFactory, resourceService) + public TextLanguagesController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<TextLanguage, Guid> 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<TestableStartup<OperationsDbCo public async Task Can_rollback_on_error() { // Arrange - string newArtistName = _fakers.Performer.Generate().ArtistName; + string newArtistName = _fakers.Performer.Generate().ArtistName!; DateTimeOffset newBornAt = _fakers.Performer.Generate().BornAt; string newTitle = _fakers.MusicTrack.Generate().Title; @@ -36,7 +36,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.ClearTablesAsync<Performer, MusicTrack>(); }); - string performerId = Unknown.StringId.For<Performer, int>(); + string unknownPerformerId = Unknown.StringId.For<Performer, int>(); 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<Performer> performersInDatabase = await dbContext.Performers.ToListAsync(); + performersInDatabase.Should().BeEmpty(); + + List<MusicTrack> 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<Performer, MusicTrack>(); + }); + + const string trackLid = "track-1"; + + string unknownPerformerId = Unknown.StringId.For<Performer, int>(); + + 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<Document>(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<IntegrationTestContext<TestableStartup<OperationsDbContext>, OperationsDbContext>> { private readonly IntegrationTestContext<TestableStartup<OperationsDbContext>, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new(); public AtomicTransactionConsistencyTests(IntegrationTestContext<TestableStartup<OperationsDbContext>, 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<Lyric, long> { 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<IQueryConstraintProvider> 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<IQueryConstraintProvider> 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<MusicTrack, Guid> { - 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<IQueryConstraintProvider> 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<Performer> + public sealed class PerformerRepository : IResourceRepository<Performer, int> { - public Task<IReadOnlyCollection<Performer>> GetAsync(QueryLayer layer, CancellationToken cancellationToken) + public Task<IReadOnlyCollection<Performer>> GetAsync(QueryLayer queryLayer, CancellationToken cancellationToken) { throw new NotImplementedException(); } - public Task<int> CountAsync(FilterExpression topFilter, CancellationToken cancellationToken) + public Task<int> CountAsync(FilterExpression? filter, CancellationToken cancellationToken) { throw new NotImplementedException(); } @@ -33,7 +33,7 @@ public Task CreateAsync(Performer resourceFromRequest, Performer resourceForData throw new NotImplementedException(); } - public Task<Performer> GetForUpdateAsync(QueryLayer queryLayer, CancellationToken cancellationToken) + public Task<Performer?> 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<Document>(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<Document>(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<Document>(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<Document>(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<Document>(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<Performer> 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<MusicTrack> 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<Document>(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<Document>(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<Document>(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<Performer> performersInDatabase = await dbContext.Performers.ToListAsync(); - performersInDatabase.Should().HaveCount(2); + performersInDatabase.ShouldHaveCount(2); }); } @@ -126,7 +126,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => List<MusicTrack> 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<Performer> 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<MusicTrack> 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<Document>(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<Document>(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<Document>(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<MusicTrack> 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<Lyric> 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<RecordCompany> 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<MusicTrack> 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<Lyric> 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<RecordCompany> 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<Document>(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<Document>(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<Performer> performersInDatabase = await dbContext.Performers.ToListAsync(); - performersInDatabase.Should().HaveCount(2); + performersInDatabase.ShouldHaveCount(2); }); } @@ -136,7 +136,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => List<MusicTrack> 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<Performer> 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<MusicTrack> 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<Document>(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<Document>(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<Document>(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<TestableStartup<Operatio testContext.ConfigureServicesAfterStartup(services => { services.AddResourceDefinition<ImplicitlyChangingTextLanguageDefinition>(); + + services.AddSingleton<ResourceDefinitionHitCounter>(); }); + + var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService<IJsonApiOptions>(); + options.AllowUnknownFieldsInRequestBody = false; } [Fact] @@ -88,7 +94,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { List<MusicTrack> 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<Document>(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<IJsonApiOptions>(); + 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<Document>(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<IJsonApiOptions>(); + 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<Document>(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<Performer, int>(), - 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<Performer, int>(), + 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<Document>(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<Document>(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<Document>(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<MusicTrack> 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<Lyric> 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<RecordCompany> 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<MusicTrack> 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<Lyric> 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<RecordCompany> 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<Document>(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<Document>(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<Document>(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<string> + public sealed class Car : Identifiable<string?> { [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<TResource, TId> : EntityFrameworkCor { private readonly CarExpressionRewriter _writer; - public CarCompositeKeyAwareRepository(ITargetedFields targetedFields, IDbContextResolver contextResolver, IResourceGraph resourceGraph, + public CarCompositeKeyAwareRepository(ITargetedFields targetedFields, IDbContextResolver dbContextResolver, IResourceGraph resourceGraph, IResourceFactory resourceFactory, IEnumerable<IQueryConstraintProvider> 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<TResource> ApplyQueryLayer(QueryLayer layer) + protected override IQueryable<TResource> 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<TResource> : CarCompositeKeyAwareRepository<TResource, int>, IResourceRepository<TResource> - where TResource : class, IIdentifiable<int> - { - public CarCompositeKeyAwareRepository(ITargetedFields targetedFields, IDbContextResolver contextResolver, IResourceGraph resourceGraph, - IResourceFactory resourceFactory, IEnumerable<IQueryConstraintProvider> 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 /// <remarks> /// This enables queries to use <see cref="Car.Id" />, which is not mapped in the database. /// </remarks> - internal sealed class CarExpressionRewriter : QueryExpressionRewriter<object> + internal sealed class CarExpressionRewriter : QueryExpressionRewriter<object?> { private readonly AttrAttribute _regionIdAttribute; private readonly AttrAttribute _licensePlateAttribute; public CarExpressionRewriter(IResourceGraph resourceGraph) { - ResourceContext carResourceContext = resourceGraph.GetResourceContext<Car>(); + ResourceType carType = resourceGraph.GetResourceType<Car>(); - _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<object>.Id) && property.DeclaringType == typeof(Car); } private QueryExpression RewriteFilterOnCarStringIds(ResourceFieldChainExpression existingCarIdChain, IEnumerable<string> 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<SortElementExpression>.Builder elementsBuilder = ImmutableArray.CreateBuilder<SortElementExpression>(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<Car, string> + public sealed class CarsController : JsonApiController<Car, string?> { - public CarsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<Car, string> resourceService) - : base(options, loggerFactory, resourceService) + public CarsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<Car, string?> 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<Car> Cars { get; set; } - public DbSet<Engine> Engines { get; set; } - public DbSet<Dealership> Dealerships { get; set; } + public DbSet<Car> Cars => Set<Car>(); + public DbSet<Engine> Engines => Set<Engine>(); + public DbSet<Dealership> Dealerships => Set<Dealership>(); public CompositeDbContext(DbContextOptions<CompositeDbContext> options) : base(options) @@ -28,12 +28,12 @@ protected override void OnModelCreating(ModelBuilder builder) builder.Entity<Engine>() .HasOne(engine => engine.Car) - .WithOne(car => car.Engine) + .WithOne(car => car!.Engine) .HasForeignKey<Engine>(); builder.Entity<Dealership>() .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<Faker<Car>> _lazyCarFaker = new(() => + new Faker<Car>() + .UseSeed(GetFakerSeed()) + .RuleFor(car => car.LicensePlate, faker => faker.Random.Replace("??-??-##")) + .RuleFor(car => car.RegionId, faker => faker.Random.Long(100, 999))); + + private readonly Lazy<Faker<Engine>> _lazyEngineFaker = new(() => + new Faker<Engine>() + .UseSeed(GetFakerSeed()) + .RuleFor(engine => engine.SerialCode, faker => faker.Random.Replace("????-????"))); + + private readonly Lazy<Faker<Dealership>> _lazyDealershipFaker = new(() => + new Faker<Dealership>() + .UseSeed(GetFakerSeed()) + .RuleFor(dealership => dealership.Address, faker => faker.Address.FullAddress())); + + public Faker<Car> Car => _lazyCarFaker.Value; + public Faker<Engine> Engine => _lazyEngineFaker.Value; + public Faker<Dealership> 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<IntegrationTestContext<TestableStartup<CompositeDbContext>, CompositeDbContext>> { private readonly IntegrationTestContext<TestableStartup<CompositeDbContext>, CompositeDbContext> _testContext; + private readonly CompositeKeyFakers _fakers = new(); public CompositeKeyTests(IntegrationTestContext<TestableStartup<CompositeDbContext>, CompositeDbContext> testContext) { @@ -27,8 +27,8 @@ public CompositeKeyTests(IntegrationTestContext<TestableStartup<CompositeDbConte testContext.ConfigureServicesAfterStartup(services => { - services.AddResourceRepository<CarCompositeKeyAwareRepository<Car, string>>(); - services.AddResourceRepository<CarCompositeKeyAwareRepository<Dealership>>(); + services.AddResourceRepository<CarCompositeKeyAwareRepository<Car, string?>>(); + services.AddResourceRepository<CarCompositeKeyAwareRepository<Dealership, int>>(); }); var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService<IJsonApiOptions>(); @@ -39,11 +39,7 @@ public CompositeKeyTests(IntegrationTestContext<TestableStartup<CompositeDbConte public async Task Can_filter_on_ID_in_primary_resources() { // Arrange - var car = new Car - { - RegionId = 123, - LicensePlate = "AA-BB-11" - }; + Car car = _fakers.Car.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -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<Document>(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<Car>(); + 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<Car> - { - 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<Car> - { - 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<int> { [Attr] - public string Address { get; set; } + public string Address { get; set; } = null!; [HasMany] - public ISet<Car> Inventory { get; set; } + public ISet<Car> Inventory { get; set; } = new HashSet<Car>(); } } 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<Dealership> + public sealed class DealershipsController : JsonApiController<Dealership, int> { - public DealershipsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<Dealership> resourceService) - : base(options, loggerFactory, resourceService) + public DealershipsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<Dealership, int> 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<int> { [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<Engine> + public sealed class EnginesController : JsonApiController<Engine, int> { - public EnginesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<Engine> resourceService) - : base(options, loggerFactory, resourceService) + public EnginesController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<Engine, int> 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<Policy> + public sealed class PoliciesController : JsonApiController<Policy, int> { - public PoliciesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<Policy> resourceService) - : base(options, loggerFactory, resourceService) + public PoliciesController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<Policy, int> 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<int> { [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<Policy> Policies { get; set; } + public DbSet<Policy> Policies => Set<Policy>(); public PolicyDbContext(DbContextOptions<PolicyDbContext> 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<Toothbrush> Toothbrushes { get; set; } + public DbSet<Toothbrush> Toothbrushes => Set<Toothbrush>(); public ActionResultDbContext(DbContextOptions<ActionResultDbContext> 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<Document>(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<Document>(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<Document>(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<Document>(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<Document>(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<Toothbrush> - { - 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<Toothbrush> resourceService) - : base(options, loggerFactory, resourceService) - { - } - - public override async Task<IActionResult> 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<int> { [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<Toothbrush, int> { - public ToothbrushesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<Toothbrush> 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<Toothbrush, int> resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } [HttpGet("{id}")] - public override Task<IActionResult> GetAsync(int id, CancellationToken cancellationToken) + public override async Task<IActionResult> 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<int> { [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<Civilian> + public sealed class CiviliansController : JsonApiController<Civilian, int> { - public CiviliansController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<Civilian> resourceService) - : base(options, loggerFactory, resourceService) + public CiviliansController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<Civilian, int> 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<Town> Towns { get; set; } - public DbSet<Civilian> Civilians { get; set; } + public DbSet<Town> Towns => Set<Town>(); + public DbSet<Civilian> Civilians => Set<Civilian>(); public CustomRouteDbContext(DbContextOptions<CustomRouteDbContext> 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<int> { [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<Civilian> Civilians { get; set; } + public ISet<Civilian> Civilians { get; set; } = new HashSet<Civilian>(); } } 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<Town> + public sealed class TownsController : JsonApiController<Town, int> { private readonly CustomRouteDbContext _dbContext; - public TownsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<Town> resourceService, CustomRouteDbContext dbContext) - : base(options, loggerFactory, resourceService) + public TownsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IResourceService<Town, int> 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<int> { - 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<Window> Windows { get; set; } + public IList<Window> Windows { get; set; } = new List<Window>(); [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<Building, int> + { + 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<Building> + public sealed class BuildingRepository : EntityFrameworkCoreRepository<Building, int> { - public BuildingRepository(ITargetedFields targetedFields, IDbContextResolver contextResolver, IResourceGraph resourceGraph, + public BuildingRepository(ITargetedFields targetedFields, IDbContextResolver dbContextResolver, IResourceGraph resourceGraph, IResourceFactory resourceFactory, IEnumerable<IQueryConstraintProvider> 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<Building> 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<Building> + public sealed class BuildingsController : JsonApiController<Building, int> { - public BuildingsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<Building> resourceService) - : base(options, loggerFactory, resourceService) + public BuildingsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<Building, int> 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<int> { [Attr] - public string Name { get; set; } + public string Name { get; set; } = null!; [HasMany] - public IList<Street> Streets { get; set; } + public IList<Street> Streets { get; set; } = new List<Street>(); } } 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<State> States { get; set; } - public DbSet<Street> Streets { get; set; } - public DbSet<Building> Buildings { get; set; } + public DbSet<State> States => Set<State>(); + public DbSet<Street> Streets => Set<Street>(); + public DbSet<Building> Buildings => Set<Building>(); + public DbSet<Door> Doors => Set<Door>(); public EagerLoadingDbContext(DbContextOptions<EagerLoadingDbContext> options) : base(options) @@ -23,13 +24,15 @@ protected override void OnModelCreating(ModelBuilder builder) .HasOne(building => building.PrimaryDoor) .WithOne() .HasForeignKey<Building>("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<Building>() .HasOne(building => building.SecondaryDoor) .WithOne() - .HasForeignKey<Building>("SecondaryDoorId") - .IsRequired(false); + .HasForeignKey<Building>("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<TestableStartup<EagerLoadingDbCo testContext.ConfigureServicesAfterStartup(services => { + services.AddResourceDefinition<BuildingDefinition>(); services.AddResourceRepository<BuildingRepository>(); }); } @@ -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<Document>(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<int> { [Attr] - public string Name { get; set; } + public string Name { get; set; } = null!; [HasMany] - public IList<City> Cities { get; set; } + public IList<City> Cities { get; set; } = new List<City>(); } } 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<State> + public sealed class StatesController : JsonApiController<State, int> { - public StatesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<State> resourceService) - : base(options, loggerFactory, resourceService) + public StatesController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<State, int> 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<int> { [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<Building> Buildings { get; set; } + public IList<Building> Buildings { get; set; } = new List<Building>(); } } 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<Street> + public sealed class StreetsController : JsonApiController<Street, int> { - public StreetsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<Street> resourceService) - : base(options, loggerFactory, resourceService) + public StreetsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<Street, int> 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<ErrorObject> CreateErrorResponse(Exception exception) { if (exception is ConsumerArticleIsNoLongerAvailableException articleException) { - articleException.Errors[0].Meta = new Dictionary<string, object> + articleException.Errors[0].Meta = new Dictionary<string, object?> { ["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<int> { [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<ConsumerArticle> + public sealed class ConsumerArticleService : JsonApiResourceService<ConsumerArticle, int> { 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<ConsumerArticle> + public sealed class ConsumerArticlesController : JsonApiController<ConsumerArticle, int> { - public ConsumerArticlesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<ConsumerArticle> resourceService) - : base(options, loggerFactory, resourceService) + public ConsumerArticlesController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<ConsumerArticle, int> 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<ConsumerArticle> ConsumerArticles { get; set; } - public DbSet<ThrowingArticle> ThrowingArticles { get; set; } + public DbSet<ConsumerArticle> ConsumerArticles => Set<ConsumerArticle>(); + public DbSet<ThrowingArticle> ThrowingArticles => Set<ThrowingArticle>(); public ErrorDbContext(DbContextOptions<ErrorDbContext> 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<JsonElement>().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<FakeLoggerFactory>(); + loggerFactory.Logger.Clear(); + + const string requestBody = @"{ ""data"": { ""type"": """" } }"; + + const string route = "/consumerArticles"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync<Document>(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<JsonElement>().Subject; + element.GetString().Should().Be(requestBody); + }); + + error.Meta.ShouldContainKey("stackTrace").With(value => + { + JsonElement element = value.Should().BeOfType<JsonElement>().Subject; + IEnumerable<string?> 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<string> 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<JsonElement>().Subject; + IEnumerable<string?> 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<int> { [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<ThrowingArticle> + public sealed class ThrowingArticlesController : JsonApiController<ThrowingArticle, int> { - public ThrowingArticlesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<ThrowingArticle> resourceService) - : base(options, loggerFactory, resourceService) + public ThrowingArticlesController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<ThrowingArticle, int> 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 +{ + /// <summary> + /// Tracks invocations on <see cref="IResourceDefinition{TResource,TId}" /> callback methods. This is used solely in our tests, so we can assert which + /// calls were made, and in which order. + /// </summary> + public abstract class HitCountingResourceDefinition<TResource, TId> : JsonApiResourceDefinition<TResource, TId> + where TResource : class, IIdentifiable<TId> + { + 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<IncludeElementExpression> OnApplyIncludes(IImmutableSet<IncludeElementExpression> existingIncludes) + { + if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.OnApplyIncludes)) + { + _hitCounter.TrackInvocation<TResource>(ResourceDefinitionExtensibilityPoints.OnApplyIncludes); + } + + return base.OnApplyIncludes(existingIncludes); + } + + public override FilterExpression? OnApplyFilter(FilterExpression? existingFilter) + { + if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.OnApplyFilter)) + { + _hitCounter.TrackInvocation<TResource>(ResourceDefinitionExtensibilityPoints.OnApplyFilter); + } + + return base.OnApplyFilter(existingFilter); + } + + public override SortExpression? OnApplySort(SortExpression? existingSort) + { + if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.OnApplySort)) + { + _hitCounter.TrackInvocation<TResource>(ResourceDefinitionExtensibilityPoints.OnApplySort); + } + + return base.OnApplySort(existingSort); + } + + public override PaginationExpression? OnApplyPagination(PaginationExpression? existingPagination) + { + if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.OnApplyPagination)) + { + _hitCounter.TrackInvocation<TResource>(ResourceDefinitionExtensibilityPoints.OnApplyPagination); + } + + return base.OnApplyPagination(existingPagination); + } + + public override SparseFieldSetExpression? OnApplySparseFieldSet(SparseFieldSetExpression? existingSparseFieldSet) + { + if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet)) + { + _hitCounter.TrackInvocation<TResource>(ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet); + } + + return base.OnApplySparseFieldSet(existingSparseFieldSet); + } + + public override QueryStringParameterHandlers<TResource>? OnRegisterQueryableHandlersForQueryStringParameters() + { + if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.OnRegisterQueryableHandlersForQueryStringParameters)) + { + _hitCounter.TrackInvocation<TResource>(ResourceDefinitionExtensibilityPoints.OnRegisterQueryableHandlersForQueryStringParameters); + } + + return base.OnRegisterQueryableHandlersForQueryStringParameters(); + } + + public override IDictionary<string, object?>? GetMeta(TResource resource) + { + if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.GetMeta)) + { + _hitCounter.TrackInvocation<TResource>(ResourceDefinitionExtensibilityPoints.GetMeta); + } + + return base.GetMeta(resource); + } + + public override Task OnPrepareWriteAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync)) + { + _hitCounter.TrackInvocation<TResource>(ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync); + } + + return base.OnPrepareWriteAsync(resource, writeOperation, cancellationToken); + } + + public override Task<IIdentifiable?> OnSetToOneRelationshipAsync(TResource leftResource, HasOneAttribute hasOneRelationship, + IIdentifiable? rightResourceId, WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.OnSetToOneRelationshipAsync)) + { + _hitCounter.TrackInvocation<TResource>(ResourceDefinitionExtensibilityPoints.OnSetToOneRelationshipAsync); + } + + return base.OnSetToOneRelationshipAsync(leftResource, hasOneRelationship, rightResourceId, writeOperation, cancellationToken); + } + + public override Task OnSetToManyRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet<IIdentifiable> rightResourceIds, + WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.OnSetToManyRelationshipAsync)) + { + _hitCounter.TrackInvocation<TResource>(ResourceDefinitionExtensibilityPoints.OnSetToManyRelationshipAsync); + } + + return base.OnSetToManyRelationshipAsync(leftResource, hasManyRelationship, rightResourceIds, writeOperation, cancellationToken); + } + + public override Task OnAddToRelationshipAsync(TId leftResourceId, HasManyAttribute hasManyRelationship, ISet<IIdentifiable> rightResourceIds, + CancellationToken cancellationToken) + { + if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.OnAddToRelationshipAsync)) + { + _hitCounter.TrackInvocation<TResource>(ResourceDefinitionExtensibilityPoints.OnAddToRelationshipAsync); + } + + return base.OnAddToRelationshipAsync(leftResourceId, hasManyRelationship, rightResourceIds, cancellationToken); + } + + public override Task OnRemoveFromRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet<IIdentifiable> rightResourceIds, + CancellationToken cancellationToken) + { + if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.OnRemoveFromRelationshipAsync)) + { + _hitCounter.TrackInvocation<TResource>(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<TResource>(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<TResource>(ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync); + } + + return base.OnWriteSucceededAsync(resource, writeOperation, cancellationToken); + } + + public override void OnDeserialize(TResource resource) + { + if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.OnDeserialize)) + { + _hitCounter.TrackInvocation<TResource>(ResourceDefinitionExtensibilityPoints.OnDeserialize); + } + + base.OnDeserialize(resource); + } + + public override void OnSerialize(TResource resource) + { + if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.OnSerialize)) + { + _hitCounter.TrackInvocation<TResource>(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<ArtGallery> + public sealed class ArtGalleriesController : JsonApiController<ArtGallery, int> { - public ArtGalleriesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<ArtGallery> resourceService) - : base(options, loggerFactory, resourceService) + public ArtGalleriesController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<ArtGallery, int> 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<int> { [Attr] - public string Theme { get; set; } + public string Theme { get; set; } = null!; [HasMany] - public ISet<Painting> Paintings { get; set; } + public ISet<Painting> Paintings { get; set; } = new HashSet<Painting>(); } } 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<ArtGallery> ArtGalleries { get; set; } - public DbSet<Painting> Paintings { get; set; } + public DbSet<ArtGallery> ArtGalleries => Set<ArtGallery>(); + public DbSet<Painting> Paintings => Set<Painting>(); public HostingDbContext(DbContextOptions<HostingDbContext> 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<int> { [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<Painting> + public sealed class PaintingsController : JsonApiController<Painting, int> { - public PaintingsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<Painting> resourceService) - : base(options, loggerFactory, resourceService) + public PaintingsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<Painting, int> 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<DebitCard> Cards { get; set; } + public IList<DebitCard> Cards { get; set; } = new List<DebitCard>(); } } 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<BankAccount> { - public BankAccountsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<BankAccount> resourceService) - : base(options, loggerFactory, resourceService) + public BankAccountsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<BankAccount, int> 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<DebitCard> { - public DebitCardsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<DebitCard> resourceService) - : base(options, loggerFactory, resourceService) + public DebitCardsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<DebitCard, int> 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<int> { 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<TResource> : BaseJsonApiController<TResource> + public abstract class ObfuscatedIdentifiableController<TResource> : BaseJsonApiController<TResource, int> where TResource : class, IIdentifiable<int> { private readonly HexadecimalCodec _codec = new(); - protected ObfuscatedIdentifiableController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<TResource> resourceService) - : base(options, loggerFactory, resourceService) + protected ObfuscatedIdentifiableController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<TResource, int> 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<BankAccount> BankAccounts { get; set; } - public DbSet<DebitCard> DebitCards { get; set; } + public DbSet<BankAccount> BankAccounts => Set<BankAccount>(); + public DbSet<DebitCard> DebitCards => Set<DebitCard>(); public ObfuscationDbContext(DbContextOptions<ObfuscationDbContext> 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<SystemDirectory> Directories { get; set; } - public DbSet<SystemFile> Files { get; set; } + public DbSet<SystemVolume> Volumes => Set<SystemVolume>(); + public DbSet<SystemDirectory> Directories => Set<SystemDirectory>(); + public DbSet<SystemFile> Files => Set<SystemFile>(); public ModelStateDbContext(DbContextOptions<ModelStateDbContext> options) : base(options) @@ -18,9 +19,15 @@ public ModelStateDbContext(DbContextOptions<ModelStateDbContext> options) protected override void OnModelCreating(ModelBuilder builder) { + builder.Entity<SystemVolume>() + .HasOne(systemVolume => systemVolume.RootDirectory) + .WithOne() + .HasForeignKey<SystemVolume>("RootDirectoryId") + .IsRequired(); + builder.Entity<SystemDirectory>() .HasMany(systemDirectory => systemDirectory.Subdirectories) - .WithOne(systemDirectory => systemDirectory.Parent); + .WithOne(systemDirectory => systemDirectory.Parent!); builder.Entity<SystemDirectory>() .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<Faker<SystemVolume>> _lazySystemVolumeFaker = new(() => + new Faker<SystemVolume>() + .UseSeed(GetFakerSeed()) + .RuleFor(systemVolume => systemVolume.Name, faker => faker.Lorem.Word())); + + private readonly Lazy<Faker<SystemFile>> _lazySystemFileFaker = new(() => + new Faker<SystemFile>() + .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<Faker<SystemDirectory>> _lazySystemDirectoryFaker = new(() => + new Faker<SystemDirectory>() + .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> SystemVolume => _lazySystemVolumeFaker.Value; + public Faker<SystemFile> SystemFile => _lazySystemFileFaker.Value; + public Faker<SystemDirectory> 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<IntegrationTestContext<ModelStateValidationStartup<ModelStateDbContext>, ModelStateDbContext>> + public sealed class ModelStateValidationTests : IClassFixture<IntegrationTestContext<TestableStartup<ModelStateDbContext>, ModelStateDbContext>> { - private readonly IntegrationTestContext<ModelStateValidationStartup<ModelStateDbContext>, ModelStateDbContext> _testContext; + private readonly IntegrationTestContext<TestableStartup<ModelStateDbContext>, ModelStateDbContext> _testContext; + private readonly ModelStateFakers _fakers = new(); - public ModelStateValidationTests(IntegrationTestContext<ModelStateValidationStartup<ModelStateDbContext>, ModelStateDbContext> testContext) + public ModelStateValidationTests(IntegrationTestContext<TestableStartup<ModelStateDbContext>, 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<Document>(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<string>(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<string>(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<Document>(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<Document>(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<string>(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<SystemDirectory> - { - new() - { - Name = "C#", - IsCaseSensitive = false - } - }, - Files = new List<SystemFile> - { - 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<string>(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<string>(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<string>(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<string>(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<SystemFile> - { - 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<string>(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<SystemFile> - { - 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<object>() + 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<string>(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<IntegrationTestContext<TestableStartup<ModelStateDbContext>, ModelStateDbContext>> + public sealed class NoModelStateValidationTests + : IClassFixture<IntegrationTestContext<NoModelStateValidationStartup<ModelStateDbContext>, ModelStateDbContext>> { - private readonly IntegrationTestContext<TestableStartup<ModelStateDbContext>, ModelStateDbContext> _testContext; + private readonly IntegrationTestContext<NoModelStateValidationStartup<ModelStateDbContext>, ModelStateDbContext> _testContext; + private readonly ModelStateFakers _fakers = new(); - public NoModelStateValidationTests(IntegrationTestContext<TestableStartup<ModelStateDbContext>, ModelStateDbContext> testContext) + public NoModelStateValidationTests(IntegrationTestContext<NoModelStateValidationStartup<ModelStateDbContext>, ModelStateDbContext> testContext) { _testContext = testContext; + testContext.UseController<SystemVolumesController>(); testContext.UseController<SystemDirectoriesController>(); testContext.UseController<SystemFilesController>(); } @@ -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<string>(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<Document>(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<SystemDirectory> + public sealed class SystemDirectoriesController : JsonApiController<SystemDirectory, int> { - public SystemDirectoriesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<SystemDirectory> resourceService) - : base(options, loggerFactory, resourceService) + public SystemDirectoriesController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<SystemDirectory, int> 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<int> { - [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<SystemDirectory> Subdirectories { get; set; } + public ICollection<SystemDirectory> Subdirectories { get; set; } = new List<SystemDirectory>(); [HasMany] - public ICollection<SystemFile> Files { get; set; } + public ICollection<SystemFile> Files { get; set; } = new List<SystemFile>(); [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<int> { [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<SystemFile> + public sealed class SystemFilesController : JsonApiController<SystemFile, int> { - public SystemFilesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<SystemFile> resourceService) - : base(options, loggerFactory, resourceService) + public SystemFilesController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<SystemFile, int> 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<int> + { + [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<SystemVolume, int> + { + public SystemVolumesController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<SystemVolume, int> 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<Workflow> Workflows { get; set; } + public DbSet<Workflow> Workflows => Set<Workflow>(); public WorkflowDbContext(DbContextOptions<WorkflowDbContext> 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<WorkflowStage>? possibleNextStages)) { - ICollection<WorkflowStage> 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<IntegrationTestContext<ModelStateValidationStartup<WorkflowDbContext>, WorkflowDbContext>> + public sealed class WorkflowTests : IClassFixture<IntegrationTestContext<TestableStartup<WorkflowDbContext>, WorkflowDbContext>> { - private readonly IntegrationTestContext<ModelStateValidationStartup<WorkflowDbContext>, WorkflowDbContext> _testContext; + private readonly IntegrationTestContext<TestableStartup<WorkflowDbContext>, WorkflowDbContext> _testContext; - public WorkflowTests(IntegrationTestContext<ModelStateValidationStartup<WorkflowDbContext>, WorkflowDbContext> testContext) + public WorkflowTests(IntegrationTestContext<TestableStartup<WorkflowDbContext>, 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<Workflow, Guid> { - public WorkflowsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<Workflow, Guid> resourceService) - : base(options, loggerFactory, resourceService) + public WorkflowsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<Workflow, Guid> 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<IntegrationTestContext<AbsoluteLinksInApiNamespaceStartup<LinksDbContext>, LinksDbContext>> { private const string HostPrefix = "http://localhost"; + private const string PathPrefix = "/api"; private readonly IntegrationTestContext<AbsoluteLinksInApiNamespaceStartup<LinksDbContext>, 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<Document>(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<Document>(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<Document>(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<Document>(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<Document>(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<Document>(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<Document>(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<Document>(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<IntegrationTestContext<AbsoluteLinksNoNamespaceStartup<LinksDbContext>, LinksDbContext>> { private const string HostPrefix = "http://localhost"; + private const string PathPrefix = ""; private readonly IntegrationTestContext<AbsoluteLinksNoNamespaceStartup<LinksDbContext>, 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<Document>(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<Document>(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<Document>(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<Document>(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<Document>(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<Document>(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<Document>(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<Document>(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<PhotoAlbum> PhotoAlbums { get; set; } - public DbSet<Photo> Photos { get; set; } - public DbSet<PhotoLocation> PhotoLocations { get; set; } + public DbSet<PhotoAlbum> PhotoAlbums => Set<PhotoAlbum>(); + public DbSet<Photo> Photos => Set<Photo>(); + public DbSet<PhotoLocation> PhotoLocations => Set<PhotoLocation>(); public LinksDbContext(DbContextOptions<LinksDbContext> options) : base(options) @@ -21,7 +21,7 @@ protected override void OnModelCreating(ModelBuilder builder) { builder.Entity<Photo>() .HasOne(photo => photo.Location) - .WithOne(location => location.Photo) + .WithOne(location => location!.Photo) .HasForeignKey<Photo>("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<Guid> { [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<Guid> { [Attr] - public string Name { get; set; } + public string Name { get; set; } = null!; [HasMany] - public ISet<Photo> Photos { get; set; } + public ISet<Photo> Photos { get; set; } = new HashSet<Photo>(); } } 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<PhotoAlbum, Guid> { - public PhotoAlbumsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<PhotoAlbum, Guid> resourceService) - : base(options, loggerFactory, resourceService) + public PhotoAlbumsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<PhotoAlbum, Guid> 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<int> { [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<PhotoLocation> + public sealed class PhotoLocationsController : JsonApiController<PhotoLocation, int> { - public PhotoLocationsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<PhotoLocation> resourceService) - : base(options, loggerFactory, resourceService) + public PhotoLocationsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<PhotoLocation, int> 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<Photo, Guid> { - public PhotosController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<Photo, Guid> resourceService) - : base(options, loggerFactory, resourceService) + public PhotosController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<Photo, Guid> 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<IntegrationTestContext<RelativeLinksInApiNamespaceStartup<LinksDbContext>, LinksDbContext>> { + private const string HostPrefix = ""; + private const string PathPrefix = "/api"; + private readonly IntegrationTestContext<RelativeLinksInApiNamespaceStartup<LinksDbContext>, 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<Document>(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<Document>(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<Document>(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<Document>(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<Document>(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<Document>(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<Document>(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<Document>(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<IntegrationTestContext<RelativeLinksNoNamespaceStartup<LinksDbContext>, LinksDbContext>> { + private const string HostPrefix = ""; + private const string PathPrefix = ""; + private readonly IntegrationTestContext<RelativeLinksNoNamespaceStartup<LinksDbContext>, 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<Document>(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<Document>(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<Document>(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<Document>(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<Document>(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<Document>(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<Document>(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<Document>(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<AuditEntry> + public sealed class AuditEntriesController : JsonApiController<AuditEntry, int> { - public AuditEntriesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<AuditEntry> resourceService) - : base(options, loggerFactory, resourceService) + public AuditEntriesController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<AuditEntry, int> 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<int> { [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<AuditEntry> AuditEntries { get; set; } + public DbSet<AuditEntry> AuditEntries => Set<AuditEntry>(); - public AuditDbContext(DbContextOptions<AuditDbContext> options) + public LoggingDbContext(DbContextOptions<LoggingDbContext> 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<Faker<AuditEntry>> _lazyAuditEntryFaker = new(() => new Faker<AuditEntry>() 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<IntegrationTestContext<TestableStartup<AuditDbContext>, AuditDbContext>> + public sealed class LoggingTests : IClassFixture<IntegrationTestContext<TestableStartup<LoggingDbContext>, LoggingDbContext>> { - private readonly IntegrationTestContext<TestableStartup<AuditDbContext>, AuditDbContext> _testContext; - private readonly AuditFakers _fakers = new(); + private readonly IntegrationTestContext<TestableStartup<LoggingDbContext>, LoggingDbContext> _testContext; + private readonly LoggingFakers _fakers = new(); - public LoggingTests(IntegrationTestContext<TestableStartup<AuditDbContext>, AuditDbContext> testContext) + public LoggingTests(IntegrationTestContext<TestableStartup<LoggingDbContext>, 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<ProductFamily> ProductFamilies => Set<ProductFamily>(); + public DbSet<SupportTicket> SupportTickets => Set<SupportTicket>(); + + public MetaDbContext(DbContextOptions<MetaDbContext> 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<Faker<ProductFamily>> _lazyProductFamilyFaker = new(() => new Faker<ProductFamily>() 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<ProductFamily> + public sealed class ProductFamiliesController : JsonApiController<ProductFamily, int> { - public ProductFamiliesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<ProductFamily> resourceService) - : base(options, loggerFactory, resourceService) + public ProductFamiliesController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<ProductFamily, int> 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<int> { [Attr] - public string Name { get; set; } + public string Name { get; set; } = null!; [HasMany] - public IList<SupportTicket> Tickets { get; set; } + public IList<SupportTicket> Tickets { get; set; } = new List<SupportTicket>(); } } 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<IntegrationTestContext<TestableStartup<SupportDbContext>, SupportDbContext>> + public sealed class ResourceMetaTests : IClassFixture<IntegrationTestContext<TestableStartup<MetaDbContext>, MetaDbContext>> { - private readonly IntegrationTestContext<TestableStartup<SupportDbContext>, SupportDbContext> _testContext; - private readonly SupportFakers _fakers = new(); + private readonly IntegrationTestContext<TestableStartup<MetaDbContext>, MetaDbContext> _testContext; + private readonly MetaFakers _fakers = new(); - public ResourceMetaTests(IntegrationTestContext<TestableStartup<SupportDbContext>, SupportDbContext> testContext) + public ResourceMetaTests(IntegrationTestContext<TestableStartup<MetaDbContext>, 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<IntegrationTestContext<TestableStartup<SupportDbContext>, SupportDbContext>> + public sealed class ResponseMetaTests : IClassFixture<IntegrationTestContext<TestableStartup<MetaDbContext>, MetaDbContext>> { - private readonly IntegrationTestContext<TestableStartup<SupportDbContext>, SupportDbContext> _testContext; + private readonly IntegrationTestContext<TestableStartup<MetaDbContext>, MetaDbContext> _testContext; - public ResponseMetaTests(IntegrationTestContext<TestableStartup<SupportDbContext>, SupportDbContext> testContext) + public ResponseMetaTests(IntegrationTestContext<TestableStartup<MetaDbContext>, 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<ProductFamily> ProductFamilies { get; set; } - public DbSet<SupportTicket> SupportTickets { get; set; } - - public SupportDbContext(DbContextOptions<SupportDbContext> 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<string, object> GetMeta() + public IReadOnlyDictionary<string, object?> GetMeta() { - return new Dictionary<string, object> + return new Dictionary<string, object?> { ["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<int> { [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<SupportTicket> + public sealed class SupportTicketDefinition : HitCountingResourceDefinition<SupportTicket, int> { - 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<string, object> GetMeta(SupportTicket resource) + public override IDictionary<string, object?>? GetMeta(SupportTicket resource) { - _hitCounter.TrackInvocation<SupportTicket>(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<string, object> + return new Dictionary<string, object?> { ["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<SupportTicket> + public sealed class SupportTicketsController : JsonApiController<SupportTicket, int> { - public SupportTicketsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<SupportTicket> resourceService) - : base(options, loggerFactory, resourceService) + public SupportTicketsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<SupportTicket, int> 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<IntegrationTestContext<TestableStartup<SupportDbContext>, SupportDbContext>> + public sealed class TopLevelCountTests : IClassFixture<IntegrationTestContext<TestableStartup<MetaDbContext>, MetaDbContext>> { - private readonly IntegrationTestContext<TestableStartup<SupportDbContext>, SupportDbContext> _testContext; - private readonly SupportFakers _fakers = new(); + private readonly IntegrationTestContext<TestableStartup<MetaDbContext>, MetaDbContext> _testContext; + private readonly MetaFakers _fakers = new(); - public TopLevelCountTests(IntegrationTestContext<TestableStartup<SupportDbContext>, SupportDbContext> testContext) + public TopLevelCountTests(IntegrationTestContext<TestableStartup<MetaDbContext>, 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<JsonElement>().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<JsonElement>().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<Guid> { [Attr] - public string Name { get; set; } + public string Name { get; set; } = null!; [HasMany] - public ISet<DomainUser> Users { get; set; } + public ISet<DomainUser> Users { get; set; } = new HashSet<DomainUser>(); } } 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<DomainGroup, Guid> { - public DomainGroupsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<DomainGroup, Guid> resourceService) - : base(options, loggerFactory, resourceService) + public DomainGroupsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<DomainGroup, Guid> 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<Guid> { [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<DomainUser, Guid> { - public DomainUsersController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<DomainUser, Guid> resourceService) - : base(options, loggerFactory, resourceService) + public DomainUsersController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<DomainUser, Guid> 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<DomainUser> Users { get; set; } - public DbSet<DomainGroup> Groups { get; set; } + public DbSet<DomainUser> Users => Set<DomainUser>(); + public DbSet<DomainGroup> Groups => Set<DomainGroup>(); public FireForgetDbContext(DbContextOptions<FireForgetDbContext> 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<DomainGroup>(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<DomainGroup>(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<DomainGroup> GetGroupToDeleteAsync(Guid groupId, CancellationToken cancellationToken) + protected override Task<DomainGroup?> 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<GroupCreatedContent>(); 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<GroupCreatedContent>(); 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<GroupRenamedContent>(); 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<UserAddedToGroupContent>(); 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<GroupDeletedContent>(); 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<UserRemovedFromGroupContent>(); 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<UserAddedToGroupContent>(); 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<UserAddedToGroupContent>(); 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<UserRemovedFromGroupContent>(); 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<MessageBroker>(); 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<UserCreatedContent>(); 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<UserCreatedContent>(); 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<UserLoginNameChangedContent>(); 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<UserDisplayNameChangedContent>(); 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<UserDisplayNameChangedContent>(); 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<UserDisplayNameChangedContent>(); 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<UserDeletedContent>(); 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<UserRemovedFromGroupContent>(); 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<UserRemovedFromGroupContent>(); 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<UserAddedToGroupContent>(); 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<UserMovedToGroupContent>(); 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<DomainUser>(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<DomainUser>(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<DomainUser> GetUserToDeleteAsync(Guid userId, CancellationToken cancellationToken) + protected override Task<DomainUser?> 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<T>() 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<DomainGroup, Guid> + public abstract class MessagingGroupDefinition : HitCountingResourceDefinition<DomainGroup, Guid> { private readonly DbSet<DomainUser> _userSet; private readonly DbSet<DomainGroup> _groupSet; - private readonly ResourceDefinitionHitCounter _hitCounter; private readonly List<OutgoingMessage> _pendingMessages = new(); - private string _beforeGroupName; + private string? _beforeGroupName; + + protected override ResourceDefinitionExtensibilityPoints ExtensibilityPointsToTrack => ResourceDefinitionExtensibilityPoints.Writing; protected MessagingGroupDefinition(IResourceGraph resourceGraph, DbSet<DomainUser> userSet, DbSet<DomainGroup> 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<DomainGroup>(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<IIdentifiable> rightResourceIds, WriteOperationKind writeOperation, CancellationToken cancellationToken) { - _hitCounter.TrackInvocation<DomainGroup>(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<IIdentifiable> rightResourceIds, CancellationToken cancellationToken) { - _hitCounter.TrackInvocation<DomainGroup>(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<IIdentifiable> rightResourceIds, + public override async Task OnRemoveFromRelationshipAsync(DomainGroup group, HasManyAttribute hasManyRelationship, ISet<IIdentifiable> rightResourceIds, CancellationToken cancellationToken) { - _hitCounter.TrackInvocation<DomainGroup>(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<DomainGroup> GetGroupToDeleteAsync(Guid groupId, CancellationToken cancellationToken) + protected virtual async Task<DomainGroup?> 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<DomainUser, Guid> + public abstract class MessagingUserDefinition : HitCountingResourceDefinition<DomainUser, Guid> { private readonly DbSet<DomainUser> _userSet; - private readonly ResourceDefinitionHitCounter _hitCounter; private readonly List<OutgoingMessage> _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<DomainUser> 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<DomainUser>(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<IIdentifiable> OnSetToOneRelationshipAsync(DomainUser user, HasOneAttribute hasOneRelationship, IIdentifiable rightResourceId, - WriteOperationKind writeOperation, CancellationToken cancellationToken) + public override async Task<IIdentifiable?> OnSetToOneRelationshipAsync(DomainUser user, HasOneAttribute hasOneRelationship, + IIdentifiable? rightResourceId, WriteOperationKind writeOperation, CancellationToken cancellationToken) { - _hitCounter.TrackInvocation<DomainUser>(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<IIdentifiable> 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<DomainUser> GetUserToDeleteAsync(Guid userId, CancellationToken cancellationToken) + protected virtual async Task<DomainUser?> 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<DomainUser> Users { get; set; } - public DbSet<DomainGroup> Groups { get; set; } - public DbSet<OutgoingMessage> OutboxMessages { get; set; } + public DbSet<DomainUser> Users => Set<DomainUser>(); + public DbSet<DomainGroup> Groups => Set<DomainGroup>(); + public DbSet<OutgoingMessage> OutboxMessages => Set<OutgoingMessage>(); public OutboxDbContext(DbContextOptions<OutboxDbContext> 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<OutgoingMessage> _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<DomainGroup>(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<OutgoingMessage> messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(1); + messages.ShouldHaveCount(1); var content = messages[0].GetContentAs<GroupCreatedContent>(); 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<OutgoingMessage> messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(3); + messages.ShouldHaveCount(3); var content1 = messages[0].GetContentAs<GroupCreatedContent>(); 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<OutgoingMessage> messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(1); + messages.ShouldHaveCount(1); var content = messages[0].GetContentAs<GroupRenamedContent>(); 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<OutgoingMessage> messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(3); + messages.ShouldHaveCount(3); var content1 = messages[0].GetContentAs<UserAddedToGroupContent>(); 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<OutgoingMessage> messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(1); + messages.ShouldHaveCount(1); var content = messages[0].GetContentAs<GroupDeletedContent>(); 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<OutgoingMessage> messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(2); + messages.ShouldHaveCount(2); var content1 = messages[0].GetContentAs<UserRemovedFromGroupContent>(); 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<OutgoingMessage> messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(3); + messages.ShouldHaveCount(3); var content1 = messages[0].GetContentAs<UserAddedToGroupContent>(); 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<OutgoingMessage> messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(2); + messages.ShouldHaveCount(2); var content1 = messages[0].GetContentAs<UserAddedToGroupContent>(); 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<OutgoingMessage> messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(1); + messages.ShouldHaveCount(1); var content = messages[0].GetContentAs<UserRemovedFromGroupContent>(); 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<ResourceDefinitionHitCounter>(); 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<OutgoingMessage> messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(1); + messages.ShouldHaveCount(1); var content = messages[0].GetContentAs<UserCreatedContent>(); 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<OutgoingMessage> messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(2); + messages.ShouldHaveCount(2); var content1 = messages[0].GetContentAs<UserCreatedContent>(); 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<OutgoingMessage> messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(2); + messages.ShouldHaveCount(2); var content1 = messages[0].GetContentAs<UserLoginNameChangedContent>(); 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<OutgoingMessage> messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(2); + messages.ShouldHaveCount(2); var content1 = messages[0].GetContentAs<UserDisplayNameChangedContent>(); 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<OutgoingMessage> messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(2); + messages.ShouldHaveCount(2); var content1 = messages[0].GetContentAs<UserDisplayNameChangedContent>(); 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<OutgoingMessage> messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(2); + messages.ShouldHaveCount(2); var content1 = messages[0].GetContentAs<UserDisplayNameChangedContent>(); 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<OutgoingMessage> messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(1); + messages.ShouldHaveCount(1); var content = messages[0].GetContentAs<UserDeletedContent>(); 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<OutgoingMessage> messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(2); + messages.ShouldHaveCount(2); var content1 = messages[0].GetContentAs<UserRemovedFromGroupContent>(); 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<OutgoingMessage> messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(1); + messages.ShouldHaveCount(1); var content = messages[0].GetContentAs<UserRemovedFromGroupContent>(); 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<OutgoingMessage> messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(1); + messages.ShouldHaveCount(1); var content = messages[0].GetContentAs<UserAddedToGroupContent>(); 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<OutgoingMessage> messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(1); + messages.ShouldHaveCount(1); var content = messages[0].GetContentAs<UserMovedToGroupContent>(); 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<OutgoingMessage> _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<DomainUser>(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<WebShop> WebShops { get; set; } - public DbSet<WebProduct> WebProducts { get; set; } + public DbSet<WebShop> WebShops => Set<WebShop>(); + public DbSet<WebProduct> WebProducts => Set<WebProduct>(); public MultiTenancyDbContext(DbContextOptions<MultiTenancyDbContext> options, ITenantProvider tenantProvider) : base(options) @@ -23,8 +23,7 @@ protected override void OnModelCreating(ModelBuilder builder) { builder.Entity<WebShop>() .HasMany(webShop => webShop.Products) - .WithOne(webProduct => webProduct.Shop) - .IsRequired(); + .WithOne(webProduct => webProduct.Shop); builder.Entity<WebShop>() .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<TestableStartup<MultiTenancyDbCo testContext.ConfigureServicesAfterStartup(services => { - services.AddResourceService<MultiTenantResourceService<WebShop>>(); - services.AddResourceService<MultiTenantResourceService<WebProduct>>(); + services.AddResourceService<MultiTenantResourceService<WebShop, int>>(); + services.AddResourceService<MultiTenantResourceService<WebProduct, int>>(); }); var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService<IJsonApiOptions>(); 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<TResource> CreateAsync(TResource resource, CancellationToken cancellationToken) + public override async Task<TResource?> CreateAsync(TResource resource, CancellationToken cancellationToken) { await AssertResourcesToAssignInRelationshipsExistAsync(resource, cancellationToken); return await base.CreateAsync(resource, cancellationToken); } - public override async Task<TResource> UpdateAsync(TId id, TResource resource, CancellationToken cancellationToken) + public override async Task<TResource?> 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<TResource> : MultiTenantResourceService<TResource, int>, IResourceService<TResource> - where TResource : class, IIdentifiable<int> - { - public MultiTenantResourceService(ITenantProvider tenantProvider, IResourceRepositoryAccessor repositoryAccessor, - IQueryLayerComposer queryLayerComposer, IPaginationContext paginationContext, IJsonApiOptions options, ILoggerFactory loggerFactory, - IJsonApiRequest request, IResourceChangeTracker<TResource> 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<int> { [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<WebProduct> + public sealed class WebProductsController : JsonApiController<WebProduct, int> { - public WebProductsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<WebProduct> resourceService) - : base(options, loggerFactory, resourceService) + public WebProductsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<WebProduct, int> 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<int>, IHasTenant { [Attr] - public string Url { get; set; } + public string Url { get; set; } = null!; public Guid TenantId { get; set; } [HasMany] - public IList<WebProduct> Products { get; set; } + public IList<WebProduct> Products { get; set; } = new List<WebProduct>(); } } 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<WebShop> + public sealed class WebShopsController : JsonApiController<WebShop, int> { - public WebShopsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<WebShop> resourceService) - : base(options, loggerFactory, resourceService) + public WebShopsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<WebShop, int> 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<int> { [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<DivingBoard> + public sealed class DivingBoardsController : JsonApiController<DivingBoard, int> { - public DivingBoardsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<DivingBoard> resourceService) - : base(options, loggerFactory, resourceService) + public DivingBoardsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<DivingBoard, int> 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<IntegrationTestContext<KebabCasingConventionStartup<SwimmingDbContext>, SwimmingDbContext>> + public sealed class KebabCasingTests : IClassFixture<IntegrationTestContext<KebabCasingConventionStartup<NamingDbContext>, NamingDbContext>> { - private readonly IntegrationTestContext<KebabCasingConventionStartup<SwimmingDbContext>, SwimmingDbContext> _testContext; - private readonly SwimmingFakers _fakers = new(); + private readonly IntegrationTestContext<KebabCasingConventionStartup<NamingDbContext>, NamingDbContext> _testContext; + private readonly NamingFakers _fakers = new(); - public KebabCasingTests(IntegrationTestContext<KebabCasingConventionStartup<SwimmingDbContext>, SwimmingDbContext> testContext) + public KebabCasingTests(IntegrationTestContext<KebabCasingConventionStartup<NamingDbContext>, 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<decimal>().Should().BeApproximately(pools[1].DivingBoards[0].HeightInMeters); + responseDocument.Included[0].Attributes.ShouldContainKey("height-in-meters").With(value => value.As<decimal>().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<JsonElement>().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<SwimmingPool> SwimmingPools => Set<SwimmingPool>(); + public DbSet<WaterSlide> WaterSlides => Set<WaterSlide>(); + public DbSet<DivingBoard> DivingBoards => Set<DivingBoard>(); + + public NamingDbContext(DbContextOptions<NamingDbContext> 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<Faker<SwimmingPool>> _lazySwimmingPoolFaker = new(() => new Faker<SwimmingPool>() 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<IntegrationTestContext<PascalCasingConventionStartup<SwimmingDbContext>, SwimmingDbContext>> + public sealed class PascalCasingTests : IClassFixture<IntegrationTestContext<PascalCasingConventionStartup<NamingDbContext>, NamingDbContext>> { - private readonly IntegrationTestContext<PascalCasingConventionStartup<SwimmingDbContext>, SwimmingDbContext> _testContext; - private readonly SwimmingFakers _fakers = new(); + private readonly IntegrationTestContext<PascalCasingConventionStartup<NamingDbContext>, NamingDbContext> _testContext; + private readonly NamingFakers _fakers = new(); - public PascalCasingTests(IntegrationTestContext<PascalCasingConventionStartup<SwimmingDbContext>, SwimmingDbContext> testContext) + public PascalCasingTests(IntegrationTestContext<PascalCasingConventionStartup<NamingDbContext>, 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<decimal>().Should().BeApproximately(pools[1].DivingBoards[0].HeightInMeters); + responseDocument.Included[0].Attributes.ShouldContainKey("HeightInMeters").With(value => value.As<decimal>().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<JsonElement>().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<SwimmingPool> SwimmingPools { get; set; } - public DbSet<WaterSlide> WaterSlides { get; set; } - public DbSet<DivingBoard> DivingBoards { get; set; } - - public SwimmingDbContext(DbContextOptions<SwimmingDbContext> 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<int> { [Attr] public bool IsIndoor { get; set; } [HasMany] - public IList<WaterSlide> WaterSlides { get; set; } + public IList<WaterSlide> WaterSlides { get; set; } = new List<WaterSlide>(); [HasMany] - public IList<DivingBoard> DivingBoards { get; set; } + public IList<DivingBoard> DivingBoards { get; set; } = new List<DivingBoard>(); } } 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<SwimmingPool> + public sealed class SwimmingPoolsController : JsonApiController<SwimmingPool, int> { - public SwimmingPoolsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<SwimmingPool> resourceService) - : base(options, loggerFactory, resourceService) + public SwimmingPoolsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<SwimmingPool, int> 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<int> { [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<IActionResult> 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<int> + { + 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<TestableStartup<NonJsonApiDbContext>, NonJsonApiDbContext> + { + public UnknownResourceControllerTests() + { + UseController<UnknownResourcesController>(); + } + + [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<InvalidConfigurationException>().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<UnknownResource, int> + { + public UnknownResourcesController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<UnknownResource, int> 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<int> { [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<int> { [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<int> { [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<BlogPost> Posts { get; set; } + public IList<BlogPost> Posts { get; set; } = new List<BlogPost>(); [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<int> { [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<Label> Labels { get; set; } + public ISet<Label> Labels { get; set; } = new HashSet<Label>(); [HasMany] - public ISet<Comment> Comments { get; set; } + public ISet<Comment> Comments { get; set; } = new HashSet<Comment>(); [HasOne(CanInclude = false)] - public Blog Parent { get; set; } + public Blog? Parent { get; set; } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/BlogPostsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/BlogPostsController.cs index ea357db55a..b8eb194e80 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/BlogPostsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/BlogPostsController.cs @@ -5,10 +5,11 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings { - public sealed class BlogPostsController : JsonApiController<BlogPost> + public sealed class BlogPostsController : JsonApiController<BlogPost, int> { - public BlogPostsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<BlogPost> resourceService) - : base(options, loggerFactory, resourceService) + public BlogPostsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<BlogPost, int> resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/BlogsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/BlogsController.cs index 565d1f3e58..4fb4ad7eea 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/BlogsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/BlogsController.cs @@ -5,10 +5,10 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings { - public sealed class BlogsController : JsonApiController<Blog> + public sealed class BlogsController : JsonApiController<Blog, int> { - public BlogsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<Blog> resourceService) - : base(options, loggerFactory, resourceService) + public BlogsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IResourceService<Blog, int> resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Calendar.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Calendar.cs index 90ffe9d9b3..ba66badfad 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Calendar.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Calendar.cs @@ -6,10 +6,10 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Calendar : Identifiable + public sealed class Calendar : Identifiable<int> { [Attr] - public string TimeZone { get; set; } + public string? TimeZone { get; set; } [Attr] public bool ShowWeekNumbers { get; set; } @@ -18,6 +18,6 @@ public sealed class Calendar : Identifiable public int DefaultAppointmentDurationInMinutes { get; set; } [HasMany] - public ISet<Appointment> Appointments { get; set; } + public ISet<Appointment> Appointments { get; set; } = new HashSet<Appointment>(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CalendarsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CalendarsController.cs index 76e6e213b5..64c0c1060c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CalendarsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CalendarsController.cs @@ -5,10 +5,11 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings { - public sealed class CalendarsController : JsonApiController<Calendar> + public sealed class CalendarsController : JsonApiController<Calendar, int> { - public CalendarsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<Calendar> resourceService) - : base(options, loggerFactory, resourceService) + public CalendarsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<Calendar, int> resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Comment.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Comment.cs index 1c849ffcd7..1e0b6b5b6e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Comment.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Comment.cs @@ -6,18 +6,18 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Comment : Identifiable + public sealed class Comment : Identifiable<int> { [Attr] - public string Text { get; set; } + public string Text { get; set; } = null!; [Attr] public DateTime CreatedAt { get; set; } [HasOne] - public WebAccount Author { get; set; } + public WebAccount? Author { get; set; } [HasOne] - public BlogPost Parent { get; set; } + public BlogPost Parent { get; set; } = null!; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CommentsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CommentsController.cs index d00f138d77..b287d173fd 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CommentsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CommentsController.cs @@ -5,10 +5,11 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings { - public sealed class CommentsController : JsonApiController<Comment> + public sealed class CommentsController : JsonApiController<Comment, int> { - public CommentsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<Comment> resourceService) - : base(options, loggerFactory, resourceService) + public CommentsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<Comment, int> resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDataTypeTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDataTypeTests.cs index f021dd27e7..76df3f3458 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDataTypeTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDataTypeTests.cs @@ -39,6 +39,7 @@ public FilterDataTypeTests(IntegrationTestContext<TestableStartup<FilterDbContex [Theory] [InlineData(nameof(FilterableResource.SomeString), "text")] + [InlineData(nameof(FilterableResource.SomeNullableString), "text")] [InlineData(nameof(FilterableResource.SomeBoolean), true)] [InlineData(nameof(FilterableResource.SomeNullableBoolean), true)] [InlineData(nameof(FilterableResource.SomeInt32), 1)] @@ -49,12 +50,12 @@ public FilterDataTypeTests(IntegrationTestContext<TestableStartup<FilterDbContex [InlineData(nameof(FilterableResource.SomeNullableDouble), 0.5d)] [InlineData(nameof(FilterableResource.SomeEnum), DayOfWeek.Saturday)] [InlineData(nameof(FilterableResource.SomeNullableEnum), DayOfWeek.Saturday)] - public async Task Can_filter_equality_on_type(string propertyName, object value) + public async Task Can_filter_equality_on_type(string propertyName, object propertyValue) { // Arrange var resource = new FilterableResource(); - PropertyInfo property = typeof(FilterableResource).GetProperty(propertyName); - property?.SetValue(resource, value); + PropertyInfo? property = typeof(FilterableResource).GetProperty(propertyName); + property?.SetValue(resource, propertyValue); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -64,7 +65,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); string attributeName = propertyName.Camelize(); - string route = $"/filterableResources?filter=equals({attributeName},'{value}')"; + string route = $"/filterableResources?filter=equals({attributeName},'{propertyValue}')"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route); @@ -72,8 +73,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(1); - responseDocument.Data.ManyValue[0].Attributes[attributeName].Should().Be(value); + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey(attributeName).With(value => value.Should().Be(value)); } [Fact] @@ -100,8 +101,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(1); - responseDocument.Data.ManyValue[0].Attributes["someDecimal"].Should().Be(resource.SomeDecimal); + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someDecimal").With(value => value.Should().Be(resource.SomeDecimal)); } [Fact] @@ -128,8 +129,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(1); - responseDocument.Data.ManyValue[0].Attributes["someGuid"].Should().Be(resource.SomeGuid); + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someGuid").With(value => value.Should().Be(resource.SomeGuid)); } [Fact] @@ -156,8 +157,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(1); - responseDocument.Data.ManyValue[0].Attributes["someDateTime"].As<DateTime>().Should().BeCloseTo(resource.SomeDateTime); + responseDocument.Data.ManyValue.ShouldHaveCount(1); + + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someDateTime") + .With(value => value.As<DateTime>().Should().BeCloseTo(resource.SomeDateTime)); } [Fact] @@ -184,8 +187,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(1); - responseDocument.Data.ManyValue[0].Attributes["someDateTimeOffset"].As<DateTimeOffset>().Should().BeCloseTo(resource.SomeDateTimeOffset); + responseDocument.Data.ManyValue.ShouldHaveCount(1); + + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someDateTimeOffset") + .With(value => value.As<DateTimeOffset>().Should().BeCloseTo(resource.SomeDateTimeOffset)); } [Fact] @@ -212,8 +217,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(1); - responseDocument.Data.ManyValue[0].Attributes["someTimeSpan"].Should().Be(resource.SomeTimeSpan); + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someTimeSpan").With(value => value.Should().Be(resource.SomeTimeSpan)); } [Fact] @@ -240,7 +245,7 @@ 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); @@ -250,7 +255,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Theory] - [InlineData(nameof(FilterableResource.SomeString))] + [InlineData(nameof(FilterableResource.SomeNullableString))] [InlineData(nameof(FilterableResource.SomeNullableBoolean))] [InlineData(nameof(FilterableResource.SomeNullableInt32))] [InlineData(nameof(FilterableResource.SomeNullableUnsignedInt64))] @@ -265,12 +270,12 @@ public async Task Can_filter_is_null_on_type(string propertyName) { // Arrange var resource = new FilterableResource(); - PropertyInfo property = typeof(FilterableResource).GetProperty(propertyName); + PropertyInfo? property = typeof(FilterableResource).GetProperty(propertyName); property?.SetValue(resource, null); var otherResource = new FilterableResource { - SomeString = "X", + SomeNullableString = "X", SomeNullableBoolean = true, SomeNullableInt32 = 1, SomeNullableUnsignedInt64 = 1, @@ -299,12 +304,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(1); - responseDocument.Data.ManyValue[0].Attributes[attributeName].Should().Be(null); + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey(attributeName).With(value => value.Should().BeNull()); } [Theory] - [InlineData(nameof(FilterableResource.SomeString))] + [InlineData(nameof(FilterableResource.SomeNullableString))] [InlineData(nameof(FilterableResource.SomeNullableBoolean))] [InlineData(nameof(FilterableResource.SomeNullableInt32))] [InlineData(nameof(FilterableResource.SomeNullableUnsignedInt64))] @@ -320,7 +325,7 @@ public async Task Can_filter_is_not_null_on_type(string propertyName) // Arrange var resource = new FilterableResource { - SomeString = "X", + SomeNullableString = "X", SomeNullableBoolean = true, SomeNullableInt32 = 1, SomeNullableUnsignedInt64 = 1, @@ -349,8 +354,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(1); - responseDocument.Data.ManyValue[0].Attributes[attributeName].Should().NotBe(null); + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey(attributeName).With(value => value.Should().NotBeNull()); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDbContext.cs index 26c59823cf..525008a6cb 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDbContext.cs @@ -6,7 +6,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.Filtering [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class FilterDbContext : DbContext { - public DbSet<FilterableResource> FilterableResources { get; set; } + public DbSet<FilterableResource> FilterableResources => Set<FilterableResource>(); public FilterDbContext(DbContextOptions<FilterDbContext> options) : base(options) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDepthTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDepthTests.cs index 4d13fd69c1..92dce6788a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDepthTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDepthTests.cs @@ -55,7 +55,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(posts[1].StringId); } @@ -79,12 +79,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("The specified filter is invalid."); error.Detail.Should().Be("This query string parameter can only be used on a collection of resources (not on a single resource)."); + error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("filter"); } @@ -111,7 +112,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(blog.Posts[1].StringId); } @@ -135,12 +136,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("The specified filter is invalid."); error.Detail.Should().Be("This query string parameter can only be used on a collection of resources (not on a single resource)."); + error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("filter"); } @@ -150,9 +152,9 @@ public async Task Can_filter_on_ManyToOne_relationship() // Arrange List<BlogPost> posts = _fakers.BlogPost.Generate(3); posts[0].Author = _fakers.WebAccount.Generate(); - posts[0].Author.UserName = "Conner"; + posts[0].Author!.UserName = "Conner"; posts[1].Author = _fakers.WebAccount.Generate(); - posts[1].Author.UserName = "Smith"; + posts[1].Author!.UserName = "Smith"; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -169,12 +171,12 @@ 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().ContainSingle(post => post.Id == posts[1].StringId); responseDocument.Data.ManyValue.Should().ContainSingle(post => post.Id == posts[2].StringId); - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Id.Should().Be(posts[1].Author.StringId); + responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included[0].Id.Should().Be(posts[1].Author!.StringId); } [Fact] @@ -199,7 +201,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(blogs[1].StringId); } @@ -228,7 +230,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(blogs[1].StringId); } @@ -254,7 +256,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(posts[1].StringId); } @@ -285,7 +287,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(blogs[1].StringId); } @@ -313,9 +315,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue.ShouldHaveCount(1); - responseDocument.Included.Should().HaveCount(1); + responseDocument.Included.ShouldHaveCount(1); responseDocument.Included[0].Id.Should().Be(blog.Posts[1].StringId); } @@ -343,9 +345,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Included.Should().HaveCount(1); + responseDocument.Included.ShouldHaveCount(1); responseDocument.Included[0].Id.Should().Be(blog.Owner.Posts[1].StringId); } @@ -381,9 +383,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(2); + responseDocument.Data.ManyValue.ShouldHaveCount(2); - responseDocument.Included.Should().HaveCount(1); + responseDocument.Included.ShouldHaveCount(1); responseDocument.Included[0].Id.Should().Be(posts[1].Labels.First().StringId); } @@ -412,10 +414,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue.ShouldHaveCount(1); - responseDocument.Included.Should().HaveCount(2); + responseDocument.Included.ShouldHaveCount(2); + + responseDocument.Included[0].Type.Should().Be("webAccounts"); responseDocument.Included[0].Id.Should().Be(blog.Owner.StringId); + + responseDocument.Included[1].Type.Should().Be("blogPosts"); responseDocument.Included[1].Id.Should().Be(blog.Owner.Posts[1].StringId); } @@ -443,7 +449,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(posts[0].StringId); responseDocument.Data.ManyValue[1].Id.Should().Be(posts[2].StringId); } @@ -460,14 +466,14 @@ public async Task Can_filter_in_same_scope_multiple_times_using_legacy_notation( posts[1].Author = _fakers.WebAccount.Generate(); posts[2].Author = _fakers.WebAccount.Generate(); - posts[0].Author.UserName = "Joe"; - posts[0].Author.DisplayName = "Smith"; + posts[0].Author!.UserName = "Joe"; + posts[0].Author!.DisplayName = "Smith"; - posts[1].Author.UserName = "John"; - posts[1].Author.DisplayName = "Doe"; + posts[1].Author!.UserName = "John"; + posts[1].Author!.DisplayName = "Doe"; - posts[2].Author.UserName = "Jack"; - posts[2].Author.DisplayName = "Miller"; + posts[2].Author!.UserName = "Jack"; + posts[2].Author!.DisplayName = "Miller"; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -484,7 +490,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(posts[0].StringId); responseDocument.Data.ManyValue[1].Id.Should().Be(posts[1].StringId); } @@ -496,13 +502,13 @@ public async Task Can_filter_in_multiple_scopes() List<Blog> blogs = _fakers.Blog.Generate(2); blogs[1].Title = "Technology"; blogs[1].Owner = _fakers.WebAccount.Generate(); - blogs[1].Owner.UserName = "Smith"; - blogs[1].Owner.Posts = _fakers.BlogPost.Generate(2); - blogs[1].Owner.Posts[0].Caption = "One"; - blogs[1].Owner.Posts[1].Caption = "Two"; - blogs[1].Owner.Posts[1].Comments = _fakers.Comment.Generate(2).ToHashSet(); - blogs[1].Owner.Posts[1].Comments.ElementAt(0).CreatedAt = 1.January(2000); - blogs[1].Owner.Posts[1].Comments.ElementAt(1).CreatedAt = 10.January(2010); + blogs[1].Owner!.UserName = "Smith"; + blogs[1].Owner!.Posts = _fakers.BlogPost.Generate(2); + blogs[1].Owner!.Posts[0].Caption = "One"; + blogs[1].Owner!.Posts[1].Caption = "Two"; + blogs[1].Owner!.Posts[1].Comments = _fakers.Comment.Generate(2).ToHashSet(); + blogs[1].Owner!.Posts[1].Comments.ElementAt(0).CreatedAt = 1.January(2000); + blogs[1].Owner!.Posts[1].Comments.ElementAt(1).CreatedAt = 10.January(2010); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -526,13 +532,19 @@ 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(blogs[1].StringId); - responseDocument.Included.Should().HaveCount(3); - responseDocument.Included[0].Id.Should().Be(blogs[1].Owner.StringId); - responseDocument.Included[1].Id.Should().Be(blogs[1].Owner.Posts[1].StringId); - responseDocument.Included[2].Id.Should().Be(blogs[1].Owner.Posts[1].Comments.ElementAt(1).StringId); + responseDocument.Included.ShouldHaveCount(3); + + responseDocument.Included[0].Type.Should().Be("webAccounts"); + responseDocument.Included[0].Id.Should().Be(blogs[1].Owner!.StringId); + + responseDocument.Included[1].Type.Should().Be("blogPosts"); + responseDocument.Included[1].Id.Should().Be(blogs[1].Owner!.Posts[1].StringId); + + responseDocument.Included[2].Type.Should().Be("comments"); + responseDocument.Included[2].Id.Should().Be(blogs[1].Owner!.Posts[1].Comments.ElementAt(1).StringId); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterOperatorTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterOperatorTests.cs index 842a26b5f0..5661252801 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterOperatorTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterOperatorTests.cs @@ -53,8 +53,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(1); - responseDocument.Data.ManyValue[0].Attributes["someString"].Should().Be(resource.SomeString); + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someString").With(value => value.Should().Be(resource.SomeString)); } [Fact] @@ -88,9 +88,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(1); - responseDocument.Data.ManyValue[0].Attributes["someInt32"].Should().Be(resource.SomeInt32); - responseDocument.Data.ManyValue[0].Attributes["otherInt32"].Should().Be(resource.OtherInt32); + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someInt32").With(value => value.Should().Be(resource.SomeInt32)); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("otherInt32").With(value => value.Should().Be(resource.OtherInt32)); } [Fact] @@ -124,9 +124,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(1); - responseDocument.Data.ManyValue[0].Attributes["someNullableInt32"].Should().Be(resource.SomeNullableInt32); - responseDocument.Data.ManyValue[0].Attributes["otherNullableInt32"].Should().Be(resource.OtherNullableInt32); + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someNullableInt32").With(value => value.Should().Be(resource.SomeNullableInt32)); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("otherNullableInt32").With(value => value.Should().Be(resource.OtherNullableInt32)); } [Fact] @@ -160,9 +160,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(1); - responseDocument.Data.ManyValue[0].Attributes["someInt32"].Should().Be(resource.SomeInt32); - responseDocument.Data.ManyValue[0].Attributes["someNullableInt32"].Should().Be(resource.SomeNullableInt32); + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someInt32").With(value => value.Should().Be(resource.SomeInt32)); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someNullableInt32").With(value => value.Should().Be(resource.SomeNullableInt32)); } [Fact] @@ -196,9 +196,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(1); - responseDocument.Data.ManyValue[0].Attributes["someInt32"].Should().Be(resource.SomeInt32); - responseDocument.Data.ManyValue[0].Attributes["someNullableInt32"].Should().Be(resource.SomeNullableInt32); + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someInt32").With(value => value.Should().Be(resource.SomeInt32)); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someNullableInt32").With(value => value.Should().Be(resource.SomeNullableInt32)); } [Fact] @@ -232,9 +232,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(1); - responseDocument.Data.ManyValue[0].Attributes["someInt32"].Should().Be(resource.SomeInt32); - responseDocument.Data.ManyValue[0].Attributes["someUnsignedInt64"].Should().Be(resource.SomeUnsignedInt64); + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someInt32").With(value => value.Should().Be(resource.SomeInt32)); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someUnsignedInt64").With(value => value.Should().Be(resource.SomeUnsignedInt64)); } [Fact] @@ -249,7 +249,7 @@ public async Task Cannot_filter_equality_on_two_attributes_of_incompatible_types // 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); @@ -295,8 +295,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(1); - responseDocument.Data.ManyValue[0].Attributes["someInt32"].Should().Be(resource.SomeInt32); + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someInt32").With(value => value.Should().Be(resource.SomeInt32)); } [Theory] @@ -337,8 +337,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(1); - responseDocument.Data.ManyValue[0].Attributes["someDouble"].Should().Be(resource.SomeDouble); + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someDouble").With(value => value.Should().Be(resource.SomeDouble)); } [Theory] @@ -380,8 +380,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(1); - responseDocument.Data.ManyValue[0].Attributes["someDateTime"].As<DateTime>().Should().BeCloseTo(resource.SomeDateTime); + responseDocument.Data.ManyValue.ShouldHaveCount(1); + + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someDateTime") + .With(value => value.As<DateTime>().Should().BeCloseTo(resource.SomeDateTime)); } [Theory] @@ -418,8 +420,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(1); - responseDocument.Data.ManyValue[0].Attributes["someString"].Should().Be(resource.SomeString); + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someString").With(value => value.Should().Be(resource.SomeString)); } [Theory] @@ -453,8 +455,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(1); - responseDocument.Data.ManyValue[0].Attributes["someString"].Should().Be(resource.SomeString); + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someString").With(value => value.Should().Be(resource.SomeString)); } [Fact] @@ -484,7 +486,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(resource.StringId); } @@ -531,7 +533,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(resources[1].StringId); } @@ -563,7 +565,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(resource.StringId); } @@ -604,7 +606,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(resource1.StringId); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterTests.cs index 8aa640c530..1a1bf3e113 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterTests.cs @@ -38,12 +38,13 @@ public async Task Cannot_filter_in_unknown_scope() // 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("The specified filter is invalid."); - error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' does not exist on resource 'webAccounts'."); + error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' does not exist on resource type 'webAccounts'."); + error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be($"filter[{Unknown.Relationship}]"); } @@ -59,12 +60,13 @@ public async Task Cannot_filter_in_unknown_nested_scope() // 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("The specified filter is invalid."); - error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' in 'posts.{Unknown.Relationship}' does not exist on resource 'blogPosts'."); + error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' in 'posts.{Unknown.Relationship}' does not exist on resource type 'blogPosts'."); + error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be($"filter[posts.{Unknown.Relationship}]"); } @@ -80,12 +82,13 @@ public async Task Cannot_filter_on_attribute_with_blocked_capability() // 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("Filtering on the requested attribute is not allowed."); error.Detail.Should().Be("Filtering on attribute 'dateOfBirth' is not allowed."); + error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("filter"); } @@ -110,9 +113,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(accounts[0].StringId); - responseDocument.Data.ManyValue[0].Attributes["userName"].Should().Be(accounts[0].UserName); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("userName").With(value => value.Should().Be(accounts[0].UserName)); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterableResource.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterableResource.cs index 515eb70db0..1ef672c059 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterableResource.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterableResource.cs @@ -7,10 +7,13 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.Filtering { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class FilterableResource : Identifiable + public sealed class FilterableResource : Identifiable<int> { [Attr] - public string SomeString { get; set; } + public string SomeString { get; set; } = string.Empty; + + [Attr] + public string? SomeNullableString { get; set; } [Attr] public bool SomeBoolean { get; set; } @@ -79,6 +82,6 @@ public sealed class FilterableResource : Identifiable public DayOfWeek? SomeNullableEnum { get; set; } [HasMany] - public ICollection<FilterableResource> Children { get; set; } + public ICollection<FilterableResource> Children { get; set; } = new List<FilterableResource>(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterableResourcesController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterableResourcesController.cs index c475498915..4ccd186fd5 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterableResourcesController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterableResourcesController.cs @@ -5,10 +5,11 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.Filtering { - public sealed class FilterableResourcesController : JsonApiController<FilterableResource> + public sealed class FilterableResourcesController : JsonApiController<FilterableResource, int> { - public FilterableResourcesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<FilterableResource> resourceService) - : base(options, loggerFactory, resourceService) + public FilterableResourcesController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<FilterableResource, int> resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Includes/IncludeTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Includes/IncludeTests.cs index 7236e285eb..85138d939a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Includes/IncludeTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Includes/IncludeTests.cs @@ -53,14 +53,14 @@ 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(post.StringId); - responseDocument.Data.ManyValue[0].Attributes["caption"].Should().Be(post.Caption); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("caption").With(value => value.Should().Be(post.Caption)); - responseDocument.Included.Should().HaveCount(1); + responseDocument.Included.ShouldHaveCount(1); responseDocument.Included[0].Type.Should().Be("webAccounts"); responseDocument.Included[0].Id.Should().Be(post.Author.StringId); - responseDocument.Included[0].Attributes["displayName"].Should().Be(post.Author.DisplayName); + responseDocument.Included[0].Attributes.ShouldContainKey("displayName").With(value => value.Should().Be(post.Author.DisplayName)); } [Fact] @@ -84,14 +84,14 @@ 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(post.StringId); - responseDocument.Data.SingleValue.Attributes["caption"].Should().Be(post.Caption); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("caption").With(value => value.Should().Be(post.Caption)); - responseDocument.Included.Should().HaveCount(1); + responseDocument.Included.ShouldHaveCount(1); responseDocument.Included[0].Type.Should().Be("webAccounts"); responseDocument.Included[0].Id.Should().Be(post.Author.StringId); - responseDocument.Included[0].Attributes["displayName"].Should().Be(post.Author.DisplayName); + responseDocument.Included[0].Attributes.ShouldContainKey("displayName").With(value => value.Should().Be(post.Author.DisplayName)); } [Fact] @@ -116,14 +116,14 @@ 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(blog.Owner.StringId); - responseDocument.Data.SingleValue.Attributes["displayName"].Should().Be(blog.Owner.DisplayName); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("displayName").With(value => value.Should().Be(blog.Owner.DisplayName)); - responseDocument.Included.Should().HaveCount(1); + responseDocument.Included.ShouldHaveCount(1); responseDocument.Included[0].Type.Should().Be("blogPosts"); responseDocument.Included[0].Id.Should().Be(blog.Owner.Posts[0].StringId); - responseDocument.Included[0].Attributes["caption"].Should().Be(blog.Owner.Posts[0].Caption); + responseDocument.Included[0].Attributes.ShouldContainKey("caption").With(value => value.Should().Be(blog.Owner.Posts[0].Caption)); } [Fact] @@ -148,14 +148,14 @@ 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(blog.Posts[0].StringId); - responseDocument.Data.ManyValue[0].Attributes["caption"].Should().Be(blog.Posts[0].Caption); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("caption").With(value => value.Should().Be(blog.Posts[0].Caption)); - responseDocument.Included.Should().HaveCount(1); + responseDocument.Included.ShouldHaveCount(1); responseDocument.Included[0].Type.Should().Be("webAccounts"); - responseDocument.Included[0].Id.Should().Be(blog.Posts[0].Author.StringId); - responseDocument.Included[0].Attributes["displayName"].Should().Be(blog.Posts[0].Author.DisplayName); + responseDocument.Included[0].Id.Should().Be(blog.Posts[0].Author!.StringId); + responseDocument.Included[0].Attributes.ShouldContainKey("displayName").With(value => value.Should().Be(blog.Posts[0].Author!.DisplayName)); } [Fact] @@ -180,19 +180,19 @@ 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(comment.StringId); - responseDocument.Data.SingleValue.Attributes["text"].Should().Be(comment.Text); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("text").With(value => value.Should().Be(comment.Text)); - responseDocument.Included.Should().HaveCount(2); + responseDocument.Included.ShouldHaveCount(2); responseDocument.Included[0].Type.Should().Be("webAccounts"); responseDocument.Included[0].Id.Should().Be(comment.Author.StringId); - responseDocument.Included[0].Attributes["userName"].Should().Be(comment.Author.UserName); + responseDocument.Included[0].Attributes.ShouldContainKey("userName").With(value => value.Should().Be(comment.Author.UserName)); responseDocument.Included[1].Type.Should().Be("blogPosts"); responseDocument.Included[1].Id.Should().Be(comment.Parent.StringId); - responseDocument.Included[1].Attributes["caption"].Should().Be(comment.Parent.Caption); + responseDocument.Included[1].Attributes.ShouldContainKey("caption").With(value => value.Should().Be(comment.Parent.Caption)); } [Fact] @@ -216,14 +216,16 @@ 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(post.StringId); - responseDocument.Data.SingleValue.Attributes["caption"].Should().Be(post.Caption); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("caption").With(value => value.Should().Be(post.Caption)); - responseDocument.Included.Should().HaveCount(1); + DateTime createdAt = post.Comments.Single().CreatedAt; + + responseDocument.Included.ShouldHaveCount(1); responseDocument.Included[0].Type.Should().Be("comments"); responseDocument.Included[0].Id.Should().Be(post.Comments.Single().StringId); - responseDocument.Included[0].Attributes["createdAt"].As<DateTime>().Should().BeCloseTo(post.Comments.Single().CreatedAt); + responseDocument.Included[0].Attributes.ShouldContainKey("createdAt").With(value => value.As<DateTime>().Should().BeCloseTo(createdAt)); } [Fact] @@ -247,14 +249,14 @@ 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(post.StringId); - responseDocument.Data.SingleValue.Attributes["caption"].Should().Be(post.Caption); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("caption").With(value => value.Should().Be(post.Caption)); - responseDocument.Included.Should().HaveCount(1); + responseDocument.Included.ShouldHaveCount(1); responseDocument.Included[0].Type.Should().Be("labels"); responseDocument.Included[0].Id.Should().Be(post.Labels.Single().StringId); - responseDocument.Included[0].Attributes["name"].Should().Be(post.Labels.Single().Name); + responseDocument.Included[0].Attributes.ShouldContainKey("name").With(value => value.Should().Be(post.Labels.Single().Name)); } [Fact] @@ -278,15 +280,15 @@ 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("labels"); responseDocument.Data.ManyValue[0].Id.Should().Be(post.Labels.ElementAt(0).StringId); - responseDocument.Data.ManyValue[0].Attributes["name"].Should().Be(post.Labels.Single().Name); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("name").With(value => value.Should().Be(post.Labels.Single().Name)); - responseDocument.Included.Should().HaveCount(1); + responseDocument.Included.ShouldHaveCount(1); responseDocument.Included[0].Type.Should().Be("blogPosts"); responseDocument.Included[0].Id.Should().Be(post.StringId); - responseDocument.Included[0].Attributes["caption"].Should().Be(post.Caption); + responseDocument.Included[0].Attributes.ShouldContainKey("caption").With(value => value.Should().Be(post.Caption)); } [Fact] @@ -312,23 +314,25 @@ 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(comment.StringId); - responseDocument.Data.SingleValue.Attributes["text"].Should().Be(comment.Text); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("text").With(value => value.Should().Be(comment.Text)); - responseDocument.Included.Should().HaveCount(3); + responseDocument.Included.ShouldHaveCount(3); responseDocument.Included[0].Type.Should().Be("blogPosts"); responseDocument.Included[0].Id.Should().Be(comment.Parent.StringId); - responseDocument.Included[0].Attributes["caption"].Should().Be(comment.Parent.Caption); + responseDocument.Included[0].Attributes.ShouldContainKey("caption").With(value => value.Should().Be(comment.Parent.Caption)); responseDocument.Included[1].Type.Should().Be("webAccounts"); responseDocument.Included[1].Id.Should().Be(comment.Parent.Author.StringId); - responseDocument.Included[1].Attributes["displayName"].Should().Be(comment.Parent.Author.DisplayName); + responseDocument.Included[1].Attributes.ShouldContainKey("displayName").With(value => value.Should().Be(comment.Parent.Author.DisplayName)); + + bool useDarkTheme = comment.Parent.Author.Preferences.UseDarkTheme; responseDocument.Included[2].Type.Should().Be("accountPreferences"); responseDocument.Included[2].Id.Should().Be(comment.Parent.Author.Preferences.StringId); - responseDocument.Included[2].Attributes["useDarkTheme"].Should().Be(comment.Parent.Author.Preferences.UseDarkTheme); + responseDocument.Included[2].Attributes.ShouldContainKey("useDarkTheme").With(value => value.Should().Be(useDarkTheme)); } [Fact] @@ -353,19 +357,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(blog.StringId); - responseDocument.Data.SingleValue.Attributes["title"].Should().Be(blog.Title); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("title").With(value => value.Should().Be(blog.Title)); - responseDocument.Included.Should().HaveCount(2); + responseDocument.Included.ShouldHaveCount(2); responseDocument.Included[0].Type.Should().Be("blogPosts"); responseDocument.Included[0].Id.Should().Be(blog.Posts[0].StringId); - responseDocument.Included[0].Attributes["caption"].Should().Be(blog.Posts[0].Caption); + responseDocument.Included[0].Attributes.ShouldContainKey("caption").With(value => value.Should().Be(blog.Posts[0].Caption)); + + DateTime createdAt = blog.Posts[0].Comments.Single().CreatedAt; responseDocument.Included[1].Type.Should().Be("comments"); responseDocument.Included[1].Id.Should().Be(blog.Posts[0].Comments.Single().StringId); - responseDocument.Included[1].Attributes["createdAt"].As<DateTime>().Should().BeCloseTo(blog.Posts[0].Comments.Single().CreatedAt); + responseDocument.Included[1].Attributes.ShouldContainKey("createdAt").With(value => value.As<DateTime>().Should().BeCloseTo(createdAt)); } [Fact] @@ -392,31 +398,33 @@ 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(comment.StringId); - responseDocument.Data.SingleValue.Attributes["text"].Should().Be(comment.Text); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("text").With(value => value.Should().Be(comment.Text)); - responseDocument.Included.Should().HaveCount(5); + responseDocument.Included.ShouldHaveCount(5); responseDocument.Included[0].Type.Should().Be("blogPosts"); responseDocument.Included[0].Id.Should().Be(comment.Parent.StringId); - responseDocument.Included[0].Attributes["caption"].Should().Be(comment.Parent.Caption); + responseDocument.Included[0].Attributes.ShouldContainKey("caption").With(value => value.Should().Be(comment.Parent.Caption)); responseDocument.Included[1].Type.Should().Be("comments"); responseDocument.Included[1].Id.Should().Be(comment.StringId); - responseDocument.Included[1].Attributes["text"].Should().Be(comment.Text); + responseDocument.Included[1].Attributes.ShouldContainKey("text").With(value => value.Should().Be(comment.Text)); responseDocument.Included[2].Type.Should().Be("comments"); responseDocument.Included[2].Id.Should().Be(comment.Parent.Comments.ElementAt(0).StringId); - responseDocument.Included[2].Attributes["text"].Should().Be(comment.Parent.Comments.ElementAt(0).Text); + responseDocument.Included[2].Attributes.ShouldContainKey("text").With(value => value.Should().Be(comment.Parent.Comments.ElementAt(0).Text)); + + string userName = comment.Parent.Comments.ElementAt(0).Author!.UserName; responseDocument.Included[3].Type.Should().Be("webAccounts"); - responseDocument.Included[3].Id.Should().Be(comment.Parent.Comments.ElementAt(0).Author.StringId); - responseDocument.Included[3].Attributes["userName"].Should().Be(comment.Parent.Comments.ElementAt(0).Author.UserName); + responseDocument.Included[3].Id.Should().Be(comment.Parent.Comments.ElementAt(0).Author!.StringId); + responseDocument.Included[3].Attributes.ShouldContainKey("userName").With(value => value.Should().Be(userName)); responseDocument.Included[4].Type.Should().Be("comments"); responseDocument.Included[4].Id.Should().Be(comment.Parent.Comments.ElementAt(1).StringId); - responseDocument.Included[4].Attributes["text"].Should().Be(comment.Parent.Comments.ElementAt(1).Text); + responseDocument.Included[4].Attributes.ShouldContainKey("text").With(value => value.Should().Be(comment.Parent.Comments.ElementAt(1).Text)); } [Fact] @@ -426,10 +434,10 @@ public async Task Can_include_chain_of_relationships_with_multiple_paths() Blog blog = _fakers.Blog.Generate(); blog.Posts = _fakers.BlogPost.Generate(1); blog.Posts[0].Author = _fakers.WebAccount.Generate(); - blog.Posts[0].Author.Preferences = _fakers.AccountPreferences.Generate(); + blog.Posts[0].Author!.Preferences = _fakers.AccountPreferences.Generate(); blog.Posts[0].Comments = _fakers.Comment.Generate(2).ToHashSet(); blog.Posts[0].Comments.ElementAt(0).Author = _fakers.WebAccount.Generate(); - blog.Posts[0].Comments.ElementAt(0).Author.Posts = _fakers.BlogPost.Generate(1); + blog.Posts[0].Comments.ElementAt(0).Author!.Posts = _fakers.BlogPost.Generate(1); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -445,39 +453,316 @@ 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(blog.StringId); - responseDocument.Data.SingleValue.Attributes["title"].Should().Be(blog.Title); - responseDocument.Included.Should().HaveCount(7); + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("posts").With(value => + { + value.ShouldNotBeNull(); + value.Data.ManyValue.ShouldNotBeEmpty(); + value.Data.ManyValue[0].Type.Should().Be("blogPosts"); + value.Data.ManyValue[0].Id.Should().Be(blog.Posts[0].StringId); + }); + + responseDocument.Included.ShouldHaveCount(7); responseDocument.Included[0].Type.Should().Be("blogPosts"); responseDocument.Included[0].Id.Should().Be(blog.Posts[0].StringId); - responseDocument.Included[0].Attributes["caption"].Should().Be(blog.Posts[0].Caption); + + responseDocument.Included[0].Relationships.ShouldContainKey("author").With(value => + { + value.ShouldNotBeNull(); + value.Data.SingleValue.ShouldNotBeNull(); + value.Data.SingleValue.Type.Should().Be("webAccounts"); + value.Data.SingleValue.Id.Should().Be(blog.Posts[0].Author!.StringId); + }); + + responseDocument.Included[0].Relationships.ShouldContainKey("comments").With(value => + { + value.ShouldNotBeNull(); + value.Data.ManyValue.ShouldNotBeEmpty(); + value.Data.ManyValue[0].Type.Should().Be("comments"); + value.Data.ManyValue[0].Id.Should().Be(blog.Posts[0].Comments.ElementAt(0).StringId); + }); responseDocument.Included[1].Type.Should().Be("webAccounts"); - responseDocument.Included[1].Id.Should().Be(blog.Posts[0].Author.StringId); - responseDocument.Included[1].Attributes["userName"].Should().Be(blog.Posts[0].Author.UserName); + responseDocument.Included[1].Id.Should().Be(blog.Posts[0].Author!.StringId); + + responseDocument.Included[1].Relationships.ShouldContainKey("preferences").With(value => + { + value.ShouldNotBeNull(); + value.Data.SingleValue.ShouldNotBeNull(); + value.Data.SingleValue.Type.Should().Be("accountPreferences"); + value.Data.SingleValue.Id.Should().Be(blog.Posts[0].Author!.Preferences!.StringId); + }); + + responseDocument.Included[1].Relationships.ShouldContainKey("posts").With(value => + { + value.ShouldNotBeNull(); + value.Data.Value.Should().BeNull(); + }); responseDocument.Included[2].Type.Should().Be("accountPreferences"); - responseDocument.Included[2].Id.Should().Be(blog.Posts[0].Author.Preferences.StringId); - responseDocument.Included[2].Attributes["useDarkTheme"].Should().Be(blog.Posts[0].Author.Preferences.UseDarkTheme); + responseDocument.Included[2].Id.Should().Be(blog.Posts[0].Author!.Preferences!.StringId); responseDocument.Included[3].Type.Should().Be("comments"); responseDocument.Included[3].Id.Should().Be(blog.Posts[0].Comments.ElementAt(0).StringId); - responseDocument.Included[3].Attributes["text"].Should().Be(blog.Posts[0].Comments.ElementAt(0).Text); + + responseDocument.Included[3].Relationships.ShouldContainKey("author").With(value => + { + value.ShouldNotBeNull(); + value.Data.SingleValue.ShouldNotBeNull(); + value.Data.SingleValue.Type.Should().Be("webAccounts"); + value.Data.SingleValue.Id.Should().Be(blog.Posts[0].Comments.ElementAt(0).Author!.StringId); + }); responseDocument.Included[4].Type.Should().Be("webAccounts"); - responseDocument.Included[4].Id.Should().Be(blog.Posts[0].Comments.ElementAt(0).Author.StringId); - responseDocument.Included[4].Attributes["userName"].Should().Be(blog.Posts[0].Comments.ElementAt(0).Author.UserName); + responseDocument.Included[4].Id.Should().Be(blog.Posts[0].Comments.ElementAt(0).Author!.StringId); + + responseDocument.Included[4].Relationships.ShouldContainKey("posts").With(value => + { + value.ShouldNotBeNull(); + value.Data.ManyValue.ShouldNotBeEmpty(); + value.Data.ManyValue[0].Type.Should().Be("blogPosts"); + value.Data.ManyValue[0].Id.Should().Be(blog.Posts[0].Comments.ElementAt(0).Author!.Posts[0].StringId); + }); + + responseDocument.Included[4].Relationships.ShouldContainKey("preferences").With(value => + { + value.ShouldNotBeNull(); + value.Data.Value.Should().BeNull(); + }); responseDocument.Included[5].Type.Should().Be("blogPosts"); - responseDocument.Included[5].Id.Should().Be(blog.Posts[0].Comments.ElementAt(0).Author.Posts[0].StringId); - responseDocument.Included[5].Attributes["caption"].Should().Be(blog.Posts[0].Comments.ElementAt(0).Author.Posts[0].Caption); + responseDocument.Included[5].Id.Should().Be(blog.Posts[0].Comments.ElementAt(0).Author!.Posts[0].StringId); + + responseDocument.Included[5].Relationships.ShouldContainKey("author").With(value => + { + value.ShouldNotBeNull(); + value.Data.Value.Should().BeNull(); + }); + + responseDocument.Included[5].Relationships.ShouldContainKey("comments").With(value => + { + value.ShouldNotBeNull(); + value.Data.Value.Should().BeNull(); + }); responseDocument.Included[6].Type.Should().Be("comments"); responseDocument.Included[6].Id.Should().Be(blog.Posts[0].Comments.ElementAt(1).StringId); - responseDocument.Included[6].Attributes["text"].Should().Be(blog.Posts[0].Comments.ElementAt(1).Text); + + responseDocument.Included[5].Relationships.ShouldContainKey("author").With(value => + { + value.ShouldNotBeNull(); + value.Data.Value.Should().BeNull(); + }); + } + + [Fact] + public async Task Can_include_chain_of_relationships_with_reused_resources() + { + WebAccount author = _fakers.WebAccount.Generate(); + author.Preferences = _fakers.AccountPreferences.Generate(); + author.LoginAttempts = _fakers.LoginAttempt.Generate(1); + + WebAccount reviewer = _fakers.WebAccount.Generate(); + reviewer.Preferences = _fakers.AccountPreferences.Generate(); + reviewer.LoginAttempts = _fakers.LoginAttempt.Generate(1); + + BlogPost post1 = _fakers.BlogPost.Generate(); + post1.Author = author; + post1.Reviewer = reviewer; + + WebAccount person = _fakers.WebAccount.Generate(); + person.Preferences = _fakers.AccountPreferences.Generate(); + person.LoginAttempts = _fakers.LoginAttempt.Generate(1); + + BlogPost post2 = _fakers.BlogPost.Generate(); + post2.Author = person; + post2.Reviewer = person; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync<BlogPost>(); + dbContext.Posts.AddRange(post1, post2); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/blogPosts?include=reviewer.loginAttempts,author.preferences"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(2); + + responseDocument.Data.ManyValue[0].Type.Should().Be("blogPosts"); + responseDocument.Data.ManyValue[0].Id.Should().Be(post1.StringId); + + responseDocument.Data.ManyValue[0].Relationships.ShouldContainKey("author").With(value => + { + value.ShouldNotBeNull(); + value.Data.SingleValue.ShouldNotBeNull(); + value.Data.SingleValue.Type.Should().Be("webAccounts"); + value.Data.SingleValue.Id.Should().Be(author.StringId); + }); + + responseDocument.Data.ManyValue[0].Relationships.ShouldContainKey("reviewer").With(value => + { + value.ShouldNotBeNull(); + value.Data.SingleValue.ShouldNotBeNull(); + value.Data.SingleValue.Type.Should().Be("webAccounts"); + value.Data.SingleValue.Id.Should().Be(reviewer.StringId); + }); + + responseDocument.Data.ManyValue[1].Type.Should().Be("blogPosts"); + responseDocument.Data.ManyValue[1].Id.Should().Be(post2.StringId); + + responseDocument.Data.ManyValue[1].Relationships.ShouldContainKey("author").With(value => + { + value.ShouldNotBeNull(); + value.Data.SingleValue.ShouldNotBeNull(); + value.Data.SingleValue.Type.Should().Be("webAccounts"); + value.Data.SingleValue.Id.Should().Be(person.StringId); + }); + + responseDocument.Data.ManyValue[1].Relationships.ShouldContainKey("reviewer").With(value => + { + value.ShouldNotBeNull(); + value.Data.SingleValue.ShouldNotBeNull(); + value.Data.SingleValue.Type.Should().Be("webAccounts"); + value.Data.SingleValue.Id.Should().Be(person.StringId); + }); + + responseDocument.Included.ShouldHaveCount(7); + + responseDocument.Included[0].Type.Should().Be("webAccounts"); + responseDocument.Included[0].Id.Should().Be(author.StringId); + + responseDocument.Included[0].Relationships.ShouldContainKey("preferences").With(value => + { + value.ShouldNotBeNull(); + value.Data.SingleValue.ShouldNotBeNull(); + value.Data.SingleValue.Type.Should().Be("accountPreferences"); + value.Data.SingleValue.Id.Should().Be(author.Preferences.StringId); + }); + + responseDocument.Included[0].Relationships.ShouldContainKey("loginAttempts").With(value => + { + value.ShouldNotBeNull(); + value.Data.Value.Should().BeNull(); + }); + + responseDocument.Included[1].Type.Should().Be("accountPreferences"); + responseDocument.Included[1].Id.Should().Be(author.Preferences.StringId); + + responseDocument.Included[2].Type.Should().Be("webAccounts"); + responseDocument.Included[2].Id.Should().Be(reviewer.StringId); + + responseDocument.Included[2].Relationships.ShouldContainKey("preferences").With(value => + { + value.ShouldNotBeNull(); + value.Data.Value.Should().BeNull(); + }); + + responseDocument.Included[2].Relationships.ShouldContainKey("loginAttempts").With(value => + { + value.ShouldNotBeNull(); + value.Data.ManyValue.ShouldNotBeEmpty(); + value.Data.ManyValue[0].Type.Should().Be("loginAttempts"); + value.Data.ManyValue[0].Id.Should().Be(reviewer.LoginAttempts[0].StringId); + }); + + responseDocument.Included[3].Type.Should().Be("loginAttempts"); + responseDocument.Included[3].Id.Should().Be(reviewer.LoginAttempts[0].StringId); + + responseDocument.Included[4].Type.Should().Be("webAccounts"); + responseDocument.Included[4].Id.Should().Be(person.StringId); + + responseDocument.Included[4].Relationships.ShouldContainKey("preferences").With(value => + { + value.ShouldNotBeNull(); + value.Data.SingleValue.ShouldNotBeNull(); + value.Data.SingleValue.Type.Should().Be("accountPreferences"); + value.Data.SingleValue.Id.Should().Be(person.Preferences.StringId); + }); + + responseDocument.Included[4].Relationships.ShouldContainKey("loginAttempts").With(value => + { + value.ShouldNotBeNull(); + value.Data.ManyValue.ShouldNotBeEmpty(); + value.Data.ManyValue[0].Type.Should().Be("loginAttempts"); + value.Data.ManyValue[0].Id.Should().Be(person.LoginAttempts[0].StringId); + }); + + responseDocument.Included[5].Type.Should().Be("accountPreferences"); + responseDocument.Included[5].Id.Should().Be(person.Preferences.StringId); + + responseDocument.Included[6].Type.Should().Be("loginAttempts"); + responseDocument.Included[6].Id.Should().Be(person.LoginAttempts[0].StringId); + } + + [Fact] + public async Task Can_include_chain_with_cyclic_dependency() + { + List<BlogPost> posts = _fakers.BlogPost.Generate(1); + + Blog blog = _fakers.Blog.Generate(); + blog.Posts = posts; + blog.Posts[0].Author = _fakers.WebAccount.Generate(); + blog.Posts[0].Author!.Posts = posts; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Blogs.Add(blog); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/blogs/{blog.StringId}?include=posts.author.posts.author.posts.author"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("blogs"); + responseDocument.Data.SingleValue.Id.Should().Be(blog.StringId); + + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("posts").With(value => + { + value.ShouldNotBeNull(); + value.Data.ManyValue.ShouldNotBeEmpty(); + value.Data.ManyValue[0].Type.Should().Be("blogPosts"); + value.Data.ManyValue[0].Id.Should().Be(blog.Posts[0].StringId); + }); + + responseDocument.Included.ShouldHaveCount(2); + + responseDocument.Included[0].Type.Should().Be("blogPosts"); + responseDocument.Included[0].Id.Should().Be(blog.Posts[0].StringId); + + responseDocument.Included[0].Relationships.ShouldContainKey("author").With(value => + { + value.ShouldNotBeNull(); + value.Data.SingleValue.ShouldNotBeNull(); + value.Data.SingleValue.Type.Should().Be("webAccounts"); + value.Data.SingleValue.Id.Should().Be(blog.Posts[0].Author!.StringId); + }); + + responseDocument.Included[1].Type.Should().Be("webAccounts"); + responseDocument.Included[1].Id.Should().Be(blog.Posts[0].Author!.StringId); + + responseDocument.Included[1].Relationships.ShouldContainKey("posts").With(value => + { + value.ShouldNotBeNull(); + value.Data.ManyValue.ShouldNotBeEmpty(); + value.Data.ManyValue[0].Type.Should().Be("blogPosts"); + value.Data.ManyValue[0].Id.Should().Be(blog.Posts[0].StringId); + }); } [Fact] @@ -504,14 +789,14 @@ 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(post.StringId); - responseDocument.Data.SingleValue.Attributes["caption"].Should().Be(post.Caption); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("caption").With(value => value.Should().Be(post.Caption)); - responseDocument.Included.Should().HaveCount(1); + responseDocument.Included.ShouldHaveCount(1); responseDocument.Included[0].Type.Should().Be("webAccounts"); responseDocument.Included[0].Id.Should().Be(account.StringId); - responseDocument.Included[0].Attributes["userName"].Should().Be(account.UserName); + responseDocument.Included[0].Attributes.ShouldContainKey("userName").With(value => value.Should().Be(account.UserName)); } [Fact] @@ -539,12 +824,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(2); + responseDocument.Data.ManyValue.ShouldHaveCount(2); - responseDocument.Included.Should().HaveCount(1); + responseDocument.Included.ShouldHaveCount(1); responseDocument.Included[0].Type.Should().Be("webAccounts"); responseDocument.Included[0].Id.Should().Be(account.StringId); - responseDocument.Included[0].Attributes["userName"].Should().Be(account.UserName); + responseDocument.Included[0].Attributes.ShouldContainKey("userName").With(value => value.Should().Be(account.UserName)); } [Fact] @@ -559,12 +844,13 @@ public async Task Cannot_include_unknown_relationship() // 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("The specified include is invalid."); - error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' does not exist on resource 'webAccounts'."); + error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' does not exist on resource type 'webAccounts'."); + error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("include"); } @@ -580,12 +866,13 @@ public async Task Cannot_include_unknown_nested_relationship() // 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("The specified include is invalid."); - error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' in 'posts.{Unknown.Relationship}' does not exist on resource 'blogPosts'."); + error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' in 'posts.{Unknown.Relationship}' does not exist on resource type 'blogPosts'."); + error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("include"); } @@ -601,12 +888,13 @@ public async Task Cannot_include_relationship_with_blocked_capability() // 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("Including the requested relationship is not allowed."); error.Detail.Should().Be("Including the relationship 'parent' on 'blogPosts' is not allowed."); + error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("include"); } @@ -632,24 +920,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(resource => resource.Relationships.ShouldContainKey("reviewer") != null); ResourceObject[] postWithReviewer = responseDocument.Data.ManyValue - .Where(resource => resource.Relationships.First(pair => pair.Key == "reviewer").Value.Data.SingleValue != null).ToArray(); + .Where(resource => resource.Relationships!.First(pair => pair.Key == "reviewer").Value!.Data.SingleValue != null).ToArray(); - postWithReviewer.Should().HaveCount(1); - postWithReviewer[0].Attributes["caption"].Should().Be(posts[0].Caption); + postWithReviewer.ShouldHaveCount(1); + postWithReviewer[0].Attributes.ShouldContainKey("caption").With(value => value.Should().Be(posts[0].Caption)); ResourceObject[] postWithoutReviewer = responseDocument.Data.ManyValue - .Where(resource => resource.Relationships.First(pair => pair.Key == "reviewer").Value.Data.SingleValue == null).ToArray(); + .Where(resource => resource.Relationships!.First(pair => pair.Key == "reviewer").Value!.Data.SingleValue == null).ToArray(); - postWithoutReviewer.Should().HaveCount(1); - postWithoutReviewer[0].Attributes["caption"].Should().Be(posts[1].Caption); + postWithoutReviewer.ShouldHaveCount(1); + postWithoutReviewer[0].Attributes.ShouldContainKey("caption").With(value => value.Should().Be(posts[1].Caption)); - responseDocument.Included.Should().HaveCount(1); + responseDocument.Included.ShouldHaveCount(1); responseDocument.Included[0].Type.Should().Be("webAccounts"); - responseDocument.Included[0].Id.Should().Be(posts[0].Reviewer.StringId); - responseDocument.Included[0].Attributes["userName"].Should().Be(posts[0].Reviewer.UserName); + responseDocument.Included[0].Id.Should().Be(posts[0].Reviewer!.StringId); + responseDocument.Included[0].Attributes.ShouldContainKey("userName").With(value => value.Should().Be(posts[0].Reviewer!.UserName)); } [Fact] @@ -691,12 +981,13 @@ public async Task Cannot_exceed_configured_maximum_inclusion_depth() // 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("The specified include is invalid."); error.Detail.Should().Be("Including 'posts.comments' exceeds the maximum inclusion depth of 1."); + error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("include"); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Label.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Label.cs index cac2a323d2..e4ddbbae38 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Label.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Label.cs @@ -6,15 +6,15 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Label : Identifiable + public sealed class Label : Identifiable<int> { [Attr] - public string Name { get; set; } + public string Name { get; set; } = null!; [Attr] public LabelColor Color { get; set; } [HasMany] - public ISet<BlogPost> Posts { get; set; } + public ISet<BlogPost> Posts { get; set; } = new HashSet<BlogPost>(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/LoginAttempt.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/LoginAttempt.cs new file mode 100644 index 0000000000..dcdb2bae95 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/LoginAttempt.cs @@ -0,0 +1,17 @@ +using System; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class LoginAttempt : Identifiable<int> + { + [Attr] + public DateTimeOffset TriedAt { get; set; } + + [Attr] + public bool IsSucceeded { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs index 8d87adfc7d..11e7f96806 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs @@ -60,13 +60,13 @@ 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(posts[1].StringId); - responseDocument.Links.Should().NotBeNull(); + responseDocument.Links.ShouldNotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.First.Should().Be($"{HostPrefix}/blogPosts?page[size]=1"); - responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); + responseDocument.Links.First.Should().Be($"{HostPrefix}/blogPosts?page%5Bsize%5D=1"); + responseDocument.Links.Last.Should().Be($"{HostPrefix}/blogPosts?page%5Bnumber%5D=2&page%5Bsize%5D=1"); responseDocument.Links.Prev.Should().Be(responseDocument.Links.First); responseDocument.Links.Next.Should().BeNull(); } @@ -91,12 +91,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("The specified paging is invalid."); error.Detail.Should().Be("This query string parameter can only be used on a collection of resources (not on a single resource)."); + error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("page[number]"); } @@ -105,15 +106,18 @@ public async Task Can_paginate_in_secondary_resources() { // Arrange Blog blog = _fakers.Blog.Generate(); - blog.Posts = _fakers.BlogPost.Generate(2); + blog.Posts = _fakers.BlogPost.Generate(5); + + Blog otherBlog = _fakers.Blog.Generate(); + otherBlog.Posts = _fakers.BlogPost.Generate(1); await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.Blogs.Add(blog); + dbContext.Blogs.AddRange(blog, otherBlog); await dbContext.SaveChangesAsync(); }); - string route = $"/blogs/{blog.StringId}/posts?page[number]=2&page[size]=1"; + string route = $"/blogs/{blog.StringId}/posts?page[number]=3&page[size]=1"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route); @@ -121,15 +125,47 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(1); - responseDocument.Data.ManyValue[0].Id.Should().Be(blog.Posts[1].StringId); + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be(blog.Posts[2].StringId); - responseDocument.Links.Should().NotBeNull(); + responseDocument.Links.ShouldNotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.First.Should().Be($"{HostPrefix}/blogs/{blog.StringId}/posts?page[size]=1"); + responseDocument.Links.First.Should().Be($"{HostPrefix}/blogs/{blog.StringId}/posts?page%5Bsize%5D=1"); + responseDocument.Links.Last.Should().Be($"{HostPrefix}/blogs/{blog.StringId}/posts?page%5Bnumber%5D=5&page%5Bsize%5D=1"); + responseDocument.Links.Prev.Should().Be($"{HostPrefix}/blogs/{blog.StringId}/posts?page%5Bnumber%5D=2&page%5Bsize%5D=1"); + responseDocument.Links.Next.Should().Be($"{HostPrefix}/blogs/{blog.StringId}/posts?page%5Bnumber%5D=4&page%5Bsize%5D=1"); + } + + [Fact] + public async Task Can_paginate_in_secondary_resources_without_inverse_relationship() + { + // Arrange + WebAccount? account = _fakers.WebAccount.Generate(); + account.LoginAttempts = _fakers.LoginAttempt.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Accounts.Add(account); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/webAccounts/{account.StringId}/loginAttempts?page[number]=2&page[size]=1"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be(account.LoginAttempts[1].StringId); + + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); + responseDocument.Links.First.Should().Be($"{HostPrefix}/webAccounts/{account.StringId}/loginAttempts?page%5Bsize%5D=1"); responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().Be(responseDocument.Links.First); - responseDocument.Links.Next.Should().Be($"{HostPrefix}/blogs/{blog.StringId}/posts?page[number]=3&page[size]=1"); + responseDocument.Links.Next.Should().Be($"{HostPrefix}/webAccounts/{account.StringId}/loginAttempts?page%5Bnumber%5D=3&page%5Bsize%5D=1"); } [Fact] @@ -152,12 +188,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("The specified paging is invalid."); error.Detail.Should().Be("This query string parameter can only be used on a collection of resources (not on a single resource)."); + error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("page[size]"); } @@ -184,16 +221,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(2); - responseDocument.Included.Should().HaveCount(2); + responseDocument.Data.ManyValue.ShouldHaveCount(2); + responseDocument.Included.ShouldHaveCount(2); responseDocument.Included[0].Id.Should().Be(blogs[0].Posts[1].StringId); responseDocument.Included[1].Id.Should().Be(blogs[1].Posts[1].StringId); - responseDocument.Links.Should().NotBeNull(); + responseDocument.Links.ShouldNotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.First.Should().Be($"{HostPrefix}/blogs?include=posts&page[size]=2,posts:1"); - responseDocument.Links.Last.Should().Be($"{HostPrefix}/blogs?include=posts&page[number]=2&page[size]=2,posts:1"); + responseDocument.Links.First.Should().Be($"{HostPrefix}/blogs?include=posts&page%5Bsize%5D=2,posts%3A1"); + responseDocument.Links.Last.Should().Be($"{HostPrefix}/blogs?include=posts&page%5Bnumber%5D=2&page%5Bsize%5D=2,posts%3A1"); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().Be(responseDocument.Links.Last); } @@ -220,11 +257,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); - responseDocument.Included.Should().HaveCount(1); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Included.ShouldHaveCount(1); responseDocument.Included[0].Id.Should().Be(blog.Owner.Posts[1].StringId); - responseDocument.Links.Should().NotBeNull(); + responseDocument.Links.ShouldNotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.First.Should().BeNull(); responseDocument.Links.Last.Should().BeNull(); @@ -237,7 +274,7 @@ public async Task Can_paginate_OneToMany_relationship_on_relationship_endpoint() { // Arrange Blog blog = _fakers.Blog.Generate(); - blog.Posts = _fakers.BlogPost.Generate(2); + blog.Posts = _fakers.BlogPost.Generate(4); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -253,15 +290,49 @@ 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(blog.Posts[1].StringId); - responseDocument.Links.Should().NotBeNull(); + responseDocument.Links.ShouldNotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.First.Should().Be($"{HostPrefix}/blogs/{blog.StringId}/relationships/posts?page[size]=1"); + responseDocument.Links.First.Should().Be($"{HostPrefix}/blogs/{blog.StringId}/relationships/posts?page%5Bsize%5D=1"); + responseDocument.Links.Last.Should().Be($"{HostPrefix}/blogs/{blog.StringId}/relationships/posts?page%5Bnumber%5D=4&page%5Bsize%5D=1"); + responseDocument.Links.Prev.Should().Be(responseDocument.Links.First); + responseDocument.Links.Next.Should().Be($"{HostPrefix}/blogs/{blog.StringId}/relationships/posts?page%5Bnumber%5D=3&page%5Bsize%5D=1"); + } + + [Fact] + public async Task Can_paginate_OneToMany_relationship_on_relationship_endpoint_without_inverse_relationship() + { + // Arrange + WebAccount? account = _fakers.WebAccount.Generate(); + account.LoginAttempts = _fakers.LoginAttempt.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Accounts.Add(account); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/webAccounts/{account.StringId}/relationships/loginAttempts?page[number]=2&page[size]=1"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be(account.LoginAttempts[1].StringId); + + string basePath = $"{HostPrefix}/webAccounts/{account.StringId}/relationships/loginAttempts"; + + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); + responseDocument.Links.First.Should().Be(basePath + "?page%5Bsize%5D=1"); responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().Be(responseDocument.Links.First); - responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.Next.Should().Be(basePath + "?page%5Bnumber%5D=3&page%5Bsize%5D=1"); } [Fact] @@ -292,15 +363,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(2); - responseDocument.Included.Should().HaveCount(2); + responseDocument.Data.ManyValue.ShouldHaveCount(2); + responseDocument.Included.ShouldHaveCount(2); responseDocument.Included[0].Id.Should().Be(posts[0].Labels.ElementAt(1).StringId); responseDocument.Included[1].Id.Should().Be(posts[1].Labels.ElementAt(1).StringId); - responseDocument.Links.Should().NotBeNull(); + responseDocument.Links.ShouldNotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.First.Should().Be($"{HostPrefix}/blogPosts?include=labels&page[size]=labels:1"); + responseDocument.Links.First.Should().Be($"{HostPrefix}/blogPosts?include=labels&page%5Bsize%5D=labels%3A1"); responseDocument.Links.Last.Should().Be(responseDocument.Links.First); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); @@ -311,7 +382,7 @@ public async Task Can_paginate_ManyToMany_relationship_on_relationship_endpoint( { // Arrange BlogPost post = _fakers.BlogPost.Generate(); - post.Labels = _fakers.Label.Generate(2).ToHashSet(); + post.Labels = _fakers.Label.Generate(4).ToHashSet(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -328,15 +399,15 @@ 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(post.Labels.ElementAt(1).StringId); - responseDocument.Links.Should().NotBeNull(); + responseDocument.Links.ShouldNotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.First.Should().Be($"{HostPrefix}/blogPosts/{post.StringId}/relationships/labels?page[size]=1"); - responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.First.Should().Be($"{HostPrefix}/blogPosts/{post.StringId}/relationships/labels?page%5Bsize%5D=1"); + responseDocument.Links.Last.Should().Be($"{HostPrefix}/blogPosts/{post.StringId}/relationships/labels?page%5Bnumber%5D=4&page%5Bsize%5D=1"); responseDocument.Links.Prev.Should().Be(responseDocument.Links.First); - responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.Next.Should().Be($"{HostPrefix}/blogPosts/{post.StringId}/relationships/labels?page%5Bnumber%5D=3&page%5Bsize%5D=1"); } [Fact] @@ -345,8 +416,8 @@ public async Task Can_paginate_in_multiple_scopes() // Arrange List<Blog> blogs = _fakers.Blog.Generate(2); blogs[1].Owner = _fakers.WebAccount.Generate(); - blogs[1].Owner.Posts = _fakers.BlogPost.Generate(2); - blogs[1].Owner.Posts[1].Comments = _fakers.Comment.Generate(2).ToHashSet(); + blogs[1].Owner!.Posts = _fakers.BlogPost.Generate(2); + blogs[1].Owner!.Posts[1].Comments = _fakers.Comment.Generate(2).ToHashSet(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -364,20 +435,26 @@ 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(blogs[1].StringId); - responseDocument.Included.Should().HaveCount(3); - responseDocument.Included[0].Id.Should().Be(blogs[1].Owner.StringId); - responseDocument.Included[1].Id.Should().Be(blogs[1].Owner.Posts[1].StringId); - responseDocument.Included[2].Id.Should().Be(blogs[1].Owner.Posts[1].Comments.ElementAt(1).StringId); + responseDocument.Included.ShouldHaveCount(3); + + responseDocument.Included[0].Type.Should().Be("webAccounts"); + responseDocument.Included[0].Id.Should().Be(blogs[1].Owner!.StringId); + + responseDocument.Included[1].Type.Should().Be("blogPosts"); + responseDocument.Included[1].Id.Should().Be(blogs[1].Owner!.Posts[1].StringId); + + responseDocument.Included[2].Type.Should().Be("comments"); + responseDocument.Included[2].Id.Should().Be(blogs[1].Owner!.Posts[1].Comments.ElementAt(1).StringId); string linkPrefix = $"{HostPrefix}/blogs?include=owner.posts.comments"; - responseDocument.Links.Should().NotBeNull(); + responseDocument.Links.ShouldNotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.First.Should().Be($"{linkPrefix}&page[size]=1,owner.posts:1,owner.posts.comments:1"); - responseDocument.Links.Last.Should().Be($"{linkPrefix}&page[size]=1,owner.posts:1,owner.posts.comments:1&page[number]=2"); + responseDocument.Links.First.Should().Be($"{linkPrefix}&page%5Bsize%5D=1,owner.posts%3A1,owner.posts.comments%3A1"); + responseDocument.Links.Last.Should().Be($"{linkPrefix}&page%5Bsize%5D=1,owner.posts%3A1,owner.posts.comments%3A1&page%5Bnumber%5D=2"); responseDocument.Links.Prev.Should().Be(responseDocument.Links.First); responseDocument.Links.Next.Should().BeNull(); } @@ -394,12 +471,13 @@ public async Task Cannot_paginate_in_unknown_scope() // 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("The specified paging is invalid."); - error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' does not exist on resource 'webAccounts'."); + error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' does not exist on resource type 'webAccounts'."); + error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("page[number]"); } @@ -415,12 +493,13 @@ public async Task Cannot_paginate_in_unknown_nested_scope() // 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("The specified paging is invalid."); - error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' in 'posts.{Unknown.Relationship}' does not exist on resource 'blogPosts'."); + error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' in 'posts.{Unknown.Relationship}' does not exist on resource type 'blogPosts'."); + error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("page[size]"); } @@ -448,16 +527,16 @@ 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(blog.Posts[0].StringId); responseDocument.Data.ManyValue[1].Id.Should().Be(blog.Posts[1].StringId); - responseDocument.Links.Should().NotBeNull(); + responseDocument.Links.ShouldNotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.First.Should().Be(responseDocument.Links.Self); - responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Last.Should().Be($"{HostPrefix}{route}?page%5Bnumber%5D=2"); responseDocument.Links.Prev.Should().BeNull(); - responseDocument.Links.Next.Should().Be($"{HostPrefix}/blogs/{blog.StringId}/posts?page[number]=2"); + responseDocument.Links.Next.Should().Be(responseDocument.Links.Last); } [Fact] @@ -484,9 +563,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(25); + responseDocument.Data.ManyValue.ShouldHaveCount(25); - responseDocument.Links.Should().NotBeNull(); + responseDocument.Links.ShouldNotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.First.Should().BeNull(); responseDocument.Links.Last.Should().BeNull(); @@ -531,6 +610,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + responseDocument.ShouldNotBeNull(); + responseDocument.Links.ShouldNotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); if (firstLink != null) @@ -575,7 +656,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => static string SetPageNumberInUrl(string url, int pageNumber) { - return pageNumber != 1 ? $"{url}&page[number]={pageNumber}" : url; + string link = pageNumber != 1 ? $"{url}&page[number]={pageNumber}" : url; + return link.Replace("[", "%5B").Replace("]", "%5D").Replace("'", "%27"); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithoutTotalCountTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithoutTotalCountTests.cs index 89ebae123e..0e6b39328a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithoutTotalCountTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithoutTotalCountTests.cs @@ -47,7 +47,7 @@ public async Task Hides_pagination_links_when_unconstrained_page_size() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.Should().NotBeNull(); + responseDocument.Links.ShouldNotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.First.Should().BeNull(); responseDocument.Links.Last.Should().BeNull(); @@ -76,9 +76,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.Should().NotBeNull(); + responseDocument.Links.ShouldNotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.First.Should().Be($"{HostPrefix}{route}"); + responseDocument.Links.First.Should().Be($"{HostPrefix}/blogPosts?page%5Bsize%5D=8&foo=bar"); responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); @@ -102,11 +102,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.Should().NotBeNull(); + responseDocument.Links.ShouldNotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.First.Should().Be($"{HostPrefix}/blogPosts?foo=bar"); responseDocument.Links.Last.Should().BeNull(); - responseDocument.Links.Prev.Should().Be($"{HostPrefix}/blogPosts?foo=bar"); + responseDocument.Links.Prev.Should().Be(responseDocument.Links.First); responseDocument.Links.Next.Should().BeNull(); } @@ -131,13 +131,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Count.Should().BeLessThan(DefaultPageSize); + responseDocument.Data.ManyValue.Should().HaveCountLessThan(DefaultPageSize); - responseDocument.Links.Should().NotBeNull(); + responseDocument.Links.ShouldNotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.First.Should().Be($"{HostPrefix}/blogPosts?foo=bar"); responseDocument.Links.Last.Should().BeNull(); - responseDocument.Links.Prev.Should().Be($"{HostPrefix}/blogPosts?foo=bar&page[number]=2"); + responseDocument.Links.Prev.Should().Be($"{HostPrefix}/blogPosts?foo=bar&page%5Bnumber%5D=2"); responseDocument.Links.Next.Should().BeNull(); } @@ -162,14 +162,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(DefaultPageSize); + responseDocument.Data.ManyValue.ShouldHaveCount(DefaultPageSize); - responseDocument.Links.Should().NotBeNull(); + responseDocument.Links.ShouldNotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.First.Should().Be($"{HostPrefix}/blogPosts?foo=bar"); responseDocument.Links.Last.Should().BeNull(); - responseDocument.Links.Prev.Should().Be($"{HostPrefix}/blogPosts?page[number]=2&foo=bar"); - responseDocument.Links.Next.Should().Be($"{HostPrefix}/blogPosts?page[number]=4&foo=bar"); + responseDocument.Links.Prev.Should().Be($"{HostPrefix}/blogPosts?page%5Bnumber%5D=2&foo=bar"); + responseDocument.Links.Next.Should().Be($"{HostPrefix}/blogPosts?page%5Bnumber%5D=4&foo=bar"); } [Fact] @@ -193,14 +193,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(DefaultPageSize); + responseDocument.Data.ManyValue.ShouldHaveCount(DefaultPageSize); - responseDocument.Links.Should().NotBeNull(); + responseDocument.Links.ShouldNotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.First.Should().Be($"{HostPrefix}/webAccounts/{account.StringId}/posts?foo=bar"); responseDocument.Links.Last.Should().BeNull(); - responseDocument.Links.Prev.Should().Be($"{HostPrefix}/webAccounts/{account.StringId}/posts?page[number]=2&foo=bar"); - responseDocument.Links.Next.Should().Be($"{HostPrefix}/webAccounts/{account.StringId}/posts?page[number]=4&foo=bar"); + responseDocument.Links.Prev.Should().Be($"{HostPrefix}/webAccounts/{account.StringId}/posts?page%5Bnumber%5D=2&foo=bar"); + responseDocument.Links.Next.Should().Be($"{HostPrefix}/webAccounts/{account.StringId}/posts?page%5Bnumber%5D=4&foo=bar"); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/RangeValidationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/RangeValidationTests.cs index 6186df580e..4afd77e8aa 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/RangeValidationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/RangeValidationTests.cs @@ -41,12 +41,13 @@ public async Task Cannot_use_negative_page_number() // 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("The specified paging is invalid."); error.Detail.Should().Be("Page number cannot be negative or zero."); + error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("page[number]"); } @@ -62,12 +63,13 @@ public async Task Cannot_use_zero_page_number() // 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("The specified paging is invalid."); error.Detail.Should().Be("Page number cannot be negative or zero."); + error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("page[number]"); } @@ -120,12 +122,13 @@ public async Task Cannot_use_negative_page_size() // 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("The specified paging is invalid."); error.Detail.Should().Be("Page size cannot be negative."); + error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("page[size]"); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/RangeValidationWithMaximumTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/RangeValidationWithMaximumTests.cs index 6d10840d7f..a449b6565e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/RangeValidationWithMaximumTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/RangeValidationWithMaximumTests.cs @@ -69,12 +69,13 @@ public async Task Cannot_use_page_number_over_maximum() // 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("The specified paging is invalid."); error.Detail.Should().Be($"Page number cannot be higher than {MaximumPageNumber}."); + error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("page[number]"); } @@ -90,12 +91,13 @@ public async Task Cannot_use_zero_page_size() // 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("The specified paging is invalid."); error.Detail.Should().Be("Page size cannot be unconstrained."); + error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("page[size]"); } @@ -140,12 +142,13 @@ public async Task Cannot_use_page_size_over_maximum() // 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("The specified paging is invalid."); error.Detail.Should().Be($"Page size cannot be higher than {MaximumPageSize}."); + error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("page[size]"); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringDbContext.cs index b09dfbc278..ca7b41cb59 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringDbContext.cs @@ -8,14 +8,15 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class QueryStringDbContext : DbContext { - public DbSet<Blog> Blogs { get; set; } - public DbSet<BlogPost> Posts { get; set; } - public DbSet<Label> Labels { get; set; } - public DbSet<Comment> Comments { get; set; } - public DbSet<WebAccount> Accounts { get; set; } - public DbSet<AccountPreferences> AccountPreferences { get; set; } - public DbSet<Calendar> Calendars { get; set; } - public DbSet<Appointment> Appointments { get; set; } + public DbSet<Blog> Blogs => Set<Blog>(); + public DbSet<BlogPost> Posts => Set<BlogPost>(); + public DbSet<Label> Labels => Set<Label>(); + public DbSet<Comment> Comments => Set<Comment>(); + public DbSet<WebAccount> Accounts => Set<WebAccount>(); + public DbSet<AccountPreferences> AccountPreferences => Set<AccountPreferences>(); + public DbSet<LoginAttempt> LoginAttempts => Set<LoginAttempt>(); + public DbSet<Calendar> Calendars => Set<Calendar>(); + public DbSet<Appointment> Appointments => Set<Appointment>(); public QueryStringDbContext(DbContextOptions<QueryStringDbContext> options) : base(options) @@ -26,7 +27,7 @@ protected override void OnModelCreating(ModelBuilder builder) { builder.Entity<WebAccount>() .HasMany(webAccount => webAccount.Posts) - .WithOne(blogPost => blogPost.Author); + .WithOne(blogPost => blogPost.Author!); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringFakers.cs index 702ba9c7f5..2cb411acb1 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringFakers.cs @@ -43,6 +43,12 @@ internal sealed class QueryStringFakers : FakerContainer .RuleFor(webAccount => webAccount.DateOfBirth, faker => faker.Person.DateOfBirth) .RuleFor(webAccount => webAccount.EmailAddress, faker => faker.Internet.Email())); + private readonly Lazy<Faker<LoginAttempt>> _lazyLoginAttemptFaker = new(() => + new Faker<LoginAttempt>() + .UseSeed(GetFakerSeed()) + .RuleFor(loginAttempt => loginAttempt.TriedAt, faker => faker.Date.PastOffset()) + .RuleFor(loginAttempt => loginAttempt.IsSucceeded, faker => faker.Random.Bool())); + private readonly Lazy<Faker<AccountPreferences>> _lazyAccountPreferencesFaker = new(() => new Faker<AccountPreferences>() .UseSeed(GetFakerSeed()) @@ -52,12 +58,14 @@ internal sealed class QueryStringFakers : FakerContainer new Faker<Calendar>() .UseSeed(GetFakerSeed()) .RuleFor(calendar => calendar.TimeZone, faker => faker.Date.TimeZoneString()) + .RuleFor(calendar => calendar.ShowWeekNumbers, faker => faker.Random.Bool()) .RuleFor(calendar => calendar.DefaultAppointmentDurationInMinutes, faker => faker.PickRandom(15, 30, 45, 60))); private readonly Lazy<Faker<Appointment>> _lazyAppointmentFaker = new(() => new Faker<Appointment>() .UseSeed(GetFakerSeed()) .RuleFor(appointment => appointment.Title, faker => faker.Random.Word()) + .RuleFor(appointment => appointment.Description, faker => faker.Lorem.Sentence()) .RuleFor(appointment => appointment.StartTime, faker => faker.Date.FutureOffset() .TruncateToWholeMilliseconds()) .RuleFor(appointment => appointment.EndTime, (faker, appointment) => appointment.StartTime.AddHours(faker.Random.Double(1, 4)))); @@ -67,6 +75,7 @@ internal sealed class QueryStringFakers : FakerContainer public Faker<Label> Label => _lazyLabelFaker.Value; public Faker<Comment> Comment => _lazyCommentFaker.Value; public Faker<WebAccount> WebAccount => _lazyWebAccountFaker.Value; + public Faker<LoginAttempt> LoginAttempt => _lazyLoginAttemptFaker.Value; public Faker<AccountPreferences> AccountPreferences => _lazyAccountPreferencesFaker.Value; public Faker<Calendar> Calendar => _lazyCalendarFaker.Value; public Faker<Appointment> Appointment => _lazyAppointmentFaker.Value; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringTests.cs index eae4e173f3..3db58ab446 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringTests.cs @@ -36,7 +36,7 @@ public async Task Cannot_use_unknown_query_string_parameter() // 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); @@ -45,6 +45,7 @@ public async Task Cannot_use_unknown_query_string_parameter() error.Detail.Should().Be("Query string parameter 'foo' is unknown. " + "Set 'AllowUnknownQueryStringParameters' to 'true' in options to ignore unknown parameters."); + error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("foo"); } @@ -84,12 +85,13 @@ public async Task Cannot_use_empty_query_string_parameter_value(string parameter // 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 query string parameter value."); error.Detail.Should().Be($"Missing value for '{parameterName}' query string parameter."); + error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be(parameterName); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SerializerIgnoreConditionTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SerializerIgnoreConditionTests.cs index e8d790446c..d30dc949f3 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SerializerIgnoreConditionTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SerializerIgnoreConditionTests.cs @@ -38,7 +38,7 @@ public async Task Applies_configuration_for_ignore_condition(JsonIgnoreCondition calendar.DefaultAppointmentDurationInMinutes = default; calendar.ShowWeekNumbers = true; calendar.Appointments = _fakers.Appointment.Generate(1).ToHashSet(); - calendar.Appointments.Single().Title = null; + calendar.Appointments.Single().Description = null; calendar.Appointments.Single().StartTime = default; calendar.Appointments.Single().EndTime = 1.January(2001); @@ -56,24 +56,24 @@ await RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); - responseDocument.Included.Should().HaveCount(1); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Included.ShouldHaveCount(1); if (expectNullValueInDocument) { - responseDocument.Data.SingleValue.Attributes.Should().ContainKey("timeZone"); - responseDocument.Included[0].Attributes.Should().ContainKey("title"); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("timeZone"); + responseDocument.Included[0].Attributes.ShouldContainKey("description"); } else { responseDocument.Data.SingleValue.Attributes.Should().NotContainKey("timeZone"); - responseDocument.Included[0].Attributes.Should().NotContainKey("title"); + responseDocument.Included[0].Attributes.Should().NotContainKey("description"); } if (expectDefaultValueInDocument) { - responseDocument.Data.SingleValue.Attributes.Should().ContainKey("defaultAppointmentDurationInMinutes"); - responseDocument.Included[0].Attributes.Should().ContainKey("startTime"); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("defaultAppointmentDurationInMinutes"); + responseDocument.Included[0].Attributes.ShouldContainKey("startTime"); } else { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Sorting/SortTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Sorting/SortTests.cs index 43ceb3fdf9..404342a554 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Sorting/SortTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Sorting/SortTests.cs @@ -49,7 +49,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(3); + responseDocument.Data.ManyValue.ShouldHaveCount(3); responseDocument.Data.ManyValue[0].Id.Should().Be(posts[1].StringId); responseDocument.Data.ManyValue[1].Id.Should().Be(posts[0].StringId); responseDocument.Data.ManyValue[2].Id.Should().Be(posts[2].StringId); @@ -75,12 +75,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("The specified sort is invalid."); error.Detail.Should().Be("This query string parameter can only be used on a collection of resources (not on a single resource)."); + error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("sort"); } @@ -108,7 +109,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(3); + responseDocument.Data.ManyValue.ShouldHaveCount(3); responseDocument.Data.ManyValue[0].Id.Should().Be(blog.Posts[1].StringId); responseDocument.Data.ManyValue[1].Id.Should().Be(blog.Posts[0].StringId); responseDocument.Data.ManyValue[2].Id.Should().Be(blog.Posts[2].StringId); @@ -134,12 +135,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("The specified sort is invalid."); error.Detail.Should().Be("This query string parameter can only be used on a collection of resources (not on a single resource)."); + error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("sort"); } @@ -166,7 +168,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(blogs[1].StringId); responseDocument.Data.ManyValue[1].Id.Should().Be(blogs[0].StringId); } @@ -194,7 +196,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(posts[1].StringId); responseDocument.Data.ManyValue[1].Id.Should().Be(posts[0].StringId); } @@ -223,10 +225,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(account.StringId); - responseDocument.Included.Should().HaveCount(3); + responseDocument.Included.ShouldHaveCount(3); responseDocument.Included[0].Id.Should().Be(account.Posts[1].StringId); responseDocument.Included[1].Id.Should().Be(account.Posts[0].StringId); responseDocument.Included[2].Id.Should().Be(account.Posts[2].StringId); @@ -257,10 +259,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(blog.Owner.StringId); - responseDocument.Included.Should().HaveCount(3); + responseDocument.Included.ShouldHaveCount(3); responseDocument.Included[0].Id.Should().Be(blog.Owner.Posts[1].StringId); responseDocument.Included[1].Id.Should().Be(blog.Owner.Posts[0].StringId); responseDocument.Included[2].Id.Should().Be(blog.Owner.Posts[2].StringId); @@ -290,10 +292,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(post.StringId); - responseDocument.Included.Should().HaveCount(3); + responseDocument.Included.ShouldHaveCount(3); responseDocument.Included[0].Id.Should().Be(post.Labels.ElementAt(1).StringId); responseDocument.Included[1].Id.Should().Be(post.Labels.ElementAt(0).StringId); responseDocument.Included[2].Id.Should().Be(post.Labels.ElementAt(2).StringId); @@ -337,11 +339,11 @@ 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(blogs[1].StringId); responseDocument.Data.ManyValue[1].Id.Should().Be(blogs[0].StringId); - responseDocument.Included.Should().HaveCount(7); + responseDocument.Included.ShouldHaveCount(7); responseDocument.Included[0].Type.Should().Be("blogPosts"); responseDocument.Included[0].Id.Should().Be(blogs[0].Posts[2].StringId); @@ -373,8 +375,8 @@ public async Task Can_sort_on_ManyToOne_relationship() posts[0].Author = _fakers.WebAccount.Generate(); posts[1].Author = _fakers.WebAccount.Generate(); - posts[0].Author.DisplayName = "Conner"; - posts[1].Author.DisplayName = "Smith"; + posts[0].Author!.DisplayName = "Conner"; + posts[1].Author!.DisplayName = "Smith"; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -391,7 +393,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(posts[1].StringId); responseDocument.Data.ManyValue[1].Id.Should().Be(posts[0].StringId); } @@ -405,13 +407,13 @@ public async Task Can_sort_in_multiple_scopes() blogs[1].Title = "Technology"; blogs[1].Owner = _fakers.WebAccount.Generate(); - blogs[1].Owner.Posts = _fakers.BlogPost.Generate(2); - blogs[1].Owner.Posts[0].Caption = "One"; - blogs[1].Owner.Posts[1].Caption = "Two"; + blogs[1].Owner!.Posts = _fakers.BlogPost.Generate(2); + blogs[1].Owner!.Posts[0].Caption = "One"; + blogs[1].Owner!.Posts[1].Caption = "Two"; - blogs[1].Owner.Posts[1].Comments = _fakers.Comment.Generate(2).ToHashSet(); - blogs[1].Owner.Posts[1].Comments.ElementAt(0).CreatedAt = 1.January(2000); - blogs[1].Owner.Posts[1].Comments.ElementAt(0).CreatedAt = 10.January(2010); + blogs[1].Owner!.Posts[1].Comments = _fakers.Comment.Generate(2).ToHashSet(); + blogs[1].Owner!.Posts[1].Comments.ElementAt(0).CreatedAt = 1.January(2000); + blogs[1].Owner!.Posts[1].Comments.ElementAt(0).CreatedAt = 10.January(2010); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -428,16 +430,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[0].Id.Should().Be(blogs[1].StringId); responseDocument.Data.ManyValue[1].Id.Should().Be(blogs[0].StringId); - responseDocument.Included.Should().HaveCount(5); - responseDocument.Included[0].Id.Should().Be(blogs[1].Owner.StringId); - responseDocument.Included[1].Id.Should().Be(blogs[1].Owner.Posts[1].StringId); - responseDocument.Included[2].Id.Should().Be(blogs[1].Owner.Posts[1].Comments.ElementAt(1).StringId); - responseDocument.Included[3].Id.Should().Be(blogs[1].Owner.Posts[1].Comments.ElementAt(0).StringId); - responseDocument.Included[4].Id.Should().Be(blogs[1].Owner.Posts[0].StringId); + responseDocument.Included.ShouldHaveCount(5); + + responseDocument.Included[0].Type.Should().Be("webAccounts"); + responseDocument.Included[0].Id.Should().Be(blogs[1].Owner!.StringId); + + responseDocument.Included[1].Type.Should().Be("blogPosts"); + responseDocument.Included[1].Id.Should().Be(blogs[1].Owner!.Posts[1].StringId); + + responseDocument.Included[2].Type.Should().Be("comments"); + responseDocument.Included[2].Id.Should().Be(blogs[1].Owner!.Posts[1].Comments.ElementAt(1).StringId); + + responseDocument.Included[3].Type.Should().Be("comments"); + responseDocument.Included[3].Id.Should().Be(blogs[1].Owner!.Posts[1].Comments.ElementAt(0).StringId); + + responseDocument.Included[4].Type.Should().Be("blogPosts"); + responseDocument.Included[4].Id.Should().Be(blogs[1].Owner!.Posts[0].StringId); } [Fact] @@ -452,12 +464,13 @@ public async Task Cannot_sort_in_unknown_scope() // 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("The specified sort is invalid."); - error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' does not exist on resource 'webAccounts'."); + error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' does not exist on resource type 'webAccounts'."); + error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be($"sort[{Unknown.Relationship}]"); } @@ -473,12 +486,13 @@ public async Task Cannot_sort_in_unknown_nested_scope() // 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("The specified sort is invalid."); - error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' in 'posts.{Unknown.Relationship}' does not exist on resource 'blogPosts'."); + error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' in 'posts.{Unknown.Relationship}' does not exist on resource type 'blogPosts'."); + error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be($"sort[posts.{Unknown.Relationship}]"); } @@ -494,12 +508,13 @@ public async Task Cannot_sort_on_attribute_with_blocked_capability() // 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("Sorting on the requested attribute is not allowed."); error.Detail.Should().Be("Sorting on attribute 'dateOfBirth' is not allowed."); + error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("sort"); } @@ -531,7 +546,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(3); + responseDocument.Data.ManyValue.ShouldHaveCount(3); responseDocument.Data.ManyValue[0].Id.Should().Be(accounts[1].StringId); responseDocument.Data.ManyValue[1].Id.Should().Be(accounts[2].StringId); responseDocument.Data.ManyValue[2].Id.Should().Be(accounts[0].StringId); @@ -562,7 +577,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(4); + responseDocument.Data.ManyValue.ShouldHaveCount(4); responseDocument.Data.ManyValue[0].Id.Should().Be(accounts[2].StringId); responseDocument.Data.ManyValue[1].Id.Should().Be(accounts[1].StringId); responseDocument.Data.ManyValue[2].Id.Should().Be(accounts[0].StringId); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SparseFieldSets/ResultCapturingRepository.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SparseFieldSets/ResultCapturingRepository.cs index 0bf6da70f0..816b105ff4 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SparseFieldSets/ResultCapturingRepository.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SparseFieldSets/ResultCapturingRepository.cs @@ -14,22 +14,22 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.SparseFieldSets /// Enables sparse fieldset tests to verify which fields were (not) retrieved from the database. /// </summary> [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class ResultCapturingRepository<TResource> : EntityFrameworkCoreRepository<TResource> - where TResource : class, IIdentifiable<int> + public sealed class ResultCapturingRepository<TResource, TId> : EntityFrameworkCoreRepository<TResource, TId> + where TResource : class, IIdentifiable<TId> { private readonly ResourceCaptureStore _captureStore; - public ResultCapturingRepository(ITargetedFields targetedFields, IDbContextResolver contextResolver, IResourceGraph resourceGraph, + public ResultCapturingRepository(ITargetedFields targetedFields, IDbContextResolver dbContextResolver, IResourceGraph resourceGraph, IResourceFactory resourceFactory, IEnumerable<IQueryConstraintProvider> constraintProviders, ILoggerFactory loggerFactory, IResourceDefinitionAccessor resourceDefinitionAccessor, ResourceCaptureStore captureStore) - : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor) + : base(targetedFields, dbContextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor) { _captureStore = captureStore; } - public override async Task<IReadOnlyCollection<TResource>> GetAsync(QueryLayer layer, CancellationToken cancellationToken) + public override async Task<IReadOnlyCollection<TResource>> GetAsync(QueryLayer queryLayer, CancellationToken cancellationToken) { - IReadOnlyCollection<TResource> resources = await base.GetAsync(layer, cancellationToken); + IReadOnlyCollection<TResource> resources = await base.GetAsync(queryLayer, cancellationToken); _captureStore.Add(resources); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SparseFieldSets/SparseFieldSetTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SparseFieldSets/SparseFieldSetTests.cs index a8681a1403..a6da424bee 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SparseFieldSets/SparseFieldSetTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/SparseFieldSets/SparseFieldSetTests.cs @@ -29,9 +29,9 @@ public SparseFieldSetTests(IntegrationTestContext<TestableStartup<QueryStringDbC { services.AddSingleton<ResourceCaptureStore>(); - services.AddResourceRepository<ResultCapturingRepository<Blog>>(); - services.AddResourceRepository<ResultCapturingRepository<BlogPost>>(); - services.AddResourceRepository<ResultCapturingRepository<WebAccount>>(); + services.AddResourceRepository<ResultCapturingRepository<Blog, int>>(); + services.AddResourceRepository<ResultCapturingRepository<BlogPost, int>>(); + services.AddResourceRepository<ResultCapturingRepository<WebAccount, int>>(); }); } @@ -59,14 +59,20 @@ 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(post.StringId); - responseDocument.Data.ManyValue[0].Attributes.Should().HaveCount(1); - responseDocument.Data.ManyValue[0].Attributes["caption"].Should().Be(post.Caption); - responseDocument.Data.ManyValue[0].Relationships.Should().HaveCount(1); - responseDocument.Data.ManyValue[0].Relationships["author"].Data.Value.Should().BeNull(); - responseDocument.Data.ManyValue[0].Relationships["author"].Links.Self.Should().NotBeNull(); - responseDocument.Data.ManyValue[0].Relationships["author"].Links.Related.Should().NotBeNull(); + responseDocument.Data.ManyValue[0].Attributes.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("caption").With(value => value.Should().Be(post.Caption)); + responseDocument.Data.ManyValue[0].Relationships.ShouldHaveCount(1); + + responseDocument.Data.ManyValue[0].Relationships.ShouldContainKey("author").With(value => + { + value.ShouldNotBeNull(); + value.Data.Value.Should().BeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.ShouldNotBeNull(); + value.Links.Related.ShouldNotBeNull(); + }); var postCaptured = (BlogPost)store.Resources.Should().ContainSingle(resource => resource is BlogPost).And.Subject.Single(); postCaptured.Caption.Should().Be(post.Caption); @@ -97,10 +103,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].Id.Should().Be(post.StringId); - responseDocument.Data.ManyValue[0].Attributes.Should().HaveCount(1); - responseDocument.Data.ManyValue[0].Attributes["caption"].Should().Be(post.Caption); + responseDocument.Data.ManyValue[0].Attributes.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("caption").With(value => value.Should().Be(post.Caption)); responseDocument.Data.ManyValue[0].Relationships.Should().BeNull(); var postCaptured = (BlogPost)store.Resources.Should().ContainSingle(resource => resource is BlogPost).And.Subject.Single(); @@ -132,13 +138,19 @@ 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(post.StringId); responseDocument.Data.ManyValue[0].Attributes.Should().BeNull(); - responseDocument.Data.ManyValue[0].Relationships.Should().HaveCount(1); - responseDocument.Data.ManyValue[0].Relationships["author"].Data.Value.Should().BeNull(); - responseDocument.Data.ManyValue[0].Relationships["author"].Links.Self.Should().NotBeNull(); - responseDocument.Data.ManyValue[0].Relationships["author"].Links.Related.Should().NotBeNull(); + responseDocument.Data.ManyValue[0].Relationships.ShouldHaveCount(1); + + responseDocument.Data.ManyValue[0].Relationships.ShouldContainKey("author").With(value => + { + value.ShouldNotBeNull(); + value.Data.Value.Should().BeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.ShouldNotBeNull(); + value.Links.Related.ShouldNotBeNull(); + }); var postCaptured = (BlogPost)store.Resources.Should().ContainSingle(resource => resource is BlogPost).And.Subject.Single(); postCaptured.Caption.Should().BeNull(); @@ -169,20 +181,26 @@ 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(blog.Posts[0].StringId); - responseDocument.Data.ManyValue[0].Attributes.Should().HaveCount(1); - responseDocument.Data.ManyValue[0].Attributes["caption"].Should().Be(blog.Posts[0].Caption); - responseDocument.Data.ManyValue[0].Relationships.Should().HaveCount(1); - responseDocument.Data.ManyValue[0].Relationships["labels"].Data.Value.Should().BeNull(); - responseDocument.Data.ManyValue[0].Relationships["labels"].Links.Self.Should().NotBeNull(); - responseDocument.Data.ManyValue[0].Relationships["labels"].Links.Related.Should().NotBeNull(); + responseDocument.Data.ManyValue[0].Attributes.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("caption").With(value => value.Should().Be(blog.Posts[0].Caption)); + responseDocument.Data.ManyValue[0].Relationships.ShouldHaveCount(1); + + responseDocument.Data.ManyValue[0].Relationships.ShouldContainKey("labels").With(value => + { + value.ShouldNotBeNull(); + value.Data.Value.Should().BeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.ShouldNotBeNull(); + value.Links.Related.ShouldNotBeNull(); + }); var blogCaptured = (Blog)store.Resources.Should().ContainSingle(resource => resource is Blog).And.Subject.Single(); blogCaptured.Id.Should().Be(blog.Id); blogCaptured.Title.Should().BeNull(); - blogCaptured.Posts.Should().HaveCount(1); + blogCaptured.Posts.ShouldHaveCount(1); blogCaptured.Posts[0].Caption.Should().Be(blog.Posts[0].Caption); blogCaptured.Posts[0].Url.Should().BeNull(); } @@ -210,14 +228,20 @@ 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(post.StringId); - responseDocument.Data.SingleValue.Attributes.Should().HaveCount(1); - responseDocument.Data.SingleValue.Attributes["url"].Should().Be(post.Url); - responseDocument.Data.SingleValue.Relationships.Should().HaveCount(1); - responseDocument.Data.SingleValue.Relationships["author"].Data.Value.Should().BeNull(); - responseDocument.Data.SingleValue.Relationships["author"].Links.Self.Should().NotBeNull(); - responseDocument.Data.SingleValue.Relationships["author"].Links.Related.Should().NotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldHaveCount(1); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("url").With(value => value.Should().Be(post.Url)); + responseDocument.Data.SingleValue.Relationships.ShouldHaveCount(1); + + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("author").With(value => + { + value.ShouldNotBeNull(); + value.Data.Value.Should().BeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.ShouldNotBeNull(); + value.Links.Related.ShouldNotBeNull(); + }); var postCaptured = (BlogPost)store.Resources.Should().ContainSingle(resource => resource is BlogPost).And.Subject.Single(); postCaptured.Url.Should().Be(post.Url); @@ -248,26 +272,41 @@ 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(post.StringId); - responseDocument.Data.SingleValue.Attributes["caption"].Should().Be(post.Caption); - responseDocument.Data.SingleValue.Relationships["author"].Data.SingleValue.Id.Should().Be(post.Author.StringId); - responseDocument.Data.SingleValue.Relationships["author"].Links.Self.Should().NotBeNull(); - responseDocument.Data.SingleValue.Relationships["author"].Links.Related.Should().NotBeNull(); - - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Attributes.Should().HaveCount(2); - responseDocument.Included[0].Attributes["displayName"].Should().Be(post.Author.DisplayName); - responseDocument.Included[0].Attributes["emailAddress"].Should().Be(post.Author.EmailAddress); - responseDocument.Included[0].Relationships.Should().HaveCount(1); - responseDocument.Included[0].Relationships["preferences"].Data.Value.Should().BeNull(); - responseDocument.Included[0].Relationships["preferences"].Links.Self.Should().NotBeNull(); - responseDocument.Included[0].Relationships["preferences"].Links.Related.Should().NotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("caption").With(value => value.Should().Be(post.Caption)); + + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("author").With(value => + { + value.ShouldNotBeNull(); + value.Data.SingleValue.ShouldNotBeNull(); + value.Data.SingleValue.Id.Should().Be(post.Author.StringId); + value.ShouldNotBeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.ShouldNotBeNull(); + value.Links.Related.ShouldNotBeNull(); + }); + + responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included[0].Attributes.ShouldHaveCount(2); + responseDocument.Included[0].Attributes.ShouldContainKey("displayName").With(value => value.Should().Be(post.Author.DisplayName)); + responseDocument.Included[0].Attributes.ShouldContainKey("emailAddress").With(value => value.Should().Be(post.Author.EmailAddress)); + responseDocument.Included[0].Relationships.ShouldHaveCount(1); + + responseDocument.Included[0].Relationships.ShouldContainKey("preferences").With(value => + { + value.ShouldNotBeNull(); + value.Data.Value.Should().BeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.ShouldNotBeNull(); + value.Links.Related.ShouldNotBeNull(); + }); var postCaptured = (BlogPost)store.Resources.Should().ContainSingle(resource => resource is BlogPost).And.Subject.Single(); postCaptured.Id.Should().Be(post.Id); postCaptured.Caption.Should().Be(post.Caption); + postCaptured.Author.ShouldNotBeNull(); postCaptured.Author.DisplayName.Should().Be(post.Author.DisplayName); postCaptured.Author.EmailAddress.Should().Be(post.Author.EmailAddress); postCaptured.Author.UserName.Should().BeNull(); @@ -297,27 +336,39 @@ 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.Data.SingleValue.Attributes["displayName"].Should().Be(account.DisplayName); - responseDocument.Data.SingleValue.Relationships["posts"].Data.ManyValue.Should().HaveCount(1); - responseDocument.Data.SingleValue.Relationships["posts"].Data.ManyValue[0].Id.Should().Be(account.Posts[0].StringId); - responseDocument.Data.SingleValue.Relationships["posts"].Links.Self.Should().NotBeNull(); - responseDocument.Data.SingleValue.Relationships["posts"].Links.Related.Should().NotBeNull(); - - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Attributes.Should().HaveCount(1); - responseDocument.Included[0].Attributes["caption"].Should().Be(account.Posts[0].Caption); - responseDocument.Included[0].Relationships.Should().HaveCount(1); - responseDocument.Included[0].Relationships["labels"].Data.Value.Should().BeNull(); - responseDocument.Included[0].Relationships["labels"].Links.Self.Should().NotBeNull(); - responseDocument.Included[0].Relationships["labels"].Links.Related.Should().NotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("displayName").With(value => value.Should().Be(account.DisplayName)); + + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("posts").With(value => + { + value.ShouldNotBeNull(); + value.Data.ManyValue.ShouldHaveCount(1); + value.Data.ManyValue[0].Id.Should().Be(account.Posts[0].StringId); + value.Links.ShouldNotBeNull(); + value.Links.Self.ShouldNotBeNull(); + value.Links.Related.ShouldNotBeNull(); + }); + + responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included[0].Attributes.ShouldHaveCount(1); + responseDocument.Included[0].Attributes.ShouldContainKey("caption").With(value => value.Should().Be(account.Posts[0].Caption)); + responseDocument.Included[0].Relationships.ShouldHaveCount(1); + + responseDocument.Included[0].Relationships.ShouldContainKey("labels").With(value => + { + value.ShouldNotBeNull(); + value.Data.Value.Should().BeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.ShouldNotBeNull(); + value.Links.Related.ShouldNotBeNull(); + }); var accountCaptured = (WebAccount)store.Resources.Should().ContainSingle(resource => resource is WebAccount).And.Subject.Single(); accountCaptured.Id.Should().Be(account.Id); accountCaptured.DisplayName.Should().Be(account.DisplayName); - accountCaptured.Posts.Should().HaveCount(1); + accountCaptured.Posts.ShouldHaveCount(1); accountCaptured.Posts[0].Caption.Should().Be(account.Posts[0].Caption); accountCaptured.Posts[0].Url.Should().BeNull(); } @@ -347,28 +398,40 @@ 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(blog.Owner.StringId); - responseDocument.Data.SingleValue.Attributes["displayName"].Should().Be(blog.Owner.DisplayName); - responseDocument.Data.SingleValue.Relationships["posts"].Data.ManyValue.Should().HaveCount(1); - responseDocument.Data.SingleValue.Relationships["posts"].Data.ManyValue[0].Id.Should().Be(blog.Owner.Posts[0].StringId); - responseDocument.Data.SingleValue.Relationships["posts"].Links.Self.Should().NotBeNull(); - responseDocument.Data.SingleValue.Relationships["posts"].Links.Related.Should().NotBeNull(); - - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Attributes.Should().HaveCount(1); - responseDocument.Included[0].Attributes["caption"].Should().Be(blog.Owner.Posts[0].Caption); - responseDocument.Included[0].Relationships.Should().HaveCount(1); - responseDocument.Included[0].Relationships["comments"].Data.Value.Should().BeNull(); - responseDocument.Included[0].Relationships["comments"].Links.Self.Should().NotBeNull(); - responseDocument.Included[0].Relationships["comments"].Links.Related.Should().NotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("displayName").With(value => value.Should().Be(blog.Owner.DisplayName)); + + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("posts").With(value => + { + value.ShouldNotBeNull(); + value.Data.ManyValue.ShouldHaveCount(1); + value.Data.ManyValue[0].Id.Should().Be(blog.Owner.Posts[0].StringId); + value.Links.ShouldNotBeNull(); + value.Links.Self.ShouldNotBeNull(); + value.Links.Related.ShouldNotBeNull(); + }); + + responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included[0].Attributes.ShouldHaveCount(1); + responseDocument.Included[0].Attributes.ShouldContainKey("caption").With(value => value.Should().Be(blog.Owner.Posts[0].Caption)); + responseDocument.Included[0].Relationships.ShouldHaveCount(1); + + responseDocument.Included[0].Relationships.ShouldContainKey("comments").With(value => + { + value.ShouldNotBeNull(); + value.Data.Value.Should().BeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.ShouldNotBeNull(); + value.Links.Related.ShouldNotBeNull(); + }); var blogCaptured = (Blog)store.Resources.Should().ContainSingle(resource => resource is Blog).And.Subject.Single(); blogCaptured.Id.Should().Be(blog.Id); - blogCaptured.Owner.Should().NotBeNull(); + blogCaptured.Owner.ShouldNotBeNull(); blogCaptured.Owner.DisplayName.Should().Be(blog.Owner.DisplayName); - blogCaptured.Owner.Posts.Should().HaveCount(1); + blogCaptured.Owner.Posts.ShouldHaveCount(1); blogCaptured.Owner.Posts[0].Caption.Should().Be(blog.Owner.Posts[0].Caption); blogCaptured.Owner.Posts[0].Url.Should().BeNull(); } @@ -397,24 +460,30 @@ 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(post.StringId); - responseDocument.Data.SingleValue.Attributes["caption"].Should().Be(post.Caption); - responseDocument.Data.SingleValue.Relationships["labels"].Data.ManyValue.Should().HaveCount(1); - responseDocument.Data.SingleValue.Relationships["labels"].Data.ManyValue[0].Id.Should().Be(post.Labels.ElementAt(0).StringId); - responseDocument.Data.SingleValue.Relationships["labels"].Links.Self.Should().NotBeNull(); - responseDocument.Data.SingleValue.Relationships["labels"].Links.Related.Should().NotBeNull(); - - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Attributes.Should().HaveCount(1); - responseDocument.Included[0].Attributes["color"].Should().Be(post.Labels.Single().Color); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("caption").With(value => value.Should().Be(post.Caption)); + + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("labels").With(value => + { + value.ShouldNotBeNull(); + value.Data.ManyValue.ShouldHaveCount(1); + value.Data.ManyValue[0].Id.Should().Be(post.Labels.ElementAt(0).StringId); + value.Links.ShouldNotBeNull(); + value.Links.Self.ShouldNotBeNull(); + value.Links.Related.ShouldNotBeNull(); + }); + + responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included[0].Attributes.ShouldHaveCount(1); + responseDocument.Included[0].Attributes.ShouldContainKey("color").With(value => value.Should().Be(post.Labels.Single().Color)); responseDocument.Included[0].Relationships.Should().BeNull(); var postCaptured = (BlogPost)store.Resources.Should().ContainSingle(resource => resource is BlogPost).And.Subject.Single(); postCaptured.Id.Should().Be(post.Id); postCaptured.Caption.Should().Be(post.Caption); - postCaptured.Labels.Should().HaveCount(1); + postCaptured.Labels.ShouldHaveCount(1); postCaptured.Labels.Single().Color.Should().Be(post.Labels.Single().Color); postCaptured.Labels.Single().Name.Should().BeNull(); } @@ -444,23 +513,26 @@ 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("blogs"); responseDocument.Data.SingleValue.Id.Should().Be(blog.StringId); - responseDocument.Data.SingleValue.Attributes.Should().HaveCount(1); - responseDocument.Data.SingleValue.Attributes["title"].Should().Be(blog.Title); + responseDocument.Data.SingleValue.Attributes.ShouldHaveCount(1); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("title").With(value => value.Should().Be(blog.Title)); responseDocument.Data.SingleValue.Relationships.Should().BeNull(); - responseDocument.Included.Should().HaveCount(2); + responseDocument.Included.ShouldHaveCount(2); + responseDocument.Included[0].Type.Should().Be("webAccounts"); responseDocument.Included[0].Id.Should().Be(blog.Owner.StringId); - responseDocument.Included[0].Attributes.Should().HaveCount(2); - responseDocument.Included[0].Attributes["userName"].Should().Be(blog.Owner.UserName); - responseDocument.Included[0].Attributes["displayName"].Should().Be(blog.Owner.DisplayName); + responseDocument.Included[0].Attributes.ShouldHaveCount(2); + responseDocument.Included[0].Attributes.ShouldContainKey("userName").With(value => value.Should().Be(blog.Owner.UserName)); + responseDocument.Included[0].Attributes.ShouldContainKey("displayName").With(value => value.Should().Be(blog.Owner.DisplayName)); responseDocument.Included[0].Relationships.Should().BeNull(); + responseDocument.Included[1].Type.Should().Be("blogPosts"); responseDocument.Included[1].Id.Should().Be(blog.Owner.Posts[0].StringId); - responseDocument.Included[1].Attributes.Should().HaveCount(1); - responseDocument.Included[1].Attributes["caption"].Should().Be(blog.Owner.Posts[0].Caption); + responseDocument.Included[1].Attributes.ShouldHaveCount(1); + responseDocument.Included[1].Attributes.ShouldContainKey("caption").With(value => value.Should().Be(blog.Owner.Posts[0].Caption)); responseDocument.Included[1].Relationships.Should().BeNull(); var blogCaptured = (Blog)store.Resources.Should().ContainSingle(resource => resource is Blog).And.Subject.Single(); @@ -468,11 +540,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => blogCaptured.Title.Should().Be(blog.Title); blogCaptured.PlatformName.Should().BeNull(); - blogCaptured.Owner.UserName.Should().Be(blog.Owner.UserName); + blogCaptured.Owner!.UserName.Should().Be(blog.Owner.UserName); blogCaptured.Owner.DisplayName.Should().Be(blog.Owner.DisplayName); blogCaptured.Owner.DateOfBirth.Should().BeNull(); - blogCaptured.Owner.Posts.Should().HaveCount(1); + blogCaptured.Owner.Posts.ShouldHaveCount(1); blogCaptured.Owner.Posts[0].Caption.Should().Be(blog.Owner.Posts[0].Caption); blogCaptured.Owner.Posts[0].Url.Should().BeNull(); } @@ -502,32 +574,56 @@ 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("blogs"); responseDocument.Data.SingleValue.Id.Should().Be(blog.StringId); - responseDocument.Data.SingleValue.Attributes.Should().HaveCount(1); - responseDocument.Data.SingleValue.Attributes["title"].Should().Be(blog.Title); - responseDocument.Data.SingleValue.Relationships.Should().HaveCount(1); - responseDocument.Data.SingleValue.Relationships["owner"].Data.SingleValue.Id.Should().Be(blog.Owner.StringId); - responseDocument.Data.SingleValue.Relationships["owner"].Links.Self.Should().NotBeNull(); - responseDocument.Data.SingleValue.Relationships["owner"].Links.Related.Should().NotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldHaveCount(1); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("title").With(value => value.Should().Be(blog.Title)); + responseDocument.Data.SingleValue.Relationships.ShouldHaveCount(1); - responseDocument.Included.Should().HaveCount(2); + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("owner").With(value => + { + value.ShouldNotBeNull(); + value.Data.SingleValue.ShouldNotBeNull(); + value.Data.SingleValue.Id.Should().Be(blog.Owner.StringId); + value.Links.ShouldNotBeNull(); + value.Links.Self.ShouldNotBeNull(); + value.Links.Related.ShouldNotBeNull(); + }); + + responseDocument.Included.ShouldHaveCount(2); + DateTime dateOfBirth = blog.Owner.DateOfBirth!.Value; + + responseDocument.Included[0].Type.Should().Be("webAccounts"); responseDocument.Included[0].Id.Should().Be(blog.Owner.StringId); - responseDocument.Included[0].Attributes["userName"].Should().Be(blog.Owner.UserName); - responseDocument.Included[0].Attributes["displayName"].Should().Be(blog.Owner.DisplayName); - responseDocument.Included[0].Attributes["dateOfBirth"].As<DateTime?>().Should().BeCloseTo(blog.Owner.DateOfBirth.GetValueOrDefault()); - responseDocument.Included[0].Relationships["posts"].Data.ManyValue.Should().HaveCount(1); - responseDocument.Included[0].Relationships["posts"].Data.ManyValue[0].Id.Should().Be(blog.Owner.Posts[0].StringId); - responseDocument.Included[0].Relationships["posts"].Links.Self.Should().NotBeNull(); - responseDocument.Included[0].Relationships["posts"].Links.Related.Should().NotBeNull(); + responseDocument.Included[0].Attributes.ShouldContainKey("userName").With(value => value.Should().Be(blog.Owner.UserName)); + responseDocument.Included[0].Attributes.ShouldContainKey("displayName").With(value => value.Should().Be(blog.Owner.DisplayName)); + responseDocument.Included[0].Attributes.ShouldContainKey("dateOfBirth").With(value => value.As<DateTime?>().Should().BeCloseTo(dateOfBirth)); + responseDocument.Included[0].Relationships.ShouldContainKey("posts").With(value => + { + value.ShouldNotBeNull(); + value.Data.ManyValue.ShouldHaveCount(1); + value.Data.ManyValue[0].Id.Should().Be(blog.Owner.Posts[0].StringId); + value.Links.ShouldNotBeNull(); + value.Links.Self.ShouldNotBeNull(); + value.Links.Related.ShouldNotBeNull(); + }); + + responseDocument.Included[1].Type.Should().Be("blogPosts"); responseDocument.Included[1].Id.Should().Be(blog.Owner.Posts[0].StringId); - responseDocument.Included[1].Attributes["caption"].Should().Be(blog.Owner.Posts[0].Caption); - responseDocument.Included[1].Attributes["url"].Should().Be(blog.Owner.Posts[0].Url); - responseDocument.Included[1].Relationships["labels"].Data.Value.Should().BeNull(); - responseDocument.Included[1].Relationships["labels"].Links.Self.Should().NotBeNull(); - responseDocument.Included[1].Relationships["labels"].Links.Related.Should().NotBeNull(); + responseDocument.Included[1].Attributes.ShouldContainKey("caption").With(value => value.Should().Be(blog.Owner.Posts[0].Caption)); + responseDocument.Included[1].Attributes.ShouldContainKey("url").With(value => value.Should().Be(blog.Owner.Posts[0].Url)); + + responseDocument.Included[1].Relationships.ShouldContainKey("labels").With(value => + { + value.ShouldNotBeNull(); + value.Data.Value.Should().BeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.ShouldNotBeNull(); + value.Links.Related.ShouldNotBeNull(); + }); var blogCaptured = (Blog)store.Resources.Should().ContainSingle(resource => resource is Blog).And.Subject.Single(); blogCaptured.Id.Should().Be(blog.Id); @@ -559,10 +655,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].Id.Should().Be(post.StringId); - responseDocument.Data.ManyValue[0].Attributes.Should().HaveCount(1); - responseDocument.Data.ManyValue[0].Attributes["caption"].Should().Be(post.Caption); + responseDocument.Data.ManyValue[0].Attributes.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("caption").With(value => value.Should().Be(post.Caption)); responseDocument.Data.ManyValue[0].Relationships.Should().BeNull(); var postCaptured = (BlogPost)store.Resources.Should().ContainSingle(resource => resource is BlogPost).And.Subject.Single(); @@ -595,7 +691,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(post.StringId); responseDocument.Data.ManyValue[0].Attributes.Should().BeNull(); responseDocument.Data.ManyValue[0].Relationships.Should().BeNull(); @@ -617,12 +713,13 @@ public async Task Cannot_select_on_unknown_resource_type() // 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("The specified fieldset is invalid."); error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); + error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be($"fields[{Unknown.ResourceType}]"); } @@ -640,12 +737,13 @@ public async Task Cannot_select_attribute_with_blocked_capability() // 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("Retrieving the requested attribute is not allowed."); error.Detail.Should().Be("Retrieving the attribute 'password' is not allowed."); + error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("fields[webAccounts]"); } @@ -672,10 +770,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(blog.StringId); - responseDocument.Data.SingleValue.Attributes.Should().HaveCount(1); - responseDocument.Data.SingleValue.Attributes["showAdvertisements"].Should().Be(blog.ShowAdvertisements); + responseDocument.Data.SingleValue.Attributes.ShouldHaveCount(1); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("showAdvertisements").With(value => value.Should().Be(blog.ShowAdvertisements)); responseDocument.Data.SingleValue.Relationships.Should().BeNull(); var blogCaptured = (Blog)store.Resources.Should().ContainSingle(resource => resource is Blog).And.Subject.Single(); @@ -706,20 +804,55 @@ 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(post.StringId); - responseDocument.Data.SingleValue.Attributes.Should().HaveCount(2); - responseDocument.Data.SingleValue.Attributes["caption"].Should().Be(post.Caption); - responseDocument.Data.SingleValue.Attributes["url"].Should().Be(post.Url); - responseDocument.Data.SingleValue.Relationships.Should().HaveCount(1); - responseDocument.Data.SingleValue.Relationships["author"].Data.Value.Should().BeNull(); - responseDocument.Data.SingleValue.Relationships["author"].Links.Self.Should().NotBeNull(); - responseDocument.Data.SingleValue.Relationships["author"].Links.Related.Should().NotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldHaveCount(2); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("caption").With(value => value.Should().Be(post.Caption)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("url").With(value => value.Should().Be(post.Url)); + responseDocument.Data.SingleValue.Relationships.ShouldHaveCount(1); + + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("author").With(value => + { + value.ShouldNotBeNull(); + value.Data.Value.Should().BeNull(); + value.Links.ShouldNotBeNull(); + value.Links.Self.ShouldNotBeNull(); + value.Links.Related.ShouldNotBeNull(); + }); var postCaptured = (BlogPost)store.Resources.Should().ContainSingle(resource => resource is BlogPost).And.Subject.Single(); postCaptured.Id.Should().Be(post.Id); postCaptured.Caption.Should().Be(post.Caption); postCaptured.Url.Should().Be(postCaptured.Url); } + + [Fact] + public async Task Returns_related_resources_on_broken_resource_linkage() + { + // Arrange + WebAccount account = _fakers.WebAccount.Generate(); + account.Posts = _fakers.BlogPost.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Accounts.Add(account); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/webAccounts/{account.StringId}?include=posts&fields[webAccounts]=displayName"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Id.Should().Be(account.StringId); + responseDocument.Data.SingleValue.Relationships.Should().BeNull(); + + responseDocument.Included.ShouldHaveCount(2); + responseDocument.Included.Should().OnlyContain(resourceObject => resourceObject.Type == "blogPosts"); + } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/WebAccount.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/WebAccount.cs index 09a3e5f2a7..21060d0eef 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/WebAccount.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/WebAccount.cs @@ -7,27 +7,30 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class WebAccount : Identifiable + public sealed class WebAccount : Identifiable<int> { [Attr] - public string UserName { get; set; } + public string UserName { get; set; } = null!; [Attr(Capabilities = ~AttrCapabilities.AllowView)] - public string Password { get; set; } + public string Password { get; set; } = null!; [Attr] - public string DisplayName { get; set; } + public string DisplayName { get; set; } = null!; [Attr(Capabilities = AttrCapabilities.All & ~(AttrCapabilities.AllowFilter | AttrCapabilities.AllowSort))] public DateTime? DateOfBirth { get; set; } [Attr] - public string EmailAddress { get; set; } + public string EmailAddress { get; set; } = null!; [HasMany] - public IList<BlogPost> Posts { get; set; } + public IList<BlogPost> Posts { get; set; } = new List<BlogPost>(); [HasOne] - public AccountPreferences Preferences { get; set; } + public AccountPreferences? Preferences { get; set; } + + [HasMany] + public IList<LoginAttempt> LoginAttempts { get; set; } = new List<LoginAttempt>(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/WebAccountsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/WebAccountsController.cs index 9842dcbc6f..52b54e19b3 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/WebAccountsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/WebAccountsController.cs @@ -5,10 +5,11 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings { - public sealed class WebAccountsController : JsonApiController<WebAccount> + public sealed class WebAccountsController : JsonApiController<WebAccount, int> { - public WebAccountsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<WebAccount> resourceService) - : base(options, loggerFactory, resourceService) + public WebAccountsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<WebAccount, int> resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs index 9a148f48b1..fe04f5dca5 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs @@ -33,6 +33,7 @@ public CreateResourceTests(IntegrationTestContext<TestableStartup<ReadWriteDbCon var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService<IJsonApiOptions>(); options.UseRelativeLinks = false; options.AllowClientGeneratedIds = false; + options.AllowUnknownFieldsInRequestBody = false; } [Fact] @@ -61,13 +62,15 @@ public async Task Sets_location_header_for_created_resource() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - string newWorkItemId = responseDocument.Data.SingleValue.Id; + string newWorkItemId = responseDocument.Data.SingleValue.ShouldNotBeNull().Id.ShouldNotBeNull(); httpResponse.Headers.Location.Should().Be($"/workItems/{newWorkItemId}"); + responseDocument.Links.ShouldNotBeNull(); responseDocument.Links.Self.Should().Be("http://localhost/workItems"); responseDocument.Links.First.Should().BeNull(); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); responseDocument.Data.SingleValue.Links.Self.Should().Be($"http://localhost{httpResponse.Headers.Location}"); } @@ -98,13 +101,13 @@ public async Task Can_create_resource_with_int_ID() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Type.Should().Be("workItems"); - responseDocument.Data.SingleValue.Attributes["description"].Should().Be(newWorkItem.Description); - responseDocument.Data.SingleValue.Attributes["dueAt"].Should().Be(newWorkItem.DueAt); - responseDocument.Data.SingleValue.Relationships.Should().NotBeEmpty(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("description").With(value => value.Should().Be(newWorkItem.Description)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("dueAt").With(value => value.Should().Be(newWorkItem.DueAt)); + responseDocument.Data.SingleValue.Relationships.ShouldNotBeEmpty(); - int newWorkItemId = int.Parse(responseDocument.Data.SingleValue.Id); + int newWorkItemId = int.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -114,8 +117,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => workItemInDatabase.DueAt.Should().Be(newWorkItem.DueAt); }); - PropertyInfo property = typeof(WorkItem).GetProperty(nameof(Identifiable.Id)); - property.Should().NotBeNull().And.Subject.PropertyType.Should().Be(typeof(int)); + PropertyInfo? property = typeof(WorkItem).GetProperty(nameof(Identifiable<object>.Id)); + property.ShouldNotBeNull(); + property.PropertyType.Should().Be(typeof(int)); } [Fact] @@ -145,13 +149,13 @@ public async Task Can_create_resource_with_long_ID() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Type.Should().Be("userAccounts"); - responseDocument.Data.SingleValue.Attributes["firstName"].Should().Be(newUserAccount.FirstName); - responseDocument.Data.SingleValue.Attributes["lastName"].Should().Be(newUserAccount.LastName); - responseDocument.Data.SingleValue.Relationships.Should().NotBeEmpty(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("firstName").With(value => value.Should().Be(newUserAccount.FirstName)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("lastName").With(value => value.Should().Be(newUserAccount.LastName)); + responseDocument.Data.SingleValue.Relationships.ShouldNotBeEmpty(); - long newUserAccountId = long.Parse(responseDocument.Data.SingleValue.Id); + long newUserAccountId = long.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -161,8 +165,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => userAccountInDatabase.LastName.Should().Be(newUserAccount.LastName); }); - PropertyInfo property = typeof(UserAccount).GetProperty(nameof(Identifiable.Id)); - property.Should().NotBeNull().And.Subject.PropertyType.Should().Be(typeof(long)); + PropertyInfo? property = typeof(UserAccount).GetProperty(nameof(Identifiable<object>.Id)); + property.ShouldNotBeNull(); + property.PropertyType.Should().Be(typeof(long)); } [Fact] @@ -191,12 +196,12 @@ public async Task Can_create_resource_with_guid_ID() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Type.Should().Be("workItemGroups"); - responseDocument.Data.SingleValue.Attributes["name"].Should().Be(newGroup.Name); - responseDocument.Data.SingleValue.Relationships.Should().NotBeEmpty(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newGroup.Name)); + responseDocument.Data.SingleValue.Relationships.ShouldNotBeEmpty(); - Guid newGroupId = Guid.Parse(responseDocument.Data.SingleValue.Id); + Guid newGroupId = Guid.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -205,8 +210,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => groupInDatabase.Name.Should().Be(newGroup.Name); }); - PropertyInfo property = typeof(WorkItemGroup).GetProperty(nameof(Identifiable.Id)); - property.Should().NotBeNull().And.Subject.PropertyType.Should().Be(typeof(Guid)); + PropertyInfo? property = typeof(WorkItemGroup).GetProperty(nameof(Identifiable<object>.Id)); + property.ShouldNotBeNull(); + property.PropertyType.Should().Be(typeof(Guid)); } [Fact] @@ -235,13 +241,13 @@ public async Task Can_create_resource_without_attributes_or_relationships() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Type.Should().Be("workItems"); - responseDocument.Data.SingleValue.Attributes["description"].Should().BeNull(); - responseDocument.Data.SingleValue.Attributes["dueAt"].Should().BeNull(); - responseDocument.Data.SingleValue.Relationships.Should().NotBeEmpty(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("description").With(value => value.Should().BeNull()); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("dueAt").With(value => value.Should().BeNull()); + responseDocument.Data.SingleValue.Relationships.ShouldNotBeEmpty(); - int newWorkItemId = int.Parse(responseDocument.Data.SingleValue.Id); + int newWorkItemId = int.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -252,10 +258,51 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } + [Fact] + public async Task Cannot_create_resource_with_unknown_attribute() + { + // Arrange + WorkItem newWorkItem = _fakers.WorkItem.Generate(); + + var requestBody = new + { + data = new + { + type = "workItems", + attributes = new + { + doesNotExist = "ignored", + description = newWorkItem.Description + } + } + }; + + const string route = "/workItems"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync<Document>(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 'workItems'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/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<IJsonApiOptions>(); + options.AllowUnknownFieldsInRequestBody = true; + WorkItem newWorkItem = _fakers.WorkItem.Generate(); var requestBody = new @@ -279,12 +326,12 @@ public async Task Can_create_resource_with_unknown_attribute() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Type.Should().Be("workItems"); - responseDocument.Data.SingleValue.Attributes["description"].Should().Be(newWorkItem.Description); - responseDocument.Data.SingleValue.Relationships.Should().NotBeEmpty(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("description").With(value => value.Should().Be(newWorkItem.Description)); + responseDocument.Data.SingleValue.Relationships.ShouldNotBeEmpty(); - int newWorkItemId = int.Parse(responseDocument.Data.SingleValue.Id); + int newWorkItemId = int.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -294,10 +341,55 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } + [Fact] + public async Task Cannot_create_resource_with_unknown_relationship() + { + // Arrange + var requestBody = new + { + data = new + { + type = "workItems", + relationships = new + { + doesNotExist = new + { + data = new + { + type = Unknown.ResourceType, + id = Unknown.StringId.Int32 + } + } + } + } + }; + + const string route = "/workItems"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync<Document>(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 'workItems'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/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<IJsonApiOptions>(); + options.AllowUnknownFieldsInRequestBody = true; + var requestBody = new { data = new @@ -325,18 +417,18 @@ public async Task Can_create_resource_with_unknown_relationship() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Type.Should().Be("workItems"); - responseDocument.Data.SingleValue.Attributes.Should().NotBeEmpty(); - responseDocument.Data.SingleValue.Relationships.Should().NotBeEmpty(); + responseDocument.Data.SingleValue.Attributes.ShouldNotBeEmpty(); + responseDocument.Data.SingleValue.Relationships.ShouldNotBeEmpty(); - int newWorkItemId = int.Parse(responseDocument.Data.SingleValue.Id); + int newWorkItemId = int.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { - WorkItem workItemInDatabase = await dbContext.WorkItems.FirstWithIdOrDefaultAsync(newWorkItemId); + WorkItem? workItemInDatabase = await dbContext.WorkItems.FirstWithIdOrDefaultAsync(newWorkItemId); - workItemInDatabase.Should().NotBeNull(); + workItemInDatabase.ShouldNotBeNull(); }); } @@ -352,7 +444,7 @@ public async Task Cannot_create_resource_with_client_generated_ID() id = "0A0B0C", attributes = new { - name = "Black" + displayName = "Black" } } }; @@ -365,13 +457,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 POST requests 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("/data/id"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -388,12 +482,130 @@ public async Task Cannot_create_resource_for_missing_request_body() // 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"); + } + + [Fact] + public async Task Cannot_create_resource_for_null_request_body() + { + // Arrange + const string requestBody = "null"; + + const string route = "/workItems"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync<Document>(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] + public async Task Cannot_create_resource_for_missing_data() + { + // Arrange + var requestBody = new + { + meta = new + { + key = "value" + } + }; + + const string route = "/workItems"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync<Document>(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.Should().BeNull(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } + + [Fact] + public async Task Cannot_create_resource_for_null_data() + { + // Arrange + var requestBody = new + { + data = (object?)null + }; + + const string route = "/workItems"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync<Document>(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Expected an object, instead of 'null'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } + + [Fact] + public async Task Cannot_create_resource_for_array_data() + { + // Arrange + var requestBody = new + { + data = new[] + { + new + { + type = "workItems" + } + } + }; + + const string route = "/workItems"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync<Document>(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 an array."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -418,12 +630,15 @@ 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: Request body must include 'type' element."); - error.Detail.Should().StartWith("Expected 'type' element in 'data' element. - Request body: <<"); + 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("/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -449,12 +664,15 @@ 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().StartWith($"Resource type '{Unknown.ResourceType}' does not exist. - Request body: <<"); + 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("/data/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -504,12 +722,15 @@ public async Task Cannot_create_on_resource_type_mismatch_between_url_and_body() // 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("Resource type mismatch between request body and endpoint URL."); - error.Detail.Should().Be("Expected resource of type 'workItems' in POST request body at endpoint '/workItems', instead of 'rgbColors'."); + error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found."); + error.Detail.Should().Be("Type 'rgbColors' is incompatible with type 'workItems'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -536,12 +757,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().StartWith("Setting the initial value of 'isImportant' is not allowed. - Request body: <<"); + error.Title.Should().Be("Failed to deserialize request body: Attribute value cannot be assigned when creating resource."); + error.Detail.Should().Be("The attribute 'isImportant' on resource type 'workItems' cannot be assigned to."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data/attributes/isImportant"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -568,12 +792,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().StartWith("Attribute 'isDeprecated' is read-only. - Request body: <<"); + error.Detail.Should().Be("Attribute 'isDeprecated' on resource type 'workItemGroups' is read-only."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data/attributes/isDeprecated"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -590,12 +817,14 @@ public async Task Cannot_create_resource_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); error.Title.Should().Be("Failed to deserialize request body."); - error.Detail.Should().Match("'{' is invalid after a property name. * - Request body: <<*"); + error.Detail.Should().StartWith("'{' is invalid after a property name."); + error.Source.Should().BeNull(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -622,14 +851,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.Detail.Should().StartWith("Failed to convert attribute 'dueAt' with value 'not-a-valid-time' " + - "of type 'String' to type 'Nullable<DateTimeOffset>'. - Request body: <<"); + error.Title.Should().Be("Failed to deserialize request body: Incompatible attribute value found."); + error.Detail.Should().Be("Failed to convert attribute 'dueAt' with value 'not-a-valid-time' of type 'String' to type 'Nullable<DateTimeOffset>'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data/attributes/dueAt"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -639,7 +869,7 @@ public async Task Can_create_resource_with_attributes_and_multiple_relationship_ List<UserAccount> existingUserAccounts = _fakers.UserAccount.Generate(2); WorkTag existingTag = _fakers.WorkTag.Generate(); - string newDescription = _fakers.WorkItem.Generate().Description; + string newDescription = _fakers.WorkItem.Generate().Description!; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -701,11 +931,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.Should().NotBeNull(); - responseDocument.Data.SingleValue.Attributes["description"].Should().Be(newDescription); - responseDocument.Data.SingleValue.Relationships.Should().NotBeEmpty(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("description").With(value => value.Should().Be(newDescription)); + responseDocument.Data.SingleValue.Relationships.ShouldNotBeEmpty(); - int newWorkItemId = int.Parse(responseDocument.Data.SingleValue.Id); + int newWorkItemId = int.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -723,13 +953,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => workItemInDatabase.Description.Should().Be(newDescription); - workItemInDatabase.Assignee.Should().NotBeNull(); + workItemInDatabase.Assignee.ShouldNotBeNull(); workItemInDatabase.Assignee.Id.Should().Be(existingUserAccounts[0].Id); - workItemInDatabase.Subscribers.Should().HaveCount(1); + workItemInDatabase.Subscribers.ShouldHaveCount(1); workItemInDatabase.Subscribers.Single().Id.Should().Be(existingUserAccounts[1].Id); - workItemInDatabase.Tags.Should().HaveCount(1); + workItemInDatabase.Tags.ShouldHaveCount(1); workItemInDatabase.Tags.Single().Id.Should().Be(existingTag.Id); }); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithClientGeneratedIdTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithClientGeneratedIdTests.cs index e8dc30f79f..781f72962f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithClientGeneratedIdTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithClientGeneratedIdTests.cs @@ -63,21 +63,24 @@ public async Task Can_create_resource_with_client_generated_guid_ID_having_side_ // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.Should().NotBeNull(); + string groupName = $"{newGroup.Name}{ImplicitlyChangingWorkItemGroupDefinition.Suffix}"; + + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Type.Should().Be("workItemGroups"); responseDocument.Data.SingleValue.Id.Should().Be(newGroup.StringId); - responseDocument.Data.SingleValue.Attributes["name"].Should().Be($"{newGroup.Name}{ImplicitlyChangingWorkItemGroupDefinition.Suffix}"); - responseDocument.Data.SingleValue.Relationships.Should().NotBeEmpty(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("name").With(value => value.Should().Be(groupName)); + responseDocument.Data.SingleValue.Relationships.ShouldNotBeEmpty(); await _testContext.RunOnDatabaseAsync(async dbContext => { WorkItemGroup groupInDatabase = await dbContext.Groups.FirstWithIdAsync(newGroup.Id); - groupInDatabase.Name.Should().Be($"{newGroup.Name}{ImplicitlyChangingWorkItemGroupDefinition.Suffix}"); + groupInDatabase.Name.Should().Be(groupName); }); - PropertyInfo property = typeof(WorkItemGroup).GetProperty(nameof(Identifiable.Id)); - property.Should().NotBeNull().And.Subject.PropertyType.Should().Be(typeof(Guid)); + PropertyInfo? property = typeof(WorkItemGroup).GetProperty(nameof(Identifiable<object>.Id)); + property.ShouldNotBeNull(); + property.PropertyType.Should().Be(typeof(Guid)); } [Fact] @@ -108,22 +111,25 @@ public async Task Can_create_resource_with_client_generated_guid_ID_having_side_ // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.Should().NotBeNull(); + string groupName = $"{newGroup.Name}{ImplicitlyChangingWorkItemGroupDefinition.Suffix}"; + + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Type.Should().Be("workItemGroups"); responseDocument.Data.SingleValue.Id.Should().Be(newGroup.StringId); - responseDocument.Data.SingleValue.Attributes.Should().HaveCount(1); - responseDocument.Data.SingleValue.Attributes["name"].Should().Be($"{newGroup.Name}{ImplicitlyChangingWorkItemGroupDefinition.Suffix}"); + responseDocument.Data.SingleValue.Attributes.ShouldHaveCount(1); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("name").With(value => value.Should().Be(groupName)); responseDocument.Data.SingleValue.Relationships.Should().BeNull(); await _testContext.RunOnDatabaseAsync(async dbContext => { WorkItemGroup groupInDatabase = await dbContext.Groups.FirstWithIdAsync(newGroup.Id); - groupInDatabase.Name.Should().Be($"{newGroup.Name}{ImplicitlyChangingWorkItemGroupDefinition.Suffix}"); + groupInDatabase.Name.Should().Be(groupName); }); - PropertyInfo property = typeof(WorkItemGroup).GetProperty(nameof(Identifiable.Id)); - property.Should().NotBeNull().And.Subject.PropertyType.Should().Be(typeof(Guid)); + PropertyInfo? property = typeof(WorkItemGroup).GetProperty(nameof(Identifiable<object>.Id)); + property.ShouldNotBeNull(); + property.PropertyType.Should().Be(typeof(Guid)); } [Fact] @@ -162,8 +168,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => colorInDatabase.DisplayName.Should().Be(newColor.DisplayName); }); - PropertyInfo property = typeof(RgbColor).GetProperty(nameof(Identifiable.Id)); - property.Should().NotBeNull().And.Subject.PropertyType.Should().Be(typeof(string)); + PropertyInfo? property = typeof(RgbColor).GetProperty(nameof(Identifiable<object>.Id)); + property.ShouldNotBeNull(); + property.PropertyType.Should().Be(typeof(string)); } [Fact] @@ -202,8 +209,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => colorInDatabase.DisplayName.Should().Be(newColor.DisplayName); }); - PropertyInfo property = typeof(RgbColor).GetProperty(nameof(Identifiable.Id)); - property.Should().NotBeNull().And.Subject.PropertyType.Should().Be(typeof(string)); + PropertyInfo? property = typeof(RgbColor).GetProperty(nameof(Identifiable<object>.Id)); + property.ShouldNotBeNull(); + property.PropertyType.Should().Be(typeof(string)); } [Fact] @@ -242,12 +250,14 @@ 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 'rgbColors' with ID '{existingColor.StringId}' already exists."); + error.Source.Should().BeNull(); + error.Meta.Should().NotContainKey("requestBody"); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToManyRelationshipTests.cs index e9eae47336..af581dc3cc 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToManyRelationshipTests.cs @@ -72,18 +72,18 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.Should().NotBeNull(); - responseDocument.Data.SingleValue.Attributes.Should().NotBeEmpty(); - responseDocument.Data.SingleValue.Relationships.Should().NotBeEmpty(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldNotBeEmpty(); + responseDocument.Data.SingleValue.Relationships.ShouldNotBeEmpty(); responseDocument.Included.Should().BeNull(); - int newWorkItemId = int.Parse(responseDocument.Data.SingleValue.Id); + int newWorkItemId = int.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { WorkItem workItemInDatabase = await dbContext.WorkItems.Include(workItem => workItem.Subscribers).FirstWithIdAsync(newWorkItemId); - workItemInDatabase.Subscribers.Should().HaveCount(2); + workItemInDatabase.Subscribers.ShouldHaveCount(2); workItemInDatabase.Subscribers.Should().ContainSingle(subscriber => subscriber.Id == existingUserAccounts[0].Id); workItemInDatabase.Subscribers.Should().ContainSingle(subscriber => subscriber.Id == existingUserAccounts[1].Id); }); @@ -136,25 +136,25 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.Should().NotBeNull(); - responseDocument.Data.SingleValue.Attributes.Should().NotBeEmpty(); - responseDocument.Data.SingleValue.Relationships.Should().NotBeEmpty(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldNotBeEmpty(); + responseDocument.Data.SingleValue.Relationships.ShouldNotBeEmpty(); - responseDocument.Included.Should().HaveCount(2); + responseDocument.Included.ShouldHaveCount(2); responseDocument.Included.Should().OnlyContain(resource => resource.Type == "userAccounts"); responseDocument.Included.Should().ContainSingle(resource => resource.Id == existingUserAccounts[0].StringId); responseDocument.Included.Should().ContainSingle(resource => resource.Id == existingUserAccounts[1].StringId); - responseDocument.Included.Should().OnlyContain(resource => resource.Attributes["firstName"] != null); - responseDocument.Included.Should().OnlyContain(resource => resource.Attributes["lastName"] != null); - responseDocument.Included.Should().OnlyContain(resource => resource.Relationships.Count > 0); + responseDocument.Included.Should().OnlyContain(resource => resource.Attributes.ShouldContainKey("firstName") != null); + responseDocument.Included.Should().OnlyContain(resource => resource.Attributes.ShouldContainKey("lastName") != null); + responseDocument.Included.Should().OnlyContain(resource => resource.Relationships.ShouldNotBeNull().Count > 0); - int newWorkItemId = int.Parse(responseDocument.Data.SingleValue.Id); + int newWorkItemId = int.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { WorkItem workItemInDatabase = await dbContext.WorkItems.Include(workItem => workItem.Subscribers).FirstWithIdAsync(newWorkItemId); - workItemInDatabase.Subscribers.Should().HaveCount(2); + workItemInDatabase.Subscribers.ShouldHaveCount(2); workItemInDatabase.Subscribers.Should().ContainSingle(userAccount => userAccount.Id == existingUserAccounts[0].Id); workItemInDatabase.Subscribers.Should().ContainSingle(userAccount => userAccount.Id == existingUserAccounts[1].Id); }); @@ -207,25 +207,25 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.Should().NotBeNull(); - responseDocument.Data.SingleValue.Attributes.Should().NotBeEmpty(); - responseDocument.Data.SingleValue.Relationships.Should().NotBeEmpty(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldNotBeEmpty(); + responseDocument.Data.SingleValue.Relationships.ShouldNotBeEmpty(); - responseDocument.Included.Should().HaveCount(2); + responseDocument.Included.ShouldHaveCount(2); responseDocument.Included.Should().OnlyContain(resource => resource.Type == "userAccounts"); responseDocument.Included.Should().ContainSingle(resource => resource.Id == existingUserAccounts[0].StringId); responseDocument.Included.Should().ContainSingle(resource => resource.Id == existingUserAccounts[1].StringId); - responseDocument.Included.Should().OnlyContain(resource => resource.Attributes.Count == 1); - responseDocument.Included.Should().OnlyContain(resource => resource.Attributes["firstName"] != null); + responseDocument.Included.Should().OnlyContain(resource => resource.Attributes.ShouldNotBeNull().Count == 1); + responseDocument.Included.Should().OnlyContain(resource => resource.Attributes.ShouldContainKey("firstName") != null); responseDocument.Included.Should().OnlyContain(resource => resource.Relationships == null); - int newWorkItemId = int.Parse(responseDocument.Data.SingleValue.Id); + int newWorkItemId = int.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { WorkItem workItemInDatabase = await dbContext.WorkItems.Include(workItem => workItem.Subscribers).FirstWithIdAsync(newWorkItemId); - workItemInDatabase.Subscribers.Should().HaveCount(2); + workItemInDatabase.Subscribers.ShouldHaveCount(2); workItemInDatabase.Subscribers.Should().ContainSingle(userAccount => userAccount.Id == existingUserAccounts[0].Id); workItemInDatabase.Subscribers.Should().ContainSingle(userAccount => userAccount.Id == existingUserAccounts[1].Id); }); @@ -289,31 +289,36 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.Should().NotBeNull(); - responseDocument.Data.SingleValue.Attributes.Should().HaveCount(1); - responseDocument.Data.SingleValue.Attributes["priority"].Should().Be(workItemToCreate.Priority); - responseDocument.Data.SingleValue.Relationships.Should().HaveCount(1); - responseDocument.Data.SingleValue.Relationships["tags"].Data.ManyValue.Should().HaveCount(3); - responseDocument.Data.SingleValue.Relationships["tags"].Data.ManyValue[0].Id.Should().Be(existingTags[0].StringId); - responseDocument.Data.SingleValue.Relationships["tags"].Data.ManyValue[1].Id.Should().Be(existingTags[1].StringId); - responseDocument.Data.SingleValue.Relationships["tags"].Data.ManyValue[2].Id.Should().Be(existingTags[2].StringId); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldHaveCount(1); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("priority").With(value => value.Should().Be(workItemToCreate.Priority)); + responseDocument.Data.SingleValue.Relationships.ShouldHaveCount(1); - responseDocument.Included.Should().HaveCount(3); + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("tags").With(value => + { + value.ShouldNotBeNull(); + value.Data.ManyValue.ShouldHaveCount(3); + value.Data.ManyValue[0].Id.Should().Be(existingTags[0].StringId); + value.Data.ManyValue[1].Id.Should().Be(existingTags[1].StringId); + value.Data.ManyValue[2].Id.Should().Be(existingTags[2].StringId); + }); + + responseDocument.Included.ShouldHaveCount(3); responseDocument.Included.Should().OnlyContain(resource => resource.Type == "workTags"); responseDocument.Included.Should().ContainSingle(resource => resource.Id == existingTags[0].StringId); responseDocument.Included.Should().ContainSingle(resource => resource.Id == existingTags[1].StringId); responseDocument.Included.Should().ContainSingle(resource => resource.Id == existingTags[2].StringId); - responseDocument.Included.Should().OnlyContain(resource => resource.Attributes.Count == 1); - responseDocument.Included.Should().OnlyContain(resource => resource.Attributes["text"] != null); + responseDocument.Included.Should().OnlyContain(resource => resource.Attributes.ShouldNotBeNull().Count == 1); + responseDocument.Included.Should().OnlyContain(resource => resource.Attributes.ShouldContainKey("text") != null); responseDocument.Included.Should().OnlyContain(resource => resource.Relationships == null); - int newWorkItemId = int.Parse(responseDocument.Data.SingleValue.Id); + int newWorkItemId = int.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { WorkItem workItemInDatabase = await dbContext.WorkItems.Include(workItem => workItem.Tags).FirstWithIdAsync(newWorkItemId); - workItemInDatabase.Tags.Should().HaveCount(3); + workItemInDatabase.Tags.ShouldHaveCount(3); workItemInDatabase.Tags.Should().ContainSingle(workTag => workTag.Id == existingTags[0].Id); workItemInDatabase.Tags.Should().ContainSingle(workTag => workTag.Id == existingTags[1].Id); workItemInDatabase.Tags.Should().ContainSingle(workTag => workTag.Id == existingTags[2].Id); @@ -353,12 +358,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().StartWith("Expected 'type' element in 'subscribers' relationship. - Request body: <<"); + 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("/data/relationships/subscribers/data[0]"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -395,12 +403,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.Detail.Should().StartWith($"Resource type '{Unknown.ResourceType}' does not exist. - Request body: <<"); + 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("/data/relationships/subscribers/data[0]/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -436,12 +447,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' element."); - error.Detail.Should().StartWith("Expected 'id' element in 'subscribers' relationship. - Request body: <<"); + 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("/data/relationships/subscribers/data[0]"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -451,11 +465,18 @@ public async Task Cannot_create_for_unknown_relationship_IDs() string workItemId1 = Unknown.StringId.For<WorkItem, int>(); string workItemId2 = Unknown.StringId.AltFor<WorkItem, int>(); + UserAccount newUserAccount = _fakers.UserAccount.Generate(); + var requestBody = new { data = new { type = "userAccounts", + attributes = new + { + firstName = newUserAccount.FirstName, + lastName = newUserAccount.LastName + }, relationships = new { assignedItems = new @@ -486,17 +507,19 @@ 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 'workItems' with ID '{workItemId1}' in relationship 'assignedItems' does not exist."); + 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 'workItems' with ID '{workItemId2}' in relationship 'assignedItems' does not exist."); + error2.Meta.Should().NotContainKey("requestBody"); } [Fact] @@ -531,14 +554,17 @@ public async Task Cannot_create_on_relationship_type_mismatch() (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync<Document>(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().StartWith("Relationship 'subscribers' contains incompatible resource type 'rgbColors'. - Request body: <<"); + error.StatusCode.Should().Be(HttpStatusCode.Conflict); + error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found."); + error.Detail.Should().Be("Type 'rgbColors' is incompatible with type 'userAccounts' of relationship 'subscribers'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data/relationships/subscribers/data[0]/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -588,27 +614,27 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.Should().NotBeNull(); - responseDocument.Data.SingleValue.Attributes.Should().NotBeEmpty(); - responseDocument.Data.SingleValue.Relationships.Should().NotBeEmpty(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldNotBeEmpty(); + responseDocument.Data.SingleValue.Relationships.ShouldNotBeEmpty(); - responseDocument.Included.Should().HaveCount(1); + responseDocument.Included.ShouldHaveCount(1); responseDocument.Included[0].Type.Should().Be("userAccounts"); responseDocument.Included[0].Id.Should().Be(existingUserAccount.StringId); - int newWorkItemId = int.Parse(responseDocument.Data.SingleValue.Id); + int newWorkItemId = int.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { WorkItem workItemInDatabase = await dbContext.WorkItems.Include(workItem => workItem.Subscribers).FirstWithIdAsync(newWorkItemId); - workItemInDatabase.Subscribers.Should().HaveCount(1); + workItemInDatabase.Subscribers.ShouldHaveCount(1); workItemInDatabase.Subscribers.Single().Id.Should().Be(existingUserAccount.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 @@ -620,7 +646,6 @@ public async Task Cannot_create_with_null_data_in_OneToMany_relationship() { subscribers = new { - data = (object)null } } } @@ -634,12 +659,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().StartWith("Expected data[] element for 'subscribers' relationship. - Request body: <<"); + 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("/data/relationships/subscribers"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -655,7 +683,47 @@ public async Task Cannot_create_with_null_data_in_ManyToMany_relationship() { tags = new { - data = (object)null + data = (object?)null + } + } + } + }; + + const string route = "/workItems"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync<Document>(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("/data/relationships/tags/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 + { + data = new + { + type = "workItems", + relationships = new + { + tags = new + { + data = new + { + } } } } @@ -669,12 +737,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().StartWith("Expected data[] element for 'tags' relationship. - Request body: <<"); + 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("/data/relationships/tags/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -714,12 +785,15 @@ public async Task Cannot_create_resource_with_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."); - error.Detail.Should().StartWith("Local IDs cannot be used at this endpoint. - Request body: <<"); + error.Title.Should().Be("Failed to deserialize request body: The 'lid' element is not supported at this endpoint."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data/lid"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToOneRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToOneRelationshipTests.cs index acd6f6136e..9346f9860b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToOneRelationshipTests.cs @@ -39,6 +39,8 @@ public async Task Can_create_OneToOne_relationship_from_principal_side() WorkItemGroup existingGroup = _fakers.WorkItemGroup.Generate(); existingGroup.Color = _fakers.RgbColor.Generate(); + string newGroupName = _fakers.WorkItemGroup.Generate().Name; + await _testContext.RunOnDatabaseAsync(async dbContext => { dbContext.Groups.Add(existingGroup); @@ -50,6 +52,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = new { type = "workItemGroups", + attributes = new + { + name = newGroupName + }, relationships = new { color = new @@ -72,19 +78,19 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.Should().NotBeNull(); - responseDocument.Data.SingleValue.Attributes.Should().NotBeEmpty(); - responseDocument.Data.SingleValue.Relationships.Should().NotBeEmpty(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldNotBeEmpty(); + responseDocument.Data.SingleValue.Relationships.ShouldNotBeEmpty(); - string newGroupId = responseDocument.Data.SingleValue.Id; - newGroupId.Should().NotBeNullOrEmpty(); + string newGroupId = responseDocument.Data.SingleValue.Id.ShouldNotBeNull(); await _testContext.RunOnDatabaseAsync(async dbContext => { List<WorkItemGroup> groupsInDatabase = await dbContext.Groups.Include(group => group.Color).ToListAsync(); WorkItemGroup newGroupInDatabase = groupsInDatabase.Single(group => group.StringId == newGroupId); - newGroupInDatabase.Color.Should().NotBeNull(); + newGroupInDatabase.Name.Should().Be(newGroupName); + newGroupInDatabase.Color.ShouldNotBeNull(); newGroupInDatabase.Color.Id.Should().Be(existingGroup.Color.Id); WorkItemGroup existingGroupInDatabase = groupsInDatabase.Single(group => group.Id == existingGroup.Id); @@ -99,20 +105,25 @@ public async Task Can_create_OneToOne_relationship_from_dependent_side() RgbColor existingColor = _fakers.RgbColor.Generate(); existingColor.Group = _fakers.WorkItemGroup.Generate(); + const string newColorId = "0A0B0C"; + string newDisplayName = _fakers.RgbColor.Generate().DisplayName; + await _testContext.RunOnDatabaseAsync(async dbContext => { dbContext.RgbColors.Add(existingColor); await dbContext.SaveChangesAsync(); }); - const string colorId = "0A0B0C"; - var requestBody = new { data = new { type = "rgbColors", - id = colorId, + id = newColorId, + attributes = new + { + displayName = newDisplayName + }, relationships = new { group = new @@ -141,13 +152,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { List<RgbColor> colorsInDatabase = await dbContext.RgbColors.Include(rgbColor => rgbColor.Group).ToListAsync(); - RgbColor newColorInDatabase = colorsInDatabase.Single(color => color.Id == colorId); - newColorInDatabase.Group.Should().NotBeNull(); + RgbColor newColorInDatabase = colorsInDatabase.Single(color => color.Id == newColorId); + newColorInDatabase.DisplayName.Should().Be(newDisplayName); + newColorInDatabase.Group.ShouldNotBeNull(); newColorInDatabase.Group.Id.Should().Be(existingColor.Group.Id); - RgbColor existingColorInDatabase = colorsInDatabase.SingleOrDefault(color => color.Id == existingColor.Id); - existingColorInDatabase.Should().NotBeNull(); - existingColorInDatabase!.Group.Should().BeNull(); + RgbColor? existingColorInDatabase = colorsInDatabase.SingleOrDefault(color => color.Id == existingColor.Id); + existingColorInDatabase.ShouldNotBeNull(); + existingColorInDatabase.Group.Should().BeNull(); }); } @@ -190,24 +202,24 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.Should().NotBeNull(); - responseDocument.Data.SingleValue.Attributes.Should().NotBeEmpty(); - responseDocument.Data.SingleValue.Relationships.Should().NotBeEmpty(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldNotBeEmpty(); + responseDocument.Data.SingleValue.Relationships.ShouldNotBeEmpty(); - responseDocument.Included.Should().HaveCount(1); + responseDocument.Included.ShouldHaveCount(1); responseDocument.Included[0].Type.Should().Be("userAccounts"); responseDocument.Included[0].Id.Should().Be(existingUserAccount.StringId); - responseDocument.Included[0].Attributes["firstName"].Should().Be(existingUserAccount.FirstName); - responseDocument.Included[0].Attributes["lastName"].Should().Be(existingUserAccount.LastName); - responseDocument.Included[0].Relationships.Should().NotBeEmpty(); + responseDocument.Included[0].Attributes.ShouldContainKey("firstName").With(value => value.Should().Be(existingUserAccount.FirstName)); + responseDocument.Included[0].Attributes.ShouldContainKey("lastName").With(value => value.Should().Be(existingUserAccount.LastName)); + responseDocument.Included[0].Relationships.ShouldNotBeEmpty(); - int newWorkItemId = int.Parse(responseDocument.Data.SingleValue.Id); + int newWorkItemId = int.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { WorkItem workItemInDatabase = await dbContext.WorkItems.Include(workItem => workItem.Assignee).FirstWithIdAsync(newWorkItemId); - workItemInDatabase.Assignee.Should().NotBeNull(); + workItemInDatabase.Assignee.ShouldNotBeNull(); workItemInDatabase.Assignee.Id.Should().Be(existingUserAccount.Id); }); } @@ -257,20 +269,26 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.Should().NotBeNull(); - responseDocument.Data.SingleValue.Attributes.Should().HaveCount(1); - responseDocument.Data.SingleValue.Attributes["description"].Should().Be(newWorkItem.Description); - responseDocument.Data.SingleValue.Relationships.Should().HaveCount(1); - responseDocument.Data.SingleValue.Relationships["assignee"].Data.SingleValue.Id.Should().Be(existingUserAccount.StringId); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldHaveCount(1); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("description").With(value => value.Should().Be(newWorkItem.Description)); + responseDocument.Data.SingleValue.Relationships.ShouldHaveCount(1); + + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("assignee").With(value => + { + value.ShouldNotBeNull(); + value.Data.SingleValue.ShouldNotBeNull(); + value.Data.SingleValue.Id.Should().Be(existingUserAccount.StringId); + }); - responseDocument.Included.Should().HaveCount(1); + responseDocument.Included.ShouldHaveCount(1); responseDocument.Included[0].Type.Should().Be("userAccounts"); responseDocument.Included[0].Id.Should().Be(existingUserAccount.StringId); - responseDocument.Included[0].Attributes["firstName"].Should().Be(existingUserAccount.FirstName); - responseDocument.Included[0].Attributes["lastName"].Should().Be(existingUserAccount.LastName); - responseDocument.Included[0].Relationships.Should().NotBeEmpty(); + responseDocument.Included[0].Attributes.ShouldContainKey("firstName").With(value => value.Should().Be(existingUserAccount.FirstName)); + responseDocument.Included[0].Attributes.ShouldContainKey("lastName").With(value => value.Should().Be(existingUserAccount.LastName)); + responseDocument.Included[0].Relationships.ShouldNotBeEmpty(); - int newWorkItemId = int.Parse(responseDocument.Data.SingleValue.Id); + int newWorkItemId = int.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -278,11 +296,152 @@ await _testContext.RunOnDatabaseAsync(async dbContext => workItemInDatabase.Description.Should().Be(newWorkItem.Description); workItemInDatabase.Priority.Should().Be(newWorkItem.Priority); - workItemInDatabase.Assignee.Should().NotBeNull(); + workItemInDatabase.Assignee.ShouldNotBeNull(); workItemInDatabase.Assignee.Id.Should().Be(existingUserAccount.Id); }); } + [Fact] + public async Task Cannot_create_with_null_relationship() + { + // Arrange + UserAccount existingUserAccount = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.UserAccounts.Add(existingUserAccount); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + relationships = new + { + assignee = (object?)null + } + } + }; + + const string route = "/workItems"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync<Document>(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("/data/relationships/assignee"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } + + [Fact] + public async Task Cannot_create_with_missing_data_in_relationship() + { + // Arrange + UserAccount existingUserAccount = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.UserAccounts.Add(existingUserAccount); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + relationships = new + { + assignee = new + { + } + } + } + }; + + const string route = "/workItems"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync<Document>(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("/data/relationships/assignee"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } + + [Fact] + public async Task Cannot_create_with_array_data_in_relationship() + { + // Arrange + UserAccount existingUserAccount = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.UserAccounts.Add(existingUserAccount); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + relationships = new + { + assignee = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingUserAccount.StringId + } + } + } + } + } + }; + + const string route = "/workItems"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync<Document>(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("/data/relationships/assignee/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } + [Fact] public async Task Cannot_create_for_missing_relationship_type() { @@ -313,12 +472,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().StartWith("Expected 'type' element in 'assignee' relationship. - Request body: <<"); + 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("/data/relationships/assignee/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -352,12 +514,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.Detail.Should().StartWith($"Resource type '{Unknown.ResourceType}' does not exist. - Request body: <<"); + 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("/data/relationships/assignee/data/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -390,12 +555,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' element."); - error.Detail.Should().StartWith("Expected 'id' element in 'assignee' relationship. - Request body: <<"); + 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("/data/relationships/assignee/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -431,12 +599,14 @@ 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 'userAccounts' with ID '{userAccountId}' in relationship 'assignee' does not exist."); + error.Source.Should().BeNull(); + error.Meta.Should().NotContainKey("requestBody"); } [Fact] @@ -468,14 +638,17 @@ public async Task Cannot_create_on_relationship_type_mismatch() (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync<Document>(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().StartWith("Relationship 'assignee' contains incompatible resource type 'rgbColors'. - Request body: <<"); + error.StatusCode.Should().Be(HttpStatusCode.Conflict); + error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found."); + error.Detail.Should().Be("Type 'rgbColors' is incompatible with type 'userAccounts' of relationship 'assignee'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data/relationships/assignee/data/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -527,78 +700,28 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.Should().NotBeNull(); - responseDocument.Data.SingleValue.Attributes.Should().NotBeEmpty(); - responseDocument.Data.SingleValue.Relationships.Should().NotBeEmpty(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldNotBeEmpty(); + responseDocument.Data.SingleValue.Relationships.ShouldNotBeEmpty(); - responseDocument.Included.Should().HaveCount(1); + responseDocument.Included.ShouldHaveCount(1); responseDocument.Included[0].Type.Should().Be("userAccounts"); responseDocument.Included[0].Id.Should().Be(existingUserAccounts[1].StringId); - responseDocument.Included[0].Attributes["firstName"].Should().Be(existingUserAccounts[1].FirstName); - responseDocument.Included[0].Attributes["lastName"].Should().Be(existingUserAccounts[1].LastName); - responseDocument.Included[0].Relationships.Should().NotBeEmpty(); + responseDocument.Included[0].Attributes.ShouldContainKey("firstName").With(value => value.Should().Be(existingUserAccounts[1].FirstName)); + responseDocument.Included[0].Attributes.ShouldContainKey("lastName").With(value => value.Should().Be(existingUserAccounts[1].LastName)); + responseDocument.Included[0].Relationships.ShouldNotBeEmpty(); - int newWorkItemId = int.Parse(responseDocument.Data.SingleValue.Id); + int newWorkItemId = int.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { WorkItem workItemInDatabase = await dbContext.WorkItems.Include(workItem => workItem.Assignee).FirstWithIdAsync(newWorkItemId); - workItemInDatabase.Assignee.Should().NotBeNull(); + workItemInDatabase.Assignee.ShouldNotBeNull(); workItemInDatabase.Assignee.Id.Should().Be(existingUserAccounts[1].Id); }); } - [Fact] - public async Task Cannot_create_with_data_array_in_relationship() - { - // Arrange - UserAccount existingUserAccount = _fakers.UserAccount.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.UserAccounts.Add(existingUserAccount); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "workItems", - relationships = new - { - assignee = new - { - data = new[] - { - new - { - type = "userAccounts", - id = existingUserAccount.StringId - } - } - } - } - } - }; - - const string route = "/workItems"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync<Document>(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().StartWith("Expected single data element for 'assignee' relationship. - Request body: <<"); - } - [Fact] public async Task Cannot_create_resource_with_local_ID() { @@ -633,12 +756,15 @@ public async Task Cannot_create_resource_with_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."); - error.Detail.Should().StartWith("Local IDs cannot be used at this endpoint. - Request body: <<"); + error.Title.Should().Be("Failed to deserialize request body: The 'lid' element is not supported at this endpoint."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data/lid"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Deleting/DeleteResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Deleting/DeleteResourceTests.cs index e9d7116889..b90d0568b7 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Deleting/DeleteResourceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Deleting/DeleteResourceTests.cs @@ -49,7 +49,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - WorkItem workItemsInDatabase = await dbContext.WorkItems.FirstWithIdOrDefaultAsync(existingWorkItem.Id); + WorkItem? workItemsInDatabase = await dbContext.WorkItems.FirstWithIdOrDefaultAsync(existingWorkItem.Id); workItemsInDatabase.Should().BeNull(); }); @@ -69,12 +69,14 @@ 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); error.Title.Should().Be("The requested resource does not exist."); error.Detail.Should().Be($"Resource of type 'workItems' with ID '{workItemId}' does not exist."); + error.Source.Should().BeNull(); + error.Meta.Should().NotContainKey("requestBody"); } [Fact] @@ -102,7 +104,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - RgbColor colorsInDatabase = await dbContext.RgbColors.FirstWithIdOrDefaultAsync(existingColor.Id); + RgbColor? colorsInDatabase = await dbContext.RgbColors.FirstWithIdOrDefaultAsync(existingColor.Id); colorsInDatabase.Should().BeNull(); @@ -137,13 +139,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - WorkItemGroup groupsInDatabase = await dbContext.Groups.FirstWithIdOrDefaultAsync(existingGroup.Id); + WorkItemGroup? groupsInDatabase = await dbContext.Groups.FirstWithIdOrDefaultAsync(existingGroup.Id); groupsInDatabase.Should().BeNull(); - RgbColor colorInDatabase = await dbContext.RgbColors.FirstWithIdOrDefaultAsync(existingGroup.Color.Id); + RgbColor? colorInDatabase = await dbContext.RgbColors.FirstWithIdOrDefaultAsync(existingGroup.Color.Id); - colorInDatabase.Should().NotBeNull(); + colorInDatabase.ShouldNotBeNull(); colorInDatabase.Group.Should().BeNull(); }); } @@ -173,7 +175,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - WorkItem workItemInDatabase = await dbContext.WorkItems.FirstWithIdOrDefaultAsync(existingWorkItem.Id); + WorkItem? workItemInDatabase = await dbContext.WorkItems.FirstWithIdOrDefaultAsync(existingWorkItem.Id); workItemInDatabase.Should().BeNull(); @@ -209,13 +211,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - WorkItem workItemInDatabase = await dbContext.WorkItems.FirstWithIdOrDefaultAsync(existingWorkItem.Id); + WorkItem? workItemInDatabase = await dbContext.WorkItems.FirstWithIdOrDefaultAsync(existingWorkItem.Id); workItemInDatabase.Should().BeNull(); - WorkTag tagInDatabase = await dbContext.WorkTags.FirstWithIdOrDefaultAsync(existingWorkItem.Tags.ElementAt(0).Id); + WorkTag? tagInDatabase = await dbContext.WorkTags.FirstWithIdOrDefaultAsync(existingWorkItem.Tags.ElementAt(0).Id); - tagInDatabase.Should().NotBeNull(); + tagInDatabase.ShouldNotBeNull(); }); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Fetching/FetchRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Fetching/FetchRelationshipTests.cs index 8e89e052d1..ef78f1cdcf 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Fetching/FetchRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Fetching/FetchRelationshipTests.cs @@ -42,7 +42,7 @@ 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("userAccounts"); responseDocument.Data.SingleValue.Id.Should().Be(workItem.Assignee.StringId); responseDocument.Data.SingleValue.Attributes.Should().BeNull(); @@ -92,7 +92,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(2); + responseDocument.Data.ManyValue.ShouldHaveCount(2); ResourceObject item1 = responseDocument.Data.ManyValue.Single(resource => resource.Id == userAccount.AssignedItems.ElementAt(0).StringId); item1.Type.Should().Be("workItems"); @@ -149,7 +149,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(2); + responseDocument.Data.ManyValue.ShouldHaveCount(2); ResourceObject item1 = responseDocument.Data.ManyValue.Single(resource => resource.Id == workItem.Tags.ElementAt(0).StringId); item1.Type.Should().Be("workTags"); @@ -212,7 +212,7 @@ public async Task Cannot_get_relationship_for_unknown_primary_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); @@ -239,7 +239,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); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Fetching/FetchResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Fetching/FetchResourceTests.cs index fbfa714995..f6873d64b8 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Fetching/FetchResourceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Fetching/FetchResourceTests.cs @@ -45,21 +45,21 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(2); + responseDocument.Data.ManyValue.ShouldHaveCount(2); ResourceObject item1 = responseDocument.Data.ManyValue.Single(resource => resource.Id == workItems[0].StringId); item1.Type.Should().Be("workItems"); - item1.Attributes["description"].Should().Be(workItems[0].Description); - item1.Attributes["dueAt"].As<DateTimeOffset?>().Should().BeCloseTo(workItems[0].DueAt.GetValueOrDefault()); - item1.Attributes["priority"].Should().Be(workItems[0].Priority); - item1.Relationships.Should().NotBeEmpty(); + item1.Attributes.ShouldContainKey("description").With(value => value.Should().Be(workItems[0].Description)); + item1.Attributes.ShouldContainKey("dueAt").With(value => value.As<DateTimeOffset?>().Should().BeCloseTo(workItems[0].DueAt!.Value)); + item1.Attributes.ShouldContainKey("priority").With(value => value.Should().Be(workItems[0].Priority)); + item1.Relationships.ShouldNotBeEmpty(); ResourceObject item2 = responseDocument.Data.ManyValue.Single(resource => resource.Id == workItems[1].StringId); item2.Type.Should().Be("workItems"); - item2.Attributes["description"].Should().Be(workItems[1].Description); - item2.Attributes["dueAt"].As<DateTimeOffset?>().Should().BeCloseTo(workItems[1].DueAt.GetValueOrDefault()); - item2.Attributes["priority"].Should().Be(workItems[1].Priority); - item2.Relationships.Should().NotBeEmpty(); + item2.Attributes.ShouldContainKey("description").With(value => value.Should().Be(workItems[1].Description)); + item2.Attributes.ShouldContainKey("dueAt").With(value => value.As<DateTimeOffset?>().Should().BeCloseTo(workItems[1].DueAt!.Value)); + item2.Attributes.ShouldContainKey("priority").With(value => value.Should().Be(workItems[1].Priority)); + item2.Relationships.ShouldNotBeEmpty(); } [Fact] @@ -97,13 +97,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); + DateTimeOffset dueAt = workItem.DueAt!.Value; + + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Type.Should().Be("workItems"); responseDocument.Data.SingleValue.Id.Should().Be(workItem.StringId); - responseDocument.Data.SingleValue.Attributes["description"].Should().Be(workItem.Description); - responseDocument.Data.SingleValue.Attributes["dueAt"].As<DateTimeOffset?>().Should().BeCloseTo(workItem.DueAt.GetValueOrDefault()); - responseDocument.Data.SingleValue.Attributes["priority"].Should().Be(workItem.Priority); - responseDocument.Data.SingleValue.Relationships.Should().NotBeEmpty(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("description").With(value => value.Should().Be(workItem.Description)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("dueAt").With(value => value.As<DateTimeOffset?>().Should().BeCloseTo(dueAt)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("priority").With(value => value.Should().Be(workItem.Priority)); + responseDocument.Data.SingleValue.Relationships.ShouldNotBeEmpty(); } [Fact] @@ -135,7 +137,7 @@ public async Task Cannot_get_primary_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); @@ -164,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.Type.Should().Be("userAccounts"); responseDocument.Data.SingleValue.Id.Should().Be(workItem.Assignee.StringId); - responseDocument.Data.SingleValue.Attributes["firstName"].Should().Be(workItem.Assignee.FirstName); - responseDocument.Data.SingleValue.Attributes["lastName"].Should().Be(workItem.Assignee.LastName); - responseDocument.Data.SingleValue.Relationships.Should().NotBeEmpty(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("firstName").With(value => value.Should().Be(workItem.Assignee.FirstName)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("lastName").With(value => value.Should().Be(workItem.Assignee.LastName)); + responseDocument.Data.SingleValue.Relationships.ShouldNotBeEmpty(); } [Fact] @@ -216,21 +218,24 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(2); + responseDocument.Data.ManyValue.ShouldHaveCount(2); + + DateTimeOffset dueAt1 = userAccount.AssignedItems.ElementAt(0).DueAt!.Value; + DateTimeOffset dueAt2 = userAccount.AssignedItems.ElementAt(1).DueAt!.Value; ResourceObject item1 = responseDocument.Data.ManyValue.Single(resource => resource.Id == userAccount.AssignedItems.ElementAt(0).StringId); item1.Type.Should().Be("workItems"); - item1.Attributes["description"].Should().Be(userAccount.AssignedItems.ElementAt(0).Description); - item1.Attributes["dueAt"].As<DateTimeOffset?>().Should().BeCloseTo(userAccount.AssignedItems.ElementAt(0).DueAt.GetValueOrDefault()); - item1.Attributes["priority"].Should().Be(userAccount.AssignedItems.ElementAt(0).Priority); - item1.Relationships.Should().NotBeEmpty(); + item1.Attributes.ShouldContainKey("description").With(value => value.Should().Be(userAccount.AssignedItems.ElementAt(0).Description)); + item1.Attributes.ShouldContainKey("dueAt").With(value => value.As<DateTimeOffset?>().Should().BeCloseTo(dueAt1)); + item1.Attributes.ShouldContainKey("priority").With(value => value.Should().Be(userAccount.AssignedItems.ElementAt(0).Priority)); + item1.Relationships.ShouldNotBeEmpty(); ResourceObject item2 = responseDocument.Data.ManyValue.Single(resource => resource.Id == userAccount.AssignedItems.ElementAt(1).StringId); item2.Type.Should().Be("workItems"); - item2.Attributes["description"].Should().Be(userAccount.AssignedItems.ElementAt(1).Description); - item2.Attributes["dueAt"].As<DateTimeOffset?>().Should().BeCloseTo(userAccount.AssignedItems.ElementAt(1).DueAt.GetValueOrDefault()); - item2.Attributes["priority"].Should().Be(userAccount.AssignedItems.ElementAt(1).Priority); - item2.Relationships.Should().NotBeEmpty(); + item2.Attributes.ShouldContainKey("description").With(value => value.Should().Be(userAccount.AssignedItems.ElementAt(1).Description)); + item2.Attributes.ShouldContainKey("dueAt").With(value => value.As<DateTimeOffset?>().Should().BeCloseTo(dueAt2)); + item2.Attributes.ShouldContainKey("priority").With(value => value.Should().Be(userAccount.AssignedItems.ElementAt(1).Priority)); + item2.Relationships.ShouldNotBeEmpty(); } [Fact] @@ -277,19 +282,19 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(2); + responseDocument.Data.ManyValue.ShouldHaveCount(2); ResourceObject item1 = responseDocument.Data.ManyValue.Single(resource => resource.Id == workItem.Tags.ElementAt(0).StringId); item1.Type.Should().Be("workTags"); - item1.Attributes["text"].Should().Be(workItem.Tags.ElementAt(0).Text); - item1.Attributes["isBuiltIn"].Should().Be(workItem.Tags.ElementAt(0).IsBuiltIn); - item1.Relationships.Should().NotBeEmpty(); + item1.Attributes.ShouldContainKey("text").With(value => value.Should().Be(workItem.Tags.ElementAt(0).Text)); + item1.Attributes.ShouldContainKey("isBuiltIn").With(value => value.Should().Be(workItem.Tags.ElementAt(0).IsBuiltIn)); + item1.Relationships.ShouldNotBeEmpty(); ResourceObject item2 = responseDocument.Data.ManyValue.Single(resource => resource.Id == workItem.Tags.ElementAt(1).StringId); item2.Type.Should().Be("workTags"); - item2.Attributes["text"].Should().Be(workItem.Tags.ElementAt(1).Text); - item2.Attributes["isBuiltIn"].Should().Be(workItem.Tags.ElementAt(1).IsBuiltIn); - item2.Relationships.Should().NotBeEmpty(); + item2.Attributes.ShouldContainKey("text").With(value => value.Should().Be(workItem.Tags.ElementAt(1).Text)); + item2.Attributes.ShouldContainKey("isBuiltIn").With(value => value.Should().Be(workItem.Tags.ElementAt(1).IsBuiltIn)); + item2.Relationships.ShouldNotBeEmpty(); } [Fact] @@ -344,7 +349,7 @@ public async Task Cannot_get_secondary_resource_for_unknown_primary_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); @@ -373,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); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/ImplicitlyChangingWorkItemDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/ImplicitlyChangingWorkItemDefinition.cs index bac9be6cc9..6f8a90b69c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/ImplicitlyChangingWorkItemDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/ImplicitlyChangingWorkItemDefinition.cs @@ -12,7 +12,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ReadWrite /// Used to simulate side effects that occur in the database while saving, typically caused by database triggers. /// </summary> [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class ImplicitlyChangingWorkItemDefinition : JsonApiResourceDefinition<WorkItem> + public sealed class ImplicitlyChangingWorkItemDefinition : JsonApiResourceDefinition<WorkItem, int> { internal const string Suffix = " (changed)"; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/ReadWriteDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/ReadWriteDbContext.cs index 92d7c28888..727d919ad3 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/ReadWriteDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/ReadWriteDbContext.cs @@ -9,11 +9,11 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ReadWrite [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class ReadWriteDbContext : DbContext { - public DbSet<WorkItem> WorkItems { get; set; } - public DbSet<WorkTag> WorkTags { get; set; } - public DbSet<WorkItemGroup> Groups { get; set; } - public DbSet<RgbColor> RgbColors { get; set; } - public DbSet<UserAccount> UserAccounts { get; set; } + public DbSet<WorkItem> WorkItems => Set<WorkItem>(); + public DbSet<WorkTag> WorkTags => Set<WorkTag>(); + public DbSet<WorkItemGroup> Groups => Set<WorkItemGroup>(); + public DbSet<RgbColor> RgbColors => Set<RgbColor>(); + public DbSet<UserAccount> UserAccounts => Set<UserAccount>(); public ReadWriteDbContext(DbContextOptions<ReadWriteDbContext> options) : base(options) @@ -24,7 +24,7 @@ protected override void OnModelCreating(ModelBuilder builder) { builder.Entity<WorkItem>() .HasOne(workItem => workItem.Assignee) - .WithMany(userAccount => userAccount.AssignedItems); + .WithMany(userAccount => userAccount!.AssignedItems); builder.Entity<WorkItem>() .HasMany(workItem => workItem.Subscribers) @@ -32,12 +32,12 @@ protected override void OnModelCreating(ModelBuilder builder) builder.Entity<WorkItemGroup>() .HasOne(workItemGroup => workItemGroup.Color) - .WithOne(color => color.Group) + .WithOne(color => color!.Group!) .HasForeignKey<RgbColor>("GroupId"); builder.Entity<WorkItem>() .HasOne(workItem => workItem.Parent) - .WithMany(workItem => workItem.Children); + .WithMany(workItem => workItem!.Children); builder.Entity<WorkItem>() .HasMany(workItem => workItem.RelatedFrom) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/ReadWriteFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/ReadWriteFakers.cs index fda5ba9218..03036f0630 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/ReadWriteFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/ReadWriteFakers.cs @@ -39,7 +39,7 @@ internal sealed class ReadWriteFakers : FakerContainer new Faker<RgbColor>() .UseSeed(GetFakerSeed()) .RuleFor(color => color.Id, faker => faker.Random.Hexadecimal(6)) - .RuleFor(color => color.DisplayName, faker => faker.Lorem.Word())); + .RuleFor(color => color.DisplayName, faker => faker.Commerce.Color())); public Faker<WorkItem> WorkItem => _lazyWorkItemFaker.Value; public Faker<WorkTag> WorkTag => _lazyWorkTagFaker.Value; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/RgbColor.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/RgbColor.cs index 71e32d6618..741a189a9c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/RgbColor.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/RgbColor.cs @@ -8,9 +8,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ReadWrite public sealed class RgbColor : Identifiable<string> { [Attr] - public string DisplayName { get; set; } + public string DisplayName { get; set; } = null!; [HasOne] - public WorkItemGroup Group { get; set; } + public WorkItemGroup? Group { get; set; } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/RgbColorsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/RgbColorsController.cs index ced6497481..f84fcc75de 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/RgbColorsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/RgbColorsController.cs @@ -7,8 +7,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ReadWrite { public sealed class RgbColorsController : JsonApiController<RgbColor, string> { - public RgbColorsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<RgbColor, string> resourceService) - : base(options, loggerFactory, resourceService) + public RgbColorsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<RgbColor, string> resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/AddToToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/AddToToManyRelationshipTests.cs index d66a425101..3e66a9a0db 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/AddToToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/AddToToManyRelationshipTests.cs @@ -54,12 +54,14 @@ 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); - error.Title.Should().Be("Only to-many relationships can be updated through this endpoint."); - error.Detail.Should().Be("Relationship 'assignee' must be a to-many relationship."); + error.Title.Should().Be("Failed to deserialize request body: Only to-many relationships can be targeted through this endpoint."); + error.Detail.Should().Be("Relationship 'assignee' is not a to-many relationship."); + error.Source.Should().BeNull(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -108,7 +110,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { WorkItem workItemInDatabase = await dbContext.WorkItems.Include(workItem => workItem.Subscribers).FirstWithIdAsync(existingWorkItem.Id); - workItemInDatabase.Subscribers.Should().HaveCount(3); + workItemInDatabase.Subscribers.ShouldHaveCount(3); workItemInDatabase.Subscribers.Should().ContainSingle(subscriber => subscriber.Id == existingWorkItem.Subscribers.ElementAt(0).Id); workItemInDatabase.Subscribers.Should().ContainSingle(subscriber => subscriber.Id == existingWorkItem.Subscribers.ElementAt(1).Id); workItemInDatabase.Subscribers.Should().ContainSingle(subscriber => subscriber.Id == existingSubscriber.Id); @@ -165,13 +167,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => int tagId1 = existingWorkItems[0].Tags.ElementAt(0).Id; int tagId2 = existingWorkItems[1].Tags.ElementAt(0).Id; - workItemInDatabase1.Tags.Should().HaveCount(2); + workItemInDatabase1.Tags.ShouldHaveCount(2); workItemInDatabase1.Tags.Should().ContainSingle(workTag => workTag.Id == tagId1); workItemInDatabase1.Tags.Should().ContainSingle(workTag => workTag.Id == tagId2); WorkItem workItemInDatabase2 = workItemsInDatabase.Single(workItem => workItem.Id == existingWorkItems[1].Id); - workItemInDatabase2.Tags.Should().HaveCount(1); + workItemInDatabase2.Tags.ShouldHaveCount(1); workItemInDatabase2.Tags.ElementAt(0).Id.Should().Be(tagId2); }); } @@ -198,12 +200,46 @@ 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("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"); + } + + [Fact] + public async Task Cannot_add_for_null_request_body() + { + // Arrange + WorkItem existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + const string requestBody = "null"; + + string route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync<Document>(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] @@ -237,12 +273,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: Request body must include 'type' element."); - error.Detail.Should().StartWith("Expected 'type' element in 'data' element. - Request body: <<"); + 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("/data[0]"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -277,12 +316,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: Request body includes unknown resource type."); - error.Detail.Should().StartWith($"Resource type '{Unknown.ResourceType}' does not exist. - Request body: <<"); + 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("/data[0]/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -316,12 +358,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: Request body must include 'id' element."); - error.Detail.Should().StartWith("Request body: <<"); + 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("/data[0]"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -364,17 +409,19 @@ 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 'userAccounts' with ID '{userAccountId1}' in relationship 'subscribers' does not exist."); + 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 'userAccounts' with ID '{userAccountId2}' in relationship 'subscribers' does not exist."); + error2.Meta.Should().NotContainKey("requestBody"); } [Fact] @@ -417,17 +464,19 @@ 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 'workTags' with ID '{tagId1}' in relationship 'tags' does not exist."); + 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 'workTags' with ID '{tagId2}' in relationship 'tags' does not exist."); + error2.Meta.Should().NotContainKey("requestBody"); } [Fact] @@ -500,12 +549,14 @@ 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 'workItems' with ID '{workItemId}' does not exist."); + error.Source.Should().BeNull(); + error.Meta.Should().NotContainKey("requestBody"); } [Fact] @@ -540,12 +591,14 @@ 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 relationship does not exist."); error.Detail.Should().Be($"Resource of type 'workItems' does not contain a relationship named '{Unknown.Relationship}'."); + error.Source.Should().BeNull(); + error.Meta.Should().NotContainKey("requestBody"); } [Fact] @@ -581,14 +634,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("Resource type mismatch between request body and endpoint URL."); - - error.Detail.Should().Be("Expected resource of type 'workTags' in POST request body at endpoint " + - $"'/workItems/{existingWorkItem.StringId}/relationships/tags', instead of 'userAccounts'."); + error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found."); + error.Detail.Should().Be("Type 'userAccounts' is incompatible with type 'workTags' of relationship 'tags'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data[0]/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -635,7 +689,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { WorkItem workItemInDatabase = await dbContext.WorkItems.Include(workItem => workItem.Subscribers).FirstWithIdAsync(existingWorkItem.Id); - workItemInDatabase.Subscribers.Should().HaveCount(1); + workItemInDatabase.Subscribers.ShouldHaveCount(1); workItemInDatabase.Subscribers.Single().Id.Should().Be(existingSubscriber.Id); }); } @@ -671,12 +725,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { WorkItem workItemInDatabase = await dbContext.WorkItems.Include(workItem => workItem.Subscribers).FirstWithIdAsync(existingWorkItem.Id); - workItemInDatabase.Subscribers.Should().HaveCount(0); + workItemInDatabase.Subscribers.ShouldHaveCount(0); }); } [Fact] - public async Task Cannot_add_with_null_data_in_OneToMany_relationship() + public async Task Cannot_add_with_missing_data_in_OneToMany_relationship() { // Arrange WorkItem existingWorkItem = _fakers.WorkItem.Generate(); @@ -689,7 +743,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var requestBody = new { - data = (object)null }; string route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; @@ -700,12 +753,14 @@ 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().StartWith("Expected data[] element for 'subscribers' relationship. - Request body: <<"); + error.Title.Should().Be("Failed to deserialize request body: The 'data' element is required."); + error.Detail.Should().BeNull(); + error.Source.Should().BeNull(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -722,7 +777,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var requestBody = new { - data = (object)null + data = (object?)null }; string route = $"/workItems/{existingWorkItem.StringId}/relationships/tags"; @@ -733,12 +788,53 @@ 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().StartWith("Expected data[] element for 'tags' relationship. - Request body: <<"); + 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("/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } + + [Fact] + public async Task Cannot_add_with_object_data_in_ManyToMany_relationship() + { + // Arrange + WorkItem existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + } + }; + + string route = $"/workItems/{existingWorkItem.StringId}/relationships/tags"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync<Document>(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("/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -780,7 +876,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { WorkItem workItemInDatabase = await dbContext.WorkItems.Include(workItem => workItem.Children).FirstWithIdAsync(existingWorkItem.Id); - workItemInDatabase.Children.Should().HaveCount(2); + workItemInDatabase.Children.ShouldHaveCount(2); workItemInDatabase.Children.Should().ContainSingle(workItem => workItem.Id == existingWorkItem.Children[0].Id); workItemInDatabase.Children.Should().ContainSingle(workItem => workItem.Id == existingWorkItem.Id); }); @@ -834,10 +930,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // @formatter:keep_existing_linebreaks restore // @formatter:wrap_chained_method_calls restore - workItemInDatabase.RelatedFrom.Should().HaveCount(1); + workItemInDatabase.RelatedFrom.ShouldHaveCount(1); workItemInDatabase.RelatedFrom.Should().OnlyContain(workItem => workItem.Id == existingWorkItem.Id); - workItemInDatabase.RelatedTo.Should().HaveCount(2); + workItemInDatabase.RelatedTo.ShouldHaveCount(2); workItemInDatabase.RelatedTo.Should().ContainSingle(workItem => workItem.Id == existingWorkItem.Id); workItemInDatabase.RelatedTo.Should().ContainSingle(workItem => workItem.Id == existingWorkItem.RelatedTo[0].Id); }); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/RemoveFromToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/RemoveFromToManyRelationshipTests.cs index ed1f9755ab..fccf76b4d8 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/RemoveFromToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/RemoveFromToManyRelationshipTests.cs @@ -32,10 +32,10 @@ public RemoveFromToManyRelationshipTests(IntegrationTestContext<TestableStartup< testContext.ConfigureServicesAfterStartup(services => { - services.AddSingleton<IResourceDefinition<WorkItem>, RemoveExtraFromWorkItemDefinition>(); + services.AddSingleton<IResourceDefinition<WorkItem, int>, RemoveExtraFromWorkItemDefinition>(); }); - var workItemDefinition = (RemoveExtraFromWorkItemDefinition)testContext.Factory.Services.GetRequiredService<IResourceDefinition<WorkItem>>(); + var workItemDefinition = (RemoveExtraFromWorkItemDefinition)testContext.Factory.Services.GetRequiredService<IResourceDefinition<WorkItem, int>>(); workItemDefinition.Reset(); } @@ -69,12 +69,14 @@ 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); - error.Title.Should().Be("Only to-many relationships can be updated through this endpoint."); - error.Detail.Should().Be("Relationship 'assignee' must be a to-many relationship."); + error.Title.Should().Be("Failed to deserialize request body: Only to-many relationships can be targeted through this endpoint."); + error.Detail.Should().Be("Relationship 'assignee' is not a to-many relationship."); + error.Source.Should().BeNull(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -123,11 +125,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { WorkItem workItemInDatabase = await dbContext.WorkItems.Include(workItem => workItem.Subscribers).FirstWithIdAsync(existingWorkItem.Id); - workItemInDatabase.Subscribers.Should().HaveCount(1); + workItemInDatabase.Subscribers.ShouldHaveCount(1); workItemInDatabase.Subscribers.Single().Id.Should().Be(existingWorkItem.Subscribers.ElementAt(1).Id); List<UserAccount> userAccountsInDatabase = await dbContext.UserAccounts.ToListAsync(); - userAccountsInDatabase.Should().HaveCount(3); + userAccountsInDatabase.ShouldHaveCount(3); }); } @@ -145,7 +147,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var workItemDefinition = (RemoveExtraFromWorkItemDefinition)_testContext.Factory.Services.GetRequiredService<IResourceDefinition<WorkItem>>(); + var workItemDefinition = (RemoveExtraFromWorkItemDefinition)_testContext.Factory.Services.GetRequiredService<IResourceDefinition<WorkItem, int>>(); workItemDefinition.ExtraSubscribersIdsToRemove.Add(existingWorkItem.Subscribers.ElementAt(2).Id); var requestBody = new @@ -170,17 +172,17 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Should().BeEmpty(); - workItemDefinition.PreloadedSubscribers.Should().HaveCount(1); + workItemDefinition.PreloadedSubscribers.ShouldHaveCount(1); await _testContext.RunOnDatabaseAsync(async dbContext => { WorkItem workItemInDatabase = await dbContext.WorkItems.Include(workItem => workItem.Subscribers).FirstWithIdAsync(existingWorkItem.Id); - workItemInDatabase.Subscribers.Should().HaveCount(1); + workItemInDatabase.Subscribers.ShouldHaveCount(1); workItemInDatabase.Subscribers.Single().Id.Should().Be(existingWorkItem.Subscribers.ElementAt(1).Id); List<UserAccount> userAccountsInDatabase = await dbContext.UserAccounts.ToListAsync(); - userAccountsInDatabase.Should().HaveCount(3); + userAccountsInDatabase.ShouldHaveCount(3); }); } @@ -231,11 +233,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { WorkItem workItemInDatabase = await dbContext.WorkItems.Include(workItem => workItem.Tags).FirstWithIdAsync(existingWorkItem.Id); - workItemInDatabase.Tags.Should().HaveCount(1); + workItemInDatabase.Tags.ShouldHaveCount(1); workItemInDatabase.Tags.Single().Id.Should().Be(existingWorkItem.Tags.ElementAt(0).Id); List<WorkTag> tagsInDatabase = await dbContext.WorkTags.ToListAsync(); - tagsInDatabase.Should().HaveCount(3); + tagsInDatabase.ShouldHaveCount(3); }); } @@ -253,7 +255,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var workItemDefinition = (RemoveExtraFromWorkItemDefinition)_testContext.Factory.Services.GetRequiredService<IResourceDefinition<WorkItem>>(); + var workItemDefinition = (RemoveExtraFromWorkItemDefinition)_testContext.Factory.Services.GetRequiredService<IResourceDefinition<WorkItem, int>>(); workItemDefinition.ExtraTagIdsToRemove.Add(existingWorkItem.Tags.ElementAt(2).Id); var requestBody = new @@ -278,17 +280,17 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Should().BeEmpty(); - workItemDefinition.PreloadedTags.Should().HaveCount(1); + workItemDefinition.PreloadedTags.ShouldHaveCount(1); await _testContext.RunOnDatabaseAsync(async dbContext => { WorkItem workItemInDatabase = await dbContext.WorkItems.Include(workItem => workItem.Tags).FirstWithIdAsync(existingWorkItem.Id); - workItemInDatabase.Tags.Should().HaveCount(1); + workItemInDatabase.Tags.ShouldHaveCount(1); workItemInDatabase.Tags.Single().Id.Should().Be(existingWorkItem.Tags.ElementAt(0).Id); List<WorkTag> tagsInDatabase = await dbContext.WorkTags.ToListAsync(); - tagsInDatabase.Should().HaveCount(3); + tagsInDatabase.ShouldHaveCount(3); }); } @@ -314,12 +316,46 @@ 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("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"); + } + + [Fact] + public async Task Cannot_remove_for_null_request_body() + { + // Arrange + WorkItem existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + const string requestBody = "null"; + + string route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteDeleteAsync<Document>(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] @@ -354,12 +390,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: Request body must include 'type' element."); - error.Detail.Should().StartWith("Expected 'type' element in 'data' element. - Request body: <<"); + 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("/data[0]"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -394,12 +433,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: Request body includes unknown resource type."); - error.Detail.Should().StartWith($"Resource type '{Unknown.ResourceType}' does not exist. - Request body: <<"); + 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("/data[0]/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -433,12 +475,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: Request body must include 'id' element."); - error.Detail.Should().StartWith("Request body: <<"); + 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("/data[0]"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -481,17 +526,19 @@ 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 'userAccounts' with ID '{userAccountId1}' in relationship 'subscribers' does not exist."); + 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 'userAccounts' with ID '{userAccountId2}' in relationship 'subscribers' does not exist."); + error2.Meta.Should().NotContainKey("requestBody"); } [Fact] @@ -534,17 +581,19 @@ 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 'workTags' with ID '{tagId1}' in relationship 'tags' does not exist."); + 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 'workTags' with ID '{tagId2}' in relationship 'tags' does not exist."); + error2.Meta.Should().NotContainKey("requestBody"); } [Fact] @@ -617,12 +666,14 @@ 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 'workItems' with ID '{workItemId}' does not exist."); + error.Source.Should().BeNull(); + error.Meta.Should().NotContainKey("requestBody"); } [Fact] @@ -657,12 +708,14 @@ 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 relationship does not exist."); error.Detail.Should().Be($"Resource of type 'workItems' does not contain a relationship named '{Unknown.Relationship}'."); + error.Source.Should().BeNull(); + error.Meta.Should().NotContainKey("requestBody"); } [Fact] @@ -698,14 +751,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("Resource type mismatch between request body and endpoint URL."); - - error.Detail.Should().Be("Expected resource of type 'workTags' in DELETE request body at endpoint " + - $"'/workItems/{existingWorkItem.StringId}/relationships/tags', instead of 'userAccounts'."); + error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found."); + error.Detail.Should().Be("Type 'userAccounts' is incompatible with type 'workTags' of relationship 'tags'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data[0]/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -752,7 +806,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { WorkItem workItemInDatabase = await dbContext.WorkItems.Include(workItem => workItem.Subscribers).FirstWithIdAsync(existingWorkItem.Id); - workItemInDatabase.Subscribers.Should().HaveCount(1); + workItemInDatabase.Subscribers.ShouldHaveCount(1); workItemInDatabase.Subscribers.Single().Id.Should().Be(existingWorkItem.Subscribers.ElementAt(1).Id); }); } @@ -789,13 +843,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { WorkItem workItemInDatabase = await dbContext.WorkItems.Include(workItem => workItem.Subscribers).FirstWithIdAsync(existingWorkItem.Id); - workItemInDatabase.Subscribers.Should().HaveCount(1); + workItemInDatabase.Subscribers.ShouldHaveCount(1); workItemInDatabase.Subscribers.Single().Id.Should().Be(existingWorkItem.Subscribers.ElementAt(0).Id); }); } [Fact] - public async Task Cannot_remove_with_null_data_in_OneToMany_relationship() + public async Task Cannot_remove_with_missing_data_in_OneToMany_relationship() { // Arrange WorkItem existingWorkItem = _fakers.WorkItem.Generate(); @@ -808,7 +862,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var requestBody = new { - data = (object)null }; string route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; @@ -819,12 +872,14 @@ 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().StartWith("Expected data[] element for 'subscribers' relationship. - Request body: <<"); + error.Title.Should().Be("Failed to deserialize request body: The 'data' element is required."); + error.Detail.Should().BeNull(); + error.Source.Should().BeNull(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -841,7 +896,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var requestBody = new { - data = (object)null + data = (object?)null }; string route = $"/workItems/{existingWorkItem.StringId}/relationships/tags"; @@ -852,12 +907,53 @@ 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().StartWith("Expected data[] element for 'tags' relationship. - Request body: <<"); + 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("/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } + + [Fact] + public async Task Cannot_remove_with_object_data_in_ManyToMany_relationship() + { + // Arrange + WorkItem existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + } + }; + + string route = $"/workItems/{existingWorkItem.StringId}/relationships/tags"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteDeleteAsync<Document>(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("/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -902,7 +998,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { WorkItem workItemInDatabase = await dbContext.WorkItems.Include(workItem => workItem.Children).FirstWithIdAsync(existingWorkItem.Id); - workItemInDatabase.Children.Should().HaveCount(1); + workItemInDatabase.Children.ShouldHaveCount(1); workItemInDatabase.Children[0].Id.Should().Be(existingWorkItem.Children[0].Id); }); } @@ -958,7 +1054,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // @formatter:keep_existing_linebreaks restore // @formatter:wrap_chained_method_calls restore - workItemInDatabase.RelatedFrom.Should().HaveCount(1); + workItemInDatabase.RelatedFrom.ShouldHaveCount(1); workItemInDatabase.RelatedFrom[0].Id.Should().Be(existingWorkItem.RelatedFrom[0].Id); workItemInDatabase.RelatedTo.Should().BeEmpty(); @@ -966,7 +1062,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - private sealed class RemoveExtraFromWorkItemDefinition : JsonApiResourceDefinition<WorkItem> + private sealed class RemoveExtraFromWorkItemDefinition : JsonApiResourceDefinition<WorkItem, int> { // Enables to verify that not the full relationship was loaded upfront. public ISet<UserAccount> PreloadedSubscribers { get; } = new HashSet<UserAccount>(IdentifiableComparer.Instance); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/ReplaceToManyRelationshipTests.cs index 3b439c1cec..4477344f53 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/ReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/ReplaceToManyRelationshipTests.cs @@ -143,7 +143,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { WorkItem workItemInDatabase = await dbContext.WorkItems.Include(workItem => workItem.Subscribers).FirstWithIdAsync(existingWorkItem.Id); - workItemInDatabase.Subscribers.Should().HaveCount(2); + workItemInDatabase.Subscribers.ShouldHaveCount(2); workItemInDatabase.Subscribers.Should().ContainSingle(userAccount => userAccount.Id == existingWorkItem.Subscribers.ElementAt(1).Id); workItemInDatabase.Subscribers.Should().ContainSingle(userAccount => userAccount.Id == existingSubscriber.Id); }); @@ -201,7 +201,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { WorkItem workItemInDatabase = await dbContext.WorkItems.Include(workItem => workItem.Tags).FirstWithIdAsync(existingWorkItem.Id); - workItemInDatabase.Tags.Should().HaveCount(3); + workItemInDatabase.Tags.ShouldHaveCount(3); workItemInDatabase.Tags.Should().ContainSingle(workTag => workTag.Id == existingWorkItem.Tags.ElementAt(0).Id); workItemInDatabase.Tags.Should().ContainSingle(workTag => workTag.Id == existingTags[0].Id); workItemInDatabase.Tags.Should().ContainSingle(workTag => workTag.Id == existingTags[1].Id); @@ -230,12 +230,46 @@ 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("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"); + } + + [Fact] + public async Task Cannot_replace_for_null_request_body() + { + // Arrange + WorkItem existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + const string requestBody = "null"; + + string route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync<Document>(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] @@ -269,12 +303,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: Request body must include 'type' element."); - error.Detail.Should().StartWith("Expected 'type' element in 'data' element. - Request body: <<"); + 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("/data[0]"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -309,12 +346,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: Request body includes unknown resource type."); - error.Detail.Should().StartWith($"Resource type '{Unknown.ResourceType}' does not exist. - Request body: <<"); + 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("/data[0]/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -348,12 +388,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: Request body must include 'id' element."); - error.Detail.Should().StartWith("Request body: <<"); + 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("/data[0]"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -396,17 +439,21 @@ 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 'userAccounts' with ID '{userAccountId1}' in relationship 'subscribers' does not exist."); + error1.Source.Should().BeNull(); + 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 'userAccounts' with ID '{userAccountId2}' in relationship 'subscribers' does not exist."); + error2.Source.Should().BeNull(); + error2.Meta.Should().NotContainKey("requestBody"); } [Fact] @@ -449,17 +496,21 @@ 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 'workTags' with ID '{tagId1}' in relationship 'tags' does not exist."); + error1.Source.Should().BeNull(); + 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 'workTags' with ID '{tagId2}' in relationship 'tags' does not exist."); + error2.Source.Should().BeNull(); + error2.Meta.Should().NotContainKey("requestBody"); } [Fact] @@ -525,12 +576,14 @@ 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 'workItems' with ID '{workItemId}' does not exist."); + error.Source.Should().BeNull(); + error.Meta.Should().NotContainKey("requestBody"); } [Fact] @@ -565,12 +618,14 @@ 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 relationship does not exist."); error.Detail.Should().Be($"Resource of type 'workItems' does not contain a relationship named '{Unknown.Relationship}'."); + error.Source.Should().BeNull(); + error.Meta.Should().NotContainKey("requestBody"); } [Fact] @@ -606,14 +661,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("Resource type mismatch between request body and endpoint URL."); - - error.Detail.Should().Be("Expected resource of type 'workTags' in PATCH request body at endpoint " + - $"'/workItems/{existingWorkItem.StringId}/relationships/tags', instead of 'userAccounts'."); + error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found."); + error.Detail.Should().Be("Type 'userAccounts' is incompatible with type 'workTags' of relationship 'tags'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data[0]/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -662,13 +718,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { WorkItem workItemInDatabase = await dbContext.WorkItems.Include(workItem => workItem.Subscribers).FirstWithIdAsync(existingWorkItem.Id); - workItemInDatabase.Subscribers.Should().HaveCount(1); + workItemInDatabase.Subscribers.ShouldHaveCount(1); workItemInDatabase.Subscribers.Single().Id.Should().Be(existingSubscriber.Id); }); } [Fact] - public async Task Cannot_replace_with_null_data_in_OneToMany_relationship() + public async Task Cannot_replace_with_missing_data_in_OneToMany_relationship() { // Arrange WorkItem existingWorkItem = _fakers.WorkItem.Generate(); @@ -681,7 +737,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var requestBody = new { - data = (object)null }; string route = $"/workItems/{existingWorkItem.StringId}/relationships/subscribers"; @@ -692,12 +747,14 @@ 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().StartWith("Expected data[] element for 'subscribers' relationship. - Request body: <<"); + error.Title.Should().Be("Failed to deserialize request body: The 'data' element is required."); + error.Detail.Should().BeNull(); + error.Source.Should().BeNull(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -714,7 +771,45 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var requestBody = new { - data = (object)null + data = (object?)null + }; + + string route = $"/workItems/{existingWorkItem.StringId}/relationships/tags"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync<Document>(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("/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } + + [Fact] + public async Task Cannot_replace_with_object_data_in_ManyToMany_relationship() + { + // Arrange + WorkItem existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + } }; string route = $"/workItems/{existingWorkItem.StringId}/relationships/tags"; @@ -725,12 +820,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 data[] element for to-many relationship."); - error.Detail.Should().StartWith("Expected data[] element for 'tags' relationship. - Request body: <<"); + 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("/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -861,7 +959,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { WorkItem workItemInDatabase = await dbContext.WorkItems.Include(workItem => workItem.Children).FirstWithIdAsync(existingWorkItem.Id); - workItemInDatabase.Children.Should().HaveCount(1); + workItemInDatabase.Children.ShouldHaveCount(1); workItemInDatabase.Children[0].Id.Should().Be(existingWorkItem.Id); }); } @@ -913,10 +1011,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // @formatter:keep_existing_linebreaks restore // @formatter:wrap_chained_method_calls restore - workItemInDatabase.RelatedFrom.Should().HaveCount(1); + workItemInDatabase.RelatedFrom.ShouldHaveCount(1); workItemInDatabase.RelatedFrom[0].Id.Should().Be(existingWorkItem.Id); - workItemInDatabase.RelatedTo.Should().HaveCount(1); + workItemInDatabase.RelatedTo.ShouldHaveCount(1); workItemInDatabase.RelatedTo[0].Id.Should().Be(existingWorkItem.Id); }); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/UpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/UpdateToOneRelationshipTests.cs index afe4fc3c97..7c27a33a45 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/UpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/UpdateToOneRelationshipTests.cs @@ -40,7 +40,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var requestBody = new { - data = (object)null + data = (object?)null }; string route = $"/workItems/{existingWorkItem.StringId}/relationships/assignee"; @@ -76,7 +76,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var requestBody = new { - data = (object)null + data = (object?)null }; string route = $"/workItemGroups/{existingGroup.StringId}/relationships/color"; @@ -91,7 +91,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - WorkItemGroup groupInDatabase = await dbContext.Groups.Include(group => group.Color).FirstWithIdOrDefaultAsync(existingGroup.Id); + WorkItemGroup groupInDatabase = await dbContext.Groups.Include(group => group.Color).FirstWithIdAsync(existingGroup.Id); groupInDatabase.Color.Should().BeNull(); }); @@ -139,7 +139,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => colorInDatabase1.Group.Should().BeNull(); RgbColor colorInDatabase2 = colorsInDatabase.Single(color => color.Id == existingColor.Id); - colorInDatabase2.Group.Should().NotBeNull(); + colorInDatabase2.Group.ShouldNotBeNull(); colorInDatabase2.Group.Id.Should().Be(existingGroup.Id); }); } @@ -163,7 +163,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = new { type = "rgbColors", - id = existingGroups[0].Color.StringId + id = existingGroups[0].Color!.StringId } }; @@ -184,18 +184,18 @@ await _testContext.RunOnDatabaseAsync(async dbContext => groupInDatabase1.Color.Should().BeNull(); WorkItemGroup groupInDatabase2 = groupsInDatabase.Single(group => group.Id == existingGroups[1].Id); - groupInDatabase2.Color.Should().NotBeNull(); - groupInDatabase2.Color.Id.Should().Be(existingGroups[0].Color.Id); + groupInDatabase2.Color.ShouldNotBeNull(); + groupInDatabase2.Color.Id.Should().Be(existingGroups[0].Color!.Id); List<RgbColor> colorsInDatabase = await dbContext.RgbColors.Include(color => color.Group).ToListAsync(); - RgbColor colorInDatabase1 = colorsInDatabase.Single(color => color.Id == existingGroups[0].Color.Id); - colorInDatabase1.Group.Should().NotBeNull(); + RgbColor colorInDatabase1 = colorsInDatabase.Single(color => color.Id == existingGroups[0].Color!.Id); + colorInDatabase1.Group.ShouldNotBeNull(); colorInDatabase1.Group.Id.Should().Be(existingGroups[1].Id); - RgbColor colorInDatabase2 = colorsInDatabase.SingleOrDefault(color => color.Id == existingGroups[1].Color.Id); - colorInDatabase1.Should().NotBeNull(); - colorInDatabase2!.Group.Should().BeNull(); + RgbColor? colorInDatabase2 = colorsInDatabase.SingleOrDefault(color => color.Id == existingGroups[1].Color!.Id); + colorInDatabase2.ShouldNotBeNull(); + colorInDatabase2.Group.Should().BeNull(); }); } @@ -236,7 +236,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => WorkItem workItemInDatabase2 = await dbContext.WorkItems.Include(workItem => workItem.Assignee).FirstWithIdAsync(workItemId); - workItemInDatabase2.Assignee.Should().NotBeNull(); + workItemInDatabase2.Assignee.ShouldNotBeNull(); workItemInDatabase2.Assignee.Id.Should().Be(existingUserAccounts[1].Id); }); } @@ -263,12 +263,125 @@ 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("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"); + } + + [Fact] + public async Task Cannot_replace_for_null_request_body() + { + // Arrange + WorkItem existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + const string requestBody = "null"; + + string route = $"/workItems/{existingWorkItem.StringId}/relationships/assignee"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync<Document>(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] + public async Task Cannot_create_for_missing_data() + { + // Arrange + WorkItem existingWorkItem = _fakers.WorkItem.Generate(); + UserAccount existingUserAccount = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingWorkItem, existingUserAccount); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + }; + + string route = $"/workItems/{existingWorkItem.StringId}/relationships/assignee"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync<Document>(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.Should().BeNull(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } + + [Fact] + public async Task Cannot_create_for_array_data() + { + // Arrange + WorkItem existingWorkItem = _fakers.WorkItem.Generate(); + UserAccount existingUserAccount = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingWorkItem, existingUserAccount); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "userAccounts", + id = existingUserAccount.StringId + } + } + }; + + string route = $"/workItems/{existingWorkItem.StringId}/relationships/assignee"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync<Document>(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("/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -299,12 +412,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: Request body must include 'type' element."); - error.Detail.Should().StartWith("Expected 'type' element in 'data' element. - Request body: <<"); + 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("/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -336,12 +452,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: Request body includes unknown resource type."); - error.Detail.Should().StartWith($"Resource type '{Unknown.ResourceType}' does not exist. - Request body: <<"); + 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("/data/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -372,12 +491,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: Request body must include 'id' element."); - error.Detail.Should().StartWith("Request body: <<"); + 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("/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -411,12 +533,14 @@ 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 'userAccounts' with ID '{userAccountId}' in relationship 'assignee' does not exist."); + error.Source.Should().BeNull(); + error.Meta.Should().NotContainKey("requestBody"); } [Fact] @@ -483,12 +607,14 @@ 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 'workItems' with ID '{workItemId}' does not exist."); + error.Source.Should().BeNull(); + error.Meta.Should().NotContainKey("requestBody"); } [Fact] @@ -520,12 +646,14 @@ 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 relationship does not exist."); error.Detail.Should().Be($"Resource of type 'workItems' does not contain a relationship named '{Unknown.Relationship}'."); + error.Source.Should().BeNull(); + error.Meta.Should().NotContainKey("requestBody"); } [Fact] @@ -558,55 +686,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("Resource type mismatch between request body and endpoint URL."); - - error.Detail.Should().Be("Expected resource of type 'userAccounts' in PATCH request body at endpoint " + - $"'/workItems/{existingWorkItem.StringId}/relationships/assignee', instead of 'rgbColors'."); - } - - [Fact] - public async Task Cannot_create_with_data_array_in_relationship() - { - // Arrange - WorkItem existingWorkItem = _fakers.WorkItem.Generate(); - UserAccount existingUserAccount = _fakers.UserAccount.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddInRange(existingWorkItem, existingUserAccount); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new[] - { - new - { - type = "userAccounts", - id = existingUserAccount.StringId - } - } - }; - - string route = $"/workItems/{existingWorkItem.StringId}/relationships/assignee"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync<Document>(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().StartWith("Expected single data element for 'assignee' relationship. - Request body: <<"); + error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found."); + error.Detail.Should().Be("Type 'rgbColors' is incompatible with type 'userAccounts' of relationship 'assignee'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -626,7 +714,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var requestBody = new { - data = (object)null + data = (object?)null }; string route = $"/workItems/{existingWorkItem.StringId}/relationships/parent"; @@ -682,7 +770,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { WorkItem workItemInDatabase = await dbContext.WorkItems.Include(workItem => workItem.Parent).FirstWithIdAsync(existingWorkItem.Id); - workItemInDatabase.Parent.Should().NotBeNull(); + workItemInDatabase.Parent.ShouldNotBeNull(); workItemInDatabase.Parent.Id.Should().Be(existingWorkItem.Id); }); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/ReplaceToManyRelationshipTests.cs index b225589c4e..71cc32dadd 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/ReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/ReplaceToManyRelationshipTests.cs @@ -68,7 +68,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -115,7 +115,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -176,13 +176,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); await _testContext.RunOnDatabaseAsync(async dbContext => { WorkItem workItemInDatabase = await dbContext.WorkItems.Include(workItem => workItem.Subscribers).FirstWithIdAsync(existingWorkItem.Id); - workItemInDatabase.Subscribers.Should().HaveCount(2); + workItemInDatabase.Subscribers.ShouldHaveCount(2); workItemInDatabase.Subscribers.Should().ContainSingle(userAccount => userAccount.Id == existingWorkItem.Subscribers.ElementAt(1).Id); workItemInDatabase.Subscribers.Should().ContainSingle(userAccount => userAccount.Id == existingSubscriber.Id); }); @@ -245,13 +245,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); await _testContext.RunOnDatabaseAsync(async dbContext => { WorkItem workItemInDatabase = await dbContext.WorkItems.Include(workItem => workItem.Tags).FirstWithIdAsync(existingWorkItem.Id); - workItemInDatabase.Tags.Should().HaveCount(3); + workItemInDatabase.Tags.ShouldHaveCount(3); workItemInDatabase.Tags.Should().ContainSingle(workTag => workTag.Id == existingWorkItem.Tags.ElementAt(0).Id); workItemInDatabase.Tags.Should().ContainSingle(workTag => workTag.Id == existingTags[0].Id); workItemInDatabase.Tags.Should().ContainSingle(workTag => workTag.Id == existingTags[1].Id); @@ -302,24 +302,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("workItems"); responseDocument.Data.SingleValue.Id.Should().Be(existingWorkItem.StringId); - responseDocument.Data.SingleValue.Attributes["priority"].Should().Be(existingWorkItem.Priority); - responseDocument.Data.SingleValue.Relationships.Should().NotBeEmpty(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("priority").With(value => value.Should().Be(existingWorkItem.Priority)); + responseDocument.Data.SingleValue.Relationships.ShouldNotBeEmpty(); - responseDocument.Included.Should().HaveCount(1); + responseDocument.Included.ShouldHaveCount(1); responseDocument.Included[0].Type.Should().Be("userAccounts"); responseDocument.Included[0].Id.Should().Be(existingUserAccount.StringId); - responseDocument.Included[0].Attributes["firstName"].Should().Be(existingUserAccount.FirstName); - responseDocument.Included[0].Attributes["lastName"].Should().Be(existingUserAccount.LastName); - responseDocument.Included[0].Relationships.Should().NotBeEmpty(); + responseDocument.Included[0].Attributes.ShouldContainKey("firstName").With(value => value.Should().Be(existingUserAccount.FirstName)); + responseDocument.Included[0].Attributes.ShouldContainKey("lastName").With(value => value.Should().Be(existingUserAccount.LastName)); + responseDocument.Included[0].Relationships.ShouldNotBeEmpty(); await _testContext.RunOnDatabaseAsync(async dbContext => { WorkItem workItemInDatabase = await dbContext.WorkItems.Include(workItem => workItem.Subscribers).FirstWithIdAsync(existingWorkItem.Id); - workItemInDatabase.Subscribers.Should().HaveCount(1); + workItemInDatabase.Subscribers.ShouldHaveCount(1); workItemInDatabase.Subscribers.Single().Id.Should().Be(existingUserAccount.Id); }); } @@ -368,29 +368,34 @@ 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("workItems"); responseDocument.Data.SingleValue.Id.Should().Be(existingWorkItem.StringId); - responseDocument.Data.SingleValue.Attributes.Should().HaveCount(1); - responseDocument.Data.SingleValue.Attributes["priority"].Should().Be(existingWorkItem.Priority); - responseDocument.Data.SingleValue.Relationships.Should().HaveCount(1); - responseDocument.Data.SingleValue.Relationships["tags"].Data.ManyValue.Should().HaveCount(1); - responseDocument.Data.SingleValue.Relationships["tags"].Data.ManyValue[0].Id.Should().Be(existingTag.StringId); + responseDocument.Data.SingleValue.Attributes.ShouldHaveCount(1); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("priority").With(value => value.Should().Be(existingWorkItem.Priority)); + responseDocument.Data.SingleValue.Relationships.ShouldHaveCount(1); - responseDocument.Included.Should().HaveCount(1); + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("tags").With(value => + { + value.ShouldNotBeNull(); + value.Data.ManyValue.ShouldHaveCount(1); + value.Data.ManyValue[0].Id.Should().Be(existingTag.StringId); + }); + + responseDocument.Included.ShouldHaveCount(1); responseDocument.Included[0].Type.Should().Be("workTags"); responseDocument.Included[0].Id.Should().Be(existingTag.StringId); - responseDocument.Included[0].Attributes.Should().HaveCount(1); - responseDocument.Included[0].Attributes["text"].Should().Be(existingTag.Text); + responseDocument.Included[0].Attributes.ShouldHaveCount(1); + responseDocument.Included[0].Attributes.ShouldContainKey("text").With(value => value.Should().Be(existingTag.Text)); responseDocument.Included[0].Relationships.Should().BeNull(); - int newWorkItemId = int.Parse(responseDocument.Data.SingleValue.Id); + int newWorkItemId = int.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { WorkItem workItemInDatabase = await dbContext.WorkItems.Include(workItem => workItem.Tags).FirstWithIdAsync(newWorkItemId); - workItemInDatabase.Tags.Should().HaveCount(1); + workItemInDatabase.Tags.ShouldHaveCount(1); workItemInDatabase.Tags.Single().Id.Should().Be(existingTag.Id); }); } @@ -437,12 +442,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: Request body must include 'type' element."); - error.Detail.Should().StartWith("Expected 'type' element in 'subscribers' relationship. - Request body: <<"); + 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("/data/relationships/subscribers/data[0]"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -488,12 +496,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: Request body includes unknown resource type."); - error.Detail.Should().StartWith($"Resource type '{Unknown.ResourceType}' does not exist. - Request body: <<"); + 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("/data/relationships/subscribers/data[0]/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -538,12 +549,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: Request body must include 'id' element."); - error.Detail.Should().StartWith("Expected 'id' element in 'subscribers' relationship. - Request body: <<"); + 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("/data/relationships/subscribers/data[0]"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -616,7 +630,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.Should().HaveCount(4); + responseDocument.Errors.ShouldHaveCount(4); ErrorObject error1 = responseDocument.Errors[0]; error1.StatusCode.Should().Be(HttpStatusCode.NotFound); @@ -680,14 +694,17 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync<Document>(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().StartWith("Relationship 'subscribers' contains incompatible resource type 'rgbColors'. - Request body: <<"); + error.StatusCode.Should().Be(HttpStatusCode.Conflict); + error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found."); + error.Detail.Should().Be("Type 'rgbColors' is incompatible with type 'userAccounts' of relationship 'subscribers'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data/relationships/subscribers/data[0]/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -741,19 +758,19 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); await _testContext.RunOnDatabaseAsync(async dbContext => { WorkItem workItemInDatabase = await dbContext.WorkItems.Include(workItem => workItem.Subscribers).FirstWithIdAsync(existingWorkItem.Id); - workItemInDatabase.Subscribers.Should().HaveCount(1); + workItemInDatabase.Subscribers.ShouldHaveCount(1); workItemInDatabase.Subscribers.Single().Id.Should().Be(existingSubscriber.Id); }); } [Fact] - public async Task Cannot_replace_with_null_data_in_OneToMany_relationship() + public async Task Cannot_replace_with_missing_data_in_OneToMany_relationship() { // Arrange WorkItem existingWorkItem = _fakers.WorkItem.Generate(); @@ -774,7 +791,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { subscribers = new { - data = (object)null } } } @@ -788,12 +804,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 data[] element for to-many relationship."); - error.Detail.Should().StartWith("Expected data[] element for 'subscribers' relationship. - Request body: <<"); + 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("/data/relationships/subscribers"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -818,7 +837,56 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { tags = new { - data = (object)null + data = (object?)null + } + } + } + }; + + string route = $"/workItems/{existingWorkItem.StringId}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync<Document>(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("/data/relationships/tags/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } + + [Fact] + public async Task Cannot_replace_with_object_data_in_ManyToMany_relationship() + { + // Arrange + WorkItem existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + tags = new + { + data = new + { + } } } } @@ -832,12 +900,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 data[] element for to-many relationship."); - error.Detail.Should().StartWith("Expected data[] element for 'tags' relationship. - Request body: <<"); + 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("/data/relationships/tags/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -879,7 +950,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -928,7 +999,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -991,13 +1062,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); await _testContext.RunOnDatabaseAsync(async dbContext => { WorkItem workItemInDatabase = await dbContext.WorkItems.Include(workItem => workItem.Children).FirstWithIdAsync(existingWorkItem.Id); - workItemInDatabase.Children.Should().HaveCount(1); + workItemInDatabase.Children.ShouldHaveCount(1); workItemInDatabase.Children[0].Id.Should().Be(existingWorkItem.Id); }); } @@ -1045,7 +1116,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -1060,10 +1131,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // @formatter:keep_existing_linebreaks restore // @formatter:wrap_chained_method_calls restore - workItemInDatabase.RelatedFrom.Should().HaveCount(1); + workItemInDatabase.RelatedFrom.ShouldHaveCount(1); workItemInDatabase.RelatedFrom[0].Id.Should().Be(existingWorkItem.Id); - workItemInDatabase.RelatedTo.Should().HaveCount(1); + workItemInDatabase.RelatedTo.ShouldHaveCount(1); workItemInDatabase.RelatedTo[0].Id.Should().Be(existingWorkItem.Id); }); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs index a3bf1986f2..d7f3779f81 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs @@ -10,6 +10,7 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; using TestBuildingBlocks; using Xunit; @@ -34,6 +35,9 @@ public UpdateResourceTests(IntegrationTestContext<TestableStartup<ReadWriteDbCon services.AddResourceDefinition<ImplicitlyChangingWorkItemDefinition>(); services.AddResourceDefinition<ImplicitlyChangingWorkItemGroupDefinition>(); }); + + var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService<IJsonApiOptions>(); + options.AllowUnknownFieldsInRequestBody = false; } [Fact] @@ -82,10 +86,59 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } + [Fact] + public async Task Cannot_update_resource_with_unknown_attribute() + { + // Arrange + UserAccount existingUserAccount = _fakers.UserAccount.Generate(); + string newFirstName = _fakers.UserAccount.Generate().FirstName; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.UserAccounts.Add(existingUserAccount); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "userAccounts", + id = existingUserAccount.StringId, + attributes = new + { + firstName = newFirstName, + doesNotExist = "Ignored" + } + } + }; + + string route = $"/userAccounts/{existingUserAccount.StringId}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync<Document>(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 'userAccounts'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/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<IJsonApiOptions>(); + options.AllowUnknownFieldsInRequestBody = true; + UserAccount existingUserAccount = _fakers.UserAccount.Generate(); string newFirstName = _fakers.UserAccount.Generate().FirstName; @@ -128,10 +181,64 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } + [Fact] + public async Task Cannot_update_resource_with_unknown_relationship() + { + // Arrange + UserAccount existingUserAccount = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.UserAccounts.Add(existingUserAccount); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "userAccounts", + id = existingUserAccount.StringId, + relationships = new + { + doesNotExist = new + { + data = new + { + type = Unknown.ResourceType, + id = Unknown.StringId.Int32 + } + } + } + } + }; + + string route = $"/userAccounts/{existingUserAccount.StringId}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync<Document>(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 'userAccounts'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/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<IJsonApiOptions>(); + options.AllowUnknownFieldsInRequestBody = true; + UserAccount existingUserAccount = _fakers.UserAccount.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => @@ -205,23 +312,26 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); + string groupName = $"{newName}{ImplicitlyChangingWorkItemGroupDefinition.Suffix}"; + + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Type.Should().Be("workItemGroups"); responseDocument.Data.SingleValue.Id.Should().Be(existingGroup.StringId); - responseDocument.Data.SingleValue.Attributes["name"].Should().Be($"{newName}{ImplicitlyChangingWorkItemGroupDefinition.Suffix}"); - responseDocument.Data.SingleValue.Attributes["isPublic"].Should().Be(existingGroup.IsPublic); - responseDocument.Data.SingleValue.Relationships.Should().NotBeEmpty(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("name").With(value => value.Should().Be(groupName)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("isPublic").With(value => value.Should().Be(existingGroup.IsPublic)); + responseDocument.Data.SingleValue.Relationships.ShouldNotBeEmpty(); await _testContext.RunOnDatabaseAsync(async dbContext => { WorkItemGroup groupInDatabase = await dbContext.Groups.FirstWithIdAsync(existingGroup.Id); - groupInDatabase.Name.Should().Be($"{newName}{ImplicitlyChangingWorkItemGroupDefinition.Suffix}"); + groupInDatabase.Name.Should().Be(groupName); groupInDatabase.IsPublic.Should().Be(existingGroup.IsPublic); }); - PropertyInfo property = typeof(WorkItemGroup).GetProperty(nameof(Identifiable.Id)); - property.Should().NotBeNull().And.Subject.PropertyType.Should().Be(typeof(Guid)); + PropertyInfo? property = typeof(WorkItemGroup).GetProperty(nameof(Identifiable<object>.Id)); + property.ShouldNotBeNull(); + property.PropertyType.Should().Be(typeof(Guid)); } [Fact] @@ -267,8 +377,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => colorInDatabase.DisplayName.Should().Be(newDisplayName); }); - PropertyInfo property = typeof(RgbColor).GetProperty(nameof(Identifiable.Id)); - property.Should().NotBeNull().And.Subject.PropertyType.Should().Be(typeof(string)); + PropertyInfo? property = typeof(RgbColor).GetProperty(nameof(Identifiable<object>.Id)); + property.ShouldNotBeNull(); + property.PropertyType.Should().Be(typeof(string)); } [Fact] @@ -322,7 +433,7 @@ public async Task Can_update_resource_with_side_effects() { // Arrange WorkItem existingWorkItem = _fakers.WorkItem.Generate(); - string newDescription = _fakers.WorkItem.Generate().Description; + string newDescription = _fakers.WorkItem.Generate().Description!; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -352,20 +463,22 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); + string itemDescription = $"{newDescription}{ImplicitlyChangingWorkItemDefinition.Suffix}"; + + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Type.Should().Be("workItems"); responseDocument.Data.SingleValue.Id.Should().Be(existingWorkItem.StringId); - responseDocument.Data.SingleValue.Attributes["description"].Should().Be($"{newDescription}{ImplicitlyChangingWorkItemDefinition.Suffix}"); - responseDocument.Data.SingleValue.Attributes["dueAt"].Should().BeNull(); - responseDocument.Data.SingleValue.Attributes["priority"].Should().Be(existingWorkItem.Priority); - responseDocument.Data.SingleValue.Attributes["isImportant"].Should().Be(existingWorkItem.IsImportant); - responseDocument.Data.SingleValue.Relationships.Should().NotBeEmpty(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("description").With(value => value.Should().Be(itemDescription)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("dueAt").With(value => value.Should().BeNull()); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("priority").With(value => value.Should().Be(existingWorkItem.Priority)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("isImportant").With(value => value.Should().Be(existingWorkItem.IsImportant)); + responseDocument.Data.SingleValue.Relationships.ShouldNotBeEmpty(); await _testContext.RunOnDatabaseAsync(async dbContext => { WorkItem workItemInDatabase = await dbContext.WorkItems.FirstWithIdAsync(existingWorkItem.Id); - workItemInDatabase.Description.Should().Be($"{newDescription}{ImplicitlyChangingWorkItemDefinition.Suffix}"); + workItemInDatabase.Description.Should().Be(itemDescription); workItemInDatabase.DueAt.Should().BeNull(); workItemInDatabase.Priority.Should().Be(existingWorkItem.Priority); }); @@ -376,7 +489,7 @@ public async Task Can_update_resource_with_side_effects_with_primary_fieldset() { // Arrange WorkItem existingWorkItem = _fakers.WorkItem.Generate(); - string newDescription = _fakers.WorkItem.Generate().Description; + string newDescription = _fakers.WorkItem.Generate().Description!; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -406,19 +519,21 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); + string itemDescription = $"{newDescription}{ImplicitlyChangingWorkItemDefinition.Suffix}"; + + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Type.Should().Be("workItems"); responseDocument.Data.SingleValue.Id.Should().Be(existingWorkItem.StringId); - responseDocument.Data.SingleValue.Attributes.Should().HaveCount(2); - responseDocument.Data.SingleValue.Attributes["description"].Should().Be($"{newDescription}{ImplicitlyChangingWorkItemDefinition.Suffix}"); - responseDocument.Data.SingleValue.Attributes["priority"].Should().Be(existingWorkItem.Priority); + responseDocument.Data.SingleValue.Attributes.ShouldHaveCount(2); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("description").With(value => value.Should().Be(itemDescription)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("priority").With(value => value.Should().Be(existingWorkItem.Priority)); responseDocument.Data.SingleValue.Relationships.Should().BeNull(); await _testContext.RunOnDatabaseAsync(async dbContext => { WorkItem workItemInDatabase = await dbContext.WorkItems.FirstWithIdAsync(existingWorkItem.Id); - workItemInDatabase.Description.Should().Be($"{newDescription}{ImplicitlyChangingWorkItemDefinition.Suffix}"); + workItemInDatabase.Description.Should().Be(itemDescription); workItemInDatabase.DueAt.Should().BeNull(); workItemInDatabase.Priority.Should().Be(existingWorkItem.Priority); }); @@ -431,7 +546,7 @@ public async Task Can_update_resource_with_side_effects_with_include_and_fieldse WorkItem existingWorkItem = _fakers.WorkItem.Generate(); existingWorkItem.Tags = _fakers.WorkTag.Generate(1).ToHashSet(); - string newDescription = _fakers.WorkItem.Generate().Description; + string newDescription = _fakers.WorkItem.Generate().Description!; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -461,28 +576,35 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); + string itemDescription = $"{newDescription}{ImplicitlyChangingWorkItemDefinition.Suffix}"; + + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Type.Should().Be("workItems"); responseDocument.Data.SingleValue.Id.Should().Be(existingWorkItem.StringId); - responseDocument.Data.SingleValue.Attributes.Should().HaveCount(2); - responseDocument.Data.SingleValue.Attributes["description"].Should().Be($"{newDescription}{ImplicitlyChangingWorkItemDefinition.Suffix}"); - responseDocument.Data.SingleValue.Attributes["priority"].Should().Be(existingWorkItem.Priority); - responseDocument.Data.SingleValue.Relationships.Should().HaveCount(1); - responseDocument.Data.SingleValue.Relationships["tags"].Data.ManyValue.Should().HaveCount(1); - responseDocument.Data.SingleValue.Relationships["tags"].Data.ManyValue[0].Id.Should().Be(existingWorkItem.Tags.Single().StringId); - - responseDocument.Included.Should().HaveCount(1); + responseDocument.Data.SingleValue.Attributes.ShouldHaveCount(2); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("description").With(value => value.Should().Be(itemDescription)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("priority").With(value => value.Should().Be(existingWorkItem.Priority)); + responseDocument.Data.SingleValue.Relationships.ShouldHaveCount(1); + + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("tags").With(value => + { + value.ShouldNotBeNull(); + value.Data.ManyValue.ShouldHaveCount(1); + value.Data.ManyValue[0].Id.Should().Be(existingWorkItem.Tags.Single().StringId); + }); + + responseDocument.Included.ShouldHaveCount(1); responseDocument.Included[0].Type.Should().Be("workTags"); responseDocument.Included[0].Id.Should().Be(existingWorkItem.Tags.Single().StringId); - responseDocument.Included[0].Attributes.Should().HaveCount(1); - responseDocument.Included[0].Attributes["text"].Should().Be(existingWorkItem.Tags.Single().Text); + responseDocument.Included[0].Attributes.ShouldHaveCount(1); + responseDocument.Included[0].Attributes.ShouldContainKey("text").With(value => value.Should().Be(existingWorkItem.Tags.Single().Text)); responseDocument.Included[0].Relationships.Should().BeNull(); await _testContext.RunOnDatabaseAsync(async dbContext => { WorkItem workItemInDatabase = await dbContext.WorkItems.FirstWithIdAsync(existingWorkItem.Id); - workItemInDatabase.Description.Should().Be($"{newDescription}{ImplicitlyChangingWorkItemDefinition.Suffix}"); + workItemInDatabase.Description.Should().Be(itemDescription); workItemInDatabase.DueAt.Should().BeNull(); workItemInDatabase.Priority.Should().Be(existingWorkItem.Priority); }); @@ -518,10 +640,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); - responseDocument.Data.SingleValue.Relationships.Should().NotBeEmpty(); - responseDocument.Data.SingleValue.Relationships.Values.Should().OnlyContain(relationshipObject => relationshipObject.Data.Value == null); - + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Relationships.ShouldNotBeEmpty(); + responseDocument.Data.SingleValue.Relationships.Values.Should().OnlyContain(value => value.ShouldNotBeNull().Data.Value == null); responseDocument.Included.Should().BeNull(); } @@ -547,12 +668,163 @@ 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("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"); + } + + [Fact] + public async Task Cannot_update_resource_for_null_request_body() + { + // Arrange + WorkItem existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + const string requestBody = "null"; + + string route = $"/workItems/{existingWorkItem.StringId}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync<Document>(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] + public async Task Cannot_update_resource_for_missing_data() + { + // Arrange + WorkItem existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + meta = new + { + key = "value" + } + }; + + string route = $"/workItems/{existingWorkItem.StringId}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync<Document>(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.Should().BeNull(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } + + [Fact] + public async Task Cannot_update_resource_for_null_data() + { + // Arrange + WorkItem existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = (object?)null + }; + + string route = $"/workItems/{existingWorkItem.StringId}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync<Document>(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("/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } + + [Fact] + public async Task Cannot_update_resource_for_array_data() + { + // Arrange + WorkItem existingWorkItem = _fakers.WorkItem.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WorkItems.Add(existingWorkItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "workItems", + id = existingWorkItem.StringId + } + } + }; + + string route = $"/workItems/{existingWorkItem.StringId}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync<Document>(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 an array."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -583,12 +855,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: Request body must include 'type' element."); - error.Detail.Should().StartWith("Expected 'type' element in 'data' element. - Request body: <<"); + 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("/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -620,12 +895,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: Request body includes unknown resource type."); - error.Detail.Should().StartWith($"Resource type '{Unknown.ResourceType}' does not exist. - Request body: <<"); + 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("/data/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -656,12 +934,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: Request body must include 'id' element."); - error.Detail.Should().StartWith("Request body: <<"); + 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("/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -719,12 +1000,14 @@ public async Task Cannot_update_resource_on_unknown_resource_ID_in_url() // 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 'workItems' with ID '{workItemId}' does not exist."); + error.Source.Should().BeNull(); + error.Meta.Should().NotContainKey("requestBody"); } [Fact] @@ -756,14 +1039,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("Resource type mismatch between request body and endpoint URL."); - - error.Detail.Should().Be("Expected resource of type 'workItems' in PATCH request body at endpoint " + - $"'/workItems/{existingWorkItem.StringId}', instead of 'rgbColors'."); + error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found."); + error.Detail.Should().Be("Type 'rgbColors' is incompatible with type 'workItems'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -795,14 +1079,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("Resource ID mismatch between request body and endpoint URL."); - - error.Detail.Should().Be($"Expected resource ID '{existingWorkItems[1].StringId}' in PATCH request body at endpoint " + - $"'/workItems/{existingWorkItems[1].StringId}', instead of '{existingWorkItems[0].StringId}'."); + error.Title.Should().Be("Failed to deserialize request body: Conflicting 'id' values found."); + error.Detail.Should().Be($"Expected '{existingWorkItems[1].StringId}' instead of '{existingWorkItems[0].StringId}'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data/id"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -838,23 +1123,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("Failed to deserialize request body: Changing the value of the requested attribute is not allowed."); - error.Detail.Should().StartWith("Changing the value of 'isImportant' is not allowed. - Request body: <<"); + error.Title.Should().Be("Failed to deserialize request body: Attribute value cannot be assigned when updating resource."); + error.Detail.Should().Be("The attribute 'isImportant' on resource type 'workItems' cannot be assigned to."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data/attributes/isImportant"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] public async Task Cannot_update_resource_with_readonly_attribute() { // Arrange - WorkItem existingWorkItem = _fakers.WorkItem.Generate(); + WorkItemGroup existingWorkItemGroup = _fakers.WorkItemGroup.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.WorkItems.Add(existingWorkItem); + dbContext.Groups.Add(existingWorkItemGroup); await dbContext.SaveChangesAsync(); }); @@ -863,7 +1151,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = new { type = "workItemGroups", - id = existingWorkItem.StringId, + id = existingWorkItemGroup.StringId, attributes = new { isDeprecated = true @@ -871,7 +1159,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - string route = $"/workItemGroups/{existingWorkItem.StringId}"; + string route = $"/workItemGroups/{existingWorkItemGroup.StringId}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync<Document>(route, requestBody); @@ -879,12 +1167,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().StartWith("Attribute 'isDeprecated' is read-only. - Request body: <<"); + error.Detail.Should().Be("Attribute 'isDeprecated' on resource type 'workItemGroups' is read-only."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data/attributes/isDeprecated"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -909,12 +1200,14 @@ 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().Match("Expected end of string, but instead reached end of data. * - Request body: <<*"); + error.Detail.Should().StartWith("Expected end of string, but instead reached end of data."); + error.Source.Should().BeNull(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -950,12 +1243,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().StartWith("Resource ID is read-only. - Request body: <<"); + 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("/data/attributes/id"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -990,12 +1286,14 @@ 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().StartWith($"Failed to convert ID '{existingWorkItem.Id}' of type 'Number' to type 'String'. - Request body: <<"); + error.Detail.Should().Be($"Failed to convert ID '{existingWorkItem.Id}' of type 'Number' to type 'String'."); + error.Source.Should().BeNull(); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -1035,14 +1333,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().Match("Failed to convert attribute 'dueAt' with value '*start*end*' " + - "of type 'Object' to type 'Nullable<DateTimeOffset>'. - Request body: <<*"); + error.Title.Should().Be("Failed to deserialize request body: Incompatible attribute value found."); + error.Detail.Should().Match("Failed to convert attribute 'dueAt' with value '*start*end*' of type 'Object' to type 'Nullable<DateTimeOffset>'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data/attributes/dueAt"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -1057,7 +1356,7 @@ public async Task Can_update_resource_with_attributes_and_multiple_relationship_ List<UserAccount> existingUserAccounts = _fakers.UserAccount.Generate(2); WorkTag existingTag = _fakers.WorkTag.Generate(); - string newDescription = _fakers.WorkItem.Generate().Description; + string newDescription = _fakers.WorkItem.Generate().Description!; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -1120,9 +1419,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); - responseDocument.Data.SingleValue.Attributes["description"].Should().Be($"{newDescription}{ImplicitlyChangingWorkItemDefinition.Suffix}"); - responseDocument.Data.SingleValue.Relationships.Should().NotBeEmpty(); + string itemDescription = $"{newDescription}{ImplicitlyChangingWorkItemDefinition.Suffix}"; + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("description").With(value => value.Should().Be(itemDescription)); + responseDocument.Data.SingleValue.Relationships.ShouldNotBeEmpty(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -1138,15 +1439,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // @formatter:keep_existing_linebreaks restore // @formatter:wrap_chained_method_calls restore - workItemInDatabase.Description.Should().Be($"{newDescription}{ImplicitlyChangingWorkItemDefinition.Suffix}"); + workItemInDatabase.Description.Should().Be(itemDescription); - workItemInDatabase.Assignee.Should().NotBeNull(); + workItemInDatabase.Assignee.ShouldNotBeNull(); workItemInDatabase.Assignee.Id.Should().Be(existingUserAccounts[0].Id); - workItemInDatabase.Subscribers.Should().HaveCount(1); + workItemInDatabase.Subscribers.ShouldHaveCount(1); workItemInDatabase.Subscribers.Single().Id.Should().Be(existingUserAccounts[1].Id); - workItemInDatabase.Tags.Should().HaveCount(1); + workItemInDatabase.Tags.ShouldHaveCount(1); workItemInDatabase.Tags.Single().Id.Should().Be(existingTag.Id); }); } @@ -1216,7 +1517,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -1233,16 +1534,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // @formatter:keep_existing_linebreaks restore // @formatter:wrap_chained_method_calls restore - workItemInDatabase.Parent.Should().NotBeNull(); + workItemInDatabase.Parent.ShouldNotBeNull(); workItemInDatabase.Parent.Id.Should().Be(existingWorkItem.Id); - workItemInDatabase.Children.Should().HaveCount(1); + workItemInDatabase.Children.ShouldHaveCount(1); workItemInDatabase.Children.Single().Id.Should().Be(existingWorkItem.Id); - workItemInDatabase.RelatedFrom.Should().HaveCount(1); + workItemInDatabase.RelatedFrom.ShouldHaveCount(1); workItemInDatabase.RelatedFrom.Single().Id.Should().Be(existingWorkItem.Id); - workItemInDatabase.RelatedTo.Should().HaveCount(1); + workItemInDatabase.RelatedTo.ShouldHaveCount(1); workItemInDatabase.RelatedTo.Single().Id.Should().Be(existingWorkItem.Id); }); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateToOneRelationshipTests.cs index 3eac83cc04..c06b3b7086 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateToOneRelationshipTests.cs @@ -54,7 +54,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { assignee = new { - data = (object)null + data = (object?)null } } } @@ -68,7 +68,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -130,7 +130,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => colorInDatabase1.Group.Should().BeNull(); RgbColor colorInDatabase2 = colorsInDatabase.Single(color => color.Id == existingColor.Id); - colorInDatabase2.Group.Should().NotBeNull(); + colorInDatabase2.Group.ShouldNotBeNull(); colorInDatabase2.Group.Id.Should().Be(existingGroup.Id); }); } @@ -154,7 +154,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = new { type = "rgbColors", - id = existingGroups[0].Color.StringId, + id = existingGroups[0].Color!.StringId, relationships = new { group = new @@ -169,7 +169,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - string route = $"/rgbColors/{existingGroups[0].Color.StringId}"; + string route = $"/rgbColors/{existingGroups[0].Color!.StringId}"; // Act (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync<string>(route, requestBody); @@ -187,18 +187,18 @@ await _testContext.RunOnDatabaseAsync(async dbContext => groupInDatabase1.Color.Should().BeNull(); WorkItemGroup groupInDatabase2 = groupsInDatabase.Single(group => group.Id == existingGroups[1].Id); - groupInDatabase2.Color.Should().NotBeNull(); - groupInDatabase2.Color.Id.Should().Be(existingGroups[0].Color.Id); + groupInDatabase2.Color.ShouldNotBeNull(); + groupInDatabase2.Color.Id.Should().Be(existingGroups[0].Color!.Id); List<RgbColor> colorsInDatabase = await dbContext.RgbColors.Include(color => color.Group).ToListAsync(); - RgbColor colorInDatabase1 = colorsInDatabase.Single(color => color.Id == existingGroups[0].Color.Id); - colorInDatabase1.Group.Should().NotBeNull(); + RgbColor colorInDatabase1 = colorsInDatabase.Single(color => color.Id == existingGroups[0].Color!.Id); + colorInDatabase1.Group.ShouldNotBeNull(); colorInDatabase1.Group.Id.Should().Be(existingGroups[1].Id); - RgbColor colorInDatabase2 = colorsInDatabase.SingleOrDefault(color => color.Id == existingGroups[1].Color.Id); - colorInDatabase2.Should().NotBeNull(); - colorInDatabase2!.Group.Should().BeNull(); + RgbColor? colorInDatabase2 = colorsInDatabase.SingleOrDefault(color => color.Id == existingGroups[1].Color!.Id); + colorInDatabase2.ShouldNotBeNull(); + colorInDatabase2.Group.Should().BeNull(); }); } @@ -225,7 +225,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { group = new { - data = (object)null + data = (object?)null } } } @@ -243,7 +243,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - RgbColor colorInDatabase = await dbContext.RgbColors.Include(color => color.Group).FirstWithIdOrDefaultAsync(existingColor.Id); + RgbColor colorInDatabase = await dbContext.RgbColors.Include(color => color.Group).FirstWithIdAsync(existingColor.Id); colorInDatabase.Group.Should().BeNull(); }); @@ -289,7 +289,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -297,7 +297,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => WorkItem workItemInDatabase2 = await dbContext.WorkItems.Include(workItem => workItem.Assignee).FirstWithIdAsync(workItemId); - workItemInDatabase2.Assignee.Should().NotBeNull(); + workItemInDatabase2.Assignee.ShouldNotBeNull(); workItemInDatabase2.Assignee.Id.Should().Be(existingUserAccounts[1].Id); }); } @@ -345,24 +345,24 @@ await _testContext.RunOnDatabaseAsync(async dbContext => string description = $"{existingWorkItem.Description}{ImplicitlyChangingWorkItemDefinition.Suffix}"; - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Type.Should().Be("workItems"); responseDocument.Data.SingleValue.Id.Should().Be(existingWorkItem.StringId); - responseDocument.Data.SingleValue.Attributes["description"].Should().Be(description); - responseDocument.Data.SingleValue.Relationships.Should().NotBeEmpty(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("description").With(value => value.Should().Be(description)); + responseDocument.Data.SingleValue.Relationships.ShouldNotBeEmpty(); - responseDocument.Included.Should().HaveCount(1); + responseDocument.Included.ShouldHaveCount(1); responseDocument.Included[0].Type.Should().Be("userAccounts"); responseDocument.Included[0].Id.Should().Be(existingUserAccount.StringId); - responseDocument.Included[0].Attributes["firstName"].Should().Be(existingUserAccount.FirstName); - responseDocument.Included[0].Attributes["lastName"].Should().Be(existingUserAccount.LastName); - responseDocument.Included[0].Relationships.Should().NotBeEmpty(); + responseDocument.Included[0].Attributes.ShouldContainKey("firstName").With(value => value.Should().Be(existingUserAccount.FirstName)); + responseDocument.Included[0].Attributes.ShouldContainKey("lastName").With(value => value.Should().Be(existingUserAccount.LastName)); + responseDocument.Included[0].Relationships.ShouldNotBeEmpty(); await _testContext.RunOnDatabaseAsync(async dbContext => { WorkItem workItemInDatabase = await dbContext.WorkItems.Include(workItem => workItem.Assignee).FirstWithIdAsync(existingWorkItem.Id); - workItemInDatabase.Assignee.Should().NotBeNull(); + workItemInDatabase.Assignee.ShouldNotBeNull(); workItemInDatabase.Assignee.Id.Should().Be(existingUserAccount.Id); }); } @@ -412,39 +412,91 @@ await _testContext.RunOnDatabaseAsync(async dbContext => string description = $"{existingWorkItem.Description}{ImplicitlyChangingWorkItemDefinition.Suffix}"; - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Type.Should().Be("workItems"); responseDocument.Data.SingleValue.Id.Should().Be(existingWorkItem.StringId); - responseDocument.Data.SingleValue.Attributes.Should().HaveCount(1); - responseDocument.Data.SingleValue.Attributes["description"].Should().Be(description); - responseDocument.Data.SingleValue.Relationships.Should().HaveCount(1); - responseDocument.Data.SingleValue.Relationships["assignee"].Data.SingleValue.Id.Should().Be(existingUserAccount.StringId); + responseDocument.Data.SingleValue.Attributes.ShouldHaveCount(1); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("description").With(value => value.Should().Be(description)); + responseDocument.Data.SingleValue.Relationships.ShouldHaveCount(1); - responseDocument.Included.Should().HaveCount(1); + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("assignee").With(value => + { + value.ShouldNotBeNull(); + value.Data.SingleValue.ShouldNotBeNull(); + value.Data.SingleValue.Id.Should().Be(existingUserAccount.StringId); + }); + + responseDocument.Included.ShouldHaveCount(1); responseDocument.Included[0].Type.Should().Be("userAccounts"); responseDocument.Included[0].Id.Should().Be(existingUserAccount.StringId); - responseDocument.Included[0].Attributes.Should().HaveCount(1); - responseDocument.Included[0].Attributes["lastName"].Should().Be(existingUserAccount.LastName); + responseDocument.Included[0].Attributes.ShouldHaveCount(1); + responseDocument.Included[0].Attributes.ShouldContainKey("lastName").With(value => value.Should().Be(existingUserAccount.LastName)); responseDocument.Included[0].Relationships.Should().BeNull(); await _testContext.RunOnDatabaseAsync(async dbContext => { WorkItem workItemInDatabase = await dbContext.WorkItems.Include(workItem => workItem.Assignee).FirstWithIdAsync(existingWorkItem.Id); - workItemInDatabase.Assignee.Should().NotBeNull(); + workItemInDatabase.Assignee.ShouldNotBeNull(); workItemInDatabase.Assignee.Id.Should().Be(existingUserAccount.Id); }); } [Fact] - public async Task Cannot_create_for_missing_relationship_type() + public async Task Cannot_create_with_null_relationship() { // Arrange WorkItem existingWorkItem = _fakers.WorkItem.Generate(); + UserAccount existingUserAccount = _fakers.UserAccount.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.WorkItems.Add(existingWorkItem); + dbContext.AddInRange(existingWorkItem, existingUserAccount); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + assignee = (object?)null + } + } + }; + + string route = $"/workItems/{existingWorkItem.StringId}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync<Document>(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("/data/relationships/assignee"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } + + [Fact] + public async Task Cannot_create_with_missing_data_in_relationship() + { + // Arrange + WorkItem existingWorkItem = _fakers.WorkItem.Generate(); + UserAccount existingUserAccount = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingWorkItem, existingUserAccount); await dbContext.SaveChangesAsync(); }); @@ -458,9 +510,60 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { assignee = new { - data = new + } + } + } + }; + + string route = $"/workItems/{existingWorkItem.StringId}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync<Document>(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("/data/relationships/assignee"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } + + [Fact] + public async Task Cannot_create_with_array_data_in_relationship() + { + // Arrange + WorkItem existingWorkItem = _fakers.WorkItem.Generate(); + UserAccount existingUserAccount = _fakers.UserAccount.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingWorkItem, existingUserAccount); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workItems", + id = existingWorkItem.StringId, + relationships = new + { + assignee = new + { + data = new[] { - id = Unknown.StringId.For<UserAccount, long>() + new + { + type = "userAccounts", + id = existingUserAccount.StringId + } } } } @@ -475,16 +578,19 @@ 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: Request body must include 'type' element."); - error.Detail.Should().StartWith("Expected 'type' element in 'assignee' relationship. - Request body: <<"); + 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("/data/relationships/assignee/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] - public async Task Cannot_create_for_unknown_relationship_type() + public async Task Cannot_create_for_missing_relationship_type() { // Arrange WorkItem existingWorkItem = _fakers.WorkItem.Generate(); @@ -507,7 +613,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { data = new { - type = Unknown.ResourceType, id = Unknown.StringId.For<UserAccount, long>() } } @@ -523,16 +628,19 @@ 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: Request body includes unknown resource type."); - error.Detail.Should().StartWith($"Resource type '{Unknown.ResourceType}' does not exist. - Request body: <<"); + 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("/data/relationships/assignee/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] - public async Task Cannot_create_for_missing_relationship_ID() + public async Task Cannot_create_for_unknown_relationship_type() { // Arrange WorkItem existingWorkItem = _fakers.WorkItem.Generate(); @@ -555,7 +663,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { data = new { - type = "userAccounts" + type = Unknown.ResourceType, + id = Unknown.StringId.For<UserAccount, long>() } } } @@ -570,16 +679,19 @@ 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: Request body must include 'id' element."); - error.Detail.Should().StartWith("Expected 'id' element in 'assignee' relationship. - Request body: <<"); + 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("/data/relationships/assignee/data/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] - public async Task Cannot_create_with_unknown_relationship_ID() + public async Task Cannot_create_for_missing_relationship_ID() { // Arrange WorkItem existingWorkItem = _fakers.WorkItem.Generate(); @@ -590,8 +702,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string userAccountId = Unknown.StringId.For<UserAccount, long>(); - var requestBody = new { data = new @@ -604,8 +714,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { data = new { - type = "userAccounts", - id = userAccountId + type = "userAccounts" } } } @@ -618,18 +727,21 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync<Document>(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - 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 'userAccounts' with ID '{userAccountId}' in relationship 'assignee' does not exist."); + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'id' element is required."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data/relationships/assignee/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] - public async Task Cannot_create_on_relationship_type_mismatch() + public async Task Cannot_create_with_unknown_relationship_ID() { // Arrange WorkItem existingWorkItem = _fakers.WorkItem.Generate(); @@ -640,6 +752,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); + string userAccountId = Unknown.StringId.For<UserAccount, long>(); + var requestBody = new { data = new @@ -652,8 +766,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { data = new { - type = "rgbColors", - id = "0A0B0C" + type = "userAccounts", + id = userAccountId } } } @@ -666,26 +780,27 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync<Document>(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - 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().StartWith("Relationship 'assignee' contains incompatible resource type 'rgbColors'. - Request body: <<"); + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("A related resource does not exist."); + error.Detail.Should().Be($"Related resource of type 'userAccounts' with ID '{userAccountId}' in relationship 'assignee' does not exist."); + error.Source.Should().BeNull(); + error.Meta.Should().NotContainKey("requestBody"); } [Fact] - public async Task Cannot_create_with_data_array_in_relationship() + public async Task Cannot_create_on_relationship_type_mismatch() { // Arrange WorkItem existingWorkItem = _fakers.WorkItem.Generate(); - UserAccount existingUserAccount = _fakers.UserAccount.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.AddInRange(existingWorkItem, existingUserAccount); + dbContext.WorkItems.Add(existingWorkItem); await dbContext.SaveChangesAsync(); }); @@ -699,13 +814,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { assignee = new { - data = new[] + data = new { - new - { - type = "userAccounts", - id = existingUserAccount.StringId - } + type = "rgbColors", + id = "0A0B0C" } } } @@ -718,14 +830,17 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync<Document>(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: Expected single data element for to-one relationship."); - error.Detail.Should().StartWith("Expected single data element for 'assignee' relationship. - Request body: <<"); + error.StatusCode.Should().Be(HttpStatusCode.Conflict); + error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found."); + error.Detail.Should().Be("Type 'rgbColors' is incompatible with type 'userAccounts' of relationship 'assignee'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data/relationships/assignee/data/type"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } [Fact] @@ -753,7 +868,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { parent = new { - data = (object)null + data = (object?)null } } } @@ -767,7 +882,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -817,13 +932,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); await _testContext.RunOnDatabaseAsync(async dbContext => { WorkItem workItemInDatabase = await dbContext.WorkItems.Include(workItem => workItem.Parent).FirstWithIdAsync(existingWorkItem.Id); - workItemInDatabase.Parent.Should().NotBeNull(); + workItemInDatabase.Parent.ShouldNotBeNull(); workItemInDatabase.Parent.Id.Should().Be(existingWorkItem.Id); }); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/UserAccount.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/UserAccount.cs index 957882a09f..0e739429b5 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/UserAccount.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/UserAccount.cs @@ -9,12 +9,12 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ReadWrite public sealed class UserAccount : Identifiable<long> { [Attr] - public string FirstName { get; set; } + public string FirstName { get; set; } = null!; [Attr] - public string LastName { get; set; } + public string LastName { get; set; } = null!; [HasMany] - public ISet<WorkItem> AssignedItems { get; set; } + public ISet<WorkItem> AssignedItems { get; set; } = new HashSet<WorkItem>(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/UserAccountsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/UserAccountsController.cs index 4d928d7ad5..61980968e8 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/UserAccountsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/UserAccountsController.cs @@ -7,8 +7,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ReadWrite { public sealed class UserAccountsController : JsonApiController<UserAccount, long> { - public UserAccountsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<UserAccount, long> resourceService) - : base(options, loggerFactory, resourceService) + public UserAccountsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<UserAccount, long> resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/WorkItem.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/WorkItem.cs index ed8a27f115..2cd0433676 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/WorkItem.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/WorkItem.cs @@ -8,10 +8,10 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ReadWrite { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class WorkItem : Identifiable + public sealed class WorkItem : Identifiable<int> { [Attr] - public string Description { get; set; } + public string? Description { get; set; } [Attr] public DateTimeOffset? DueAt { get; set; } @@ -28,27 +28,27 @@ public bool IsImportant } [HasOne] - public UserAccount Assignee { get; set; } + public UserAccount? Assignee { get; set; } [HasMany] - public ISet<UserAccount> Subscribers { get; set; } + public ISet<UserAccount> Subscribers { get; set; } = new HashSet<UserAccount>(); [HasMany] - public ISet<WorkTag> Tags { get; set; } + public ISet<WorkTag> Tags { get; set; } = new HashSet<WorkTag>(); [HasOne] - public WorkItem Parent { get; set; } + public WorkItem? Parent { get; set; } [HasMany] - public IList<WorkItem> Children { get; set; } + public IList<WorkItem> Children { get; set; } = new List<WorkItem>(); [HasMany] - public IList<WorkItem> RelatedFrom { get; set; } + public IList<WorkItem> RelatedFrom { get; set; } = new List<WorkItem>(); [HasMany] - public IList<WorkItem> RelatedTo { get; set; } + public IList<WorkItem> RelatedTo { get; set; } = new List<WorkItem>(); [HasOne] - public WorkItemGroup Group { get; set; } + public WorkItemGroup? Group { get; set; } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/WorkItemGroup.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/WorkItemGroup.cs index de97a74176..a1f66c0549 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/WorkItemGroup.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/WorkItemGroup.cs @@ -11,19 +11,19 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ReadWrite public sealed class WorkItemGroup : Identifiable<Guid> { [Attr] - public string Name { get; set; } + public string Name { get; set; } = null!; [Attr] public bool IsPublic { get; set; } [NotMapped] [Attr] - public bool IsDeprecated => Name != null && Name.StartsWith("DEPRECATED:", StringComparison.OrdinalIgnoreCase); + public bool IsDeprecated => !string.IsNullOrEmpty(Name) && Name.StartsWith("DEPRECATED:", StringComparison.OrdinalIgnoreCase); [HasOne] - public RgbColor Color { get; set; } + public RgbColor? Color { get; set; } [HasMany] - public IList<WorkItem> Items { get; set; } + public IList<WorkItem> Items { get; set; } = new List<WorkItem>(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/WorkItemGroupsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/WorkItemGroupsController.cs index 5a15706245..c9ce804450 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/WorkItemGroupsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/WorkItemGroupsController.cs @@ -8,8 +8,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ReadWrite { public sealed class WorkItemGroupsController : JsonApiController<WorkItemGroup, Guid> { - public WorkItemGroupsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<WorkItemGroup, Guid> resourceService) - : base(options, loggerFactory, resourceService) + public WorkItemGroupsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<WorkItemGroup, Guid> resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/WorkItemToWorkItem.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/WorkItemToWorkItem.cs index 564ec9b497..65e063d8d7 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/WorkItemToWorkItem.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/WorkItemToWorkItem.cs @@ -5,7 +5,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ReadWrite [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class WorkItemToWorkItem { - public WorkItem FromItem { get; set; } - public WorkItem ToItem { get; set; } + public WorkItem FromItem { get; set; } = null!; + public WorkItem ToItem { get; set; } = null!; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/WorkItemsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/WorkItemsController.cs index 7ad111fa28..3f069590ff 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/WorkItemsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/WorkItemsController.cs @@ -5,10 +5,11 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ReadWrite { - public sealed class WorkItemsController : JsonApiController<WorkItem> + public sealed class WorkItemsController : JsonApiController<WorkItem, int> { - public WorkItemsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<WorkItem> resourceService) - : base(options, loggerFactory, resourceService) + public WorkItemsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<WorkItem, int> resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/WorkTag.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/WorkTag.cs index fbb0be35f5..54fb89bd10 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/WorkTag.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/WorkTag.cs @@ -6,15 +6,15 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ReadWrite { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class WorkTag : Identifiable + public sealed class WorkTag : Identifiable<int> { [Attr] - public string Text { get; set; } + public string Text { get; set; } = null!; [Attr] public bool IsBuiltIn { get; set; } [HasMany] - public ISet<WorkItem> WorkItems { get; set; } + public ISet<WorkItem> WorkItems { get; set; } = new HashSet<WorkItem>(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RequiredRelationships/Customer.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RequiredRelationships/Customer.cs index 27f6623a92..01420de8dc 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/RequiredRelationships/Customer.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/RequiredRelationships/Customer.cs @@ -6,12 +6,12 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.RequiredRelationships { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Customer : Identifiable + public sealed class Customer : Identifiable<int> { [Attr] - public string EmailAddress { get; set; } + public string EmailAddress { get; set; } = null!; [HasMany] - public ISet<Order> Orders { get; set; } + public ISet<Order> Orders { get; set; } = new HashSet<Order>(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RequiredRelationships/CustomersController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RequiredRelationships/CustomersController.cs index b3fc7316b5..c580d90075 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/RequiredRelationships/CustomersController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/RequiredRelationships/CustomersController.cs @@ -5,10 +5,11 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.RequiredRelationships { - public sealed class CustomersController : JsonApiController<Customer> + public sealed class CustomersController : JsonApiController<Customer, int> { - public CustomersController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<Customer> resourceService) - : base(options, loggerFactory, resourceService) + public CustomersController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<Customer, int> resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RequiredRelationships/DefaultBehaviorDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RequiredRelationships/DefaultBehaviorDbContext.cs index 9e2a89163a..cce1c5e2ed 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/RequiredRelationships/DefaultBehaviorDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/RequiredRelationships/DefaultBehaviorDbContext.cs @@ -8,9 +8,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.RequiredRelationships [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class DefaultBehaviorDbContext : DbContext { - public DbSet<Customer> Customers { get; set; } - public DbSet<Order> Orders { get; set; } - public DbSet<Shipment> Shipments { get; set; } + public DbSet<Customer> Customers => Set<Customer>(); + public DbSet<Order> Orders => Set<Order>(); + public DbSet<Shipment> Shipments => Set<Shipment>(); public DefaultBehaviorDbContext(DbContextOptions<DefaultBehaviorDbContext> options) : base(options) @@ -21,18 +21,17 @@ protected override void OnModelCreating(ModelBuilder builder) { builder.Entity<Customer>() .HasMany(customer => customer.Orders) - .WithOne(order => order.Customer) - .IsRequired(); + .WithOne(order => order.Customer); - // By default, EF Core generates an identifying foreign key for a required 1-to-1 relationship. + // 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. - // 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 key explicitly forces to create a foreign key column. + // 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. builder.Entity<Order>() .HasOne(order => order.Shipment) .WithOne(shipment => shipment.Order) - .HasForeignKey<Shipment>("OrderId") - .IsRequired(); + .HasForeignKey<Shipment>("OrderId"); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RequiredRelationships/DefaultBehaviorFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RequiredRelationships/DefaultBehaviorFakers.cs index b561b479e4..1c7386413f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/RequiredRelationships/DefaultBehaviorFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/RequiredRelationships/DefaultBehaviorFakers.cs @@ -9,25 +9,25 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.RequiredRelationships { internal sealed class DefaultBehaviorFakers : FakerContainer { - private readonly Lazy<Faker<Order>> _orderFaker = new(() => + private readonly Lazy<Faker<Order>> _lazyOrderFaker = new(() => new Faker<Order>() .UseSeed(GetFakerSeed()) .RuleFor(order => order.Amount, faker => faker.Finance.Amount())); - private readonly Lazy<Faker<Customer>> _customerFaker = new(() => + private readonly Lazy<Faker<Customer>> _lazyCustomerFaker = new(() => new Faker<Customer>() .UseSeed(GetFakerSeed()) .RuleFor(customer => customer.EmailAddress, faker => faker.Person.Email)); - private readonly Lazy<Faker<Shipment>> _shipmentFaker = new(() => + private readonly Lazy<Faker<Shipment>> _lazyShipmentFaker = new(() => new Faker<Shipment>() .UseSeed(GetFakerSeed()) .RuleFor(shipment => shipment.TrackAndTraceCode, faker => faker.Commerce.Ean13()) .RuleFor(shipment => shipment.ShippedAt, faker => faker.Date.Past() .TruncateToWholeMilliseconds())); - public Faker<Order> Orders => _orderFaker.Value; - public Faker<Customer> Customers => _customerFaker.Value; - public Faker<Shipment> Shipments => _shipmentFaker.Value; + public Faker<Order> Order => _lazyOrderFaker.Value; + public Faker<Customer> Customer => _lazyCustomerFaker.Value; + public Faker<Shipment> Shipment => _lazyShipmentFaker.Value; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RequiredRelationships/DefaultBehaviorTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RequiredRelationships/DefaultBehaviorTests.cs index 20448ea5db..8ed55f57b3 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/RequiredRelationships/DefaultBehaviorTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/RequiredRelationships/DefaultBehaviorTests.cs @@ -34,7 +34,7 @@ public DefaultBehaviorTests(IntegrationTestContext<TestableStartup<DefaultBehavi public async Task Cannot_create_dependent_side_of_required_ManyToOne_relationship_without_providing_principal_side() { // Arrange - Order order = _fakers.Orders.Generate(); + Order order = _fakers.Order.Generate(); var requestBody = new { @@ -43,7 +43,7 @@ public async Task Cannot_create_dependent_side_of_required_ManyToOne_relationshi type = "orders", attributes = new { - order = order.Amount + amount = order.Amount } } }; @@ -54,21 +54,30 @@ public async Task Cannot_create_dependent_side_of_required_ManyToOne_relationshi (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync<Document>(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.InternalServerError); - - responseDocument.Errors.Should().HaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.InternalServerError); - error.Title.Should().Be("An unhandled error occurred while processing this request."); - error.Detail.Should().Be("Failed to persist changes in the underlying data store."); + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + 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 Customer field is required."); + error1.Source.ShouldNotBeNull(); + error1.Source.Pointer.Should().Be("/data/relationships/customer/data"); + + ErrorObject error2 = responseDocument.Errors[1]; + error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error2.Title.Should().Be("Input validation failed."); + error2.Detail.Should().Be("The Shipment field is required."); + error2.Source.ShouldNotBeNull(); + error2.Source.Pointer.Should().Be("/data/relationships/shipment/data"); } [Fact] public async Task Cannot_create_dependent_side_of_required_OneToOne_relationship_without_providing_principal_side() { // Arrange - Shipment shipment = _fakers.Shipments.Generate(); + Shipment shipment = _fakers.Shipment.Generate(); var requestBody = new { @@ -88,22 +97,24 @@ public async Task Cannot_create_dependent_side_of_required_OneToOne_relationship (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync<Document>(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.InternalServerError); + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - 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("Failed to persist changes in the underlying data store."); + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Input validation failed."); + error.Detail.Should().Be("The Order field is required."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data/relationships/order/data"); } [Fact] public async Task Deleting_principal_side_of_required_OneToMany_relationship_triggers_cascading_delete() { // Arrange - Order existingOrder = _fakers.Orders.Generate(); - existingOrder.Customer = _fakers.Customers.Generate(); + Order existingOrder = _fakers.Order.Generate(); + existingOrder.Customer = _fakers.Customer.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -123,11 +134,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - Customer existingCustomerInDatabase = await dbContext.Customers.FirstWithIdOrDefaultAsync(existingOrder.Customer.Id); - existingCustomerInDatabase.Should().BeNull(); + Customer? customerInDatabase = await dbContext.Customers.FirstWithIdOrDefaultAsync(existingOrder.Customer.Id); + customerInDatabase.Should().BeNull(); - Order existingOrderInDatabase = await dbContext.Orders.FirstWithIdOrDefaultAsync(existingOrder.Id); - existingOrderInDatabase.Should().BeNull(); + Order? orderInDatabase = await dbContext.Orders.FirstWithIdOrDefaultAsync(existingOrder.Id); + orderInDatabase.Should().BeNull(); }); } @@ -135,9 +146,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Deleting_principal_side_of_required_OneToOne_relationship_triggers_cascading_delete() { // Arrange - Order existingOrder = _fakers.Orders.Generate(); - existingOrder.Shipment = _fakers.Shipments.Generate(); - existingOrder.Customer = _fakers.Customers.Generate(); + Order existingOrder = _fakers.Order.Generate(); + existingOrder.Shipment = _fakers.Shipment.Generate(); + existingOrder.Customer = _fakers.Customer.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -157,14 +168,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - Order existingOrderInDatabase = await dbContext.Orders.FirstWithIdOrDefaultAsync(existingOrder.Id); - existingOrderInDatabase.Should().BeNull(); + Order? orderInDatabase = await dbContext.Orders.FirstWithIdOrDefaultAsync(existingOrder.Id); + orderInDatabase.Should().BeNull(); - Shipment existingShipmentInDatabase = await dbContext.Shipments.FirstWithIdOrDefaultAsync(existingOrder.Shipment.Id); - existingShipmentInDatabase.Should().BeNull(); + Shipment? shipmentInDatabase = await dbContext.Shipments.FirstWithIdOrDefaultAsync(existingOrder.Shipment.Id); + shipmentInDatabase.Should().BeNull(); - Customer existingCustomerInDatabase = await dbContext.Customers.FirstWithIdOrDefaultAsync(existingOrder.Customer.Id); - existingCustomerInDatabase.Should().NotBeNull(); + Customer? customerInDatabase = await dbContext.Customers.FirstWithIdOrDefaultAsync(existingOrder.Customer.Id); + customerInDatabase.ShouldNotBeNull(); }); } @@ -172,9 +183,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_clear_required_ManyToOne_relationship_through_primary_endpoint() { // Arrange - Order existingOrder = _fakers.Orders.Generate(); - existingOrder.Shipment = _fakers.Shipments.Generate(); - existingOrder.Customer = _fakers.Customers.Generate(); + Order existingOrder = _fakers.Order.Generate(); + existingOrder.Shipment = _fakers.Shipment.Generate(); + existingOrder.Customer = _fakers.Customer.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -186,13 +197,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { data = new { - id = existingOrder.StringId, type = "orders", + id = existingOrder.StringId, relationships = new { customer = new { - data = (object)null + data = (object?)null } } } @@ -204,25 +215,25 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync<Document>(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Failed to clear a required relationship."); - - error.Detail.Should().Be($"The relationship 'customer' of resource type 'orders' with ID '{existingOrder.StringId}' " + - "cannot be cleared because it is a required relationship."); + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Input validation failed."); + error.Detail.Should().Be("The Customer field is required."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data/relationships/customer/data"); } [Fact] public async Task Cannot_clear_required_ManyToOne_relationship_through_relationship_endpoint() { // Arrange - Order existingOrder = _fakers.Orders.Generate(); - existingOrder.Shipment = _fakers.Shipments.Generate(); - existingOrder.Customer = _fakers.Customers.Generate(); + Order existingOrder = _fakers.Order.Generate(); + existingOrder.Shipment = _fakers.Shipment.Generate(); + existingOrder.Customer = _fakers.Customer.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -232,7 +243,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var requestBody = new { - data = (object)null + data = (object?)null }; string route = $"/orders/{existingOrder.StringId}/relationships/customer"; @@ -243,23 +254,23 @@ 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("Failed to clear a required relationship."); - error.Detail.Should().Be($"The relationship 'customer' of resource type 'orders' with ID '{existingOrder.StringId}' " + + error.Detail.Should().Be($"The relationship 'customer' on resource type 'orders' with ID '{existingOrder.StringId}' " + "cannot be cleared because it is a required relationship."); } [Fact] - public async Task Cannot_clear_required_OneToMany_relationship_through_primary_endpoint() + public async Task Clearing_OneToMany_relationship_through_primary_endpoint_triggers_cascading_delete() { // Arrange - Order existingOrder = _fakers.Orders.Generate(); - existingOrder.Shipment = _fakers.Shipments.Generate(); - existingOrder.Customer = _fakers.Customers.Generate(); + Order existingOrder = _fakers.Order.Generate(); + existingOrder.Shipment = _fakers.Shipment.Generate(); + existingOrder.Customer = _fakers.Customer.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -271,8 +282,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { data = new { - id = existingOrder.Customer.StringId, type = "customers", + id = existingOrder.Customer.StringId, relationships = new { orders = new @@ -286,28 +297,30 @@ await _testContext.RunOnDatabaseAsync(async dbContext => string route = $"/customers/{existingOrder.Customer.StringId}"; // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync<Document>(route, requestBody); + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync<string>(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Should().BeEmpty(); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Failed to clear a required relationship."); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Order? orderInDatabase = await dbContext.Orders.Include(order => order.Customer).FirstWithIdOrDefaultAsync(existingOrder.Id); + orderInDatabase.Should().BeNull(); - error.Detail.Should().Be($"The relationship 'orders' of resource type 'customers' with ID '{existingOrder.StringId}' " + - "cannot be cleared because it is a required relationship."); + Customer customerInDatabase = await dbContext.Customers.Include(customer => customer.Orders).FirstWithIdAsync(existingOrder.Customer.Id); + customerInDatabase.Orders.Should().BeEmpty(); + }); } [Fact] - public async Task Cannot_clear_required_OneToMany_relationship_by_updating_through_relationship_endpoint() + public async Task Clearing_OneToMany_relationship_through_update_relationship_endpoint_triggers_cascading_delete() { // Arrange - Order existingOrder = _fakers.Orders.Generate(); - existingOrder.Shipment = _fakers.Shipments.Generate(); - existingOrder.Customer = _fakers.Customers.Generate(); + Order existingOrder = _fakers.Order.Generate(); + existingOrder.Shipment = _fakers.Shipment.Generate(); + existingOrder.Customer = _fakers.Customer.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -323,28 +336,30 @@ await _testContext.RunOnDatabaseAsync(async dbContext => string route = $"/customers/{existingOrder.Customer.StringId}/relationships/orders"; // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync<Document>(route, requestBody); + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync<string>(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Should().BeEmpty(); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Failed to clear a required relationship."); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Order? orderInDatabase = await dbContext.Orders.Include(order => order.Customer).FirstWithIdOrDefaultAsync(existingOrder.Id); + orderInDatabase.Should().BeNull(); - error.Detail.Should().Be($"The relationship 'orders' of resource type 'customers' with ID '{existingOrder.StringId}' " + - "cannot be cleared because it is a required relationship."); + Customer customerInDatabase = await dbContext.Customers.Include(customer => customer.Orders).FirstWithIdAsync(existingOrder.Customer.Id); + customerInDatabase.Orders.Should().BeEmpty(); + }); } [Fact] - public async Task Cannot_clear_required_OneToMany_relationship_by_deleting_through_relationship_endpoint() + public async Task Clearing_OneToMany_relationship_through_delete_relationship_endpoint_triggers_cascading_delete() { // Arrange - Order existingOrder = _fakers.Orders.Generate(); - existingOrder.Shipment = _fakers.Shipments.Generate(); - existingOrder.Customer = _fakers.Customers.Generate(); + Order existingOrder = _fakers.Order.Generate(); + existingOrder.Shipment = _fakers.Shipment.Generate(); + existingOrder.Customer = _fakers.Customer.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -367,31 +382,33 @@ await _testContext.RunOnDatabaseAsync(async dbContext => string route = $"/customers/{existingOrder.Customer.StringId}/relationships/orders"; // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteDeleteAsync<Document>(route, requestBody); + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync<string>(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Should().BeEmpty(); - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Failed to clear a required relationship."); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Order? orderInDatabase = await dbContext.Orders.Include(order => order.Customer).FirstWithIdOrDefaultAsync(existingOrder.Id); + orderInDatabase.Should().BeNull(); - error.Detail.Should().Be($"The relationship 'orders' of resource type 'customers' with ID '{existingOrder.StringId}' " + - "cannot be cleared because it is a required relationship."); + Customer customerInDatabase = await dbContext.Customers.Include(customer => customer.Orders).FirstWithIdAsync(existingOrder.Customer.Id); + customerInDatabase.Orders.Should().BeEmpty(); + }); } [Fact] public async Task Can_reassign_dependent_side_of_ZeroOrOneToOne_relationship_through_primary_endpoint() { // Arrange - Order orderWithShipment = _fakers.Orders.Generate(); - orderWithShipment.Shipment = _fakers.Shipments.Generate(); - orderWithShipment.Customer = _fakers.Customers.Generate(); + Order orderWithShipment = _fakers.Order.Generate(); + orderWithShipment.Shipment = _fakers.Shipment.Generate(); + orderWithShipment.Customer = _fakers.Customer.Generate(); - Order orderWithoutShipment = _fakers.Orders.Generate(); - orderWithoutShipment.Customer = _fakers.Customers.Generate(); + Order orderWithoutShipment = _fakers.Order.Generate(); + orderWithoutShipment.Customer = _fakers.Customer.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -403,16 +420,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { data = new { - id = orderWithoutShipment.StringId, type = "orders", + id = orderWithoutShipment.StringId, relationships = new { shipment = new { data = new { - id = orderWithShipment.Shipment.StringId, - type = "shipments" + type = "shipments", + id = orderWithShipment.Shipment.StringId } } } @@ -431,10 +448,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - Shipment existingShipmentInDatabase = - await dbContext.Shipments.Include(shipment => shipment.Order).FirstWithIdOrDefaultAsync(orderWithShipment.Shipment.Id); + Shipment shipmentInDatabase = await dbContext.Shipments.Include(shipment => shipment.Order).FirstWithIdAsync(orderWithShipment.Shipment.Id); - existingShipmentInDatabase.Order.Id.Should().Be(orderWithoutShipment.Id); + shipmentInDatabase.Order.Id.Should().Be(orderWithoutShipment.Id); }); } @@ -442,12 +458,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_reassign_dependent_side_of_ZeroOrOneToOne_relationship_through_relationship_endpoint() { // Arrange - Order orderWithShipment = _fakers.Orders.Generate(); - orderWithShipment.Shipment = _fakers.Shipments.Generate(); - orderWithShipment.Customer = _fakers.Customers.Generate(); + Order orderWithShipment = _fakers.Order.Generate(); + orderWithShipment.Shipment = _fakers.Shipment.Generate(); + orderWithShipment.Customer = _fakers.Customer.Generate(); - Order orderWithoutShipment = _fakers.Orders.Generate(); - orderWithoutShipment.Customer = _fakers.Customers.Generate(); + Order orderWithoutShipment = _fakers.Order.Generate(); + orderWithoutShipment.Customer = _fakers.Customer.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -459,8 +475,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { data = new { - id = orderWithShipment.Shipment.StringId, - type = "shipments" + type = "shipments", + id = orderWithShipment.Shipment.StringId } }; @@ -476,10 +492,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - Shipment existingShipmentInDatabase = - await dbContext.Shipments.Include(shipment => shipment.Order).FirstWithIdOrDefaultAsync(orderWithShipment.Shipment.Id); + Shipment shipmentInDatabase = await dbContext.Shipments.Include(shipment => shipment.Order).FirstWithIdAsync(orderWithShipment.Shipment.Id); - existingShipmentInDatabase.Order.Id.Should().Be(orderWithoutShipment.Id); + shipmentInDatabase.Order.Id.Should().Be(orderWithoutShipment.Id); }); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RequiredRelationships/Order.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RequiredRelationships/Order.cs index 124382e8f4..e371b25bb5 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/RequiredRelationships/Order.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/RequiredRelationships/Order.cs @@ -5,15 +5,15 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.RequiredRelationships { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Order : Identifiable + public sealed class Order : Identifiable<int> { [Attr] public decimal Amount { get; set; } [HasOne] - public Customer Customer { get; set; } + public Customer Customer { get; set; } = null!; [HasOne] - public Shipment Shipment { get; set; } + public Shipment Shipment { get; set; } = null!; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RequiredRelationships/OrdersController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RequiredRelationships/OrdersController.cs index 5f0159428c..47c4d47d2a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/RequiredRelationships/OrdersController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/RequiredRelationships/OrdersController.cs @@ -5,10 +5,11 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.RequiredRelationships { - public sealed class OrdersController : JsonApiController<Order> + public sealed class OrdersController : JsonApiController<Order, int> { - public OrdersController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<Order> resourceService) - : base(options, loggerFactory, resourceService) + public OrdersController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<Order, int> resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RequiredRelationships/Shipment.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RequiredRelationships/Shipment.cs index 24768ad68e..aeef7e0cbf 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/RequiredRelationships/Shipment.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/RequiredRelationships/Shipment.cs @@ -8,15 +8,15 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.RequiredRelationships { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Shipment : Identifiable + public sealed class Shipment : Identifiable<int> { [Attr] - public string TrackAndTraceCode { get; set; } + public string TrackAndTraceCode { get; set; } = null!; [Attr] public DateTimeOffset ShippedAt { get; set; } [HasOne] - public Order Order { get; set; } + public Order Order { get; set; } = null!; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RequiredRelationships/ShipmentsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RequiredRelationships/ShipmentsController.cs index 7ca704fa6b..0bd68b5af6 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/RequiredRelationships/ShipmentsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/RequiredRelationships/ShipmentsController.cs @@ -5,10 +5,11 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.RequiredRelationships { - public sealed class ShipmentsController : JsonApiController<Shipment> + public sealed class ShipmentsController : JsonApiController<Shipment, int> { - public ShipmentsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<Shipment> resourceService) - : base(options, loggerFactory, resourceService) + public ShipmentsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<Shipment, int> resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceConstructorInjection/GiftCertificate.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceConstructorInjection/GiftCertificate.cs index 0c96d5c58a..e6a5b7a0e2 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceConstructorInjection/GiftCertificate.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceConstructorInjection/GiftCertificate.cs @@ -8,7 +8,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ResourceConstructorInjection { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class GiftCertificate : Identifiable + public sealed class GiftCertificate : Identifiable<int> { private readonly ISystemClock _systemClock; @@ -20,7 +20,7 @@ public sealed class GiftCertificate : Identifiable public bool HasExpired => IssueDate.AddYears(1) < _systemClock.UtcNow; [HasOne] - public PostOffice Issuer { get; set; } + public PostOffice? Issuer { get; set; } public GiftCertificate(InjectionDbContext injectionDbContext) { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceConstructorInjection/GiftCertificatesController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceConstructorInjection/GiftCertificatesController.cs index b8592eab1e..d51ceb542b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceConstructorInjection/GiftCertificatesController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceConstructorInjection/GiftCertificatesController.cs @@ -5,10 +5,11 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ResourceConstructorInjection { - public sealed class GiftCertificatesController : JsonApiController<GiftCertificate> + public sealed class GiftCertificatesController : JsonApiController<GiftCertificate, int> { - public GiftCertificatesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<GiftCertificate> resourceService) - : base(options, loggerFactory, resourceService) + public GiftCertificatesController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<GiftCertificate, int> resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceConstructorInjection/InjectionDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceConstructorInjection/InjectionDbContext.cs index c885fc6cac..cf8fd93d8f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceConstructorInjection/InjectionDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceConstructorInjection/InjectionDbContext.cs @@ -10,8 +10,8 @@ public sealed class InjectionDbContext : DbContext { public ISystemClock SystemClock { get; } - public DbSet<PostOffice> PostOffice { get; set; } - public DbSet<GiftCertificate> GiftCertificates { get; set; } + public DbSet<PostOffice> PostOffice => Set<PostOffice>(); + public DbSet<GiftCertificate> GiftCertificates => Set<GiftCertificate>(); public InjectionDbContext(DbContextOptions<InjectionDbContext> options, ISystemClock systemClock) : base(options) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceConstructorInjection/PostOffice.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceConstructorInjection/PostOffice.cs index 688faf36e4..5ca24b69b2 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceConstructorInjection/PostOffice.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceConstructorInjection/PostOffice.cs @@ -9,19 +9,19 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ResourceConstructorInjection { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class PostOffice : Identifiable + public sealed class PostOffice : Identifiable<int> { private readonly ISystemClock _systemClock; [Attr] - public string Address { get; set; } + public string Address { get; set; } = null!; [Attr(Capabilities = AttrCapabilities.AllowView)] [NotMapped] public bool IsOpen => IsWithinOperatingHours(); [HasMany] - public IList<GiftCertificate> GiftCertificates { get; set; } + public IList<GiftCertificate> GiftCertificates { get; set; } = new List<GiftCertificate>(); public PostOffice(InjectionDbContext injectionDbContext) { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceConstructorInjection/PostOfficesController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceConstructorInjection/PostOfficesController.cs index 32167b3d2e..5d0bbb5b0f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceConstructorInjection/PostOfficesController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceConstructorInjection/PostOfficesController.cs @@ -5,10 +5,11 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ResourceConstructorInjection { - public sealed class PostOfficesController : JsonApiController<PostOffice> + public sealed class PostOfficesController : JsonApiController<PostOffice, int> { - public PostOfficesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<PostOffice> resourceService) - : base(options, loggerFactory, resourceService) + public PostOfficesController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<PostOffice, int> resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceConstructorInjection/ResourceInjectionTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceConstructorInjection/ResourceInjectionTests.cs index 97716b7812..b744dfa690 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceConstructorInjection/ResourceInjectionTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceConstructorInjection/ResourceInjectionTests.cs @@ -59,10 +59,13 @@ 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(certificate.StringId); - responseDocument.Data.SingleValue.Attributes["issueDate"].As<DateTimeOffset>().Should().BeCloseTo(certificate.IssueDate); - responseDocument.Data.SingleValue.Attributes["hasExpired"].Should().Be(false); + + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("issueDate") + .With(value => value.As<DateTimeOffset>().Should().BeCloseTo(certificate.IssueDate)); + + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("hasExpired").With(value => value.Should().Be(false)); } [Fact] @@ -89,10 +92,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].Id.Should().Be(postOffices[1].StringId); - responseDocument.Data.ManyValue[0].Attributes["address"].Should().Be(postOffices[1].Address); - responseDocument.Data.ManyValue[0].Attributes["isOpen"].Should().Be(true); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("address").With(value => value.Should().Be(postOffices[1].Address)); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("isOpen").With(value => value.Should().Be(true)); } [Fact] @@ -119,10 +122,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(certificate.Issuer.StringId); - responseDocument.Data.SingleValue.Attributes.Should().HaveCount(1); - responseDocument.Data.SingleValue.Attributes["isOpen"].Should().Be(true); + responseDocument.Data.SingleValue.Attributes.ShouldHaveCount(1); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("isOpen").With(value => value.Should().Be(true)); } [Fact] @@ -173,17 +176,30 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.Should().NotBeNull(); - responseDocument.Data.SingleValue.Attributes["issueDate"].As<DateTimeOffset>().Should().BeCloseTo(newIssueDate); - responseDocument.Data.SingleValue.Attributes["hasExpired"].Should().Be(true); - responseDocument.Data.SingleValue.Relationships["issuer"].Data.SingleValue.Id.Should().Be(existingOffice.StringId); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("issueDate") + .With(value => value.As<DateTimeOffset>().Should().BeCloseTo(newIssueDate)); + + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("hasExpired").With(value => value.Should().Be(true)); + + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("issuer").With(value => + { + value.ShouldNotBeNull(); + value.Data.SingleValue.ShouldNotBeNull(); + value.Data.SingleValue.Id.Should().Be(existingOffice.StringId); + }); + + responseDocument.Included.ShouldHaveCount(1); - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Id.Should().Be(existingOffice.StringId); - responseDocument.Included[0].Attributes["address"].Should().Be(existingOffice.Address); - responseDocument.Included[0].Attributes["isOpen"].Should().Be(false); + responseDocument.Included[0].With(resource => + { + resource.Id.Should().Be(existingOffice.StringId); + resource.Attributes.ShouldContainKey("address").With(value => value.Should().Be(existingOffice.Address)); + resource.Attributes.ShouldContainKey("isOpen").With(value => value.Should().Be(false)); + }); - int newCertificateId = int.Parse(responseDocument.Data.SingleValue.Id); + int newCertificateId = int.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -192,7 +208,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => certificateInDatabase.IssueDate.Should().Be(newIssueDate); - certificateInDatabase.Issuer.Should().NotBeNull(); + certificateInDatabase.Issuer.ShouldNotBeNull(); certificateInDatabase.Issuer.Id.Should().Be(existingOffice.Id); }); } @@ -258,7 +274,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => officeInDatabase.Address.Should().Be(newAddress); - officeInDatabase.GiftCertificates.Should().HaveCount(1); + officeInDatabase.GiftCertificates.ShouldHaveCount(1); officeInDatabase.GiftCertificates[0].Id.Should().Be(existingOffice.GiftCertificates[0].Id); }); } @@ -287,7 +303,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - PostOffice officeInDatabase = await dbContext.PostOffice.FirstWithIdOrDefaultAsync(existingOffice.Id); + PostOffice? officeInDatabase = await dbContext.PostOffice.FirstWithIdOrDefaultAsync(existingOffice.Id); officeInDatabase.Should().BeNull(); }); @@ -307,7 +323,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); @@ -356,7 +372,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { PostOffice officeInDatabase = await dbContext.PostOffice.Include(postOffice => postOffice.GiftCertificates).FirstWithIdAsync(existingOffice.Id); - officeInDatabase.GiftCertificates.Should().HaveCount(2); + officeInDatabase.GiftCertificates.ShouldHaveCount(2); }); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitionExtensibilityPoints.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitionExtensibilityPoints.cs new file mode 100644 index 0000000000..212a92cd75 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitionExtensibilityPoints.cs @@ -0,0 +1,42 @@ +using System; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCoreTests.IntegrationTests +{ + /// <summary> + /// Lists the various extensibility points on <see cref="IResourceDefinition{TResource,TId}" />. + /// </summary> + [Flags] + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public enum ResourceDefinitionExtensibilityPoints + { + None = 0, + OnApplyIncludes = 1, + OnApplyFilter = 1 << 1, + OnApplySort = 1 << 2, + OnApplyPagination = 1 << 3, + OnApplySparseFieldSet = 1 << 4, + OnRegisterQueryableHandlersForQueryStringParameters = 1 << 5, + GetMeta = 1 << 6, + OnPrepareWriteAsync = 1 << 7, + OnSetToOneRelationshipAsync = 1 << 8, + OnSetToManyRelationshipAsync = 1 << 9, + OnAddToRelationshipAsync = 1 << 10, + OnRemoveFromRelationshipAsync = 1 << 11, + OnWritingAsync = 1 << 12, + OnWriteSucceededAsync = 1 << 13, + OnDeserialize = 1 << 14, + OnSerialize = 1 << 15, + + Reading = OnApplyIncludes | OnApplyFilter | OnApplySort | OnApplyPagination | OnApplySparseFieldSet | + OnRegisterQueryableHandlersForQueryStringParameters | GetMeta, + + Writing = OnPrepareWriteAsync | OnSetToOneRelationshipAsync | OnSetToManyRelationshipAsync | OnAddToRelationshipAsync | OnRemoveFromRelationshipAsync | + OnWritingAsync | OnWriteSucceededAsync, + + Serialization = OnDeserialize | OnSerialize, + + All = Reading | Writing | Serialization + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitionHitCounter.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitionHitCounter.cs index 3bbe48baea..14c0fdaf9e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitionHitCounter.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitionHitCounter.cs @@ -5,13 +5,14 @@ namespace JsonApiDotNetCoreTests.IntegrationTests { /// <summary> - /// This is used solely in our tests, so we can assert which calls were made. + /// Used to keep track of invocations on <see cref="IResourceDefinition{TResource,TId}" /> callback methods. /// </summary> public sealed class ResourceDefinitionHitCounter { - internal IList<(Type, ExtensibilityPoint)> HitExtensibilityPoints { get; } = new List<(Type, ExtensibilityPoint)>(); + internal IList<(Type, ResourceDefinitionExtensibilityPoints)> HitExtensibilityPoints { get; } = + new List<(Type, ResourceDefinitionExtensibilityPoints)>(); - internal void TrackInvocation<TResource>(ExtensibilityPoint extensibilityPoint) + internal void TrackInvocation<TResource>(ResourceDefinitionExtensibilityPoints extensibilityPoint) where TResource : IIdentifiable { HitExtensibilityPoints.Add((typeof(TResource), extensibilityPoint)); @@ -21,25 +22,5 @@ internal void Reset() { HitExtensibilityPoints.Clear(); } - - internal enum ExtensibilityPoint - { - OnApplyIncludes, - OnApplyFilter, - OnApplySort, - OnApplyPagination, - OnApplySparseFieldSet, - OnRegisterQueryableHandlersForQueryStringParameters, - GetMeta, - OnPrepareWriteAsync, - OnSetToOneRelationshipAsync, - OnSetToManyRelationshipAsync, - OnAddToRelationshipAsync, - OnRemoveFromRelationshipAsync, - OnWritingAsync, - OnWriteSucceededAsync, - OnDeserialize, - OnSerialize - } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/Moon.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/Moon.cs index a3a2e23199..67de51806a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/Moon.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/Moon.cs @@ -5,15 +5,15 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ResourceDefinitions.Reading { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Moon : Identifiable + public sealed class Moon : Identifiable<int> { [Attr] - public string Name { get; set; } + public string Name { get; set; } = null!; [Attr] public decimal SolarRadius { get; set; } [HasOne] - public Planet OrbitsAround { get; set; } + public Planet OrbitsAround { get; set; } = null!; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/MoonDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/MoonDefinition.cs index a40696a23a..550a733739 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/MoonDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/MoonDefinition.cs @@ -10,24 +10,24 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ResourceDefinitions.Reading { [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class MoonDefinition : JsonApiResourceDefinition<Moon> + public sealed class MoonDefinition : HitCountingResourceDefinition<Moon, int> { private readonly IClientSettingsProvider _clientSettingsProvider; - private readonly ResourceDefinitionHitCounter _hitCounter; + + protected override ResourceDefinitionExtensibilityPoints ExtensibilityPointsToTrack => ResourceDefinitionExtensibilityPoints.Reading; public MoonDefinition(IResourceGraph resourceGraph, IClientSettingsProvider clientSettingsProvider, ResourceDefinitionHitCounter hitCounter) - : base(resourceGraph) + : base(resourceGraph, hitCounter) { // This constructor will be resolved from the container, which means // you can take on any dependency that is also defined in the container. _clientSettingsProvider = clientSettingsProvider; - _hitCounter = hitCounter; } - public override IImmutableList<IncludeElementExpression> OnApplyIncludes(IImmutableList<IncludeElementExpression> existingIncludes) + public override IImmutableSet<IncludeElementExpression> OnApplyIncludes(IImmutableSet<IncludeElementExpression> existingIncludes) { - _hitCounter.TrackInvocation<Moon>(ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplyIncludes); + base.OnApplyIncludes(existingIncludes); if (!_clientSettingsProvider.IsMoonOrbitingPlanetAutoIncluded || existingIncludes.Any(include => include.Relationship.Property.Name == nameof(Moon.OrbitsAround))) @@ -35,14 +35,14 @@ public override IImmutableList<IncludeElementExpression> OnApplyIncludes(IImmuta return existingIncludes; } - RelationshipAttribute orbitsAroundRelationship = ResourceContext.GetRelationshipByPropertyName(nameof(Moon.OrbitsAround)); + RelationshipAttribute orbitsAroundRelationship = ResourceType.GetRelationshipByPropertyName(nameof(Moon.OrbitsAround)); return existingIncludes.Add(new IncludeElementExpression(orbitsAroundRelationship)); } public override QueryStringParameterHandlers<Moon> OnRegisterQueryableHandlersForQueryStringParameters() { - _hitCounter.TrackInvocation<Moon>(ResourceDefinitionHitCounter.ExtensibilityPoint.OnRegisterQueryableHandlersForQueryStringParameters); + base.OnRegisterQueryableHandlersForQueryStringParameters(); return new QueryStringParameterHandlers<Moon> { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/MoonsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/MoonsController.cs index f72d0c121a..d498a8630b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/MoonsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/MoonsController.cs @@ -5,10 +5,10 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ResourceDefinitions.Reading { - public sealed class MoonsController : JsonApiController<Moon> + public sealed class MoonsController : JsonApiController<Moon, int> { - public MoonsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<Moon> resourceService) - : base(options, loggerFactory, resourceService) + public MoonsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IResourceService<Moon, int> resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/Planet.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/Planet.cs index a4090b4228..21f196935a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/Planet.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/Planet.cs @@ -6,13 +6,13 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ResourceDefinitions.Reading { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Planet : Identifiable + public sealed class Planet : Identifiable<int> { [Attr] - public string PublicName { get; set; } + public string PublicName { get; set; } = null!; [Attr] - public string PrivateName { get; set; } + public string? PrivateName { get; set; } [Attr] public bool HasRingSystem { get; set; } @@ -21,9 +21,9 @@ public sealed class Planet : Identifiable public decimal SolarMass { get; set; } [HasMany] - public ISet<Moon> Moons { get; set; } + public ISet<Moon> Moons { get; set; } = new HashSet<Moon>(); [HasOne] - public Star BelongsTo { get; set; } + public Star? BelongsTo { get; set; } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/PlanetDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/PlanetDefinition.cs index b2313bee98..d7152bf962 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/PlanetDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/PlanetDefinition.cs @@ -5,31 +5,30 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.Objects; namespace JsonApiDotNetCoreTests.IntegrationTests.ResourceDefinitions.Reading { [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class PlanetDefinition : JsonApiResourceDefinition<Planet> + public sealed class PlanetDefinition : HitCountingResourceDefinition<Planet, int> { private readonly IClientSettingsProvider _clientSettingsProvider; - private readonly ResourceDefinitionHitCounter _hitCounter; + + protected override ResourceDefinitionExtensibilityPoints ExtensibilityPointsToTrack => ResourceDefinitionExtensibilityPoints.Reading; public PlanetDefinition(IResourceGraph resourceGraph, IClientSettingsProvider clientSettingsProvider, ResourceDefinitionHitCounter hitCounter) - : base(resourceGraph) + : base(resourceGraph, hitCounter) { // This constructor will be resolved from the container, which means // you can take on any dependency that is also defined in the container. _clientSettingsProvider = clientSettingsProvider; - _hitCounter = hitCounter; } - public override IImmutableList<IncludeElementExpression> OnApplyIncludes(IImmutableList<IncludeElementExpression> existingIncludes) + public override IImmutableSet<IncludeElementExpression> OnApplyIncludes(IImmutableSet<IncludeElementExpression> existingIncludes) { - _hitCounter.TrackInvocation<Planet>(ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplyIncludes); + base.OnApplyIncludes(existingIncludes); if (_clientSettingsProvider.IsIncludePlanetMoonsBlocked && existingIncludes.Any(include => include.Relationship.Property.Name == nameof(Planet.Moons))) @@ -43,18 +42,18 @@ public override IImmutableList<IncludeElementExpression> OnApplyIncludes(IImmuta return existingIncludes; } - public override FilterExpression OnApplyFilter(FilterExpression existingFilter) + public override FilterExpression? OnApplyFilter(FilterExpression? existingFilter) { - _hitCounter.TrackInvocation<Planet>(ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplyFilter); + base.OnApplyFilter(existingFilter); if (_clientSettingsProvider.ArePlanetsWithPrivateNameHidden) { - AttrAttribute privateNameAttribute = ResourceContext.GetAttributeByPropertyName(nameof(Planet.PrivateName)); + AttrAttribute privateNameAttribute = ResourceType.GetAttributeByPropertyName(nameof(Planet.PrivateName)); FilterExpression hasNoPrivateName = new ComparisonExpression(ComparisonOperator.Equals, new ResourceFieldChainExpression(privateNameAttribute), - new NullConstantExpression()); + NullConstantExpression.Instance); - return existingFilter == null ? hasNoPrivateName : new LogicalExpression(LogicalOperator.And, hasNoPrivateName, existingFilter); + return LogicalExpression.Compose(LogicalOperator.And, hasNoPrivateName, existingFilter); } return existingFilter; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/PlanetsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/PlanetsController.cs index 3ccf4362ec..ec8a670592 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/PlanetsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/PlanetsController.cs @@ -5,10 +5,11 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ResourceDefinitions.Reading { - public sealed class PlanetsController : JsonApiController<Planet> + public sealed class PlanetsController : JsonApiController<Planet, int> { - public PlanetsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<Planet> resourceService) - : base(options, loggerFactory, resourceService) + public PlanetsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<Planet, int> resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/ResourceDefinitionReadTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/ResourceDefinitionReadTests.cs index 675d562f5f..ea8971d6f0 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/ResourceDefinitionReadTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/ResourceDefinitionReadTests.cs @@ -72,7 +72,7 @@ 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); @@ -81,9 +81,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(Planet), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplyFilter), - (typeof(Planet), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplyFilter), - (typeof(Planet), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplyIncludes) + (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplyFilter), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplyPagination), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplyFilter), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplySort), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplyIncludes) }, options => options.WithStrictOrdering()); } @@ -113,18 +116,95 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); - responseDocument.Data.SingleValue.Relationships["orbitsAround"].Data.SingleValue.Type.Should().Be("planets"); - responseDocument.Data.SingleValue.Relationships["orbitsAround"].Data.SingleValue.Id.Should().Be(moon.OrbitsAround.StringId); + responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Included.Should().HaveCount(1); + responseDocument.Data.SingleValue.Relationships.ShouldContainKey("orbitsAround").With(value => + { + value.ShouldNotBeNull(); + value.Data.SingleValue.ShouldNotBeNull(); + value.Data.SingleValue.Type.Should().Be("planets"); + value.Data.SingleValue.Id.Should().Be(moon.OrbitsAround.StringId); + }); + + responseDocument.Included.ShouldHaveCount(1); responseDocument.Included[0].Type.Should().Be("planets"); responseDocument.Included[0].Id.Should().Be(moon.OrbitsAround.StringId); - responseDocument.Included[0].Attributes["publicName"].Should().Be(moon.OrbitsAround.PublicName); + responseDocument.Included[0].Attributes.ShouldContainKey("publicName").With(value => value.Should().Be(moon.OrbitsAround.PublicName)); hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(Moon), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplyIncludes) + (typeof(Moon), ResourceDefinitionExtensibilityPoints.OnApplyPagination), + (typeof(Moon), ResourceDefinitionExtensibilityPoints.OnApplyFilter), + (typeof(Moon), ResourceDefinitionExtensibilityPoints.OnApplySort), + (typeof(Moon), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Moon), ResourceDefinitionExtensibilityPoints.OnApplyIncludes), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplyIncludes), + (typeof(Moon), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Moon), ResourceDefinitionExtensibilityPoints.GetMeta), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.GetMeta) + }, options => options.WithStrictOrdering()); + } + + [Fact] + public async Task Include_from_included_resource_definition_is_added() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService<ResourceDefinitionHitCounter>(); + + var settingsProvider = (TestClientSettingsProvider)_testContext.Factory.Services.GetRequiredService<IClientSettingsProvider>(); + settingsProvider.AutoIncludeOrbitingPlanetForMoons(); + + Planet planet = _fakers.Planet.Generate(); + planet.Moons = _fakers.Moon.Generate(1).ToHashSet(); + planet.Moons.ElementAt(0).OrbitsAround = _fakers.Planet.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Planets.Add(planet); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/planets/{planet.StringId}?include=moons"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + + responseDocument.Included.ShouldHaveCount(2); + + responseDocument.Included[0].Type.Should().Be("moons"); + responseDocument.Included[0].Id.Should().Be(planet.Moons.ElementAt(0).StringId); + responseDocument.Included[0].Attributes.ShouldContainKey("name").With(value => value.Should().Be(planet.Moons.ElementAt(0).Name)); + + string moonName = planet.Moons.ElementAt(0).OrbitsAround.PublicName; + + responseDocument.Included[1].Type.Should().Be("planets"); + responseDocument.Included[1].Id.Should().Be(planet.Moons.ElementAt(0).OrbitsAround.StringId); + responseDocument.Included[1].Attributes.ShouldContainKey("publicName").With(value => value.Should().Be(moonName)); + + hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] + { + (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplyPagination), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplyFilter), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplySort), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplyIncludes), + (typeof(Moon), ResourceDefinitionExtensibilityPoints.OnApplyFilter), + (typeof(Moon), ResourceDefinitionExtensibilityPoints.OnApplySort), + (typeof(Moon), ResourceDefinitionExtensibilityPoints.OnApplyPagination), + (typeof(Moon), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Moon), ResourceDefinitionExtensibilityPoints.OnApplyIncludes), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplyIncludes), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.GetMeta), + (typeof(Moon), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Moon), ResourceDefinitionExtensibilityPoints.GetMeta) }, options => options.WithStrictOrdering()); } @@ -156,17 +236,27 @@ 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(planets[1].StringId); responseDocument.Data.ManyValue[1].Id.Should().Be(planets[3].StringId); - ((JsonElement)responseDocument.Meta["total"]).GetInt32().Should().Be(2); + responseDocument.Meta.ShouldContainKey("total").With(value => + { + JsonElement element = value.Should().BeOfType<JsonElement>().Subject; + element.GetInt32().Should().Be(2); + }); hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(Planet), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplyFilter), - (typeof(Planet), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplyFilter), - (typeof(Planet), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplyIncludes) + (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplyFilter), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplyPagination), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplyFilter), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplySort), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplyIncludes), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.GetMeta), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.GetMeta) }, options => options.WithStrictOrdering()); } @@ -206,16 +296,135 @@ 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(planets[3].StringId); - ((JsonElement)responseDocument.Meta["total"]).GetInt32().Should().Be(1); + responseDocument.Meta.ShouldContainKey("total").With(value => + { + JsonElement element = value.Should().BeOfType<JsonElement>().Subject; + element.GetInt32().Should().Be(1); + }); + + hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] + { + (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplyFilter), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplyPagination), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplyFilter), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplySort), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplyIncludes), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.GetMeta) + }, options => options.WithStrictOrdering()); + } + + [Fact] + public async Task Filter_from_resource_definition_is_applied_on_secondary_endpoint() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService<ResourceDefinitionHitCounter>(); + + var settingsProvider = (TestClientSettingsProvider)_testContext.Factory.Services.GetRequiredService<IClientSettingsProvider>(); + settingsProvider.HidePlanetsWithPrivateName(); + + Star star = _fakers.Star.Generate(); + star.Planets = _fakers.Planet.Generate(4).ToHashSet(); + star.Planets.ElementAt(0).PrivateName = "A"; + star.Planets.ElementAt(2).PrivateName = "B"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync<Planet>(); + dbContext.Stars.AddRange(star); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/stars/{star.StringId}/planets"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(2); + responseDocument.Data.ManyValue[0].Id.Should().Be(star.Planets.ElementAt(1).StringId); + responseDocument.Data.ManyValue[1].Id.Should().Be(star.Planets.ElementAt(3).StringId); + + responseDocument.Meta.ShouldContainKey("total").With(value => + { + JsonElement element = value.Should().BeOfType<JsonElement>().Subject; + element.GetInt32().Should().Be(2); + }); hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(Planet), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplyFilter), - (typeof(Planet), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplyFilter), - (typeof(Planet), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplyIncludes) + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyFilter), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplyFilter), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplyPagination), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplyFilter), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplySort), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplyIncludes), + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyFilter), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.GetMeta), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.GetMeta) + }, options => options.WithStrictOrdering()); + } + + [Fact] + public async Task Filter_from_resource_definition_is_applied_on_relationship_endpoint() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService<ResourceDefinitionHitCounter>(); + + var settingsProvider = (TestClientSettingsProvider)_testContext.Factory.Services.GetRequiredService<IClientSettingsProvider>(); + settingsProvider.HidePlanetsWithPrivateName(); + + Star star = _fakers.Star.Generate(); + star.Planets = _fakers.Planet.Generate(4).ToHashSet(); + star.Planets.ElementAt(0).PrivateName = "A"; + star.Planets.ElementAt(2).PrivateName = "B"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync<Planet>(); + dbContext.Stars.AddRange(star); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/stars/{star.StringId}/relationships/planets"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(2); + responseDocument.Data.ManyValue[0].Id.Should().Be(star.Planets.ElementAt(1).StringId); + responseDocument.Data.ManyValue[1].Id.Should().Be(star.Planets.ElementAt(3).StringId); + + responseDocument.Meta.ShouldContainKey("total").With(value => + { + JsonElement element = value.Should().BeOfType<JsonElement>().Subject; + element.GetInt32().Should().Be(2); + }); + + hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] + { + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyFilter), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplyFilter), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplyPagination), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplyFilter), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplySort), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplyIncludes), + (typeof(Planet), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyFilter) }, options => options.WithStrictOrdering()); } @@ -251,18 +460,23 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(3); + responseDocument.Data.ManyValue.ShouldHaveCount(3); responseDocument.Data.ManyValue[0].Id.Should().Be(stars[1].StringId); responseDocument.Data.ManyValue[1].Id.Should().Be(stars[0].StringId); responseDocument.Data.ManyValue[2].Id.Should().Be(stars[2].StringId); hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(Star), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplyPagination), - (typeof(Star), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplySort), - (typeof(Star), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplySparseFieldSet), - (typeof(Star), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplySparseFieldSet), - (typeof(Star), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplySparseFieldSet) + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyFilter), + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyPagination), + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyFilter), + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplySort), + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyIncludes), + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Star), ResourceDefinitionExtensibilityPoints.GetMeta), + (typeof(Star), ResourceDefinitionExtensibilityPoints.GetMeta), + (typeof(Star), ResourceDefinitionExtensibilityPoints.GetMeta) }, options => options.WithStrictOrdering()); } @@ -298,18 +512,23 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(3); + responseDocument.Data.ManyValue.ShouldHaveCount(3); responseDocument.Data.ManyValue[0].Id.Should().Be(stars[2].StringId); responseDocument.Data.ManyValue[1].Id.Should().Be(stars[0].StringId); responseDocument.Data.ManyValue[2].Id.Should().Be(stars[1].StringId); hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(Star), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplyPagination), - (typeof(Star), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplySort), - (typeof(Star), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplySparseFieldSet), - (typeof(Star), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplySparseFieldSet), - (typeof(Star), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplySparseFieldSet) + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyFilter), + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyPagination), + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyFilter), + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplySort), + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyIncludes), + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Star), ResourceDefinitionExtensibilityPoints.GetMeta), + (typeof(Star), ResourceDefinitionExtensibilityPoints.GetMeta), + (typeof(Star), ResourceDefinitionExtensibilityPoints.GetMeta) }, options => options.WithStrictOrdering()); } @@ -336,15 +555,22 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(5); + responseDocument.Data.ManyValue.ShouldHaveCount(5); hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(Star), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplyPagination), - (typeof(Star), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplySort), - (typeof(Star), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplySparseFieldSet), - (typeof(Star), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplySparseFieldSet), - (typeof(Star), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplySparseFieldSet) + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyFilter), + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyPagination), + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyFilter), + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplySort), + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyIncludes), + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Star), ResourceDefinitionExtensibilityPoints.GetMeta), + (typeof(Star), ResourceDefinitionExtensibilityPoints.GetMeta), + (typeof(Star), ResourceDefinitionExtensibilityPoints.GetMeta), + (typeof(Star), ResourceDefinitionExtensibilityPoints.GetMeta), + (typeof(Star), ResourceDefinitionExtensibilityPoints.GetMeta) }, options => options.WithStrictOrdering()); } @@ -370,19 +596,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(star.StringId); - responseDocument.Data.SingleValue.Attributes["name"].Should().Be(star.Name); - responseDocument.Data.SingleValue.Attributes["kind"].Should().Be(star.Kind); - responseDocument.Data.SingleValue.Relationships.Should().NotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("name").With(value => value.Should().Be(star.Name)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("kind").With(value => value.Should().Be(star.Kind)); + responseDocument.Data.SingleValue.Relationships.ShouldNotBeNull(); hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(Star), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplyPagination), - (typeof(Star), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplySort), - (typeof(Star), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplySparseFieldSet), - (typeof(Star), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplySparseFieldSet), - (typeof(Star), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplySparseFieldSet) + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyPagination), + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyFilter), + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplySort), + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyIncludes), + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Star), ResourceDefinitionExtensibilityPoints.GetMeta) }, options => options.WithStrictOrdering()); } @@ -408,20 +636,22 @@ 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(star.StringId); - responseDocument.Data.SingleValue.Attributes.Should().HaveCount(2); - responseDocument.Data.SingleValue.Attributes["name"].Should().Be(star.Name); - responseDocument.Data.SingleValue.Attributes["solarRadius"].As<decimal>().Should().BeApproximately(star.SolarRadius); + responseDocument.Data.SingleValue.Attributes.ShouldHaveCount(2); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("name").With(value => value.Should().Be(star.Name)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("solarRadius").With(value => value.Should().Be(star.SolarRadius)); responseDocument.Data.SingleValue.Relationships.Should().BeNull(); hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(Star), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplyPagination), - (typeof(Star), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplySort), - (typeof(Star), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplySparseFieldSet), - (typeof(Star), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplySparseFieldSet), - (typeof(Star), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplySparseFieldSet) + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyPagination), + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyFilter), + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplySort), + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyIncludes), + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Star), ResourceDefinitionExtensibilityPoints.GetMeta) }, options => options.WithStrictOrdering()); } @@ -447,19 +677,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(star.StringId); - responseDocument.Data.SingleValue.Attributes["name"].Should().Be(star.Name); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("name").With(value => value.Should().Be(star.Name)); responseDocument.Data.SingleValue.Attributes.Should().NotContainKey("isVisibleFromEarth"); - responseDocument.Data.SingleValue.Relationships.Should().NotBeNull(); + responseDocument.Data.SingleValue.Relationships.ShouldNotBeNull(); hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(Star), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplyPagination), - (typeof(Star), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplySort), - (typeof(Star), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplySparseFieldSet), - (typeof(Star), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplySparseFieldSet), - (typeof(Star), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplySparseFieldSet) + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyPagination), + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyFilter), + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplySort), + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyIncludes), + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Star), ResourceDefinitionExtensibilityPoints.GetMeta) }, options => options.WithStrictOrdering()); } @@ -485,19 +717,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(star.StringId); - responseDocument.Data.SingleValue.Attributes.Should().HaveCount(1); - responseDocument.Data.SingleValue.Attributes["name"].Should().Be(star.Name); + responseDocument.Data.SingleValue.Attributes.ShouldHaveCount(1); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("name").With(value => value.Should().Be(star.Name)); responseDocument.Data.SingleValue.Relationships.Should().BeNull(); hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(Star), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplyPagination), - (typeof(Star), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplySort), - (typeof(Star), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplySparseFieldSet), - (typeof(Star), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplySparseFieldSet), - (typeof(Star), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplySparseFieldSet) + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyPagination), + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyFilter), + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplySort), + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplyIncludes), + (typeof(Star), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Star), ResourceDefinitionExtensibilityPoints.GetMeta) }, options => options.WithStrictOrdering()); } @@ -510,7 +744,10 @@ public async Task Queryable_parameter_handler_from_resource_definition_is_applie List<Moon> moons = _fakers.Moon.Generate(2); moons[0].SolarRadius = .5m; + moons[0].OrbitsAround = _fakers.Planet.Generate(); + moons[1].SolarRadius = 50m; + moons[1].OrbitsAround = _fakers.Planet.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -527,14 +764,21 @@ 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(moons[1].StringId); hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(Moon), ResourceDefinitionHitCounter.ExtensibilityPoint.OnRegisterQueryableHandlersForQueryStringParameters), - (typeof(Moon), ResourceDefinitionHitCounter.ExtensibilityPoint.OnRegisterQueryableHandlersForQueryStringParameters), - (typeof(Moon), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplyIncludes) + (typeof(Moon), ResourceDefinitionExtensibilityPoints.OnRegisterQueryableHandlersForQueryStringParameters), + (typeof(Moon), ResourceDefinitionExtensibilityPoints.OnRegisterQueryableHandlersForQueryStringParameters), + (typeof(Moon), ResourceDefinitionExtensibilityPoints.OnApplyFilter), + (typeof(Moon), ResourceDefinitionExtensibilityPoints.OnApplyPagination), + (typeof(Moon), ResourceDefinitionExtensibilityPoints.OnApplyFilter), + (typeof(Moon), ResourceDefinitionExtensibilityPoints.OnApplySort), + (typeof(Moon), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Moon), ResourceDefinitionExtensibilityPoints.OnApplyIncludes), + (typeof(Moon), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Moon), ResourceDefinitionExtensibilityPoints.GetMeta) }, options => options.WithStrictOrdering()); } @@ -548,15 +792,19 @@ public async Task Queryable_parameter_handler_from_resource_definition_and_query moons[0].Name = "Alpha1"; moons[0].SolarRadius = 1m; + moons[0].OrbitsAround = _fakers.Planet.Generate(); moons[1].Name = "Alpha2"; moons[1].SolarRadius = 5m; + moons[1].OrbitsAround = _fakers.Planet.Generate(); moons[2].Name = "Beta1"; moons[2].SolarRadius = 1m; + moons[2].OrbitsAround = _fakers.Planet.Generate(); moons[3].Name = "Beta2"; moons[3].SolarRadius = 5m; + moons[3].OrbitsAround = _fakers.Planet.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -573,14 +821,21 @@ 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(moons[2].StringId); hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(Moon), ResourceDefinitionHitCounter.ExtensibilityPoint.OnRegisterQueryableHandlersForQueryStringParameters), - (typeof(Moon), ResourceDefinitionHitCounter.ExtensibilityPoint.OnRegisterQueryableHandlersForQueryStringParameters), - (typeof(Moon), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplyIncludes) + (typeof(Moon), ResourceDefinitionExtensibilityPoints.OnRegisterQueryableHandlersForQueryStringParameters), + (typeof(Moon), ResourceDefinitionExtensibilityPoints.OnRegisterQueryableHandlersForQueryStringParameters), + (typeof(Moon), ResourceDefinitionExtensibilityPoints.OnApplyFilter), + (typeof(Moon), ResourceDefinitionExtensibilityPoints.OnApplyPagination), + (typeof(Moon), ResourceDefinitionExtensibilityPoints.OnApplyFilter), + (typeof(Moon), ResourceDefinitionExtensibilityPoints.OnApplySort), + (typeof(Moon), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Moon), ResourceDefinitionExtensibilityPoints.OnApplyIncludes), + (typeof(Moon), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), + (typeof(Moon), ResourceDefinitionExtensibilityPoints.GetMeta) }, options => options.WithStrictOrdering()); } @@ -607,17 +862,18 @@ 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("Custom query string parameters cannot be used on nested resource endpoints."); error.Detail.Should().Be("Query string parameter 'isLargerThanTheSun' cannot be used on a nested resource endpoint."); + error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("isLargerThanTheSun"); hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(Moon), ResourceDefinitionHitCounter.ExtensibilityPoint.OnRegisterQueryableHandlersForQueryStringParameters) + (typeof(Moon), ResourceDefinitionExtensibilityPoints.OnRegisterQueryableHandlersForQueryStringParameters) }, options => options.WithStrictOrdering()); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/Star.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/Star.cs index 84e51726c3..b8d40ebde7 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/Star.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/Star.cs @@ -6,10 +6,10 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ResourceDefinitions.Reading { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Star : Identifiable + public sealed class Star : Identifiable<int> { [Attr] - public string Name { get; set; } + public string Name { get; set; } = null!; [Attr] public StarKind Kind { get; set; } @@ -24,6 +24,6 @@ public sealed class Star : Identifiable public bool IsVisibleFromEarth { get; set; } [HasMany] - public ISet<Planet> Planets { get; set; } + public ISet<Planet> Planets { get; set; } = new HashSet<Planet>(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/StarDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/StarDefinition.cs index 1ac33675be..c209ff3295 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/StarDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/StarDefinition.cs @@ -2,27 +2,24 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Resources; namespace JsonApiDotNetCoreTests.IntegrationTests.ResourceDefinitions.Reading { [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class StarDefinition : JsonApiResourceDefinition<Star> + public sealed class StarDefinition : HitCountingResourceDefinition<Star, int> { - private readonly ResourceDefinitionHitCounter _hitCounter; + protected override ResourceDefinitionExtensibilityPoints ExtensibilityPointsToTrack => ResourceDefinitionExtensibilityPoints.Reading; public StarDefinition(IResourceGraph resourceGraph, ResourceDefinitionHitCounter hitCounter) - : base(resourceGraph) + : base(resourceGraph, hitCounter) { // This constructor will be resolved from the container, which means // you can take on any dependency that is also defined in the container. - - _hitCounter = hitCounter; } - public override SortExpression OnApplySort(SortExpression existingSort) + public override SortExpression OnApplySort(SortExpression? existingSort) { - _hitCounter.TrackInvocation<Star>(ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplySort); + base.OnApplySort(existingSort); return existingSort ?? GetDefaultSortOrder(); } @@ -36,9 +33,9 @@ private SortExpression GetDefaultSortOrder() }); } - public override PaginationExpression OnApplyPagination(PaginationExpression existingPagination) + public override PaginationExpression OnApplyPagination(PaginationExpression? existingPagination) { - _hitCounter.TrackInvocation<Star>(ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplyPagination); + base.OnApplyPagination(existingPagination); var maxPageSize = new PageSize(5); @@ -51,9 +48,9 @@ public override PaginationExpression OnApplyPagination(PaginationExpression exis return new PaginationExpression(PageNumber.ValueOne, maxPageSize); } - public override SparseFieldSetExpression OnApplySparseFieldSet(SparseFieldSetExpression existingSparseFieldSet) + public override SparseFieldSetExpression? OnApplySparseFieldSet(SparseFieldSetExpression? existingSparseFieldSet) { - _hitCounter.TrackInvocation<Star>(ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplySparseFieldSet); + base.OnApplySparseFieldSet(existingSparseFieldSet); // @formatter:keep_existing_linebreaks true diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/StarsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/StarsController.cs index a23eabe4ec..b1d661c207 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/StarsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/StarsController.cs @@ -5,10 +5,10 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ResourceDefinitions.Reading { - public sealed class StarsController : JsonApiController<Star> + public sealed class StarsController : JsonApiController<Star, int> { - public StarsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<Star> resourceService) - : base(options, loggerFactory, resourceService) + public StarsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IResourceService<Star, int> resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/UniverseDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/UniverseDbContext.cs index 94f2065d07..49b96ee7c7 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/UniverseDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/UniverseDbContext.cs @@ -6,9 +6,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ResourceDefinitions.Reading [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class UniverseDbContext : DbContext { - public DbSet<Star> Stars { get; set; } - public DbSet<Planet> Planets { get; set; } - public DbSet<Moon> Moons { get; set; } + public DbSet<Star> Stars => Set<Star>(); + public DbSet<Planet> Planets => Set<Planet>(); + public DbSet<Moon> Moons => Set<Moon>(); public UniverseDbContext(DbContextOptions<UniverseDbContext> options) : base(options) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Serialization/ResourceDefinitionSerializationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Serialization/ResourceDefinitionSerializationTests.cs index 216de1a998..7bd35f1130 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Serialization/ResourceDefinitionSerializationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Serialization/ResourceDefinitionSerializationTests.cs @@ -63,18 +63,28 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(2); + responseDocument.Data.ManyValue.ShouldHaveCount(2); - string socialSecurityNumber1 = encryptionService.Decrypt((string)responseDocument.Data.ManyValue[0].Attributes["socialSecurityNumber"]); - socialSecurityNumber1.Should().Be(students[0].SocialSecurityNumber); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("socialSecurityNumber").With(value => + { + string stringValue = value.Should().BeOfType<string?>().Subject.ShouldNotBeNull(); + string socialSecurityNumber = encryptionService.Decrypt(stringValue); + + socialSecurityNumber.Should().Be(students[0].SocialSecurityNumber); + }); - string socialSecurityNumber2 = encryptionService.Decrypt((string)responseDocument.Data.ManyValue[1].Attributes["socialSecurityNumber"]); - socialSecurityNumber2.Should().Be(students[1].SocialSecurityNumber); + responseDocument.Data.ManyValue[1].Attributes.ShouldContainKey("socialSecurityNumber").With(value => + { + string stringValue = value.Should().BeOfType<string?>().Subject.ShouldNotBeNull(); + string socialSecurityNumber = encryptionService.Decrypt(stringValue); + + socialSecurityNumber.Should().Be(students[1].SocialSecurityNumber); + }); hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(Student), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSerialize), - (typeof(Student), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSerialize) + (typeof(Student), ResourceDefinitionExtensibilityPoints.OnSerialize), + (typeof(Student), ResourceDefinitionExtensibilityPoints.OnSerialize) }, options => options.WithStrictOrdering()); } @@ -104,28 +114,48 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(2); + responseDocument.Data.ManyValue.ShouldHaveCount(2); + + responseDocument.Included.ShouldHaveCount(4); + + responseDocument.Included[0].Attributes.ShouldContainKey("socialSecurityNumber").With(value => + { + string stringValue = value.Should().BeOfType<string?>().Subject.ShouldNotBeNull(); + string socialSecurityNumber = encryptionService.Decrypt(stringValue); + + socialSecurityNumber.Should().Be(scholarships[0].Participants[0].SocialSecurityNumber); + }); + + responseDocument.Included[1].Attributes.ShouldContainKey("socialSecurityNumber").With(value => + { + string stringValue = value.Should().BeOfType<string?>().Subject.ShouldNotBeNull(); + string socialSecurityNumber = encryptionService.Decrypt(stringValue); - responseDocument.Included.Should().HaveCount(4); + socialSecurityNumber.Should().Be(scholarships[0].Participants[1].SocialSecurityNumber); + }); - string socialSecurityNumber1 = encryptionService.Decrypt((string)responseDocument.Included[0].Attributes["socialSecurityNumber"]); - socialSecurityNumber1.Should().Be(scholarships[0].Participants[0].SocialSecurityNumber); + responseDocument.Included[2].Attributes.ShouldContainKey("socialSecurityNumber").With(value => + { + string stringValue = value.Should().BeOfType<string?>().Subject.ShouldNotBeNull(); + string socialSecurityNumber = encryptionService.Decrypt(stringValue); - string socialSecurityNumber2 = encryptionService.Decrypt((string)responseDocument.Included[1].Attributes["socialSecurityNumber"]); - socialSecurityNumber2.Should().Be(scholarships[0].Participants[1].SocialSecurityNumber); + socialSecurityNumber.Should().Be(scholarships[1].Participants[0].SocialSecurityNumber); + }); - string socialSecurityNumber3 = encryptionService.Decrypt((string)responseDocument.Included[2].Attributes["socialSecurityNumber"]); - socialSecurityNumber3.Should().Be(scholarships[1].Participants[0].SocialSecurityNumber); + responseDocument.Included[3].Attributes.ShouldContainKey("socialSecurityNumber").With(value => + { + string stringValue = value.Should().BeOfType<string?>().Subject.ShouldNotBeNull(); + string socialSecurityNumber = encryptionService.Decrypt(stringValue); - string socialSecurityNumber4 = encryptionService.Decrypt((string)responseDocument.Included[3].Attributes["socialSecurityNumber"]); - socialSecurityNumber4.Should().Be(scholarships[1].Participants[1].SocialSecurityNumber); + socialSecurityNumber.Should().Be(scholarships[1].Participants[1].SocialSecurityNumber); + }); hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(Student), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSerialize), - (typeof(Student), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSerialize), - (typeof(Student), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSerialize), - (typeof(Student), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSerialize) + (typeof(Student), ResourceDefinitionExtensibilityPoints.OnSerialize), + (typeof(Student), ResourceDefinitionExtensibilityPoints.OnSerialize), + (typeof(Student), ResourceDefinitionExtensibilityPoints.OnSerialize), + (typeof(Student), ResourceDefinitionExtensibilityPoints.OnSerialize) }, options => options.WithStrictOrdering()); } @@ -152,14 +182,19 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); - string socialSecurityNumber = encryptionService.Decrypt((string)responseDocument.Data.SingleValue.Attributes["socialSecurityNumber"]); - socialSecurityNumber.Should().Be(student.SocialSecurityNumber); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("socialSecurityNumber").With(value => + { + string stringValue = value.Should().BeOfType<string?>().Subject.ShouldNotBeNull(); + string socialSecurityNumber = encryptionService.Decrypt(stringValue); + + socialSecurityNumber.Should().Be(student.SocialSecurityNumber); + }); hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(Student), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSerialize) + (typeof(Student), ResourceDefinitionExtensibilityPoints.OnSerialize) }, options => options.WithStrictOrdering()); } @@ -187,18 +222,28 @@ 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].Attributes.ShouldContainKey("socialSecurityNumber").With(value => + { + string stringValue = value.Should().BeOfType<string?>().Subject.ShouldNotBeNull(); + string socialSecurityNumber = encryptionService.Decrypt(stringValue); + + socialSecurityNumber.Should().Be(scholarship.Participants[0].SocialSecurityNumber); + }); - string socialSecurityNumber1 = encryptionService.Decrypt((string)responseDocument.Data.ManyValue[0].Attributes["socialSecurityNumber"]); - socialSecurityNumber1.Should().Be(scholarship.Participants[0].SocialSecurityNumber); + responseDocument.Data.ManyValue[1].Attributes.ShouldContainKey("socialSecurityNumber").With(value => + { + string stringValue = value.Should().BeOfType<string?>().Subject.ShouldNotBeNull(); + string socialSecurityNumber = encryptionService.Decrypt(stringValue); - string socialSecurityNumber2 = encryptionService.Decrypt((string)responseDocument.Data.ManyValue[1].Attributes["socialSecurityNumber"]); - socialSecurityNumber2.Should().Be(scholarship.Participants[1].SocialSecurityNumber); + socialSecurityNumber.Should().Be(scholarship.Participants[1].SocialSecurityNumber); + }); hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(Student), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSerialize), - (typeof(Student), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSerialize) + (typeof(Student), ResourceDefinitionExtensibilityPoints.OnSerialize), + (typeof(Student), ResourceDefinitionExtensibilityPoints.OnSerialize) }, options => options.WithStrictOrdering()); } @@ -226,14 +271,19 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); - string socialSecurityNumber = encryptionService.Decrypt((string)responseDocument.Data.SingleValue.Attributes["socialSecurityNumber"]); - socialSecurityNumber.Should().Be(scholarship.PrimaryContact.SocialSecurityNumber); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("socialSecurityNumber").With(value => + { + string stringValue = value.Should().BeOfType<string?>().Subject.ShouldNotBeNull(); + string socialSecurityNumber = encryptionService.Decrypt(stringValue); + + socialSecurityNumber.Should().Be(scholarship.PrimaryContact.SocialSecurityNumber); + }); hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(Student), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSerialize) + (typeof(Student), ResourceDefinitionExtensibilityPoints.OnSerialize) }, options => options.WithStrictOrdering()); } @@ -261,16 +311,21 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Included.Should().HaveCount(1); + responseDocument.Included.ShouldHaveCount(1); - string socialSecurityNumber = encryptionService.Decrypt((string)responseDocument.Included[0].Attributes["socialSecurityNumber"]); - socialSecurityNumber.Should().Be(scholarship.PrimaryContact.SocialSecurityNumber); + responseDocument.Included[0].Attributes.ShouldContainKey("socialSecurityNumber").With(value => + { + string stringValue = value.Should().BeOfType<string?>().Subject.ShouldNotBeNull(); + string socialSecurityNumber = encryptionService.Decrypt(stringValue); + + socialSecurityNumber.Should().Be(scholarship.PrimaryContact.SocialSecurityNumber); + }); hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(Student), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSerialize) + (typeof(Student), ResourceDefinitionExtensibilityPoints.OnSerialize) }, options => options.WithStrictOrdering()); } @@ -305,12 +360,17 @@ public async Task Decrypts_on_create_resource() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); - string socialSecurityNumber = encryptionService.Decrypt((string)responseDocument.Data.SingleValue.Attributes["socialSecurityNumber"]); - socialSecurityNumber.Should().Be(newSocialSecurityNumber); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("socialSecurityNumber").With(value => + { + string stringValue = value.Should().BeOfType<string?>().Subject.ShouldNotBeNull(); + string socialSecurityNumber = encryptionService.Decrypt(stringValue); + + socialSecurityNumber.Should().Be(newSocialSecurityNumber); + }); - int newStudentId = int.Parse(responseDocument.Data.SingleValue.Id); + int newStudentId = int.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -321,8 +381,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(Student), ResourceDefinitionHitCounter.ExtensibilityPoint.OnDeserialize), - (typeof(Student), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSerialize) + (typeof(Student), ResourceDefinitionExtensibilityPoints.OnDeserialize), + (typeof(Student), ResourceDefinitionExtensibilityPoints.OnSerialize) }, options => options.WithStrictOrdering()); } @@ -376,16 +436,21 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Included.Should().HaveCount(1); + responseDocument.Included.ShouldHaveCount(1); - string socialSecurityNumber = encryptionService.Decrypt((string)responseDocument.Included[0].Attributes["socialSecurityNumber"]); - socialSecurityNumber.Should().Be(existingStudent.SocialSecurityNumber); + responseDocument.Included[0].Attributes.ShouldContainKey("socialSecurityNumber").With(value => + { + string stringValue = value.Should().BeOfType<string?>().Subject.ShouldNotBeNull(); + string socialSecurityNumber = encryptionService.Decrypt(stringValue); + + socialSecurityNumber.Should().Be(existingStudent.SocialSecurityNumber); + }); hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(Student), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSerialize) + (typeof(Student), ResourceDefinitionExtensibilityPoints.OnSerialize) }, options => options.WithStrictOrdering()); } @@ -427,10 +492,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); - string socialSecurityNumber = encryptionService.Decrypt((string)responseDocument.Data.SingleValue.Attributes["socialSecurityNumber"]); - socialSecurityNumber.Should().Be(newSocialSecurityNumber); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("socialSecurityNumber").With(value => + { + string stringValue = value.Should().BeOfType<string?>().Subject.ShouldNotBeNull(); + string socialSecurityNumber = encryptionService.Decrypt(stringValue); + + socialSecurityNumber.Should().Be(newSocialSecurityNumber); + }); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -441,8 +511,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(Student), ResourceDefinitionHitCounter.ExtensibilityPoint.OnDeserialize), - (typeof(Student), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSerialize) + (typeof(Student), ResourceDefinitionExtensibilityPoints.OnDeserialize), + (typeof(Student), ResourceDefinitionExtensibilityPoints.OnSerialize) }, options => options.WithStrictOrdering()); } @@ -504,20 +574,30 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Included.Should().HaveCount(2); + responseDocument.Included.ShouldHaveCount(2); - string socialSecurityNumber1 = encryptionService.Decrypt((string)responseDocument.Included[0].Attributes["socialSecurityNumber"]); - socialSecurityNumber1.Should().Be(existingScholarship.Participants[0].SocialSecurityNumber); + responseDocument.Included[0].Attributes.ShouldContainKey("socialSecurityNumber").With(value => + { + string stringValue = value.Should().BeOfType<string?>().Subject.ShouldNotBeNull(); + string socialSecurityNumber = encryptionService.Decrypt(stringValue); + + socialSecurityNumber.Should().Be(existingScholarship.Participants[0].SocialSecurityNumber); + }); - string socialSecurityNumber2 = encryptionService.Decrypt((string)responseDocument.Included[1].Attributes["socialSecurityNumber"]); - socialSecurityNumber2.Should().Be(existingScholarship.Participants[2].SocialSecurityNumber); + responseDocument.Included[1].Attributes.ShouldContainKey("socialSecurityNumber").With(value => + { + string stringValue = value.Should().BeOfType<string?>().Subject.ShouldNotBeNull(); + string socialSecurityNumber = encryptionService.Decrypt(stringValue); + + socialSecurityNumber.Should().Be(existingScholarship.Participants[2].SocialSecurityNumber); + }); hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { - (typeof(Student), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSerialize), - (typeof(Student), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSerialize) + (typeof(Student), ResourceDefinitionExtensibilityPoints.OnSerialize), + (typeof(Student), ResourceDefinitionExtensibilityPoints.OnSerialize) }, options => options.WithStrictOrdering()); } @@ -544,7 +624,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(scholarship.PrimaryContact.StringId); hitCounter.HitExtensibilityPoints.Should().BeEmpty(); @@ -573,7 +653,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(scholarship.Participants[0].StringId); responseDocument.Data.ManyValue[1].Id.Should().Be(scholarship.Participants[1].StringId); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Serialization/Scholarship.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Serialization/Scholarship.cs index cc8981559c..60a0bb0170 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Serialization/Scholarship.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Serialization/Scholarship.cs @@ -6,18 +6,18 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ResourceDefinitions.Serialization { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Scholarship : Identifiable + public sealed class Scholarship : Identifiable<int> { [Attr] - public string ProgramName { get; set; } + public string ProgramName { get; set; } = null!; [Attr] public decimal Amount { get; set; } [HasMany] - public IList<Student> Participants { get; set; } + public IList<Student> Participants { get; set; } = new List<Student>(); [HasOne] - public Student PrimaryContact { get; set; } + public Student? PrimaryContact { get; set; } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Serialization/ScholarshipsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Serialization/ScholarshipsController.cs index 227e96a2ad..bd792e0cdf 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Serialization/ScholarshipsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Serialization/ScholarshipsController.cs @@ -5,10 +5,11 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ResourceDefinitions.Serialization { - public sealed class ScholarshipsController : JsonApiController<Scholarship> + public sealed class ScholarshipsController : JsonApiController<Scholarship, int> { - public ScholarshipsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<Scholarship> resourceService) - : base(options, loggerFactory, resourceService) + public ScholarshipsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<Scholarship, int> resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Serialization/SerializationDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Serialization/SerializationDbContext.cs index dc9a7f8614..215cee8fc6 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Serialization/SerializationDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Serialization/SerializationDbContext.cs @@ -8,8 +8,8 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ResourceDefinitions.Serializat [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class SerializationDbContext : DbContext { - public DbSet<Student> Students { get; set; } - public DbSet<Scholarship> Scholarships { get; set; } + public DbSet<Student> Students => Set<Student>(); + public DbSet<Scholarship> Scholarships => Set<Scholarship>(); public SerializationDbContext(DbContextOptions<SerializationDbContext> options) : base(options) @@ -20,7 +20,7 @@ protected override void OnModelCreating(ModelBuilder builder) { builder.Entity<Scholarship>() .HasMany(scholarship => scholarship.Participants) - .WithOne(student => student.Scholarship); + .WithOne(student => student.Scholarship!); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Serialization/Student.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Serialization/Student.cs index 32ebe1feb2..f6d0e5f97b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Serialization/Student.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Serialization/Student.cs @@ -5,15 +5,15 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ResourceDefinitions.Serialization { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Student : Identifiable + public sealed class Student : Identifiable<int> { [Attr] - public string Name { get; set; } + public string Name { get; set; } = null!; [Attr] - public string SocialSecurityNumber { get; set; } + public string SocialSecurityNumber { get; set; } = null!; [HasOne] - public Scholarship Scholarship { get; set; } + public Scholarship? Scholarship { get; set; } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Serialization/StudentDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Serialization/StudentDefinition.cs index b1a2776c06..82b02e682f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Serialization/StudentDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Serialization/StudentDefinition.cs @@ -1,28 +1,27 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Resources; namespace JsonApiDotNetCoreTests.IntegrationTests.ResourceDefinitions.Serialization { [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class StudentDefinition : JsonApiResourceDefinition<Student> + public sealed class StudentDefinition : HitCountingResourceDefinition<Student, int> { private readonly IEncryptionService _encryptionService; - private readonly ResourceDefinitionHitCounter _hitCounter; + + protected override ResourceDefinitionExtensibilityPoints ExtensibilityPointsToTrack => ResourceDefinitionExtensibilityPoints.Serialization; public StudentDefinition(IResourceGraph resourceGraph, IEncryptionService encryptionService, ResourceDefinitionHitCounter hitCounter) - : base(resourceGraph) + : base(resourceGraph, hitCounter) { // This constructor will be resolved from the container, which means // you can take on any dependency that is also defined in the container. _encryptionService = encryptionService; - _hitCounter = hitCounter; } public override void OnDeserialize(Student resource) { - _hitCounter.TrackInvocation<Student>(ResourceDefinitionHitCounter.ExtensibilityPoint.OnDeserialize); + base.OnDeserialize(resource); if (!string.IsNullOrEmpty(resource.SocialSecurityNumber)) { @@ -32,7 +31,7 @@ public override void OnDeserialize(Student resource) public override void OnSerialize(Student resource) { - _hitCounter.TrackInvocation<Student>(ResourceDefinitionHitCounter.ExtensibilityPoint.OnSerialize); + base.OnSerialize(resource); if (!string.IsNullOrEmpty(resource.SocialSecurityNumber)) { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Serialization/StudentsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Serialization/StudentsController.cs index 3b82931c61..3cb63c97c4 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Serialization/StudentsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Serialization/StudentsController.cs @@ -5,10 +5,11 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ResourceDefinitions.Serialization { - public sealed class StudentsController : JsonApiController<Student> + public sealed class StudentsController : JsonApiController<Student, int> { - public StudentsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<Student> resourceService) - : base(options, loggerFactory, resourceService) + public StudentsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<Student, int> resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Models/Book.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Book.cs similarity index 95% rename from test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Models/Book.cs rename to test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Book.cs index 6c0e11eeee..357e4d35e2 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Models/Book.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Book.cs @@ -1,7 +1,7 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.ResourceInheritance.Models +namespace JsonApiDotNetCoreTests.IntegrationTests.ResourceInheritance { [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class Book : ContentItem diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Models/CompanyHealthInsurance.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/CompanyHealthInsurance.cs similarity index 82% rename from test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Models/CompanyHealthInsurance.cs rename to test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/CompanyHealthInsurance.cs index 7b2a38715d..9c3a24255e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Models/CompanyHealthInsurance.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/CompanyHealthInsurance.cs @@ -1,12 +1,12 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.ResourceInheritance.Models +namespace JsonApiDotNetCoreTests.IntegrationTests.ResourceInheritance { [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class CompanyHealthInsurance : HealthInsurance { [Attr] - public string CompanyCode { get; set; } + public string CompanyCode { get; set; } = null!; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Models/ContentItem.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/ContentItem.cs similarity index 69% rename from test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Models/ContentItem.cs rename to test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/ContentItem.cs index 17c3683ae8..d8e8d90c39 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Models/ContentItem.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/ContentItem.cs @@ -2,12 +2,12 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.ResourceInheritance.Models +namespace JsonApiDotNetCoreTests.IntegrationTests.ResourceInheritance { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public abstract class ContentItem : Identifiable + public abstract class ContentItem : Identifiable<int> { [Attr] - public string Title { get; set; } + public string Title { get; set; } = null!; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Models/FamilyHealthInsurance.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/FamilyHealthInsurance.cs similarity index 96% rename from test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Models/FamilyHealthInsurance.cs rename to test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/FamilyHealthInsurance.cs index aaefbbf074..df9b4af31f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Models/FamilyHealthInsurance.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/FamilyHealthInsurance.cs @@ -1,7 +1,7 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.ResourceInheritance.Models +namespace JsonApiDotNetCoreTests.IntegrationTests.ResourceInheritance { [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class FamilyHealthInsurance : HealthInsurance diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Models/HealthInsurance.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/HealthInsurance.cs similarity index 69% rename from test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Models/HealthInsurance.cs rename to test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/HealthInsurance.cs index e4a2588e8a..34ddee9a22 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Models/HealthInsurance.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/HealthInsurance.cs @@ -2,12 +2,12 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.ResourceInheritance.Models +namespace JsonApiDotNetCoreTests.IntegrationTests.ResourceInheritance { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public abstract class HealthInsurance : Identifiable + public abstract class HealthInsurance : Identifiable<int> { [Attr] - public bool MonthlyFee { get; set; } + public bool HasMonthlyFee { get; set; } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Models/Human.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Human.cs similarity index 62% rename from test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Models/Human.cs rename to test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Human.cs index 684d8d4bd2..63efc0443d 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Models/Human.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Human.cs @@ -3,24 +3,24 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.ResourceInheritance.Models +namespace JsonApiDotNetCoreTests.IntegrationTests.ResourceInheritance { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public abstract class Human : Identifiable + public abstract class Human : Identifiable<int> { [Attr] - public string FamilyName { get; set; } + public string FamilyName { get; set; } = null!; [Attr] public bool IsRetired { get; set; } [HasOne] - public HealthInsurance HealthInsurance { get; set; } + public HealthInsurance? HealthInsurance { get; set; } [HasMany] - public ICollection<Human> Parents { get; set; } + public ICollection<Human> Parents { get; set; } = new List<Human>(); [HasMany] - public ICollection<ContentItem> FavoriteContent { get; set; } + public ICollection<ContentItem> FavoriteContent { get; set; } = new List<ContentItem>(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/InheritanceDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/InheritanceDbContext.cs index dc6e2f1533..08444d9080 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/InheritanceDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/InheritanceDbContext.cs @@ -1,5 +1,4 @@ using JetBrains.Annotations; -using JsonApiDotNetCoreTests.IntegrationTests.ResourceInheritance.Models; using Microsoft.EntityFrameworkCore; // @formatter:wrap_chained_method_calls chop_always @@ -9,10 +8,10 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ResourceInheritance [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class InheritanceDbContext : DbContext { - public DbSet<Human> Humans { get; set; } - public DbSet<Man> Men { get; set; } - public DbSet<CompanyHealthInsurance> CompanyHealthInsurances { get; set; } - public DbSet<ContentItem> ContentItems { get; set; } + public DbSet<Human> Humans => Set<Human>(); + public DbSet<Man> Men => Set<Man>(); + public DbSet<CompanyHealthInsurance> CompanyHealthInsurances => Set<CompanyHealthInsurance>(); + public DbSet<ContentItem> ContentItems => Set<ContentItem>(); public InheritanceDbContext(DbContextOptions<InheritanceDbContext> options) : base(options) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/InheritanceFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/InheritanceFakers.cs new file mode 100644 index 0000000000..298ebb6f64 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/InheritanceFakers.cs @@ -0,0 +1,57 @@ +using System; +using Bogus; +using TestBuildingBlocks; + +// @formatter:wrap_chained_method_calls chop_always +// @formatter:keep_existing_linebreaks true + +namespace JsonApiDotNetCoreTests.IntegrationTests.ResourceInheritance +{ + internal sealed class InheritanceFakers : FakerContainer + { + private readonly Lazy<Faker<Man>> _lazyManFaker = new(() => + new Faker<Man>() + .UseSeed(GetFakerSeed()) + .RuleFor(man => man.FamilyName, faker => faker.Person.LastName) + .RuleFor(man => man.IsRetired, faker => faker.Random.Bool()) + .RuleFor(man => man.HasBeard, faker => faker.Random.Bool())); + + private readonly Lazy<Faker<Woman>> _lazyWomanFaker = new(() => + new Faker<Woman>() + .UseSeed(GetFakerSeed()) + .RuleFor(woman => woman.FamilyName, faker => faker.Person.LastName) + .RuleFor(woman => woman.IsRetired, faker => faker.Random.Bool()) + .RuleFor(woman => woman.IsPregnant, faker => faker.Random.Bool())); + + private readonly Lazy<Faker<Book>> _lazyBookFaker = new(() => + new Faker<Book>() + .UseSeed(GetFakerSeed()) + .RuleFor(book => book.Title, faker => faker.Commerce.ProductName()) + .RuleFor(book => book.PageCount, faker => faker.Random.Int(50, 150))); + + private readonly Lazy<Faker<Video>> _lazyVideoFaker = new(() => + new Faker<Video>() + .UseSeed(GetFakerSeed()) + .RuleFor(video => video.Title, faker => faker.Commerce.ProductName()) + .RuleFor(video => video.DurationInSeconds, faker => faker.Random.Int(250, 750))); + + private readonly Lazy<Faker<FamilyHealthInsurance>> _lazyFamilyHealthInsuranceFaker = new(() => + new Faker<FamilyHealthInsurance>() + .UseSeed(GetFakerSeed()) + .RuleFor(familyHealthInsurance => familyHealthInsurance.HasMonthlyFee, faker => faker.Random.Bool()) + .RuleFor(familyHealthInsurance => familyHealthInsurance.PermittedFamilySize, faker => faker.Random.Int(2, 10))); + + private readonly Lazy<Faker<CompanyHealthInsurance>> _lazyCompanyHealthInsuranceFaker = new(() => + new Faker<CompanyHealthInsurance>() + .UseSeed(GetFakerSeed()) + .RuleFor(companyHealthInsurance => companyHealthInsurance.HasMonthlyFee, faker => faker.Random.Bool()) + .RuleFor(companyHealthInsurance => companyHealthInsurance.CompanyCode, faker => faker.Company.CompanyName())); + + public Faker<Man> Man => _lazyManFaker.Value; + public Faker<Woman> Woman => _lazyWomanFaker.Value; + public Faker<Book> Book => _lazyBookFaker.Value; + public Faker<Video> Video => _lazyVideoFaker.Value; + public Faker<FamilyHealthInsurance> FamilyHealthInsurance => _lazyFamilyHealthInsuranceFaker.Value; + public Faker<CompanyHealthInsurance> CompanyHealthInsurance => _lazyCompanyHealthInsuranceFaker.Value; + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/InheritanceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/InheritanceTests.cs index 477b450167..98ea25890a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/InheritanceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/InheritanceTests.cs @@ -3,7 +3,6 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreTests.IntegrationTests.ResourceInheritance.Models; using Microsoft.EntityFrameworkCore; using TestBuildingBlocks; using Xunit; @@ -13,6 +12,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ResourceInheritance public sealed class InheritanceTests : IClassFixture<IntegrationTestContext<TestableStartup<InheritanceDbContext>, InheritanceDbContext>> { private readonly IntegrationTestContext<TestableStartup<InheritanceDbContext>, InheritanceDbContext> _testContext; + private readonly InheritanceFakers _fakers = new(); public InheritanceTests(IntegrationTestContext<TestableStartup<InheritanceDbContext>, InheritanceDbContext> testContext) { @@ -25,12 +25,7 @@ public InheritanceTests(IntegrationTestContext<TestableStartup<InheritanceDbCont public async Task Can_create_resource_with_inherited_attributes() { // Arrange - var newMan = new Man - { - FamilyName = "Smith", - IsRetired = true, - HasBeard = true - }; + Man newMan = _fakers.Man.Generate(); var requestBody = new { @@ -54,13 +49,13 @@ public async Task Can_create_resource_with_inherited_attributes() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Type.Should().Be("men"); - responseDocument.Data.SingleValue.Attributes["familyName"].Should().Be(newMan.FamilyName); - responseDocument.Data.SingleValue.Attributes["isRetired"].Should().Be(newMan.IsRetired); - responseDocument.Data.SingleValue.Attributes["hasBeard"].Should().Be(newMan.HasBeard); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("familyName").With(value => value.Should().Be(newMan.FamilyName)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("isRetired").With(value => value.Should().Be(newMan.IsRetired)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("hasBeard").With(value => value.Should().Be(newMan.HasBeard)); - int newManId = int.Parse(responseDocument.Data.SingleValue.Id); + int newManId = int.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -76,7 +71,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_create_resource_with_ToOne_relationship() { // Arrange - var existingInsurance = new CompanyHealthInsurance(); + CompanyHealthInsurance existingInsurance = _fakers.CompanyHealthInsurance.Generate(); + + string newFamilyName = _fakers.Man.Generate().FamilyName; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -90,6 +87,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = new { type = "men", + attributes = new + { + familyName = newFamilyName + }, relationships = new { healthInsurance = new @@ -112,13 +113,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.Should().NotBeNull(); - int newManId = int.Parse(responseDocument.Data.SingleValue.Id); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + int newManId = int.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { Man manInDatabase = await dbContext.Men.Include(man => man.HealthInsurance).FirstWithIdAsync(newManId); + manInDatabase.HealthInsurance.ShouldNotBeNull(); manInDatabase.HealthInsurance.Should().BeOfType<CompanyHealthInsurance>(); manInDatabase.HealthInsurance.Id.Should().Be(existingInsurance.Id); }); @@ -128,19 +130,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_update_resource() { // Arrange - var existingMan = new Man - { - FamilyName = "Smith", - IsRetired = false, - HasBeard = true - }; + Man existingMan = _fakers.Man.Generate(); - var newMan = new Man - { - FamilyName = "Jackson", - IsRetired = true, - HasBeard = false - }; + Man newMan = _fakers.Man.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -187,8 +179,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_assign_ToOne_relationship() { // Arrange - var existingMan = new Man(); - var existingInsurance = new CompanyHealthInsurance(); + Man existingMan = _fakers.Man.Generate(); + FamilyHealthInsurance existingInsurance = _fakers.FamilyHealthInsurance.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -201,7 +193,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { data = new { - type = "companyHealthInsurances", + type = "familyHealthInsurances", id = existingInsurance.StringId } }; @@ -220,7 +212,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { Man manInDatabase = await dbContext.Men.Include(man => man.HealthInsurance).FirstWithIdAsync(existingMan.Id); - manInDatabase.HealthInsurance.Should().BeOfType<CompanyHealthInsurance>(); + manInDatabase.HealthInsurance.ShouldNotBeNull(); + manInDatabase.HealthInsurance.Should().BeOfType<FamilyHealthInsurance>(); manInDatabase.HealthInsurance.Id.Should().Be(existingInsurance.Id); }); } @@ -229,8 +222,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_create_resource_with_OneToMany_relationship() { // Arrange - var existingFather = new Man(); - var existingMother = new Woman(); + Man existingFather = _fakers.Man.Generate(); + Woman existingMother = _fakers.Woman.Generate(); + + string newFamilyName = _fakers.Man.Generate().FamilyName; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -244,6 +239,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = new { type = "men", + attributes = new + { + familyName = newFamilyName + }, relationships = new { parents = new @@ -274,14 +273,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.Should().NotBeNull(); - int newManId = int.Parse(responseDocument.Data.SingleValue.Id); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + int newManId = int.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { Man manInDatabase = await dbContext.Men.Include(man => man.Parents).FirstWithIdAsync(newManId); - manInDatabase.Parents.Should().HaveCount(2); + manInDatabase.Parents.ShouldHaveCount(2); manInDatabase.Parents.Should().ContainSingle(human => human is Man); manInDatabase.Parents.Should().ContainSingle(human => human is Woman); }); @@ -291,9 +290,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_assign_OneToMany_relationship() { // Arrange - var existingChild = new Man(); - var existingFather = new Man(); - var existingMother = new Woman(); + Man existingChild = _fakers.Man.Generate(); + Man existingFather = _fakers.Man.Generate(); + Woman existingMother = _fakers.Woman.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -333,7 +332,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { Man manInDatabase = await dbContext.Men.Include(man => man.Parents).FirstWithIdAsync(existingChild.Id); - manInDatabase.Parents.Should().HaveCount(2); + manInDatabase.Parents.ShouldHaveCount(2); manInDatabase.Parents.Should().ContainSingle(human => human is Man); manInDatabase.Parents.Should().ContainSingle(human => human is Woman); }); @@ -343,8 +342,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_create_resource_with_ManyToMany_relationship() { // Arrange - var existingBook = new Book(); - var existingVideo = new Video(); + Book existingBook = _fakers.Book.Generate(); + Video existingVideo = _fakers.Video.Generate(); + + string newFamilyName = _fakers.Man.Generate().FamilyName; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -358,6 +359,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = new { type = "men", + attributes = new + { + familyName = newFamilyName + }, relationships = new { favoriteContent = new @@ -388,14 +393,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.Should().NotBeNull(); - int newManId = int.Parse(responseDocument.Data.SingleValue.Id); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + int newManId = int.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); await _testContext.RunOnDatabaseAsync(async dbContext => { Man manInDatabase = await dbContext.Men.Include(man => man.FavoriteContent).FirstWithIdAsync(newManId); - manInDatabase.FavoriteContent.Should().HaveCount(2); + manInDatabase.FavoriteContent.ShouldHaveCount(2); manInDatabase.FavoriteContent.Should().ContainSingle(item => item is Book && item.Id == existingBook.Id); manInDatabase.FavoriteContent.Should().ContainSingle(item => item is Video && item.Id == existingVideo.Id); }); @@ -405,9 +410,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_assign_ManyToMany_relationship() { // Arrange - var existingBook = new Book(); - var existingVideo = new Video(); - var existingMan = new Man(); + Book existingBook = _fakers.Book.Generate(); + Video existingVideo = _fakers.Video.Generate(); + Man existingMan = _fakers.Man.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -447,7 +452,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { Man manInDatabase = await dbContext.Men.Include(man => man.FavoriteContent).FirstWithIdAsync(existingMan.Id); - manInDatabase.FavoriteContent.Should().HaveCount(2); + manInDatabase.FavoriteContent.ShouldHaveCount(2); manInDatabase.FavoriteContent.Should().ContainSingle(item => item is Book && item.Id == existingBook.Id); manInDatabase.FavoriteContent.Should().ContainSingle(item => item is Video && item.Id == existingVideo.Id); }); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Models/Man.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Man.cs similarity index 95% rename from test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Models/Man.cs rename to test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Man.cs index 2621c7008f..943a5df785 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Models/Man.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Man.cs @@ -1,7 +1,7 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.ResourceInheritance.Models +namespace JsonApiDotNetCoreTests.IntegrationTests.ResourceInheritance { [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class Man : Human diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/MenController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/MenController.cs index b0da27471e..76026d6e58 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/MenController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/MenController.cs @@ -1,15 +1,14 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Services; -using JsonApiDotNetCoreTests.IntegrationTests.ResourceInheritance.Models; using Microsoft.Extensions.Logging; namespace JsonApiDotNetCoreTests.IntegrationTests.ResourceInheritance { - public sealed class MenController : JsonApiController<Man> + public sealed class MenController : JsonApiController<Man, int> { - public MenController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<Man> resourceService) - : base(options, loggerFactory, resourceService) + public MenController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IResourceService<Man, int> resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Models/Video.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Video.cs similarity index 82% rename from test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Models/Video.cs rename to test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Video.cs index 2c315151dc..0cd13a1818 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Models/Video.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Video.cs @@ -1,12 +1,12 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.ResourceInheritance.Models +namespace JsonApiDotNetCoreTests.IntegrationTests.ResourceInheritance { [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class Video : ContentItem { [Attr] - public int Duration { get; set; } + public int DurationInSeconds { get; set; } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Models/Woman.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Woman.cs similarity index 95% rename from test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Models/Woman.cs rename to test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Woman.cs index b66f965666..2de4b9b2d6 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Models/Woman.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/Woman.cs @@ -1,7 +1,7 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreTests.IntegrationTests.ResourceInheritance.Models +namespace JsonApiDotNetCoreTests.IntegrationTests.ResourceInheritance { [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class Woman : Human diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/Bed.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/Bed.cs index 286b6bca66..d197b4337e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/Bed.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/Bed.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; @@ -5,9 +6,15 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.RestrictedControllers { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Bed : Identifiable + public sealed class Bed : Identifiable<int> { [Attr] public bool IsDouble { get; set; } + + [HasMany] + public IList<Pillow> Pillows { get; set; } = new List<Pillow>(); + + [HasOne] + public Room? Room { get; set; } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/BedsReadOnlyController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/BedsReadOnlyController.cs new file mode 100644 index 0000000000..0b0aa04d45 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/BedsReadOnlyController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreTests.IntegrationTests.RestrictedControllers +{ + public sealed class BedsReadOnlyController : JsonApiQueryController<Bed, int> + { + public BedsReadOnlyController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceQueryService<Bed, int> resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/BlockingHttpPatchController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/BlockingHttpPatchController.cs deleted file mode 100644 index b3b2e5f55c..0000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/BlockingHttpPatchController.cs +++ /dev/null @@ -1,17 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Controllers.Annotations; -using JsonApiDotNetCore.Services; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreTests.IntegrationTests.RestrictedControllers -{ - [NoHttpPatch] - public sealed class BlockingHttpPatchController : JsonApiController<Chair> - { - public BlockingHttpPatchController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<Chair> resourceService) - : base(options, loggerFactory, resourceService) - { - } - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/BlockingHttpPostController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/BlockingHttpPostController.cs deleted file mode 100644 index 1dd6a8f401..0000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/BlockingHttpPostController.cs +++ /dev/null @@ -1,17 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Controllers.Annotations; -using JsonApiDotNetCore.Services; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreTests.IntegrationTests.RestrictedControllers -{ - [NoHttpPost] - public sealed class BlockingHttpPostController : JsonApiController<Table> - { - public BlockingHttpPostController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<Table> resourceService) - : base(options, loggerFactory, resourceService) - { - } - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/BlockingWritesController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/BlockingWritesController.cs deleted file mode 100644 index b5cd00e10d..0000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/BlockingWritesController.cs +++ /dev/null @@ -1,18 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Controllers.Annotations; -using JsonApiDotNetCore.Services; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreTests.IntegrationTests.RestrictedControllers -{ - [HttpReadOnly] - [DisableQueryString("skipCache")] - public sealed class BlockingWritesController : JsonApiController<Bed> - { - public BlockingWritesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<Bed> resourceService) - : base(options, loggerFactory, resourceService) - { - } - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/Chair.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/Chair.cs index d133b847ce..4e4a337b5b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/Chair.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/Chair.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; @@ -5,9 +6,15 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.RestrictedControllers { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Chair : Identifiable + public sealed class Chair : Identifiable<int> { [Attr] public int LegCount { get; set; } + + [HasMany] + public IList<Pillow> Pillows { get; set; } = new List<Pillow>(); + + [HasOne] + public Room? Room { get; set; } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/ChairsNoRelationshipsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/ChairsNoRelationshipsController.cs new file mode 100644 index 0000000000..5b4bb83f3f --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/ChairsNoRelationshipsController.cs @@ -0,0 +1,17 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreTests.IntegrationTests.RestrictedControllers +{ + public sealed class ChairsNoRelationshipsController : JsonApiController<Chair, int> + { + public ChairsNoRelationshipsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IGetAllService<Chair, int>? getAll, IGetByIdService<Chair, int>? getById, ICreateService<Chair, int>? create, IUpdateService<Chair, int>? update, + IDeleteService<Chair, int>? delete) + : base(options, resourceGraph, loggerFactory, getAll, getById, null, null, create, null, update, null, delete) + { + } + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/DisableQueryStringTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/DisableQueryStringTests.cs index 54356a48f7..003a481928 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/DisableQueryStringTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/DisableQueryStringTests.cs @@ -18,8 +18,8 @@ public DisableQueryStringTests(IntegrationTestContext<TestableStartup<Restrictio { _testContext = testContext; - testContext.UseController<BlockingHttpDeleteController>(); - testContext.UseController<BlockingWritesController>(); + testContext.UseController<SofasBlockingSortPageController>(); + testContext.UseController<PillowsNoSkipCacheController>(); testContext.ConfigureServicesAfterStartup(services => { @@ -40,12 +40,13 @@ public async Task Cannot_sort_if_query_string_parameter_is_blocked_by_controller // 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"); } @@ -61,20 +62,36 @@ public async Task Cannot_paginate_if_query_string_parameter_is_blocked_by_contro // 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]"); } + [Fact] + public async Task Can_use_custom_query_string_parameter() + { + // Arrange + const string route = "/sofas?skipCache"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteGetAsync<string>(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ShouldNotBeEmpty(); + } + [Fact] public async Task Cannot_use_custom_query_string_parameter_if_blocked_by_controller() { // Arrange - const string route = "/beds?skipCache=true"; + const string route = "/pillows?skipCache"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route); @@ -82,12 +99,13 @@ public async Task Cannot_use_custom_query_string_parameter_if_blocked_by_control // 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 'skipCache' cannot be used at this endpoint."); + error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("skipCache"); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/HttpReadOnlyTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/HttpReadOnlyTests.cs deleted file mode 100644 index 8a307b1de7..0000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/HttpReadOnlyTests.cs +++ /dev/null @@ -1,135 +0,0 @@ -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore.Serialization.Objects; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreTests.IntegrationTests.RestrictedControllers -{ - public sealed class HttpReadOnlyTests : IClassFixture<IntegrationTestContext<TestableStartup<RestrictionDbContext>, RestrictionDbContext>> - { - private readonly IntegrationTestContext<TestableStartup<RestrictionDbContext>, RestrictionDbContext> _testContext; - private readonly RestrictionFakers _fakers = new(); - - public HttpReadOnlyTests(IntegrationTestContext<TestableStartup<RestrictionDbContext>, RestrictionDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController<BlockingWritesController>(); - } - - [Fact] - public async Task Can_get_resources() - { - // Arrange - const string route = "/beds"; - - // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteGetAsync<string>(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - } - - [Fact] - public async Task Cannot_create_resource() - { - // Arrange - var requestBody = new - { - data = new - { - type = "beds", - attributes = new - { - } - } - }; - - const string route = "/beds"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync<Document>(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.MethodNotAllowed); - - responseDocument.Errors.Should().HaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.MethodNotAllowed); - error.Title.Should().Be("The request method is not allowed."); - error.Detail.Should().Be("Endpoint does not support POST requests."); - } - - [Fact] - public async Task Cannot_update_resource() - { - // Arrange - Bed existingBed = _fakers.Bed.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Beds.Add(existingBed); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "beds", - id = existingBed.StringId, - attributes = new - { - } - } - }; - - string route = $"/beds/{existingBed.StringId}"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync<Document>(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.MethodNotAllowed); - - responseDocument.Errors.Should().HaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.MethodNotAllowed); - error.Title.Should().Be("The request method is not allowed."); - error.Detail.Should().Be("Endpoint does not support PATCH requests."); - } - - [Fact] - public async Task Cannot_delete_resource() - { - // Arrange - Bed existingBed = _fakers.Bed.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Beds.Add(existingBed); - await dbContext.SaveChangesAsync(); - }); - - string route = $"/beds/{existingBed.StringId}"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteDeleteAsync<Document>(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.MethodNotAllowed); - - responseDocument.Errors.Should().HaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.MethodNotAllowed); - error.Title.Should().Be("The request method is not allowed."); - error.Detail.Should().Be("Endpoint does not support DELETE requests."); - } - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/NoHttpDeleteTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/NoHttpDeleteTests.cs deleted file mode 100644 index fb7ceadc45..0000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/NoHttpDeleteTests.cs +++ /dev/null @@ -1,121 +0,0 @@ -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore.Serialization.Objects; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreTests.IntegrationTests.RestrictedControllers -{ - public sealed class NoHttpDeleteTests : IClassFixture<IntegrationTestContext<TestableStartup<RestrictionDbContext>, RestrictionDbContext>> - { - private readonly IntegrationTestContext<TestableStartup<RestrictionDbContext>, RestrictionDbContext> _testContext; - private readonly RestrictionFakers _fakers = new(); - - public NoHttpDeleteTests(IntegrationTestContext<TestableStartup<RestrictionDbContext>, RestrictionDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController<BlockingHttpDeleteController>(); - } - - [Fact] - public async Task Can_get_resources() - { - // Arrange - const string route = "/sofas"; - - // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteGetAsync<string>(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - } - - [Fact] - public async Task Can_create_resource() - { - // Arrange - var requestBody = new - { - data = new - { - type = "sofas", - attributes = new - { - } - } - }; - - const string route = "/sofas"; - - // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAsync<string>(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - } - - [Fact] - public async Task Can_update_resource() - { - // Arrange - Sofa existingSofa = _fakers.Sofa.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Sofas.Add(existingSofa); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "sofas", - id = existingSofa.StringId, - attributes = new - { - } - } - }; - - string route = $"/sofas/{existingSofa.StringId}"; - - // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePatchAsync<string>(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - } - - [Fact] - public async Task Cannot_delete_resource() - { - // Arrange - Sofa existingSofa = _fakers.Sofa.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Sofas.Add(existingSofa); - await dbContext.SaveChangesAsync(); - }); - - string route = $"/sofas/{existingSofa.StringId}"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteDeleteAsync<Document>(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.MethodNotAllowed); - - responseDocument.Errors.Should().HaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.MethodNotAllowed); - error.Title.Should().Be("The request method is not allowed."); - error.Detail.Should().Be("Endpoint does not support DELETE requests."); - } - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/NoHttpPatchTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/NoHttpPatchTests.cs deleted file mode 100644 index 5be0254212..0000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/NoHttpPatchTests.cs +++ /dev/null @@ -1,121 +0,0 @@ -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore.Serialization.Objects; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreTests.IntegrationTests.RestrictedControllers -{ - public sealed class NoHttpPatchTests : IClassFixture<IntegrationTestContext<TestableStartup<RestrictionDbContext>, RestrictionDbContext>> - { - private readonly IntegrationTestContext<TestableStartup<RestrictionDbContext>, RestrictionDbContext> _testContext; - private readonly RestrictionFakers _fakers = new(); - - public NoHttpPatchTests(IntegrationTestContext<TestableStartup<RestrictionDbContext>, RestrictionDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController<BlockingHttpPatchController>(); - } - - [Fact] - public async Task Can_get_resources() - { - // Arrange - const string route = "/chairs"; - - // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteGetAsync<string>(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - } - - [Fact] - public async Task Can_create_resource() - { - // Arrange - var requestBody = new - { - data = new - { - type = "chairs", - attributes = new - { - } - } - }; - - const string route = "/chairs"; - - // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAsync<string>(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - } - - [Fact] - public async Task Cannot_update_resource() - { - // Arrange - Chair existingChair = _fakers.Chair.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Chairs.Add(existingChair); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "chairs", - id = existingChair.StringId, - attributes = new - { - } - } - }; - - string route = $"/chairs/{existingChair.StringId}"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync<Document>(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.MethodNotAllowed); - - responseDocument.Errors.Should().HaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.MethodNotAllowed); - error.Title.Should().Be("The request method is not allowed."); - error.Detail.Should().Be("Endpoint does not support PATCH requests."); - } - - [Fact] - public async Task Can_delete_resource() - { - // Arrange - Chair existingChair = _fakers.Chair.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Chairs.Add(existingChair); - await dbContext.SaveChangesAsync(); - }); - - string route = $"/chairs/{existingChair.StringId}"; - - // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteDeleteAsync<string>(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - } - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/NoHttpPostTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/NoHttpPostTests.cs deleted file mode 100644 index a6f16a1cb0..0000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/NoHttpPostTests.cs +++ /dev/null @@ -1,121 +0,0 @@ -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore.Serialization.Objects; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreTests.IntegrationTests.RestrictedControllers -{ - public sealed class NoHttpPostTests : IClassFixture<IntegrationTestContext<TestableStartup<RestrictionDbContext>, RestrictionDbContext>> - { - private readonly IntegrationTestContext<TestableStartup<RestrictionDbContext>, RestrictionDbContext> _testContext; - private readonly RestrictionFakers _fakers = new(); - - public NoHttpPostTests(IntegrationTestContext<TestableStartup<RestrictionDbContext>, RestrictionDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController<BlockingHttpPostController>(); - } - - [Fact] - public async Task Can_get_resources() - { - // Arrange - const string route = "/tables"; - - // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteGetAsync<string>(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - } - - [Fact] - public async Task Cannot_create_resource() - { - // Arrange - var requestBody = new - { - data = new - { - type = "tables", - attributes = new - { - } - } - }; - - const string route = "/tables"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync<Document>(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.MethodNotAllowed); - - responseDocument.Errors.Should().HaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.MethodNotAllowed); - error.Title.Should().Be("The request method is not allowed."); - error.Detail.Should().Be("Endpoint does not support POST requests."); - } - - [Fact] - public async Task Can_update_resource() - { - // Arrange - Table existingTable = _fakers.Table.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Tables.Add(existingTable); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "tables", - id = existingTable.StringId, - attributes = new - { - } - } - }; - - string route = $"/tables/{existingTable.StringId}"; - - // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePatchAsync<string>(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - } - - [Fact] - public async Task Can_delete_resource() - { - // Arrange - Table existingTable = _fakers.Table.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Tables.Add(existingTable); - await dbContext.SaveChangesAsync(); - }); - - string route = $"/tables/{existingTable.StringId}"; - - // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteDeleteAsync<string>(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - } - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/NoRelationshipsControllerTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/NoRelationshipsControllerTests.cs new file mode 100644 index 0000000000..191238463f --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/NoRelationshipsControllerTests.cs @@ -0,0 +1,319 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.RestrictedControllers +{ + public sealed class NoRelationshipsControllerTests : IClassFixture<IntegrationTestContext<TestableStartup<RestrictionDbContext>, RestrictionDbContext>> + { + private readonly IntegrationTestContext<TestableStartup<RestrictionDbContext>, RestrictionDbContext> _testContext; + private readonly RestrictionFakers _fakers = new(); + + public NoRelationshipsControllerTests(IntegrationTestContext<TestableStartup<RestrictionDbContext>, RestrictionDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController<ChairsNoRelationshipsController>(); + } + + [Fact] + public async Task Can_get_resources() + { + // Arrange + const string route = "/chairs"; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteGetAsync<string>(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + } + + [Fact] + public async Task Can_get_resource() + { + // Arrange + Chair chair = _fakers.Chair.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Chairs.Add(chair); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/chairs/{chair.StringId}"; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteGetAsync<string>(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + } + + [Fact] + public async Task Cannot_get_secondary_resources() + { + // Arrange + Chair chair = _fakers.Chair.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Chairs.Add(chair); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/chairs/{chair.StringId}/pillows"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Forbidden); + error.Title.Should().Be("The requested endpoint is not accessible."); + error.Detail.Should().Be($"Endpoint '{route}' is not accessible for GET requests."); + } + + [Fact] + public async Task Cannot_get_secondary_resource() + { + // Arrange + Chair chair = _fakers.Chair.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Chairs.Add(chair); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/chairs/{chair.StringId}/room"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Forbidden); + error.Title.Should().Be("The requested endpoint is not accessible."); + error.Detail.Should().Be($"Endpoint '{route}' is not accessible for GET requests."); + } + + [Fact] + public async Task Cannot_get_relationship() + { + // Arrange + Chair chair = _fakers.Chair.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Chairs.Add(chair); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/chairs/{chair.StringId}/relationships/pillows"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Forbidden); + error.Title.Should().Be("The requested endpoint is not accessible."); + error.Detail.Should().Be($"Endpoint '{route}' is not accessible for GET requests."); + } + + [Fact] + public async Task Can_create_resource() + { + // Arrange + var requestBody = new + { + data = new + { + type = "chairs", + attributes = new + { + } + } + }; + + const string route = "/chairs"; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAsync<string>(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + } + + [Fact] + public async Task Can_update_resource() + { + // Arrange + Chair existingChair = _fakers.Chair.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Chairs.Add(existingChair); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "chairs", + id = existingChair.StringId, + attributes = new + { + } + } + }; + + string route = $"/chairs/{existingChair.StringId}"; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePatchAsync<Document>(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + } + + [Fact] + public async Task Can_delete_resource() + { + // Arrange + Chair existingChair = _fakers.Chair.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Chairs.Add(existingChair); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/chairs/{existingChair.StringId}"; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteDeleteAsync<string>(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + } + + [Fact] + public async Task Cannot_update_relationship() + { + // Arrange + Chair existingChair = _fakers.Chair.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Chairs.Add(existingChair); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = (object?)null + }; + + string route = $"/chairs/{existingChair.StringId}/relationships/room"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync<Document>(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Forbidden); + error.Title.Should().Be("The requested endpoint is not accessible."); + error.Detail.Should().Be($"Endpoint '{route}' is not accessible for PATCH requests."); + } + + [Fact] + public async Task Cannot_add_to_ToMany_relationship() + { + // Arrange + Chair existingChair = _fakers.Chair.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Chairs.Add(existingChair); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = Array.Empty<object>() + }; + + string route = $"/chairs/{existingChair.StringId}/relationships/pillows"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync<Document>(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Forbidden); + error.Title.Should().Be("The requested endpoint is not accessible."); + error.Detail.Should().Be($"Endpoint '{route}' is not accessible for POST requests."); + } + + [Fact] + public async Task Cannot_remove_from_ToMany_relationship() + { + // Arrange + Chair existingChair = _fakers.Chair.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Chairs.Add(existingChair); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = Array.Empty<object>() + }; + + string route = $"/chairs/{existingChair.StringId}/relationships/pillows"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteDeleteAsync<Document>(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Forbidden); + error.Title.Should().Be("The requested endpoint is not accessible."); + error.Detail.Should().Be($"Endpoint '{route}' is not accessible for DELETE requests."); + } + } +} diff --git a/benchmarks/SubResource.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/Pillow.cs similarity index 52% rename from benchmarks/SubResource.cs rename to test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/Pillow.cs index 73536a87ae..cf3642aeaf 100644 --- a/benchmarks/SubResource.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/Pillow.cs @@ -2,12 +2,12 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace Benchmarks +namespace JsonApiDotNetCoreTests.IntegrationTests.RestrictedControllers { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class SubResource : Identifiable + public sealed class Pillow : Identifiable<int> { [Attr] - public string Value { get; set; } + public string Color { get; set; } = null!; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/PillowsNoSkipCacheController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/PillowsNoSkipCacheController.cs new file mode 100644 index 0000000000..2ca39b0072 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/PillowsNoSkipCacheController.cs @@ -0,0 +1,18 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Controllers.Annotations; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreTests.IntegrationTests.RestrictedControllers +{ + [DisableQueryString("skipCache")] + public sealed class PillowsNoSkipCacheController : JsonApiController<Pillow, int> + { + public PillowsNoSkipCacheController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<Pillow, int> resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/ReadOnlyControllerTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/ReadOnlyControllerTests.cs new file mode 100644 index 0000000000..d171982473 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/ReadOnlyControllerTests.cs @@ -0,0 +1,319 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.RestrictedControllers +{ + public sealed class ReadOnlyControllerTests : IClassFixture<IntegrationTestContext<TestableStartup<RestrictionDbContext>, RestrictionDbContext>> + { + private readonly IntegrationTestContext<TestableStartup<RestrictionDbContext>, RestrictionDbContext> _testContext; + private readonly RestrictionFakers _fakers = new(); + + public ReadOnlyControllerTests(IntegrationTestContext<TestableStartup<RestrictionDbContext>, RestrictionDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController<BedsReadOnlyController>(); + } + + [Fact] + public async Task Can_get_resources() + { + // Arrange + const string route = "/beds"; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteGetAsync<string>(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + } + + [Fact] + public async Task Can_get_resource() + { + // Arrange + Bed bed = _fakers.Bed.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Beds.Add(bed); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/beds/{bed.StringId}"; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteGetAsync<string>(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + } + + [Fact] + public async Task Can_get_secondary_resources() + { + // Arrange + Bed bed = _fakers.Bed.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Beds.Add(bed); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/beds/{bed.StringId}/pillows"; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteGetAsync<string>(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + } + + [Fact] + public async Task Can_get_secondary_resource() + { + // Arrange + Bed bed = _fakers.Bed.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Beds.Add(bed); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/beds/{bed.StringId}/room"; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteGetAsync<string>(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + } + + [Fact] + public async Task Can_get_relationship() + { + // Arrange + Bed bed = _fakers.Bed.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Beds.Add(bed); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/beds/{bed.StringId}/relationships/pillows"; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteGetAsync<string>(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + } + + [Fact] + public async Task Cannot_create_resource() + { + // Arrange + var requestBody = new + { + data = new + { + type = "beds", + attributes = new + { + } + } + }; + + const string route = "/beds?include=pillows"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync<Document>(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Forbidden); + error.Title.Should().Be("The requested endpoint is not accessible."); + error.Detail.Should().Be("Endpoint '/beds' is not accessible for POST requests."); + } + + [Fact] + public async Task Cannot_update_resource() + { + // Arrange + Bed existingBed = _fakers.Bed.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Beds.Add(existingBed); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "beds", + id = existingBed.StringId, + attributes = new + { + } + } + }; + + string route = $"/beds/{existingBed.StringId}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync<Document>(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Forbidden); + error.Title.Should().Be("The requested endpoint is not accessible."); + error.Detail.Should().Be($"Endpoint '{route}' is not accessible for PATCH requests."); + } + + [Fact] + public async Task Cannot_delete_resource() + { + // Arrange + Bed existingBed = _fakers.Bed.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Beds.Add(existingBed); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/beds/{existingBed.StringId}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteDeleteAsync<Document>(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Forbidden); + error.Title.Should().Be("The requested endpoint is not accessible."); + error.Detail.Should().Be($"Endpoint '{route}' is not accessible for DELETE requests."); + } + + [Fact] + public async Task Cannot_update_relationship() + { + // Arrange + Bed existingBed = _fakers.Bed.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Beds.Add(existingBed); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = (object?)null + }; + + string route = $"/beds/{existingBed.StringId}/relationships/room"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync<Document>(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Forbidden); + error.Title.Should().Be("The requested endpoint is not accessible."); + error.Detail.Should().Be($"Endpoint '{route}' is not accessible for PATCH requests."); + } + + [Fact] + public async Task Cannot_add_to_ToMany_relationship() + { + // Arrange + Bed existingBed = _fakers.Bed.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Beds.Add(existingBed); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = Array.Empty<object>() + }; + + string route = $"/beds/{existingBed.StringId}/relationships/pillows"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync<Document>(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Forbidden); + error.Title.Should().Be("The requested endpoint is not accessible."); + error.Detail.Should().Be($"Endpoint '{route}' is not accessible for POST requests."); + } + + [Fact] + public async Task Cannot_remove_from_ToMany_relationship() + { + // Arrange + Bed existingBed = _fakers.Bed.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Beds.Add(existingBed); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = Array.Empty<object>() + }; + + string route = $"/beds/{existingBed.StringId}/relationships/pillows"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteDeleteAsync<Document>(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Forbidden); + error.Title.Should().Be("The requested endpoint is not accessible."); + error.Detail.Should().Be($"Endpoint '{route}' is not accessible for DELETE requests."); + } + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/RestrictionDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/RestrictionDbContext.cs index 02fd1482f1..ab2cae949b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/RestrictionDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/RestrictionDbContext.cs @@ -6,10 +6,10 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.RestrictedControllers [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class RestrictionDbContext : DbContext { - public DbSet<Table> Tables { get; set; } - public DbSet<Chair> Chairs { get; set; } - public DbSet<Sofa> Sofas { get; set; } - public DbSet<Bed> Beds { get; set; } + public DbSet<Table> Tables => Set<Table>(); + public DbSet<Chair> Chairs => Set<Chair>(); + public DbSet<Sofa> Sofas => Set<Sofa>(); + public DbSet<Bed> Beds => Set<Bed>(); public RestrictionDbContext(DbContextOptions<RestrictionDbContext> options) : base(options) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/RestrictionFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/RestrictionFakers.cs index a811193dfb..d7d8b884de 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/RestrictionFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/RestrictionFakers.cs @@ -1,5 +1,6 @@ using System; using Bogus; +using JetBrains.Annotations; using TestBuildingBlocks; // @formatter:wrap_chained_method_calls chop_always @@ -7,27 +8,44 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.RestrictedControllers { + [UsedImplicitly(ImplicitUseTargetFlags.Members)] internal sealed class RestrictionFakers : FakerContainer { + private readonly Lazy<Faker<Room>> _lazyRoomFaker = new(() => + new Faker<Room>() + .UseSeed(GetFakerSeed()) + .RuleFor(room => room.WindowCount, faker => faker.Random.Int(0, 3))); + private readonly Lazy<Faker<Table>> _lazyTableFaker = new(() => new Faker<Table>() - .UseSeed(GetFakerSeed())); + .UseSeed(GetFakerSeed()) + .RuleFor(table => table.LegCount, faker => faker.Random.Int(1, 4))); private readonly Lazy<Faker<Chair>> _lazyChairFaker = new(() => new Faker<Chair>() - .UseSeed(GetFakerSeed())); + .UseSeed(GetFakerSeed()) + .RuleFor(chair => chair.LegCount, faker => faker.Random.Int(2, 4))); private readonly Lazy<Faker<Sofa>> _lazySofaFaker = new(() => new Faker<Sofa>() - .UseSeed(GetFakerSeed())); + .UseSeed(GetFakerSeed()) + .RuleFor(sofa => sofa.SeatCount, faker => faker.Random.Int(2, 6))); + + private readonly Lazy<Faker<Pillow>> _lazyPillowFaker = new(() => + new Faker<Pillow>() + .UseSeed(GetFakerSeed()) + .RuleFor(pillow => pillow.Color, faker => faker.Internet.Color())); private readonly Lazy<Faker<Bed>> _lazyBedFaker = new(() => new Faker<Bed>() - .UseSeed(GetFakerSeed())); + .UseSeed(GetFakerSeed()) + .RuleFor(bed => bed.IsDouble, faker => faker.Random.Bool())); + public Faker<Room> Room => _lazyRoomFaker.Value; public Faker<Table> Table => _lazyTableFaker.Value; public Faker<Chair> Chair => _lazyChairFaker.Value; public Faker<Sofa> Sofa => _lazySofaFaker.Value; public Faker<Bed> Bed => _lazyBedFaker.Value; + public Faker<Pillow> Pillow => _lazyPillowFaker.Value; } } diff --git a/test/UnitTests/TestModels/Food.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/Room.cs similarity index 54% rename from test/UnitTests/TestModels/Food.cs rename to test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/Room.cs index 940ef18cb8..74714d0d01 100644 --- a/test/UnitTests/TestModels/Food.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/Room.cs @@ -2,12 +2,12 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace UnitTests.TestModels +namespace JsonApiDotNetCoreTests.IntegrationTests.RestrictedControllers { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Food : Identifiable + public sealed class Room : Identifiable<int> { [Attr] - public string Dish { get; set; } + public int WindowCount { get; set; } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/SkipCacheQueryStringParameterReader.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/SkipCacheQueryStringParameterReader.cs index 43fe12f42a..ec8e731983 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/SkipCacheQueryStringParameterReader.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/SkipCacheQueryStringParameterReader.cs @@ -1,6 +1,5 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Controllers.Annotations; -using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.QueryStrings; using Microsoft.Extensions.Primitives; @@ -13,6 +12,8 @@ public sealed class SkipCacheQueryStringParameterReader : IQueryStringParameterR [UsedImplicitly] public bool SkipCache { get; private set; } + public bool AllowEmptyValue => true; + public bool IsEnabled(DisableQueryStringAttribute disableQueryStringAttribute) { return !disableQueryStringAttribute.ParameterNames.Contains(SkipCacheParameterName); @@ -25,13 +26,7 @@ public bool CanRead(string parameterName) public void Read(string parameterName, StringValues parameterValue) { - if (!bool.TryParse(parameterValue, out bool skipCache)) - { - throw new InvalidQueryStringParameterException(parameterName, "Boolean value required.", - $"The value '{parameterValue}' is not a valid boolean."); - } - - SkipCache = skipCache; + SkipCache = true; } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/Sofa.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/Sofa.cs index c34602fc18..c110b5d0d8 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/Sofa.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/Sofa.cs @@ -5,7 +5,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.RestrictedControllers { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Sofa : Identifiable + public sealed class Sofa : Identifiable<int> { [Attr] public int SeatCount { get; set; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/BlockingHttpDeleteController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/SofasBlockingSortPageController.cs similarity index 56% rename from test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/BlockingHttpDeleteController.cs rename to test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/SofasBlockingSortPageController.cs index 18d753341e..7f300a3e0a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/BlockingHttpDeleteController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/SofasBlockingSortPageController.cs @@ -7,12 +7,12 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.RestrictedControllers { - [NoHttpDelete] [DisableQueryString(JsonApiQueryStringParameters.Sort | JsonApiQueryStringParameters.Page)] - public sealed class BlockingHttpDeleteController : JsonApiController<Sofa> + public sealed class SofasBlockingSortPageController : JsonApiController<Sofa, int> { - public BlockingHttpDeleteController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<Sofa> resourceService) - : base(options, loggerFactory, resourceService) + public SofasBlockingSortPageController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<Sofa, int> resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/Table.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/Table.cs index 8f3901bf26..c0df183f38 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/Table.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/Table.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; @@ -5,9 +6,15 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.RestrictedControllers { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Table : Identifiable + public sealed class Table : Identifiable<int> { [Attr] public int LegCount { get; set; } + + [HasMany] + public IList<Chair> Chairs { get; set; } = new List<Chair>(); + + [HasOne] + public Room? Room { get; set; } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/TablesWriteOnlyController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/TablesWriteOnlyController.cs new file mode 100644 index 0000000000..484e8fc13a --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/TablesWriteOnlyController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreTests.IntegrationTests.RestrictedControllers +{ + public sealed class TablesWriteOnlyController : JsonApiCommandController<Table, int> + { + public TablesWriteOnlyController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceCommandService<Table, int> resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/WriteOnlyControllerTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/WriteOnlyControllerTests.cs new file mode 100644 index 0000000000..7008eafd9a --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/WriteOnlyControllerTests.cs @@ -0,0 +1,312 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.RestrictedControllers +{ + public sealed class WriteOnlyControllerTests : IClassFixture<IntegrationTestContext<TestableStartup<RestrictionDbContext>, RestrictionDbContext>> + { + private readonly IntegrationTestContext<TestableStartup<RestrictionDbContext>, RestrictionDbContext> _testContext; + private readonly RestrictionFakers _fakers = new(); + + public WriteOnlyControllerTests(IntegrationTestContext<TestableStartup<RestrictionDbContext>, RestrictionDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController<TablesWriteOnlyController>(); + } + + [Fact] + public async Task Cannot_get_resources() + { + // Arrange + const string route = "/tables?fields[tables]=legCount"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Forbidden); + error.Title.Should().Be("The requested endpoint is not accessible."); + error.Detail.Should().Be("Endpoint '/tables' is not accessible for GET requests."); + } + + [Fact] + public async Task Cannot_get_resource() + { + // Arrange + Table table = _fakers.Table.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Tables.Add(table); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/tables/{table.StringId}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Forbidden); + error.Title.Should().Be("The requested endpoint is not accessible."); + error.Detail.Should().Be($"Endpoint '{route}' is not accessible for GET requests."); + } + + [Fact] + public async Task Cannot_get_secondary_resources() + { + // Arrange + Table table = _fakers.Table.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Tables.Add(table); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/tables/{table.StringId}/chairs"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Forbidden); + error.Title.Should().Be("The requested endpoint is not accessible."); + error.Detail.Should().Be($"Endpoint '{route}' is not accessible for GET requests."); + } + + [Fact] + public async Task Cannot_get_secondary_resource() + { + // Arrange + Table table = _fakers.Table.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Tables.Add(table); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/tables/{table.StringId}/room"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Forbidden); + error.Title.Should().Be("The requested endpoint is not accessible."); + error.Detail.Should().Be($"Endpoint '{route}' is not accessible for GET requests."); + } + + [Fact] + public async Task Cannot_get_relationship() + { + // Arrange + Table table = _fakers.Table.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Tables.Add(table); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/tables/{table.StringId}/relationships/chairs"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Forbidden); + error.Title.Should().Be("The requested endpoint is not accessible."); + error.Detail.Should().Be($"Endpoint '{route}' is not accessible for GET requests."); + } + + [Fact] + public async Task Can_create_resource() + { + // Arrange + var requestBody = new + { + data = new + { + type = "tables", + attributes = new + { + } + } + }; + + const string route = "/tables"; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAsync<string>(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + } + + [Fact] + public async Task Can_update_resource() + { + // Arrange + Table existingTable = _fakers.Table.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Tables.Add(existingTable); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "tables", + id = existingTable.StringId, + attributes = new + { + } + } + }; + + string route = $"/tables/{existingTable.StringId}"; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePatchAsync<string>(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + } + + [Fact] + public async Task Can_delete_resource() + { + // Arrange + Table existingTable = _fakers.Table.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Tables.Add(existingTable); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/tables/{existingTable.StringId}"; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteDeleteAsync<string>(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + } + + [Fact] + public async Task Can_update_relationship() + { + // Arrange + Table existingTable = _fakers.Table.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Tables.Add(existingTable); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = (object?)null + }; + + string route = $"/tables/{existingTable.StringId}/relationships/room"; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePatchAsync<string>(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + } + + [Fact] + public async Task Can_add_to_ToMany_relationship() + { + // Arrange + Table existingTable = _fakers.Table.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Tables.Add(existingTable); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = Array.Empty<object>() + }; + + string route = $"/tables/{existingTable.StringId}/relationships/chairs"; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAsync<string>(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + } + + [Fact] + public async Task Can_remove_from_ToMany_relationship() + { + // Arrange + Table existingTable = _fakers.Table.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Tables.Add(existingTable); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = Array.Empty<object>() + }; + + string route = $"/tables/{existingTable.StringId}/relationships/chairs"; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteDeleteAsync<string>(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + } + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/ETagTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/ETagTests.cs index 8763390bd9..540b820bc8 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/ETagTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/ETagTests.cs @@ -45,9 +45,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - httpResponse.Headers.ETag.Should().NotBeNull(); - httpResponse.Headers.ETag!.IsWeak.Should().BeFalse(); - httpResponse.Headers.ETag.Tag.Should().NotBeNullOrEmpty(); + httpResponse.Headers.ETag.ShouldNotBeNull(); + httpResponse.Headers.ETag.IsWeak.Should().BeFalse(); + httpResponse.Headers.ETag.Tag.ShouldNotBeNullOrEmpty(); responseDocument.Should().BeEmpty(); } @@ -73,11 +73,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - httpResponse.Headers.ETag.Should().NotBeNull(); - httpResponse.Headers.ETag!.IsWeak.Should().BeFalse(); - httpResponse.Headers.ETag.Tag.Should().NotBeNullOrEmpty(); + httpResponse.Headers.ETag.ShouldNotBeNull(); + httpResponse.Headers.ETag.IsWeak.Should().BeFalse(); + httpResponse.Headers.ETag.Tag.ShouldNotBeNullOrEmpty(); - responseDocument.Should().NotBeEmpty(); + responseDocument.ShouldNotBeEmpty(); } [Fact] @@ -94,7 +94,7 @@ public async Task Returns_no_ETag_for_failed_GET_request() httpResponse.Headers.ETag.Should().BeNull(); - responseDocument.Should().NotBeEmpty(); + responseDocument.ShouldNotBeEmpty(); } [Fact] @@ -125,7 +125,7 @@ public async Task Returns_no_ETag_for_POST_request() httpResponse.Headers.ETag.Should().BeNull(); - responseDocument.Should().NotBeEmpty(); + responseDocument.ShouldNotBeEmpty(); } [Fact] @@ -169,12 +169,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.PreconditionFailed); - responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); error.Title.Should().Be("Detection of mid-air edit collisions using ETags is not supported."); error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); error.Source.Header.Should().Be("If-Match"); } @@ -208,9 +209,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse2.Should().HaveStatusCode(HttpStatusCode.NotModified); - httpResponse2.Headers.ETag.Should().NotBeNull(); - httpResponse2.Headers.ETag!.IsWeak.Should().BeFalse(); - httpResponse2.Headers.ETag.Tag.Should().NotBeNullOrEmpty(); + httpResponse2.Headers.ETag.ShouldNotBeNull(); + httpResponse2.Headers.ETag.IsWeak.Should().BeFalse(); + httpResponse2.Headers.ETag.Tag.ShouldNotBeNullOrEmpty(); responseDocument2.Should().BeEmpty(); } @@ -241,11 +242,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - httpResponse.Headers.ETag.Should().NotBeNull(); - httpResponse.Headers.ETag!.IsWeak.Should().BeFalse(); - httpResponse.Headers.ETag.Tag.Should().NotBeNullOrEmpty(); + httpResponse.Headers.ETag.ShouldNotBeNull(); + httpResponse.Headers.ETag.IsWeak.Should().BeFalse(); + httpResponse.Headers.ETag.Tag.ShouldNotBeNullOrEmpty(); - responseDocument.Should().NotBeEmpty(); + responseDocument.ShouldNotBeEmpty(); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/Meeting.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/Meeting.cs index 148391d0d4..7e35498f92 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/Meeting.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/Meeting.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations.Schema; +using System.Diagnostics.CodeAnalysis; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; @@ -11,7 +12,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Serialization public sealed class Meeting : Identifiable<Guid> { [Attr] - public string Title { get; set; } + public string Title { get; set; } = null!; [Attr] public DateTimeOffset StartTime { get; set; } @@ -21,6 +22,7 @@ public sealed class Meeting : Identifiable<Guid> [Attr] [NotMapped] + [AllowNull] public MeetingLocation Location { get => @@ -40,6 +42,6 @@ public MeetingLocation Location public double Longitude { get; set; } [HasMany] - public IList<MeetingAttendee> Attendees { get; set; } + public IList<MeetingAttendee> Attendees { get; set; } = new List<MeetingAttendee>(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/MeetingAttendee.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/MeetingAttendee.cs index 410f0710d1..76d3f17b3b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/MeetingAttendee.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/MeetingAttendee.cs @@ -9,9 +9,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Serialization public sealed class MeetingAttendee : Identifiable<Guid> { [Attr] - public string DisplayName { get; set; } + public string DisplayName { get; set; } = null!; [HasOne] - public Meeting Meeting { get; set; } + public Meeting? Meeting { get; set; } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/MeetingAttendeesController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/MeetingAttendeesController.cs index 822908bc98..bc3e213001 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/MeetingAttendeesController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/MeetingAttendeesController.cs @@ -8,8 +8,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Serialization { public sealed class MeetingAttendeesController : JsonApiController<MeetingAttendee, Guid> { - public MeetingAttendeesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<MeetingAttendee, Guid> resourceService) - : base(options, loggerFactory, resourceService) + public MeetingAttendeesController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<MeetingAttendee, Guid> resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/MeetingsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/MeetingsController.cs index 216b1081cd..f21b312e42 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/MeetingsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/MeetingsController.cs @@ -8,8 +8,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Serialization { public sealed class MeetingsController : JsonApiController<Meeting, Guid> { - public MeetingsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<Meeting, Guid> resourceService) - : base(options, loggerFactory, resourceService) + public MeetingsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<Meeting, Guid> resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationDbContext.cs index 31a6f3f8c6..9d74cc499d 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationDbContext.cs @@ -6,8 +6,8 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Serialization [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class SerializationDbContext : DbContext { - public DbSet<Meeting> Meetings { get; set; } - public DbSet<MeetingAttendee> Attendees { get; set; } + public DbSet<Meeting> Meetings => Set<Meeting>(); + public DbSet<MeetingAttendee> Attendees => Set<MeetingAttendee>(); public SerializationDbContext(DbContextOptions<SerializationDbContext> options) : base(options) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationTests.cs index f73e03004d..eaedd8324b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/SerializationTests.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Net; @@ -38,6 +37,7 @@ public SerializationTests(IntegrationTestContext<TestableStartup<SerializationDb options.IncludeExceptionStackTraceInErrors = false; options.AllowClientGeneratedIds = true; options.IncludeJsonApiVersion = false; + options.IncludeTotalResourceCount = true; if (!options.SerializerOptions.Converters.Any(converter => converter is JsonTimeSpanConverter)) { @@ -65,6 +65,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + httpResponse.Content.Headers.ContentLength.Should().BeGreaterThan(0); + responseDocument.Should().BeEmpty(); } @@ -80,6 +82,8 @@ public async Task Returns_no_body_for_failed_HEAD_request() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + httpResponse.Content.Headers.ContentLength.Should().BeGreaterThan(0); + responseDocument.Should().BeEmpty(); } @@ -87,13 +91,13 @@ public async Task Returns_no_body_for_failed_HEAD_request() public async Task Can_get_primary_resources_with_include() { // Arrange - List<Meeting> meetings = _fakers.Meeting.Generate(1); - meetings[0].Attendees = _fakers.MeetingAttendee.Generate(1); + Meeting meeting = _fakers.Meeting.Generate(); + meeting.Attendees = _fakers.MeetingAttendee.Generate(1); await _testContext.RunOnDatabaseAsync(async dbContext => { await dbContext.ClearTableAsync<Meeting>(); - dbContext.Meetings.AddRange(meetings); + dbContext.Meetings.Add(meeting); await dbContext.SaveChangesAsync(); }); @@ -108,73 +112,124 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Should().BeJson(@"{ ""links"": { ""self"": ""http://localhost/meetings?include=attendees"", - ""first"": ""http://localhost/meetings?include=attendees"" + ""first"": ""http://localhost/meetings?include=attendees"", + ""last"": ""http://localhost/meetings?include=attendees"" }, ""data"": [ { ""type"": ""meetings"", - ""id"": """ + meetings[0].StringId + @""", + ""id"": """ + meeting.StringId + @""", ""attributes"": { - ""title"": """ + meetings[0].Title + @""", - ""startTime"": """ + meetings[0].StartTime.ToString(JsonDateTimeOffsetFormatSpecifier) + @""", - ""duration"": """ + meetings[0].Duration + @""", + ""title"": """ + meeting.Title + @""", + ""startTime"": """ + meeting.StartTime.ToString(JsonDateTimeOffsetFormatSpecifier) + @""", + ""duration"": """ + meeting.Duration + @""", ""location"": { - ""lat"": " + meetings[0].Location.Latitude.ToString(CultureInfo.InvariantCulture) + @", - ""lng"": " + meetings[0].Location.Longitude.ToString(CultureInfo.InvariantCulture) + @" + ""lat"": " + meeting.Location.Latitude.ToString(CultureInfo.InvariantCulture) + @", + ""lng"": " + meeting.Location.Longitude.ToString(CultureInfo.InvariantCulture) + @" } }, ""relationships"": { ""attendees"": { ""links"": { - ""self"": ""http://localhost/meetings/" + meetings[0].StringId + @"/relationships/attendees"", - ""related"": ""http://localhost/meetings/" + meetings[0].StringId + @"/attendees"" + ""self"": ""http://localhost/meetings/" + meeting.StringId + @"/relationships/attendees"", + ""related"": ""http://localhost/meetings/" + meeting.StringId + @"/attendees"" }, ""data"": [ { ""type"": ""meetingAttendees"", - ""id"": """ + meetings[0].Attendees[0].StringId + @""" + ""id"": """ + meeting.Attendees[0].StringId + @""" } ] } }, ""links"": { - ""self"": ""http://localhost/meetings/" + meetings[0].StringId + @""" + ""self"": ""http://localhost/meetings/" + meeting.StringId + @""" } } ], ""included"": [ { ""type"": ""meetingAttendees"", - ""id"": """ + meetings[0].Attendees[0].StringId + @""", + ""id"": """ + meeting.Attendees[0].StringId + @""", ""attributes"": { - ""displayName"": """ + meetings[0].Attendees[0].DisplayName + @""" + ""displayName"": """ + meeting.Attendees[0].DisplayName + @""" }, ""relationships"": { ""meeting"": { ""links"": { - ""self"": ""http://localhost/meetingAttendees/" + meetings[0].Attendees[0].StringId + @"/relationships/meeting"", - ""related"": ""http://localhost/meetingAttendees/" + meetings[0].Attendees[0].StringId + @"/meeting"" + ""self"": ""http://localhost/meetingAttendees/" + meeting.Attendees[0].StringId + @"/relationships/meeting"", + ""related"": ""http://localhost/meetingAttendees/" + meeting.Attendees[0].StringId + @"/meeting"" } } }, ""links"": { - ""self"": ""http://localhost/meetingAttendees/" + meetings[0].Attendees[0].StringId + @""" + ""self"": ""http://localhost/meetingAttendees/" + meeting.Attendees[0].StringId + @""" } } - ] + ], + ""meta"": { + ""total"": 1 + } +}"); + } + + [Fact] + public async Task Can_get_primary_resource_with_empty_ToOne_include() + { + // Arrange + MeetingAttendee attendee = _fakers.MeetingAttendee.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Attendees.Add(attendee); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/meetingAttendees/{attendee.StringId}?include=meeting"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteGetAsync<string>(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Should().BeJson(@"{ + ""links"": { + ""self"": ""http://localhost/meetingAttendees/" + attendee.StringId + @"?include=meeting"" + }, + ""data"": { + ""type"": ""meetingAttendees"", + ""id"": """ + attendee.StringId + @""", + ""attributes"": { + ""displayName"": """ + attendee.DisplayName + @""" + }, + ""relationships"": { + ""meeting"": { + ""links"": { + ""self"": ""http://localhost/meetingAttendees/" + attendee.StringId + @"/relationships/meeting"", + ""related"": ""http://localhost/meetingAttendees/" + attendee.StringId + @"/meeting"" + }, + ""data"": null + } + }, + ""links"": { + ""self"": ""http://localhost/meetingAttendees/" + attendee.StringId + @""" + } + }, + ""included"": [] }"); } [Fact] - public async Task Can_get_primary_resources_with_empty_include() + public async Task Can_get_primary_resources_with_empty_ToMany_include() { // Arrange - List<Meeting> meetings = _fakers.Meeting.Generate(1); + Meeting meeting = _fakers.Meeting.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { await dbContext.ClearTableAsync<Meeting>(); - dbContext.Meetings.AddRange(meetings); + dbContext.Meetings.Add(meeting); await dbContext.SaveChangesAsync(); }); @@ -189,36 +244,40 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Should().BeJson(@"{ ""links"": { ""self"": ""http://localhost/meetings/?include=attendees"", - ""first"": ""http://localhost/meetings/?include=attendees"" + ""first"": ""http://localhost/meetings/?include=attendees"", + ""last"": ""http://localhost/meetings/?include=attendees"" }, ""data"": [ { ""type"": ""meetings"", - ""id"": """ + meetings[0].StringId + @""", + ""id"": """ + meeting.StringId + @""", ""attributes"": { - ""title"": """ + meetings[0].Title + @""", - ""startTime"": """ + meetings[0].StartTime.ToString(JsonDateTimeOffsetFormatSpecifier) + @""", - ""duration"": """ + meetings[0].Duration + @""", + ""title"": """ + meeting.Title + @""", + ""startTime"": """ + meeting.StartTime.ToString(JsonDateTimeOffsetFormatSpecifier) + @""", + ""duration"": """ + meeting.Duration + @""", ""location"": { - ""lat"": " + meetings[0].Location.Latitude.ToString(CultureInfo.InvariantCulture) + @", - ""lng"": " + meetings[0].Location.Longitude.ToString(CultureInfo.InvariantCulture) + @" + ""lat"": " + meeting.Location.Latitude.ToString(CultureInfo.InvariantCulture) + @", + ""lng"": " + meeting.Location.Longitude.ToString(CultureInfo.InvariantCulture) + @" } }, ""relationships"": { ""attendees"": { ""links"": { - ""self"": ""http://localhost/meetings/" + meetings[0].StringId + @"/relationships/attendees"", - ""related"": ""http://localhost/meetings/" + meetings[0].StringId + @"/attendees"" + ""self"": ""http://localhost/meetings/" + meeting.StringId + @"/relationships/attendees"", + ""related"": ""http://localhost/meetings/" + meeting.StringId + @"/attendees"" }, ""data"": [] } }, ""links"": { - ""self"": ""http://localhost/meetings/" + meetings[0].StringId + @""" + ""self"": ""http://localhost/meetings/" + meeting.StringId + @""" } } ], - ""included"": [] + ""included"": [], + ""meta"": { + ""total"": 1 + } }"); } @@ -290,6 +349,9 @@ public async Task Cannot_get_unknown_primary_resource_by_ID() string errorId = JsonApiStringConverter.ExtractErrorId(responseDocument); responseDocument.Should().BeJson(@"{ + ""links"": { + ""self"": ""http://localhost/meetings/ffffffff-ffff-ffff-ffff-ffffffffffff"" + }, ""errors"": [ { ""id"": """ + errorId + @""", @@ -405,7 +467,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Should().BeJson(@"{ ""links"": { ""self"": ""http://localhost/meetings/" + meeting.StringId + @"/attendees"", - ""first"": ""http://localhost/meetings/" + meeting.StringId + @"/attendees"" + ""first"": ""http://localhost/meetings/" + meeting.StringId + @"/attendees"", + ""last"": ""http://localhost/meetings/" + meeting.StringId + @"/attendees"" }, ""data"": [ { @@ -426,7 +489,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ""self"": ""http://localhost/meetingAttendees/" + meeting.Attendees[0].StringId + @""" } } - ] + ], + ""meta"": { + ""total"": 1 + } }"); } @@ -455,7 +521,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ""self"": ""http://localhost/meetings/" + meeting.StringId + @"/attendees"", ""first"": ""http://localhost/meetings/" + meeting.StringId + @"/attendees"" }, - ""data"": [] + ""data"": [], + ""meta"": { + ""total"": 0 + } }"); } @@ -513,13 +582,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - string[] meetingIds = meeting.Attendees.Select(attendee => attendee.StringId).OrderBy(id => id).ToArray(); + string[] meetingIds = meeting.Attendees.Select(attendee => attendee.StringId!).OrderBy(id => id).ToArray(); responseDocument.Should().BeJson(@"{ ""links"": { ""self"": ""http://localhost/meetings/" + meeting.StringId + @"/relationships/attendees"", ""related"": ""http://localhost/meetings/" + meeting.StringId + @"/attendees"", - ""first"": ""http://localhost/meetings/" + meeting.StringId + @"/relationships/attendees"" + ""first"": ""http://localhost/meetings/" + meeting.StringId + @"/relationships/attendees"", + ""last"": ""http://localhost/meetings/" + meeting.StringId + @"/relationships/attendees"" }, ""data"": [ { @@ -530,7 +600,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => ""type"": ""meetingAttendees"", ""id"": """ + meetingIds[1] + @""" } - ] + ], + ""meta"": { + ""total"": 2 + } }"); } @@ -761,6 +834,9 @@ public async Task Includes_version_on_error_in_resource_endpoint() ""jsonapi"": { ""version"": ""1.1"" }, + ""links"": { + ""self"": ""http://localhost/meetingAttendees/ffffffff-ffff-ffff-ffff-ffffffffffff"" + }, ""errors"": [ { ""id"": """ + errorId + @""", diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/SoftDeletion/CompaniesController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/SoftDeletion/CompaniesController.cs index d866a48ac6..fab829e553 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/SoftDeletion/CompaniesController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/SoftDeletion/CompaniesController.cs @@ -5,10 +5,11 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.SoftDeletion { - public sealed class CompaniesController : JsonApiController<Company> + public sealed class CompaniesController : JsonApiController<Company, int> { - public CompaniesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<Company> resourceService) - : base(options, loggerFactory, resourceService) + public CompaniesController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<Company, int> resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/SoftDeletion/Company.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/SoftDeletion/Company.cs index dcb9f82509..9246a523ab 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/SoftDeletion/Company.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/SoftDeletion/Company.cs @@ -7,14 +7,14 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.SoftDeletion { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Company : Identifiable, ISoftDeletable + public sealed class Company : Identifiable<int>, ISoftDeletable { [Attr] - public string Name { get; set; } + public string Name { get; set; } = null!; public DateTimeOffset? SoftDeletedAt { get; set; } [HasMany] - public ICollection<Department> Departments { get; set; } + public ICollection<Department> Departments { get; set; } = new List<Department>(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/SoftDeletion/Department.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/SoftDeletion/Department.cs index 0913cbb84c..26f85f4026 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/SoftDeletion/Department.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/SoftDeletion/Department.cs @@ -6,14 +6,14 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.SoftDeletion { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Department : Identifiable, ISoftDeletable + public sealed class Department : Identifiable<int>, ISoftDeletable { [Attr] - public string Name { get; set; } + public string Name { get; set; } = null!; public DateTimeOffset? SoftDeletedAt { get; set; } [HasOne] - public Company Company { get; set; } + public Company? Company { get; set; } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/SoftDeletion/DepartmentsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/SoftDeletion/DepartmentsController.cs index f49c4e1877..b3302da92d 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/SoftDeletion/DepartmentsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/SoftDeletion/DepartmentsController.cs @@ -5,10 +5,11 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.SoftDeletion { - public sealed class DepartmentsController : JsonApiController<Department> + public sealed class DepartmentsController : JsonApiController<Department, int> { - public DepartmentsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<Department> resourceService) - : base(options, loggerFactory, resourceService) + public DepartmentsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<Department, int> resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/SoftDeletion/SoftDeletionAwareResourceService.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/SoftDeletion/SoftDeletionAwareResourceService.cs index 68ac5a6942..5da32576b6 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/SoftDeletion/SoftDeletionAwareResourceService.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/SoftDeletion/SoftDeletionAwareResourceService.cs @@ -39,9 +39,9 @@ public SoftDeletionAwareResourceService(ISystemClock systemClock, ITargetedField // To optimize performance, the default resource service does not always fetch all resources on write operations. // We do that here, to assure a 404 error is thrown for soft-deleted resources. - public override async Task<TResource> CreateAsync(TResource resource, CancellationToken cancellationToken) + public override async Task<TResource?> CreateAsync(TResource resource, CancellationToken cancellationToken) { - if (_targetedFields.Relationships.Any(relationship => IsSoftDeletable(relationship.RightType))) + if (_targetedFields.Relationships.Any(relationship => IsSoftDeletable(relationship.RightType.ClrType))) { await AssertResourcesToAssignInRelationshipsExistAsync(resource, cancellationToken); } @@ -49,9 +49,9 @@ public override async Task<TResource> CreateAsync(TResource resource, Cancellati return await base.CreateAsync(resource, cancellationToken); } - public override async Task<TResource> UpdateAsync(TId id, TResource resource, CancellationToken cancellationToken) + public override async Task<TResource?> UpdateAsync(TId id, TResource resource, CancellationToken cancellationToken) { - if (_targetedFields.Relationships.Any(relationship => IsSoftDeletable(relationship.RightType))) + if (_targetedFields.Relationships.Any(relationship => IsSoftDeletable(relationship.RightType.ClrType))) { await AssertResourcesToAssignInRelationshipsExistAsync(resource, cancellationToken); } @@ -59,9 +59,9 @@ public override async Task<TResource> UpdateAsync(TId id, TResource resource, Ca 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) { - if (IsSoftDeletable(_request.Relationship.RightType)) + if (IsSoftDeletable(_request.Relationship!.RightType.ClrType)) { await AssertRightResourcesExistAsync(rightValue, cancellationToken); } @@ -77,7 +77,7 @@ public override async Task AddToToManyRelationshipAsync(TId leftId, string relat _ = await GetPrimaryResourceByIdAsync(leftId, TopFieldSelection.OnlyIdAttribute, cancellationToken); } - if (IsSoftDeletable(_request.Relationship.RightType)) + if (IsSoftDeletable(_request.Relationship!.RightType.ClrType)) { await AssertRightResourcesExistAsync(rightResourceIds, cancellationToken); } @@ -107,22 +107,9 @@ private async Task SoftDeleteAsync(TId id, CancellationToken cancellationToken) await _repositoryAccessor.UpdateAsync(resourceFromDatabase, resourceFromDatabase, cancellationToken); } - private static bool IsSoftDeletable(Type resourceType) - { - return typeof(ISoftDeletable).IsAssignableFrom(resourceType); - } - } - - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public class SoftDeletionAwareResourceService<TResource> : SoftDeletionAwareResourceService<TResource, int>, IResourceService<TResource> - where TResource : class, IIdentifiable<int> - { - public SoftDeletionAwareResourceService(ISystemClock systemClock, ITargetedFields targetedFields, IResourceRepositoryAccessor repositoryAccessor, - IQueryLayerComposer queryLayerComposer, IPaginationContext paginationContext, IJsonApiOptions options, ILoggerFactory loggerFactory, - IJsonApiRequest request, IResourceChangeTracker<TResource> resourceChangeTracker, IResourceDefinitionAccessor resourceDefinitionAccessor) - : base(systemClock, targetedFields, repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, - resourceChangeTracker, resourceDefinitionAccessor) + private static bool IsSoftDeletable(Type resourceClrType) { + return typeof(ISoftDeletable).IsAssignableFrom(resourceClrType); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/SoftDeletion/SoftDeletionDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/SoftDeletion/SoftDeletionDbContext.cs index 843e5d9aeb..0fe98b869e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/SoftDeletion/SoftDeletionDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/SoftDeletion/SoftDeletionDbContext.cs @@ -8,8 +8,8 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.SoftDeletion [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class SoftDeletionDbContext : DbContext { - public DbSet<Company> Companies { get; set; } - public DbSet<Department> Departments { get; set; } + public DbSet<Company> Companies => Set<Company>(); + public DbSet<Department> Departments => Set<Department>(); public SoftDeletionDbContext(DbContextOptions<SoftDeletionDbContext> options) : base(options) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs index 702aa7cee3..f9ffae63bb 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs @@ -38,8 +38,8 @@ public SoftDeletionTests(IntegrationTestContext<TestableStartup<SoftDeletionDbCo UtcNow = 1.January(2005).ToDateTimeOffset() }); - services.AddResourceService<SoftDeletionAwareResourceService<Company>>(); - services.AddResourceService<SoftDeletionAwareResourceService<Department>>(); + services.AddResourceService<SoftDeletionAwareResourceService<Company, int>>(); + services.AddResourceService<SoftDeletionAwareResourceService<Department, int>>(); }); } @@ -65,7 +65,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(departments[1].StringId); } @@ -97,7 +97,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(departments[0].StringId); } @@ -128,11 +128,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("companies"); responseDocument.Data.ManyValue[0].Id.Should().Be(companies[1].StringId); - responseDocument.Included.Should().HaveCount(1); + responseDocument.Included.ShouldHaveCount(1); responseDocument.Included[0].Type.Should().Be("departments"); responseDocument.Included[0].Id.Should().Be(companies[1].Departments.ElementAt(0).StringId); } @@ -158,7 +158,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 +188,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 +218,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(company.Departments.ElementAt(1).StringId); } @@ -244,7 +244,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); @@ -299,7 +299,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); @@ -329,7 +329,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(company.Departments.ElementAt(1).StringId); } @@ -355,7 +355,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); @@ -437,7 +437,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); @@ -493,7 +493,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); @@ -537,7 +537,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); @@ -591,7 +591,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); @@ -644,7 +644,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); @@ -679,7 +679,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); @@ -722,7 +722,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); @@ -747,7 +747,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var requestBody = new { - data = (object)null + data = (object?)null }; string route = $"/departments/{existingDepartment.StringId}/relationships/company"; @@ -758,7 +758,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); @@ -798,7 +798,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); @@ -841,7 +841,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); @@ -884,7 +884,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); @@ -928,7 +928,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); @@ -970,7 +970,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); @@ -1007,7 +1007,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => Company companyInDatabase = await dbContext.Companies.IgnoreQueryFilters().FirstWithIdAsync(existingCompany.Id); companyInDatabase.Name.Should().Be(existingCompany.Name); - companyInDatabase.SoftDeletedAt.Should().NotBeNull(); + companyInDatabase.SoftDeletedAt.ShouldNotBeNull(); }); } @@ -1032,7 +1032,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); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/EmptyGuidAsKeyTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/EmptyGuidAsKeyTests.cs index 5fa4761150..954e5d2ab1 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/EmptyGuidAsKeyTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/EmptyGuidAsKeyTests.cs @@ -54,9 +54,14 @@ 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("00000000-0000-0000-0000-000000000000"); - responseDocument.Data.ManyValue[0].Links.Self.Should().Be("/maps/00000000-0000-0000-0000-000000000000"); + + responseDocument.Data.ManyValue[0].With(resource => + { + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be("/maps/00000000-0000-0000-0000-000000000000"); + }); } [Fact] @@ -83,11 +88,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("00000000-0000-0000-0000-000000000000"); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); responseDocument.Data.SingleValue.Links.Self.Should().Be("/maps/00000000-0000-0000-0000-000000000000"); - responseDocument.Included.Should().HaveCount(1); + responseDocument.Included.ShouldHaveCount(1); responseDocument.Included[0].Id.Should().Be("0"); } @@ -131,7 +137,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { Map mapInDatabase = await dbContext.Maps.FirstWithIdAsync((Guid?)Guid.Empty); - mapInDatabase.Should().NotBeNull(); + mapInDatabase.ShouldNotBeNull(); mapInDatabase.Name.Should().Be(newName); }); } @@ -179,7 +185,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { Map mapInDatabase = await dbContext.Maps.FirstWithIdAsync((Guid?)Guid.Empty); - mapInDatabase.Should().NotBeNull(); + mapInDatabase.ShouldNotBeNull(); mapInDatabase.Name.Should().Be(newName); }); } @@ -201,7 +207,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var requestBody = new { - data = (object)null + data = (object?)null }; string route = $"/games/{existingGame.StringId}/relationships/activeMap"; @@ -218,7 +224,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { Game gameInDatabase = await dbContext.Games.Include(game => game.ActiveMap).FirstWithIdAsync(existingGame.Id); - gameInDatabase.Should().NotBeNull(); + gameInDatabase.ShouldNotBeNull(); gameInDatabase.ActiveMap.Should().BeNull(); }); } @@ -262,7 +268,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { Game gameInDatabase = await dbContext.Games.Include(game => game.ActiveMap).FirstWithIdAsync(existingGame.Id); - gameInDatabase.Should().NotBeNull(); + gameInDatabase.ShouldNotBeNull(); + gameInDatabase.ActiveMap.ShouldNotBeNull(); gameInDatabase.ActiveMap.Id.Should().Be(Guid.Empty); }); } @@ -307,7 +314,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { Game gameInDatabase = await dbContext.Games.Include(game => game.ActiveMap).FirstWithIdAsync(existingGame.Id); - gameInDatabase.Should().NotBeNull(); + gameInDatabase.ShouldNotBeNull(); + gameInDatabase.ActiveMap.ShouldNotBeNull(); gameInDatabase.ActiveMap.Id.Should().Be(Guid.Empty); }); } @@ -346,7 +354,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { Game gameInDatabase = await dbContext.Games.Include(game => game.Maps).FirstWithIdAsync(existingGame.Id); - gameInDatabase.Should().NotBeNull(); + gameInDatabase.ShouldNotBeNull(); gameInDatabase.Maps.Should().BeEmpty(); }); } @@ -393,8 +401,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { Game gameInDatabase = await dbContext.Games.Include(game => game.Maps).FirstWithIdAsync(existingGame.Id); - gameInDatabase.Should().NotBeNull(); - gameInDatabase.Maps.Should().HaveCount(1); + gameInDatabase.ShouldNotBeNull(); + gameInDatabase.Maps.ShouldHaveCount(1); gameInDatabase.Maps.ElementAt(0).Id.Should().Be(Guid.Empty); }); } @@ -442,8 +450,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { Game gameInDatabase = await dbContext.Games.Include(game => game.Maps).FirstWithIdAsync(existingGame.Id); - gameInDatabase.Should().NotBeNull(); - gameInDatabase.Maps.Should().HaveCount(1); + gameInDatabase.ShouldNotBeNull(); + gameInDatabase.Maps.ShouldHaveCount(1); gameInDatabase.Maps.ElementAt(0).Id.Should().Be(Guid.Empty); }); } @@ -491,8 +499,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { Game gameInDatabase = await dbContext.Games.Include(game => game.Maps).FirstWithIdAsync(existingGame.Id); - gameInDatabase.Should().NotBeNull(); - gameInDatabase.Maps.Should().HaveCount(2); + gameInDatabase.ShouldNotBeNull(); + gameInDatabase.Maps.ShouldHaveCount(2); gameInDatabase.Maps.Should().ContainSingle(map => map.Id == Guid.Empty); }); } @@ -538,8 +546,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { Game gameInDatabase = await dbContext.Games.Include(game => game.Maps).FirstWithIdAsync(existingGame.Id); - gameInDatabase.Should().NotBeNull(); - gameInDatabase.Maps.Should().HaveCount(1); + gameInDatabase.ShouldNotBeNull(); + gameInDatabase.Maps.ShouldHaveCount(1); gameInDatabase.Maps.Should().ContainSingle(map => map.Id != Guid.Empty); }); } @@ -570,7 +578,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - Map gameInDatabase = await dbContext.Maps.FirstWithIdOrDefaultAsync(existingMap.Id); + Map? gameInDatabase = await dbContext.Maps.FirstWithIdOrDefaultAsync(existingMap.Id); gameInDatabase.Should().BeNull(); }); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/Game.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/Game.cs index eb67acb164..afe1365d16 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/Game.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/Game.cs @@ -11,19 +11,19 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ZeroKeys public sealed class Game : Identifiable<int?> { [Attr] - public string Title { get; set; } + public string Title { get; set; } = null!; [NotMapped] [Attr] public Guid SessionToken => Guid.NewGuid(); [HasMany] - public ICollection<Player> ActivePlayers { get; set; } + public ICollection<Player> ActivePlayers { get; set; } = new List<Player>(); [HasOne] - public Map ActiveMap { get; set; } + public Map? ActiveMap { get; set; } [HasMany] - public ICollection<Map> Maps { get; set; } + public ICollection<Map> Maps { get; set; } = new List<Map>(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/GamesController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/GamesController.cs index 21d9e8b7e7..8497e9c91e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/GamesController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/GamesController.cs @@ -7,8 +7,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ZeroKeys { public sealed class GamesController : JsonApiController<Game, int?> { - public GamesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<Game, int?> resourceService) - : base(options, loggerFactory, resourceService) + public GamesController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<Game, int?> resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/Map.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/Map.cs index 15b2c93491..bf17d26b1c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/Map.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/Map.cs @@ -9,9 +9,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ZeroKeys public sealed class Map : Identifiable<Guid?> { [Attr] - public string Name { get; set; } + public string Name { get; set; } = null!; [HasOne] - public Game Game { get; set; } + public Game? Game { get; set; } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/MapsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/MapsController.cs index 537de1425b..af38cf0a92 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/MapsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/MapsController.cs @@ -8,8 +8,8 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ZeroKeys { public sealed class MapsController : JsonApiController<Map, Guid?> { - public MapsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<Map, Guid?> resourceService) - : base(options, loggerFactory, resourceService) + public MapsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IResourceService<Map, Guid?> resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/Player.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/Player.cs index fec2c0cc08..d83d14ddc2 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/Player.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/Player.cs @@ -9,12 +9,12 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ZeroKeys public sealed class Player : Identifiable<string> { [Attr] - public string EmailAddress { get; set; } + public string EmailAddress { get; set; } = null!; [HasOne] - public Game ActiveGame { get; set; } + public Game? ActiveGame { get; set; } [HasMany] - public ICollection<Game> RecentlyPlayed { get; set; } + public ICollection<Game> RecentlyPlayed { get; set; } = new List<Game>(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/PlayersController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/PlayersController.cs index d22143619a..d9c9ebd302 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/PlayersController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/PlayersController.cs @@ -7,8 +7,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ZeroKeys { public sealed class PlayersController : JsonApiController<Player, string> { - public PlayersController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<Player, string> resourceService) - : base(options, loggerFactory, resourceService) + public PlayersController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<Player, string> resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/ZeroAsKeyTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/ZeroAsKeyTests.cs index 04133f348b..45b445e7f8 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/ZeroAsKeyTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/ZeroAsKeyTests.cs @@ -54,9 +54,14 @@ 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("0"); - responseDocument.Data.ManyValue[0].Links.Self.Should().Be("/games/0"); + + responseDocument.Data.ManyValue[0].With(resource => + { + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be("/games/0"); + }); } [Fact] @@ -82,11 +87,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("0"); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); responseDocument.Data.SingleValue.Links.Self.Should().Be("/games/0"); - responseDocument.Included.Should().HaveCount(1); + responseDocument.Included.ShouldHaveCount(1); responseDocument.Included[0].Id.Should().Be(game.ActivePlayers.ElementAt(0).StringId); } @@ -124,14 +130,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => httpResponse.Headers.Location.Should().Be("/games/0"); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Id.Should().Be("0"); await _testContext.RunOnDatabaseAsync(async dbContext => { Game gameInDatabase = await dbContext.Games.FirstWithIdAsync((int?)0); - gameInDatabase.Should().NotBeNull(); + gameInDatabase.ShouldNotBeNull(); gameInDatabase.Title.Should().Be(newTitle); }); } @@ -173,15 +179,15 @@ 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("0"); - responseDocument.Data.SingleValue.Attributes["title"].Should().Be(newTitle); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTitle)); await _testContext.RunOnDatabaseAsync(async dbContext => { Game gameInDatabase = await dbContext.Games.FirstWithIdAsync((int?)0); - gameInDatabase.Should().NotBeNull(); + gameInDatabase.ShouldNotBeNull(); gameInDatabase.Title.Should().Be(newTitle); }); } @@ -203,7 +209,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var requestBody = new { - data = (object)null + data = (object?)null }; string route = $"/players/{existingPlayer.StringId}/relationships/activeGame"; @@ -220,7 +226,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { Player playerInDatabase = await dbContext.Players.Include(player => player.ActiveGame).FirstWithIdAsync(existingPlayer.Id); - playerInDatabase.Should().NotBeNull(); + playerInDatabase.ShouldNotBeNull(); playerInDatabase.ActiveGame.Should().BeNull(); }); } @@ -264,7 +270,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { Player playerInDatabase = await dbContext.Players.Include(player => player.ActiveGame).FirstWithIdAsync(existingPlayer.Id); - playerInDatabase.Should().NotBeNull(); + playerInDatabase.ShouldNotBeNull(); + playerInDatabase.ActiveGame.ShouldNotBeNull(); playerInDatabase.ActiveGame.Id.Should().Be(0); }); } @@ -309,7 +316,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { Player playerInDatabase = await dbContext.Players.Include(player => player.ActiveGame).FirstWithIdAsync(existingPlayer.Id); - playerInDatabase.Should().NotBeNull(); + playerInDatabase.ShouldNotBeNull(); + playerInDatabase.ActiveGame.ShouldNotBeNull(); playerInDatabase.ActiveGame.Id.Should().Be(0); }); } @@ -348,7 +356,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { Player playerInDatabase = await dbContext.Players.Include(player => player.RecentlyPlayed).FirstWithIdAsync(existingPlayer.Id); - playerInDatabase.Should().NotBeNull(); + playerInDatabase.ShouldNotBeNull(); playerInDatabase.RecentlyPlayed.Should().BeEmpty(); }); } @@ -395,8 +403,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { Player playerInDatabase = await dbContext.Players.Include(player => player.RecentlyPlayed).FirstWithIdAsync(existingPlayer.Id); - playerInDatabase.Should().NotBeNull(); - playerInDatabase.RecentlyPlayed.Should().HaveCount(1); + playerInDatabase.ShouldNotBeNull(); + playerInDatabase.RecentlyPlayed.ShouldHaveCount(1); playerInDatabase.RecentlyPlayed.ElementAt(0).Id.Should().Be(0); }); } @@ -444,8 +452,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { Player playerInDatabase = await dbContext.Players.Include(player => player.RecentlyPlayed).FirstWithIdAsync(existingPlayer.Id); - playerInDatabase.Should().NotBeNull(); - playerInDatabase.RecentlyPlayed.Should().HaveCount(1); + playerInDatabase.ShouldNotBeNull(); + playerInDatabase.RecentlyPlayed.ShouldHaveCount(1); playerInDatabase.RecentlyPlayed.ElementAt(0).Id.Should().Be(0); }); } @@ -493,8 +501,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { Player playerInDatabase = await dbContext.Players.Include(player => player.RecentlyPlayed).FirstWithIdAsync(existingPlayer.Id); - playerInDatabase.Should().NotBeNull(); - playerInDatabase.RecentlyPlayed.Should().HaveCount(2); + playerInDatabase.ShouldNotBeNull(); + playerInDatabase.RecentlyPlayed.ShouldHaveCount(2); playerInDatabase.RecentlyPlayed.Should().ContainSingle(game => game.Id == 0); }); } @@ -540,8 +548,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { Player playerInDatabase = await dbContext.Players.Include(player => player.RecentlyPlayed).FirstWithIdAsync(existingPlayer.Id); - playerInDatabase.Should().NotBeNull(); - playerInDatabase.RecentlyPlayed.Should().HaveCount(1); + playerInDatabase.ShouldNotBeNull(); + playerInDatabase.RecentlyPlayed.ShouldHaveCount(1); playerInDatabase.RecentlyPlayed.Should().ContainSingle(game => game.Id != 0); }); } @@ -572,7 +580,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - Game gameInDatabase = await dbContext.Games.FirstWithIdOrDefaultAsync(existingGame.Id); + Game? gameInDatabase = await dbContext.Games.FirstWithIdOrDefaultAsync(existingGame.Id); gameInDatabase.Should().BeNull(); }); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/ZeroKeyDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/ZeroKeyDbContext.cs index b9a32cbad7..051e08f4d0 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/ZeroKeyDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/ZeroKeyDbContext.cs @@ -8,9 +8,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ZeroKeys [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class ZeroKeyDbContext : DbContext { - public DbSet<Game> Games { get; set; } - public DbSet<Player> Players { get; set; } - public DbSet<Map> Maps { get; set; } + public DbSet<Game> Games => Set<Game>(); + public DbSet<Player> Players => Set<Player>(); + public DbSet<Map> Maps => Set<Map>(); public ZeroKeyDbContext(DbContextOptions<ZeroKeyDbContext> options) : base(options) @@ -21,11 +21,11 @@ protected override void OnModelCreating(ModelBuilder builder) { builder.Entity<Game>() .HasMany(game => game.Maps) - .WithOne(map => map.Game); + .WithOne(map => map.Game!); builder.Entity<Player>() .HasOne(player => player.ActiveGame) - .WithMany(game => game.ActivePlayers); + .WithMany(game => game!.ActivePlayers); } } } diff --git a/test/JsonApiDotNetCoreTests/JsonApiDotNetCoreTests.csproj b/test/JsonApiDotNetCoreTests/JsonApiDotNetCoreTests.csproj index efb9e23173..742c8a7674 100644 --- a/test/JsonApiDotNetCoreTests/JsonApiDotNetCoreTests.csproj +++ b/test/JsonApiDotNetCoreTests/JsonApiDotNetCoreTests.csproj @@ -18,6 +18,7 @@ <PackageReference Include="Macross.Json.Extensions" Version="2.0.0" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="$(AspNetCoreVersion)" /> <PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="$(EFCoreVersion)" /> + <PackageReference Include="Microsoft.EntityFrameworkCore.Proxies" Version="$(EFCoreVersion)" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="$(TestSdkVersion)" /> </ItemGroup> </Project> diff --git a/test/JsonApiDotNetCoreTests/Startups/ModelStateValidationStartup.cs b/test/JsonApiDotNetCoreTests/Startups/NoModelStateValidationStartup.cs similarity index 75% rename from test/JsonApiDotNetCoreTests/Startups/ModelStateValidationStartup.cs rename to test/JsonApiDotNetCoreTests/Startups/NoModelStateValidationStartup.cs index 374392ce5c..8ec7e856a0 100644 --- a/test/JsonApiDotNetCoreTests/Startups/ModelStateValidationStartup.cs +++ b/test/JsonApiDotNetCoreTests/Startups/NoModelStateValidationStartup.cs @@ -6,14 +6,14 @@ namespace JsonApiDotNetCoreTests.Startups { [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class ModelStateValidationStartup<TDbContext> : TestableStartup<TDbContext> + public sealed class NoModelStateValidationStartup<TDbContext> : TestableStartup<TDbContext> where TDbContext : DbContext { protected override void SetJsonApiOptions(JsonApiOptions options) { base.SetJsonApiOptions(options); - options.ValidateModelState = true; + options.ValidateModelState = false; } } } diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Configuration/DependencyContainerRegistrationTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/Configuration/DependencyContainerRegistrationTests.cs index cc2d9566b0..eb957266d3 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/Configuration/DependencyContainerRegistrationTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/Configuration/DependencyContainerRegistrationTests.cs @@ -44,8 +44,8 @@ public void Cannot_resolve_registered_services_with_conflicting_scopes() }; // Assert - action.Should().ThrowExactly<AggregateException>().WithMessage("Some services are not able to be constructed * " + - "Singleton * Cannot consume scoped service *"); + action.Should().ThrowExactly<AggregateException>() + .WithMessage("Some services are not able to be constructed * Singleton * Cannot consume scoped service *"); } [Fact] @@ -67,8 +67,8 @@ public void Cannot_resolve_registered_services_with_circular_dependency() }; // Assert - action.Should().ThrowExactly<AggregateException>().WithMessage("Some services are not able to be constructed * " + - "A circular dependency was detected *"); + action.Should().ThrowExactly<AggregateException>() + .WithMessage("Some services are not able to be constructed * A circular dependency was detected *"); } private static IHostBuilder CreateValidatingHostBuilder() @@ -128,7 +128,7 @@ public CircularServiceB(CircularServiceA serviceA) [UsedImplicitly(ImplicitUseTargetFlags.Members)] private sealed class DependencyContainerRegistrationDbContext : DbContext { - public DbSet<Resource> Resources { get; set; } + public DbSet<Resource> Resources => Set<Resource>(); public DependencyContainerRegistrationDbContext(DbContextOptions<DependencyContainerRegistrationDbContext> options) : base(options) @@ -137,10 +137,10 @@ public DependencyContainerRegistrationDbContext(DbContextOptions<DependencyConta } [UsedImplicitly(ImplicitUseTargetFlags.Members)] - private sealed class Resource : Identifiable + private sealed class Resource : Identifiable<int> { [Attr] - public string Field { get; set; } + public string? Field { get; set; } } } } diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Links/LinkInclusionTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/Links/LinkInclusionTests.cs index deceb19902..d834d8e11e 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/Links/LinkInclusionTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/Links/LinkInclusionTests.cs @@ -1,15 +1,15 @@ using System; using FluentAssertions; -using JsonApiDotNetCore; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCore.Serialization.Building; using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCore.Serialization.Response; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreTests.UnitTests.Links @@ -53,11 +53,10 @@ public sealed class LinkInclusionTests [InlineData(LinkTypes.All, LinkTypes.Related, LinkTypes.All)] [InlineData(LinkTypes.All, LinkTypes.Paging, LinkTypes.All)] [InlineData(LinkTypes.All, LinkTypes.All, LinkTypes.All)] - public void Applies_cascading_settings_for_top_level_links(LinkTypes linksInResourceContext, LinkTypes linksInOptions, LinkTypes expected) + public void Applies_cascading_settings_for_top_level_links(LinkTypes linksInResourceType, LinkTypes linksInOptions, LinkTypes expected) { // Arrange - var exampleResourceContext = new ResourceContext(nameof(ExampleResource), typeof(ExampleResource), typeof(int), Array.Empty<AttrAttribute>(), - Array.Empty<RelationshipAttribute>(), Array.Empty<EagerLoadAttribute>(), linksInResourceContext); + var exampleResourceType = new ResourceType(nameof(ExampleResource), typeof(ExampleResource), typeof(int), topLevelLinks: linksInResourceType); var options = new JsonApiOptions { @@ -66,7 +65,7 @@ public void Applies_cascading_settings_for_top_level_links(LinkTypes linksInReso var request = new JsonApiRequest { - PrimaryResource = exampleResourceContext, + PrimaryResourceType = exampleResourceType, PrimaryId = "1", IsCollection = true, Kind = EndpointKind.Relationship, @@ -80,16 +79,13 @@ public void Applies_cascading_settings_for_top_level_links(LinkTypes linksInReso TotalResourceCount = 10 }; - var resourceGraph = new ResourceGraph(exampleResourceContext.AsHashSet()); var httpContextAccessor = new FakeHttpContextAccessor(); var linkGenerator = new FakeLinkGenerator(); var controllerResourceMapping = new FakeControllerResourceMapping(); - - var linkBuilder = new LinkBuilder(options, request, paginationContext, resourceGraph, httpContextAccessor, linkGenerator, - controllerResourceMapping); + var linkBuilder = new LinkBuilder(options, request, paginationContext, httpContextAccessor, linkGenerator, controllerResourceMapping); // Act - TopLevelLinks topLevelLinks = linkBuilder.GetTopLevelLinks(); + TopLevelLinks? topLevelLinks = linkBuilder.GetTopLevelLinks(); // Assert if (expected == LinkTypes.None) @@ -98,9 +94,11 @@ public void Applies_cascading_settings_for_top_level_links(LinkTypes linksInReso } else { + topLevelLinks.ShouldNotBeNull(); + if (expected.HasFlag(LinkTypes.Self)) { - topLevelLinks.Self.Should().NotBeNull(); + topLevelLinks.Self.ShouldNotBeNull(); } else { @@ -109,7 +107,7 @@ public void Applies_cascading_settings_for_top_level_links(LinkTypes linksInReso if (expected.HasFlag(LinkTypes.Related)) { - topLevelLinks.Related.Should().NotBeNull(); + topLevelLinks.Related.ShouldNotBeNull(); } else { @@ -118,10 +116,10 @@ public void Applies_cascading_settings_for_top_level_links(LinkTypes linksInReso if (expected.HasFlag(LinkTypes.Paging)) { - topLevelLinks.First.Should().NotBeNull(); - topLevelLinks.Last.Should().NotBeNull(); - topLevelLinks.Prev.Should().NotBeNull(); - topLevelLinks.Next.Should().NotBeNull(); + topLevelLinks.First.ShouldNotBeNull(); + topLevelLinks.Last.ShouldNotBeNull(); + topLevelLinks.Prev.ShouldNotBeNull(); + topLevelLinks.Next.ShouldNotBeNull(); } else { @@ -150,11 +148,10 @@ public void Applies_cascading_settings_for_top_level_links(LinkTypes linksInReso [InlineData(LinkTypes.All, LinkTypes.None, LinkTypes.Self)] [InlineData(LinkTypes.All, LinkTypes.Self, LinkTypes.Self)] [InlineData(LinkTypes.All, LinkTypes.All, LinkTypes.Self)] - public void Applies_cascading_settings_for_resource_links(LinkTypes linksInResourceContext, LinkTypes linksInOptions, LinkTypes expected) + public void Applies_cascading_settings_for_resource_links(LinkTypes linksInResourceType, LinkTypes linksInOptions, LinkTypes expected) { // Arrange - var exampleResourceContext = new ResourceContext(nameof(ExampleResource), typeof(ExampleResource), typeof(int), Array.Empty<AttrAttribute>(), - Array.Empty<RelationshipAttribute>(), Array.Empty<EagerLoadAttribute>(), resourceLinks: linksInResourceContext); + var exampleResourceType = new ResourceType(nameof(ExampleResource), typeof(ExampleResource), typeof(int), resourceLinks: linksInResourceType); var options = new JsonApiOptions { @@ -163,21 +160,19 @@ public void Applies_cascading_settings_for_resource_links(LinkTypes linksInResou var request = new JsonApiRequest(); var paginationContext = new PaginationContext(); - var resourceGraph = new ResourceGraph(exampleResourceContext.AsHashSet()); var httpContextAccessor = new FakeHttpContextAccessor(); var linkGenerator = new FakeLinkGenerator(); var controllerResourceMapping = new FakeControllerResourceMapping(); - - var linkBuilder = new LinkBuilder(options, request, paginationContext, resourceGraph, httpContextAccessor, linkGenerator, - controllerResourceMapping); + var linkBuilder = new LinkBuilder(options, request, paginationContext, httpContextAccessor, linkGenerator, controllerResourceMapping); // Act - ResourceLinks resourceLinks = linkBuilder.GetResourceLinks(nameof(ExampleResource), "id"); + ResourceLinks? resourceLinks = linkBuilder.GetResourceLinks(exampleResourceType, new ExampleResource()); // Assert if (expected == LinkTypes.Self) { - resourceLinks.Self.Should().NotBeNull(); + resourceLinks.ShouldNotBeNull(); + resourceLinks.Self.ShouldNotBeNull(); } else { @@ -311,12 +306,11 @@ public void Applies_cascading_settings_for_resource_links(LinkTypes linksInResou [InlineData(LinkTypes.All, LinkTypes.All, LinkTypes.Self, LinkTypes.All)] [InlineData(LinkTypes.All, LinkTypes.All, LinkTypes.Related, LinkTypes.All)] [InlineData(LinkTypes.All, LinkTypes.All, LinkTypes.All, LinkTypes.All)] - public void Applies_cascading_settings_for_relationship_links(LinkTypes linksInRelationshipAttribute, LinkTypes linksInResourceContext, + public void Applies_cascading_settings_for_relationship_links(LinkTypes linksInRelationshipAttribute, LinkTypes linksInResourceType, LinkTypes linksInOptions, LinkTypes expected) { // Arrange - var exampleResourceContext = new ResourceContext(nameof(ExampleResource), typeof(ExampleResource), typeof(int), Array.Empty<AttrAttribute>(), - Array.Empty<RelationshipAttribute>(), Array.Empty<EagerLoadAttribute>(), relationshipLinks: linksInResourceContext); + var exampleResourceType = new ResourceType(nameof(ExampleResource), typeof(ExampleResource), typeof(int), relationshipLinks: linksInResourceType); var options = new JsonApiOptions { @@ -325,21 +319,19 @@ public void Applies_cascading_settings_for_relationship_links(LinkTypes linksInR var request = new JsonApiRequest(); var paginationContext = new PaginationContext(); - var resourceGraph = new ResourceGraph(exampleResourceContext.AsHashSet()); var httpContextAccessor = new FakeHttpContextAccessor(); var linkGenerator = new FakeLinkGenerator(); var controllerResourceMapping = new FakeControllerResourceMapping(); - - var linkBuilder = new LinkBuilder(options, request, paginationContext, resourceGraph, httpContextAccessor, linkGenerator, - controllerResourceMapping); + var linkBuilder = new LinkBuilder(options, request, paginationContext, httpContextAccessor, linkGenerator, controllerResourceMapping); var relationship = new HasOneAttribute { - Links = linksInRelationshipAttribute + Links = linksInRelationshipAttribute, + LeftType = exampleResourceType }; // Act - RelationshipLinks relationshipLinks = linkBuilder.GetRelationshipLinks(relationship, new ExampleResource()); + RelationshipLinks? relationshipLinks = linkBuilder.GetRelationshipLinks(relationship, new ExampleResource()); // Assert if (expected == LinkTypes.None) @@ -348,9 +340,11 @@ public void Applies_cascading_settings_for_relationship_links(LinkTypes linksInR } else { + relationshipLinks.ShouldNotBeNull(); + if (expected.HasFlag(LinkTypes.Self)) { - relationshipLinks.Self.Should().NotBeNull(); + relationshipLinks.Self.ShouldNotBeNull(); } else { @@ -359,7 +353,7 @@ public void Applies_cascading_settings_for_relationship_links(LinkTypes linksInR if (expected.HasFlag(LinkTypes.Related)) { - relationshipLinks.Related.Should().NotBeNull(); + relationshipLinks.Related.ShouldNotBeNull(); } else { @@ -368,13 +362,13 @@ public void Applies_cascading_settings_for_relationship_links(LinkTypes linksInR } } - private sealed class ExampleResource : Identifiable + private sealed class ExampleResource : Identifiable<int> { } private sealed class FakeHttpContextAccessor : IHttpContextAccessor { - public HttpContext HttpContext { get; set; } = new DefaultHttpContext + public HttpContext? HttpContext { get; set; } = new DefaultHttpContext { Request = { @@ -386,12 +380,12 @@ private sealed class FakeHttpContextAccessor : IHttpContextAccessor private sealed class FakeControllerResourceMapping : IControllerResourceMapping { - public Type GetResourceTypeForController(Type controllerType) + public ResourceType GetResourceTypeForController(Type? controllerType) { throw new NotImplementedException(); } - public string GetControllerNameForResourceType(Type resourceType) + public string? GetControllerNameForResourceType(ResourceType? resourceType) { return null; } @@ -400,26 +394,26 @@ public string GetControllerNameForResourceType(Type resourceType) private sealed class FakeLinkGenerator : LinkGenerator { public override string GetPathByAddress<TAddress>(HttpContext httpContext, TAddress address, RouteValueDictionary values, - RouteValueDictionary ambientValues = null, PathString? pathBase = null, FragmentString fragment = new(), LinkOptions options = null) + RouteValueDictionary? ambientValues = null, PathString? pathBase = null, FragmentString fragment = new(), LinkOptions? options = null) { throw new NotImplementedException(); } public override string GetPathByAddress<TAddress>(TAddress address, RouteValueDictionary values, PathString pathBase = new(), - FragmentString fragment = new(), LinkOptions options = null) + FragmentString fragment = new(), LinkOptions? options = null) { throw new NotImplementedException(); } public override string GetUriByAddress<TAddress>(HttpContext httpContext, TAddress address, RouteValueDictionary values, - RouteValueDictionary ambientValues = null, string scheme = null, HostString? host = null, PathString? pathBase = null, - FragmentString fragment = new(), LinkOptions options = null) + RouteValueDictionary? ambientValues = null, string? scheme = null, HostString? host = null, PathString? pathBase = null, + FragmentString fragment = new(), LinkOptions? options = null) { return "https://domain.com/some/path"; } - public override string GetUriByAddress<TAddress>(TAddress address, RouteValueDictionary values, string scheme, HostString host, - PathString pathBase = new(), FragmentString fragment = new(), LinkOptions options = null) + public override string GetUriByAddress<TAddress>(TAddress address, RouteValueDictionary values, string? scheme, HostString host, + PathString pathBase = new(), FragmentString fragment = new(), LinkOptions? options = null) { throw new NotImplementedException(); } diff --git a/test/JsonApiDotNetCoreTests/UnitTests/ModelStateValidation/ModelStateValidationTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/ModelStateValidation/ModelStateValidationTests.cs new file mode 100644 index 0000000000..efb63a856b --- /dev/null +++ b/test/JsonApiDotNetCoreTests/UnitTests/ModelStateValidation/ModelStateValidationTests.cs @@ -0,0 +1,160 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; +using FluentAssertions; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.Extensions.Logging.Abstractions; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.UnitTests.ModelStateValidation +{ + public sealed class ModelStateValidationTests + { + [Theory] + [InlineData("", null)] + [InlineData("NotMappedInParent", null)] + [InlineData("Id", "/data/id")] + [InlineData("One", "/data/attributes/publicNameOfOne")] + [InlineData("ComplexObject", "/data/attributes/publicNameOfComplexObject")] + [InlineData("ComplexObject.First", "/data/attributes/publicNameOfComplexObject/jsonFirst")] + [InlineData("ComplexObject.ParentObject.First", "/data/attributes/publicNameOfComplexObject/jsonParentObject/jsonFirst")] + [InlineData("ComplexObject.Elements[0]", "/data/attributes/publicNameOfComplexObject/jsonElements[0]")] + [InlineData("ComplexObject.Elements[0].First", "/data/attributes/publicNameOfComplexObject/jsonElements[0]/jsonFirst")] + [InlineData("[ComplexObject][Elements][0][First]", "/data/attributes/publicNameOfComplexObject/jsonElements[0]/jsonFirst")] + [InlineData("ComplexList", "/data/attributes/publicNameOfComplexList")] + [InlineData("ComplexList[0].First", "/data/attributes/publicNameOfComplexList[0]/jsonFirst")] + [InlineData("PrimaryChild", "/data/relationships/publicNameOfPrimaryChild/data")] + [InlineData("PrimaryChild.NotMappedInChild", null)] + [InlineData("PrimaryChild.Id", "/data/relationships/publicNameOfPrimaryChild/data/id")] + [InlineData("Children[0]", "/data/relationships/publicNameOfChildren/data[0]")] + [InlineData("Children[0].NotMappedInChild", null)] + [InlineData("Children[0].Id", "/data/relationships/publicNameOfChildren/data[0]/id")] + public void Renders_JSON_path_for_ModelState_key_in_resource_request(string modelStateKey, string? expectedJsonPath) + { + // Arrange + var options = new JsonApiOptions(); + IResourceGraph resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add<Parent, int>().Add<Child, int>().Build(); + + var modelState = new ModelStateDictionary(); + modelState.AddModelError(modelStateKey, "(ignored error message)"); + + // Act + var exception = new InvalidModelStateException(modelState, typeof(Parent), false, resourceGraph); + + // Assert + exception.Errors.ShouldHaveCount(1); + + if (expectedJsonPath == null) + { + exception.Errors[0].Source.Should().BeNull(); + } + else + { + exception.Errors[0].Source.ShouldNotBeNull().With(value => value.Pointer.Should().Be(expectedJsonPath)); + } + } + + [Theory] + [InlineData("[0]", "/atomic:operations[0]")] + [InlineData("[0].Resource", null)] + [InlineData("[0].Resource.NotMappedInParent", null)] + [InlineData("[0].Resource.Id", "/atomic:operations[0]/data/id")] + [InlineData("[0].Resource.One", "/atomic:operations[0]/data/attributes/publicNameOfOne")] + [InlineData("[0].Resource.ComplexObject", "/atomic:operations[0]/data/attributes/publicNameOfComplexObject")] + [InlineData("[0].Resource.ComplexObject.First", "/atomic:operations[0]/data/attributes/publicNameOfComplexObject/jsonFirst")] + [InlineData("[0].Resource.ComplexObject.ParentObject.First", + "/atomic:operations[0]/data/attributes/publicNameOfComplexObject/jsonParentObject/jsonFirst")] + [InlineData("[0].Resource.ComplexObject.Elements[0]", "/atomic:operations[0]/data/attributes/publicNameOfComplexObject/jsonElements[0]")] + [InlineData("[0].Resource.ComplexObject.Elements[0].First", + "/atomic:operations[0]/data/attributes/publicNameOfComplexObject/jsonElements[0]/jsonFirst")] + [InlineData("[0][Resource][ComplexObject][Elements][0][First]", + "/atomic:operations[0]/data/attributes/publicNameOfComplexObject/jsonElements[0]/jsonFirst")] + [InlineData("[0].Resource.ComplexList", "/atomic:operations[0]/data/attributes/publicNameOfComplexList")] + [InlineData("[0].Resource.ComplexList[0].First", "/atomic:operations[0]/data/attributes/publicNameOfComplexList[0]/jsonFirst")] + [InlineData("[0].Resource.PrimaryChild", "/atomic:operations[0]/data/relationships/publicNameOfPrimaryChild/data")] + [InlineData("[0].Resource.PrimaryChild.NotMappedInChild", null)] + [InlineData("[0].Resource.PrimaryChild.Id", "/atomic:operations[0]/data/relationships/publicNameOfPrimaryChild/data/id")] + [InlineData("[0].Resource.Children[0]", "/atomic:operations[0]/data/relationships/publicNameOfChildren/data[0]")] + [InlineData("[0].Resource.Children[0].NotMappedInChild", null)] + [InlineData("[0].Resource.Children[0].Id", "/atomic:operations[0]/data/relationships/publicNameOfChildren/data[0]/id")] + public void Renders_JSON_path_for_ModelState_key_in_operations_request(string modelStateKey, string? expectedJsonPath) + { + // Arrange + var options = new JsonApiOptions(); + IResourceGraph resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add<Parent, int>().Add<Child, int>().Build(); + + var modelState = new ModelStateDictionary(); + modelState.AddModelError(modelStateKey, "(ignored error message)"); + + Func<Type, int, Type?> getOperationTypeCallback = (collectionType, _) => + collectionType == typeof(IList<OperationContainer>) ? typeof(Parent) : null; + + // Act + var exception = new InvalidModelStateException(modelState, typeof(IList<OperationContainer>), false, resourceGraph, getOperationTypeCallback); + + // Assert + exception.Errors.ShouldHaveCount(1); + + if (expectedJsonPath == null) + { + exception.Errors[0].Source.Should().BeNull(); + } + else + { + exception.Errors[0].Source.ShouldNotBeNull().With(value => value.Pointer.Should().Be(expectedJsonPath)); + } + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + private sealed class Parent : Identifiable<int> + { + public string? NotMappedInParent { get; set; } + + [Attr(PublicName = "publicNameOfOne")] + public string? One { get; set; } + + [Attr(PublicName = "publicNameOfComplexObject")] + public ComplexObject? ComplexObject { get; set; } + + [Attr(PublicName = "publicNameOfComplexList")] + public IList<ComplexObject> ComplexList { get; set; } = null!; + + [HasOne(PublicName = "publicNameOfPrimaryChild")] + public Child? PrimaryChild { get; set; } + + [HasMany(PublicName = "publicNameOfChildren")] + public ISet<Child> Children { get; set; } = null!; + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + private sealed class Child : Identifiable<int> + { + public string? NotMappedInChild { get; set; } + + [Attr(PublicName = "publicNameOfTwo")] + public string? Two { get; set; } + + [HasOne(PublicName = "publicNameOfParent")] + public Parent? Parent { get; set; } + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + private sealed class ComplexObject + { + [JsonPropertyName("jsonFirst")] + public string? First { get; set; } + + [JsonPropertyName("jsonParentObject")] + public ComplexObject? ParentObject { get; set; } + + [JsonPropertyName("jsonElements")] + public IList<ComplexObject> Elements { get; set; } = null!; + } + } +} diff --git a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/BaseParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/BaseParseTests.cs index 9d55b6fe3c..c0929a162f 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/BaseParseTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/BaseParseTests.cs @@ -19,12 +19,13 @@ protected BaseParseTests() // @formatter:keep_existing_linebreaks true ResourceGraph = new ResourceGraphBuilder(Options, NullLoggerFactory.Instance) - .Add<Blog>() - .Add<BlogPost>() - .Add<Label>() - .Add<Comment>() - .Add<WebAccount>() - .Add<AccountPreferences>() + .Add<Blog, int>() + .Add<BlogPost, int>() + .Add<Label, int>() + .Add<Comment, int>() + .Add<WebAccount, int>() + .Add<AccountPreferences, int>() + .Add<LoginAttempt, int>() .Build(); // @formatter:wrap_chained_method_calls restore @@ -32,7 +33,7 @@ protected BaseParseTests() Request = new JsonApiRequest { - PrimaryResource = ResourceGraph.GetResourceContext<Blog>(), + PrimaryResourceType = ResourceGraph.GetResourceType<Blog>(), IsCollection = true }; } diff --git a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/FilterParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/FilterParseTests.cs index b628a06167..4ac19a9b68 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/FilterParseTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/FilterParseTests.cs @@ -11,6 +11,8 @@ using JsonApiDotNetCore.QueryStrings; using JsonApiDotNetCore.QueryStrings.Internal; using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreTests.UnitTests.QueryStringParameters @@ -58,14 +60,15 @@ public void Reader_Is_Enabled(JsonApiQueryStringParameters parametersDisabled, b [Theory] [InlineData("filter[", "equals(caption,'some')", "Field name expected.")] - [InlineData("filter[caption]", "equals(url,'some')", "Relationship 'caption' does not exist on resource 'blogs'.")] - [InlineData("filter[posts.caption]", "equals(firstName,'some')", "Relationship 'caption' in 'posts.caption' does not exist on resource 'blogPosts'.")] + [InlineData("filter[caption]", "equals(url,'some')", "Relationship 'caption' does not exist on resource type 'blogs'.")] + [InlineData("filter[posts.caption]", "equals(firstName,'some')", + "Relationship 'caption' in 'posts.caption' does not exist on resource type 'blogPosts'.")] [InlineData("filter[posts.author]", "equals(firstName,'some')", - "Relationship 'author' in 'posts.author' must be a to-many relationship on resource 'blogPosts'.")] + "Relationship 'author' in 'posts.author' must be a to-many relationship on resource type 'blogPosts'.")] [InlineData("filter[posts.comments.author]", "equals(firstName,'some')", - "Relationship 'author' in 'posts.comments.author' must be a to-many relationship on resource 'comments'.")] - [InlineData("filter[posts]", "equals(author,'some')", "Attribute 'author' does not exist on resource 'blogPosts'.")] - [InlineData("filter[posts]", "lessThan(author,null)", "Attribute 'author' does not exist on resource 'blogPosts'.")] + "Relationship 'author' in 'posts.comments.author' must be a to-many relationship on resource type 'comments'.")] + [InlineData("filter[posts]", "equals(author,'some')", "Attribute 'author' does not exist on resource type 'blogPosts'.")] + [InlineData("filter[posts]", "lessThan(author,null)", "Attribute 'author' does not exist on resource type 'blogPosts'.")] [InlineData("filter", " ", "Unexpected whitespace.")] [InlineData("filter", "contains(owner.displayName, )", "Unexpected whitespace.")] [InlineData("filter", "some", "Filter function expected.")] @@ -76,19 +79,19 @@ public void Reader_Is_Enabled(JsonApiQueryStringParameters parametersDisabled, b [InlineData("filter", "equals(count(posts),", "Count function, value between quotes, null or field name expected.")] [InlineData("filter", "equals(title,')", "' expected.")] [InlineData("filter", "equals(title,null", ") expected.")] - [InlineData("filter", "equals(null", "Field 'null' does not exist on resource 'blogs'.")] + [InlineData("filter", "equals(null", "Field 'null' does not exist on resource type 'blogs'.")] [InlineData("filter", "equals(title,(", "Count function, value between quotes, null or field name expected.")] - [InlineData("filter", "equals(has(posts),'true')", "Field 'has' does not exist on resource 'blogs'.")] + [InlineData("filter", "equals(has(posts),'true')", "Field 'has' does not exist on resource type 'blogs'.")] [InlineData("filter", "has(posts,", "Filter function expected.")] [InlineData("filter", "contains)", "( expected.")] [InlineData("filter", "contains(title,'a','b')", ") expected.")] [InlineData("filter", "contains(title,null)", "Value between quotes expected.")] - [InlineData("filter[posts]", "contains(author,null)", "Attribute 'author' does not exist on resource 'blogPosts'.")] - [InlineData("filter", "any(null,'a','b')", "Attribute 'null' does not exist on resource 'blogs'.")] + [InlineData("filter[posts]", "contains(author,null)", "Attribute 'author' does not exist on resource type 'blogPosts'.")] + [InlineData("filter", "any(null,'a','b')", "Attribute 'null' does not exist on resource type 'blogs'.")] [InlineData("filter", "any('a','b','c')", "Field name expected.")] [InlineData("filter", "any(title,'b','c',)", "Value between quotes expected.")] [InlineData("filter", "any(title,'b')", ", expected.")] - [InlineData("filter[posts]", "any(author,'a','b')", "Attribute 'author' does not exist on resource 'blogPosts'.")] + [InlineData("filter[posts]", "any(author,'a','b')", "Attribute 'author' does not exist on resource type 'blogPosts'.")] [InlineData("filter", "and(", "Filter function expected.")] [InlineData("filter", "or(equals(title,'some'),equals(title,'other')", ") expected.")] [InlineData("filter", "or(equals(title,'some'),equals(title,'other')))", "End of expression expected.")] @@ -104,12 +107,15 @@ public void Reader_Read_Fails(string parameterName, string parameterValue, strin // Assert InvalidQueryStringParameterException exception = action.Should().ThrowExactly<InvalidQueryStringParameterException>().And; - exception.QueryParameterName.Should().Be(parameterName); - exception.Errors.Should().HaveCount(1); - exception.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); - exception.Errors[0].Title.Should().Be("The specified filter is invalid."); - exception.Errors[0].Detail.Should().Be(errorMessage); - exception.Errors[0].Source.Parameter.Should().Be(parameterName); + exception.ParameterName.Should().Be(parameterName); + exception.Errors.ShouldHaveCount(1); + + ErrorObject error = exception.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified filter is invalid."); + error.Detail.Should().Be(errorMessage); + error.Source.ShouldNotBeNull(); + error.Source.Parameter.Should().Be(parameterName); } [Theory] @@ -148,7 +154,7 @@ public void Reader_Read_Succeeds(string parameterName, string parameterValue, st IReadOnlyCollection<ExpressionInScope> constraints = _reader.GetConstraints(); // Assert - ResourceFieldChainExpression scope = constraints.Select(expressionInScope => expressionInScope.Scope).Single(); + ResourceFieldChainExpression? scope = constraints.Select(expressionInScope => expressionInScope.Scope).Single(); scope?.ToString().Should().Be(scopeExpected); QueryExpression value = constraints.Select(expressionInScope => expressionInScope.Expression).Single(); diff --git a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/IncludeParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/IncludeParseTests.cs index 30af87ab5c..1873e60e30 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/IncludeParseTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/IncludeParseTests.cs @@ -10,6 +10,8 @@ using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.QueryStrings; using JsonApiDotNetCore.QueryStrings.Internal; +using JsonApiDotNetCore.Serialization.Objects; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreTests.UnitTests.QueryStringParameters @@ -56,9 +58,9 @@ public void Reader_Is_Enabled(JsonApiQueryStringParameters parametersDisabled, b [InlineData("includes", ",", "Relationship name expected.")] [InlineData("includes", "posts,", "Relationship name expected.")] [InlineData("includes", "posts[", ", expected.")] - [InlineData("includes", "title", "Relationship 'title' does not exist on resource 'blogs'.")] + [InlineData("includes", "title", "Relationship 'title' does not exist on resource type 'blogs'.")] [InlineData("includes", "posts.comments.publishTime,", - "Relationship 'publishTime' in 'posts.comments.publishTime' does not exist on resource 'comments'.")] + "Relationship 'publishTime' in 'posts.comments.publishTime' does not exist on resource type 'comments'.")] public void Reader_Read_Fails(string parameterName, string parameterValue, string errorMessage) { // Act @@ -67,12 +69,15 @@ public void Reader_Read_Fails(string parameterName, string parameterValue, strin // Assert InvalidQueryStringParameterException exception = action.Should().ThrowExactly<InvalidQueryStringParameterException>().And; - exception.QueryParameterName.Should().Be(parameterName); - exception.Errors.Should().HaveCount(1); - exception.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); - exception.Errors[0].Title.Should().Be("The specified include is invalid."); - exception.Errors[0].Detail.Should().Be(errorMessage); - exception.Errors[0].Source.Parameter.Should().Be(parameterName); + exception.ParameterName.Should().Be(parameterName); + exception.Errors.ShouldHaveCount(1); + + ErrorObject error = exception.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified include is invalid."); + error.Detail.Should().Be(errorMessage); + error.Source.ShouldNotBeNull(); + error.Source.Parameter.Should().Be(parameterName); } [Theory] @@ -82,7 +87,7 @@ public void Reader_Read_Fails(string parameterName, string parameterValue, strin [InlineData("includes", "posts.author", "posts.author")] [InlineData("includes", "posts.comments", "posts.comments")] [InlineData("includes", "posts,posts.comments", "posts.comments")] - [InlineData("includes", "posts,posts.comments,posts.labels", "posts.comments,posts.labels")] + [InlineData("includes", "posts,posts.labels,posts.comments", "posts.comments,posts.labels")] public void Reader_Read_Succeeds(string parameterName, string parameterValue, string valueExpected) { // Act @@ -91,7 +96,7 @@ public void Reader_Read_Succeeds(string parameterName, string parameterValue, st IReadOnlyCollection<ExpressionInScope> constraints = _reader.GetConstraints(); // Assert - ResourceFieldChainExpression scope = constraints.Select(expressionInScope => expressionInScope.Scope).Single(); + ResourceFieldChainExpression? scope = constraints.Select(expressionInScope => expressionInScope.Scope).Single(); scope.Should().BeNull(); QueryExpression value = constraints.Select(expressionInScope => expressionInScope.Expression).Single(); diff --git a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/LegacyFilterParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/LegacyFilterParseTests.cs index 1c447a297a..67060a7349 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/LegacyFilterParseTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/LegacyFilterParseTests.cs @@ -9,7 +9,9 @@ using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.QueryStrings.Internal; using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreTests.IntegrationTests.QueryStrings; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreTests.UnitTests.QueryStringParameters @@ -22,7 +24,7 @@ public LegacyFilterParseTests() { Options.EnableLegacyFilterNotation = true; - Request.PrimaryResource = ResourceGraph.GetResourceContext<BlogPost>(); + Request.PrimaryResourceType = ResourceGraph.GetResourceType<BlogPost>(); var resourceFactory = new ResourceFactory(new ServiceContainer()); _reader = new FilterQueryStringParameterReader(Request, ResourceGraph, resourceFactory, Options); @@ -32,15 +34,16 @@ public LegacyFilterParseTests() [InlineData("filter", "some", "Expected field name between brackets in filter parameter name.")] [InlineData("filter[", "some", "Expected field name between brackets in filter parameter name.")] [InlineData("filter[]", "some", "Expected field name between brackets in filter parameter name.")] - [InlineData("filter[.]", "some", "Relationship '' in '.' does not exist on resource 'blogPosts'.")] - [InlineData("filter[some]", "other", "Field 'some' does not exist on resource 'blogPosts'.")] - [InlineData("filter[author]", "some", "Attribute 'author' does not exist on resource 'blogPosts'.")] - [InlineData("filter[author.posts]", "some", "Field 'posts' in 'author.posts' must be an attribute or a to-one relationship on resource 'webAccounts'.")] - [InlineData("filter[unknown.id]", "some", "Relationship 'unknown' in 'unknown.id' does not exist on resource 'blogPosts'.")] - [InlineData("filter", "expr:equals(some,'other')", "Field 'some' does not exist on resource 'blogPosts'.")] - [InlineData("filter", "expr:equals(author,'Joe')", "Attribute 'author' does not exist on resource 'blogPosts'.")] - [InlineData("filter", "expr:has(author)", "Relationship 'author' must be a to-many relationship on resource 'blogPosts'.")] - [InlineData("filter", "expr:equals(count(author),'1')", "Relationship 'author' must be a to-many relationship on resource 'blogPosts'.")] + [InlineData("filter[.]", "some", "Relationship '' in '.' does not exist on resource type 'blogPosts'.")] + [InlineData("filter[some]", "other", "Field 'some' does not exist on resource type 'blogPosts'.")] + [InlineData("filter[author]", "some", "Attribute 'author' does not exist on resource type 'blogPosts'.")] + [InlineData("filter[author.posts]", "some", + "Field 'posts' in 'author.posts' must be an attribute or a to-one relationship on resource type 'webAccounts'.")] + [InlineData("filter[unknown.id]", "some", "Relationship 'unknown' in 'unknown.id' does not exist on resource type 'blogPosts'.")] + [InlineData("filter", "expr:equals(some,'other')", "Field 'some' does not exist on resource type 'blogPosts'.")] + [InlineData("filter", "expr:equals(author,'Joe')", "Attribute 'author' does not exist on resource type 'blogPosts'.")] + [InlineData("filter", "expr:has(author)", "Relationship 'author' must be a to-many relationship on resource type 'blogPosts'.")] + [InlineData("filter", "expr:equals(count(author),'1')", "Relationship 'author' must be a to-many relationship on resource type 'blogPosts'.")] public void Reader_Read_Fails(string parameterName, string parameterValue, string errorMessage) { // Act @@ -49,12 +52,15 @@ public void Reader_Read_Fails(string parameterName, string parameterValue, strin // Assert InvalidQueryStringParameterException exception = action.Should().ThrowExactly<InvalidQueryStringParameterException>().And; - exception.QueryParameterName.Should().Be(parameterName); - exception.Errors.Should().HaveCount(1); - exception.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); - exception.Errors[0].Title.Should().Be("The specified filter is invalid."); - exception.Errors[0].Detail.Should().Be(errorMessage); - exception.Errors[0].Source.Parameter.Should().Be(parameterName); + exception.ParameterName.Should().Be(parameterName); + exception.Errors.ShouldHaveCount(1); + + ErrorObject error = exception.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified filter is invalid."); + error.Detail.Should().Be(errorMessage); + error.Source.ShouldNotBeNull(); + error.Source.Parameter.Should().Be(parameterName); } [Theory] @@ -91,7 +97,7 @@ public void Reader_Read_Succeeds(string parameterName, string parameterValue, st IReadOnlyCollection<ExpressionInScope> constraints = _reader.GetConstraints(); // Assert - ResourceFieldChainExpression scope = constraints.Select(expressionInScope => expressionInScope.Scope).Single(); + ResourceFieldChainExpression? scope = constraints.Select(expressionInScope => expressionInScope.Scope).Single(); scope.Should().BeNull(); QueryExpression value = constraints.Select(expressionInScope => expressionInScope.Expression).Single(); diff --git a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/PaginationParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/PaginationParseTests.cs index 71afbca69f..4555a27eb7 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/PaginationParseTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/PaginationParseTests.cs @@ -10,6 +10,8 @@ using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.QueryStrings; using JsonApiDotNetCore.QueryStrings.Internal; +using JsonApiDotNetCore.Serialization.Objects; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreTests.UnitTests.QueryStringParameters @@ -66,10 +68,10 @@ public void Reader_Is_Enabled(JsonApiQueryStringParameters parametersDisabled, b [InlineData("1(", ", expected.")] [InlineData("posts:-abc", "Digits expected.")] [InlineData("posts:-1", "Page number cannot be negative or zero.")] - [InlineData("posts.id", "Relationship 'id' in 'posts.id' does not exist on resource 'blogPosts'.")] - [InlineData("posts.comments.id", "Relationship 'id' in 'posts.comments.id' does not exist on resource 'comments'.")] - [InlineData("posts.author", "Relationship 'author' in 'posts.author' must be a to-many relationship on resource 'blogPosts'.")] - [InlineData("something", "Relationship 'something' does not exist on resource 'blogs'.")] + [InlineData("posts.id", "Relationship 'id' in 'posts.id' does not exist on resource type 'blogPosts'.")] + [InlineData("posts.comments.id", "Relationship 'id' in 'posts.comments.id' does not exist on resource type 'comments'.")] + [InlineData("posts.author", "Relationship 'author' in 'posts.author' must be a to-many relationship on resource type 'blogPosts'.")] + [InlineData("something", "Relationship 'something' does not exist on resource type 'blogs'.")] public void Reader_Read_Page_Number_Fails(string parameterValue, string errorMessage) { // Act @@ -78,12 +80,15 @@ public void Reader_Read_Page_Number_Fails(string parameterValue, string errorMes // Assert InvalidQueryStringParameterException exception = action.Should().ThrowExactly<InvalidQueryStringParameterException>().And; - exception.QueryParameterName.Should().Be("page[number]"); - exception.Errors.Should().HaveCount(1); - exception.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); - exception.Errors[0].Title.Should().Be("The specified paging is invalid."); - exception.Errors[0].Detail.Should().Be(errorMessage); - exception.Errors[0].Source.Parameter.Should().Be("page[number]"); + exception.ParameterName.Should().Be("page[number]"); + exception.Errors.ShouldHaveCount(1); + + ErrorObject error = exception.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified paging is invalid."); + error.Detail.Should().Be(errorMessage); + error.Source.ShouldNotBeNull(); + error.Source.Parameter.Should().Be("page[number]"); } [Theory] @@ -99,10 +104,10 @@ public void Reader_Read_Page_Number_Fails(string parameterValue, string errorMes [InlineData("1(", ", expected.")] [InlineData("posts:-abc", "Digits expected.")] [InlineData("posts:-1", "Page size cannot be negative.")] - [InlineData("posts.id", "Relationship 'id' in 'posts.id' does not exist on resource 'blogPosts'.")] - [InlineData("posts.comments.id", "Relationship 'id' in 'posts.comments.id' does not exist on resource 'comments'.")] - [InlineData("posts.author", "Relationship 'author' in 'posts.author' must be a to-many relationship on resource 'blogPosts'.")] - [InlineData("something", "Relationship 'something' does not exist on resource 'blogs'.")] + [InlineData("posts.id", "Relationship 'id' in 'posts.id' does not exist on resource type 'blogPosts'.")] + [InlineData("posts.comments.id", "Relationship 'id' in 'posts.comments.id' does not exist on resource type 'comments'.")] + [InlineData("posts.author", "Relationship 'author' in 'posts.author' must be a to-many relationship on resource type 'blogPosts'.")] + [InlineData("something", "Relationship 'something' does not exist on resource type 'blogs'.")] public void Reader_Read_Page_Size_Fails(string parameterValue, string errorMessage) { // Act @@ -111,12 +116,15 @@ public void Reader_Read_Page_Size_Fails(string parameterValue, string errorMessa // Assert InvalidQueryStringParameterException exception = action.Should().ThrowExactly<InvalidQueryStringParameterException>().And; - exception.QueryParameterName.Should().Be("page[size]"); - exception.Errors.Should().HaveCount(1); - exception.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); - exception.Errors[0].Title.Should().Be("The specified paging is invalid."); - exception.Errors[0].Detail.Should().Be(errorMessage); - exception.Errors[0].Source.Parameter.Should().Be("page[size]"); + exception.ParameterName.Should().Be("page[size]"); + exception.Errors.ShouldHaveCount(1); + + ErrorObject error = exception.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified paging is invalid."); + error.Detail.Should().Be(errorMessage); + error.Source.ShouldNotBeNull(); + error.Source.Parameter.Should().Be("page[size]"); } [Theory] @@ -130,7 +138,7 @@ public void Reader_Read_Page_Size_Fails(string parameterValue, string errorMessa [InlineData("posts:4,3", "posts:10,20", "|posts", "Page number: 3, size: 20|Page number: 4, size: 10")] [InlineData("posts:4,posts.comments:5,3", "posts:10,posts.comments:15,20", "|posts|posts.comments", "Page number: 3, size: 20|Page number: 4, size: 10|Page number: 5, size: 15")] - public void Reader_Read_Pagination_Succeeds(string pageNumber, string pageSize, string scopeTreesExpected, string valueTreesExpected) + public void Reader_Read_Pagination_Succeeds(string? pageNumber, string? pageSize, string scopeTreesExpected, string valueTreesExpected) { // Act if (pageNumber != null) @@ -147,7 +155,7 @@ public void Reader_Read_Pagination_Succeeds(string pageNumber, string pageSize, // Assert string[] scopeTreesExpectedArray = scopeTreesExpected.Split("|"); - ResourceFieldChainExpression[] scopeTrees = constraints.Select(expressionInScope => expressionInScope.Scope).ToArray(); + ResourceFieldChainExpression?[] scopeTrees = constraints.Select(expressionInScope => expressionInScope.Scope).ToArray(); scopeTrees.Should().HaveSameCount(scopeTreesExpectedArray); scopeTrees.Select(tree => tree?.ToString() ?? "").Should().BeEquivalentTo(scopeTreesExpectedArray, options => options.WithStrictOrdering()); diff --git a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/SortParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/SortParseTests.cs index b5776bfbbf..217a211afd 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/SortParseTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/SortParseTests.cs @@ -9,6 +9,8 @@ using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.QueryStrings; using JsonApiDotNetCore.QueryStrings.Internal; +using JsonApiDotNetCore.Serialization.Objects; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreTests.UnitTests.QueryStringParameters @@ -54,22 +56,23 @@ public void Reader_Is_Enabled(JsonApiQueryStringParameters parametersDisabled, b [Theory] [InlineData("sort[", "id", "Field name expected.")] - [InlineData("sort[abc.def]", "id", "Relationship 'abc' in 'abc.def' does not exist on resource 'blogs'.")] - [InlineData("sort[posts.author]", "id", "Relationship 'author' in 'posts.author' must be a to-many relationship on resource 'blogPosts'.")] + [InlineData("sort[abc.def]", "id", "Relationship 'abc' in 'abc.def' does not exist on resource type 'blogs'.")] + [InlineData("sort[posts.author]", "id", "Relationship 'author' in 'posts.author' must be a to-many relationship on resource type 'blogPosts'.")] [InlineData("sort", "", "-, count function or field name expected.")] [InlineData("sort", " ", "Unexpected whitespace.")] [InlineData("sort", "-", "Count function or field name expected.")] - [InlineData("sort", "abc", "Attribute 'abc' does not exist on resource 'blogs'.")] - [InlineData("sort[posts]", "author", "Attribute 'author' does not exist on resource 'blogPosts'.")] - [InlineData("sort[posts]", "author.livingAddress", "Attribute 'livingAddress' in 'author.livingAddress' does not exist on resource 'webAccounts'.")] + [InlineData("sort", "abc", "Attribute 'abc' does not exist on resource type 'blogs'.")] + [InlineData("sort[posts]", "author", "Attribute 'author' does not exist on resource type 'blogPosts'.")] + [InlineData("sort[posts]", "author.livingAddress", + "Attribute 'livingAddress' in 'author.livingAddress' does not exist on resource type 'webAccounts'.")] [InlineData("sort", "-count", "( expected.")] [InlineData("sort", "count", "( expected.")] [InlineData("sort", "count(posts", ") expected.")] [InlineData("sort", "count(", "Field name expected.")] [InlineData("sort", "count(-abc)", "Field name expected.")] - [InlineData("sort", "count(abc)", "Relationship 'abc' does not exist on resource 'blogs'.")] - [InlineData("sort", "count(id)", "Relationship 'id' does not exist on resource 'blogs'.")] - [InlineData("sort[posts]", "count(author)", "Relationship 'author' must be a to-many relationship on resource 'blogPosts'.")] + [InlineData("sort", "count(abc)", "Relationship 'abc' does not exist on resource type 'blogs'.")] + [InlineData("sort", "count(id)", "Relationship 'id' does not exist on resource type 'blogs'.")] + [InlineData("sort[posts]", "count(author)", "Relationship 'author' must be a to-many relationship on resource type 'blogPosts'.")] [InlineData("sort[posts]", "caption,", "-, count function or field name expected.")] [InlineData("sort[posts]", "caption:", ", expected.")] [InlineData("sort[posts]", "caption,-", "Count function or field name expected.")] @@ -81,12 +84,15 @@ public void Reader_Read_Fails(string parameterName, string parameterValue, strin // Assert InvalidQueryStringParameterException exception = action.Should().ThrowExactly<InvalidQueryStringParameterException>().And; - exception.QueryParameterName.Should().Be(parameterName); - exception.Errors.Should().HaveCount(1); - exception.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); - exception.Errors[0].Title.Should().Be("The specified sort is invalid."); - exception.Errors[0].Detail.Should().Be(errorMessage); - exception.Errors[0].Source.Parameter.Should().Be(parameterName); + exception.ParameterName.Should().Be(parameterName); + exception.Errors.ShouldHaveCount(1); + + ErrorObject error = exception.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified sort is invalid."); + error.Detail.Should().Be(errorMessage); + error.Source.ShouldNotBeNull(); + error.Source.Parameter.Should().Be(parameterName); } [Theory] @@ -109,7 +115,7 @@ public void Reader_Read_Succeeds(string parameterName, string parameterValue, st IReadOnlyCollection<ExpressionInScope> constraints = _reader.GetConstraints(); // Assert - ResourceFieldChainExpression scope = constraints.Select(expressionInScope => expressionInScope.Scope).Single(); + ResourceFieldChainExpression? scope = constraints.Select(expressionInScope => expressionInScope.Scope).Single(); scope?.ToString().Should().Be(scopeExpected); QueryExpression value = constraints.Select(expressionInScope => expressionInScope.Expression).Single(); diff --git a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/SparseFieldSetParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/SparseFieldSetParseTests.cs index fb017cfd9b..0632641392 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/SparseFieldSetParseTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/SparseFieldSetParseTests.cs @@ -9,6 +9,8 @@ using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.QueryStrings; using JsonApiDotNetCore.QueryStrings.Internal; +using JsonApiDotNetCore.Serialization.Objects; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreTests.UnitTests.QueryStringParameters @@ -61,11 +63,11 @@ public void Reader_Is_Enabled(JsonApiQueryStringParameters parametersDisabled, b [InlineData("fields[owner]", "", "Resource type 'owner' does not exist.")] [InlineData("fields[owner.posts]", "id", "Resource type 'owner.posts' does not exist.")] [InlineData("fields[blogPosts]", " ", "Unexpected whitespace.")] - [InlineData("fields[blogPosts]", "some", "Field 'some' does not exist on resource 'blogPosts'.")] - [InlineData("fields[blogPosts]", "id,owner.name", "Field 'owner.name' does not exist on resource 'blogPosts'.")] + [InlineData("fields[blogPosts]", "some", "Field 'some' does not exist on resource type 'blogPosts'.")] + [InlineData("fields[blogPosts]", "id,owner.name", "Field 'owner.name' does not exist on resource type 'blogPosts'.")] [InlineData("fields[blogPosts]", "id(", ", expected.")] [InlineData("fields[blogPosts]", "id,", "Field name expected.")] - [InlineData("fields[blogPosts]", "author.id,", "Field 'author.id' does not exist on resource 'blogPosts'.")] + [InlineData("fields[blogPosts]", "author.id,", "Field 'author.id' does not exist on resource type 'blogPosts'.")] public void Reader_Read_Fails(string parameterName, string parameterValue, string errorMessage) { // Act @@ -74,12 +76,15 @@ public void Reader_Read_Fails(string parameterName, string parameterValue, strin // Assert InvalidQueryStringParameterException exception = action.Should().ThrowExactly<InvalidQueryStringParameterException>().And; - exception.QueryParameterName.Should().Be(parameterName); - exception.Errors.Should().HaveCount(1); - exception.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); - exception.Errors[0].Title.Should().Be("The specified fieldset is invalid."); - exception.Errors[0].Detail.Should().Be(errorMessage); - exception.Errors[0].Source.Parameter.Should().Be(parameterName); + exception.ParameterName.Should().Be(parameterName); + exception.Errors.ShouldHaveCount(1); + + ErrorObject error = exception.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified fieldset is invalid."); + error.Detail.Should().Be(errorMessage); + error.Source.ShouldNotBeNull(); + error.Source.Parameter.Should().Be(parameterName); } [Theory] @@ -95,7 +100,7 @@ public void Reader_Read_Succeeds(string parameterName, string parameterValue, st IReadOnlyCollection<ExpressionInScope> constraints = _reader.GetConstraints(); // Assert - ResourceFieldChainExpression scope = constraints.Select(expressionInScope => expressionInScope.Scope).Single(); + ResourceFieldChainExpression? scope = constraints.Select(expressionInScope => expressionInScope.Scope).Single(); scope.Should().BeNull(); QueryExpression value = constraints.Select(expressionInScope => expressionInScope.Expression).Single(); diff --git a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs new file mode 100644 index 0000000000..6092dc2113 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs @@ -0,0 +1,363 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Castle.DynamicProxy; +using FluentAssertions; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.UnitTests.ResourceGraph +{ + public sealed class ResourceGraphBuilderTests + { + [Fact] + public void Resource_without_public_name_gets_pluralized_with_naming_convention_applied() + { + // Arrange + var options = new JsonApiOptions(); + var builder = new ResourceGraphBuilder(options, NullLoggerFactory.Instance); + + // Act + builder.Add<ResourceWithAttribute, int>(); + + // Assert + IResourceGraph resourceGraph = builder.Build(); + ResourceType resourceType = resourceGraph.GetResourceType<ResourceWithAttribute>(); + + resourceType.PublicName.Should().Be("resourceWithAttributes"); + } + + [Fact] + public void Attribute_without_public_name_gets_naming_convention_applied() + { + // Arrange + var options = new JsonApiOptions(); + var builder = new ResourceGraphBuilder(options, NullLoggerFactory.Instance); + + // Act + builder.Add<ResourceWithAttribute, int>(); + + // Assert + IResourceGraph resourceGraph = builder.Build(); + ResourceType resourceType = resourceGraph.GetResourceType<ResourceWithAttribute>(); + + AttrAttribute attribute = resourceType.GetAttributeByPropertyName(nameof(ResourceWithAttribute.PrimaryValue)); + attribute.PublicName.Should().Be("primaryValue"); + } + + [Fact] + public void HasOne_relationship_without_public_name_gets_naming_convention_applied() + { + // Arrange + var options = new JsonApiOptions(); + var builder = new ResourceGraphBuilder(options, NullLoggerFactory.Instance); + + // Act + builder.Add<ResourceWithAttribute, int>(); + + // Assert + IResourceGraph resourceGraph = builder.Build(); + ResourceType resourceType = resourceGraph.GetResourceType<ResourceWithAttribute>(); + + RelationshipAttribute relationship = resourceType.GetRelationshipByPropertyName(nameof(ResourceWithAttribute.PrimaryChild)); + relationship.PublicName.Should().Be("primaryChild"); + } + + [Fact] + public void HasMany_relationship_without_public_name_gets_naming_convention_applied() + { + // Arrange + var options = new JsonApiOptions(); + var builder = new ResourceGraphBuilder(options, NullLoggerFactory.Instance); + + // Act + builder.Add<ResourceWithAttribute, int>(); + + // Assert + IResourceGraph resourceGraph = builder.Build(); + ResourceType resourceType = resourceGraph.GetResourceType<ResourceWithAttribute>(); + + RelationshipAttribute relationship = resourceType.GetRelationshipByPropertyName(nameof(ResourceWithAttribute.TopLevelChildren)); + relationship.PublicName.Should().Be("topLevelChildren"); + } + + [Fact] + public void Cannot_use_duplicate_resource_name() + { + // Arrange + var options = new JsonApiOptions(); + var builder = new ResourceGraphBuilder(options, NullLoggerFactory.Instance); + builder.Add<ResourceWithHasOneRelationship, int>("duplicate"); + + // Act + Action action = () => builder.Add<ResourceWithAttribute, int>("duplicate"); + + // Assert + action.Should().ThrowExactly<InvalidConfigurationException>().WithMessage( + $"Resource '{typeof(ResourceWithHasOneRelationship)}' and '{typeof(ResourceWithAttribute)}' both use public name 'duplicate'."); + } + + [Fact] + public void Cannot_use_duplicate_attribute_name() + { + // Arrange + var options = new JsonApiOptions(); + var builder = new ResourceGraphBuilder(options, NullLoggerFactory.Instance); + + // Act + Action action = () => builder.Add<ResourceWithDuplicateAttrPublicName, int>(); + + // Assert + action.Should().ThrowExactly<InvalidConfigurationException>().WithMessage( + $"Properties '{typeof(ResourceWithDuplicateAttrPublicName)}.Value1' and " + + $"'{typeof(ResourceWithDuplicateAttrPublicName)}.Value2' both use public name 'duplicate'."); + } + + [Fact] + public void Cannot_use_duplicate_relationship_name() + { + // Arrange + var options = new JsonApiOptions(); + var builder = new ResourceGraphBuilder(options, NullLoggerFactory.Instance); + + // Act + Action action = () => builder.Add<ResourceWithDuplicateRelationshipPublicName, int>(); + + // Assert + action.Should().ThrowExactly<InvalidConfigurationException>().WithMessage( + $"Properties '{typeof(ResourceWithDuplicateRelationshipPublicName)}.PrimaryChild' and " + + $"'{typeof(ResourceWithDuplicateRelationshipPublicName)}.Children' both use public name 'duplicate'."); + } + + [Fact] + public void Cannot_use_duplicate_attribute_and_relationship_name() + { + // Arrange + var options = new JsonApiOptions(); + var builder = new ResourceGraphBuilder(options, NullLoggerFactory.Instance); + + // Act + Action action = () => builder.Add<ResourceWithDuplicateAttrRelationshipPublicName, int>(); + + // Assert + action.Should().ThrowExactly<InvalidConfigurationException>().WithMessage( + $"Properties '{typeof(ResourceWithDuplicateAttrRelationshipPublicName)}.Value' and " + + $"'{typeof(ResourceWithDuplicateAttrRelationshipPublicName)}.Children' both use public name 'duplicate'."); + } + + [Fact] + public void Cannot_add_resource_that_implements_only_non_generic_IIdentifiable() + { + // Arrange + var options = new JsonApiOptions(); + var builder = new ResourceGraphBuilder(options, NullLoggerFactory.Instance); + + // Act + Action action = () => builder.Add(typeof(ResourceWithoutId)); + + // Assert + action.Should().ThrowExactly<InvalidConfigurationException>() + .WithMessage($"Resource type '{typeof(ResourceWithoutId)}' implements 'IIdentifiable', but not 'IIdentifiable<TId>'."); + } + + [Fact] + public void Cannot_build_graph_with_missing_related_HasOne_resource() + { + // Arrange + var options = new JsonApiOptions(); + var builder = new ResourceGraphBuilder(options, NullLoggerFactory.Instance); + + builder.Add<ResourceWithHasOneRelationship, int>(); + + // Act + Action action = () => builder.Build(); + + // Assert + action.Should().ThrowExactly<InvalidConfigurationException>().WithMessage($"Resource type '{typeof(ResourceWithHasOneRelationship)}' " + + $"depends on '{typeof(ResourceWithAttribute)}', which was not added to the resource graph."); + } + + [Fact] + public void Cannot_build_graph_with_missing_related_HasMany_resource() + { + // Arrange + var options = new JsonApiOptions(); + var builder = new ResourceGraphBuilder(options, NullLoggerFactory.Instance); + + builder.Add<ResourceWithHasManyRelationship, int>(); + + // Act + Action action = () => builder.Build(); + + // Assert + action.Should().ThrowExactly<InvalidConfigurationException>().WithMessage($"Resource type '{typeof(ResourceWithHasManyRelationship)}' " + + $"depends on '{typeof(ResourceWithAttribute)}', which was not added to the resource graph."); + } + + [Fact] + public void Logs_warning_when_adding_non_resource_type() + { + // Arrange + var options = new JsonApiOptions(); + var loggerFactory = new FakeLoggerFactory(LogLevel.Warning); + var builder = new ResourceGraphBuilder(options, loggerFactory); + + // Act + builder.Add(typeof(NonResource)); + + // Assert + loggerFactory.Logger.Messages.ShouldHaveCount(1); + + FakeLoggerFactory.FakeLogMessage message = loggerFactory.Logger.Messages.ElementAt(0); + message.LogLevel.Should().Be(LogLevel.Warning); + message.Text.Should().Be($"Skipping: Type '{typeof(NonResource)}' does not implement 'IIdentifiable'."); + } + + [Fact] + public void Logs_warning_when_adding_resource_without_attributes() + { + // Arrange + var options = new JsonApiOptions(); + var loggerFactory = new FakeLoggerFactory(LogLevel.Warning); + var builder = new ResourceGraphBuilder(options, loggerFactory); + + // Act + builder.Add<ResourceWithHasOneRelationship, int>(); + + // Assert + loggerFactory.Logger.Messages.ShouldHaveCount(1); + + FakeLoggerFactory.FakeLogMessage message = loggerFactory.Logger.Messages.ElementAt(0); + message.LogLevel.Should().Be(LogLevel.Warning); + message.Text.Should().Be($"Type '{typeof(ResourceWithHasOneRelationship)}' does not contain any attributes."); + } + + [Fact] + public void Logs_warning_on_empty_graph() + { + // Arrange + var options = new JsonApiOptions(); + var loggerFactory = new FakeLoggerFactory(LogLevel.Warning); + var builder = new ResourceGraphBuilder(options, loggerFactory); + + // Act + builder.Build(); + + // Assert + loggerFactory.Logger.Messages.ShouldHaveCount(1); + + FakeLoggerFactory.FakeLogMessage message = loggerFactory.Logger.Messages.ElementAt(0); + message.LogLevel.Should().Be(LogLevel.Warning); + message.Text.Should().Be("The resource graph is empty."); + } + + [Fact] + public void Resolves_correct_type_for_lazy_loading_proxy() + { + // Arrange + var options = new JsonApiOptions(); + + var builder = new ResourceGraphBuilder(options, NullLoggerFactory.Instance); + builder.Add<ResourceOfInt32, int>(); + IResourceGraph resourceGraph = builder.Build(); + + var proxyGenerator = new ProxyGenerator(); + var proxy = proxyGenerator.CreateClassProxy<ResourceOfInt32>(); + + // Act + ResourceType resourceType = resourceGraph.GetResourceType(proxy.GetType()); + + // Assert + resourceType.ClrType.Should().Be(typeof(ResourceOfInt32)); + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + private sealed class ResourceWithHasOneRelationship : Identifiable<int> + { + [HasOne] + public ResourceWithAttribute? PrimaryChild { get; set; } + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + private sealed class ResourceWithHasManyRelationship : Identifiable<int> + { + [HasMany] + public ISet<ResourceWithAttribute> Children { get; set; } = new HashSet<ResourceWithAttribute>(); + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + private sealed class ResourceWithAttribute : Identifiable<int> + { + [Attr] + public string? PrimaryValue { get; set; } + + [HasOne] + public ResourceWithAttribute? PrimaryChild { get; set; } + + [HasMany] + public ISet<ResourceWithAttribute> TopLevelChildren { get; set; } = new HashSet<ResourceWithAttribute>(); + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + private sealed class ResourceWithDuplicateAttrPublicName : Identifiable<int> + { + [Attr(PublicName = "duplicate")] + public string? Value1 { get; set; } + + [Attr(PublicName = "duplicate")] + public string? Value2 { get; set; } + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + private sealed class ResourceWithDuplicateRelationshipPublicName : Identifiable<int> + { + [HasOne(PublicName = "duplicate")] + public ResourceWithHasOneRelationship? PrimaryChild { get; set; } + + [HasMany(PublicName = "duplicate")] + public ISet<ResourceWithAttribute> Children { get; set; } = new HashSet<ResourceWithAttribute>(); + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + private sealed class ResourceWithDuplicateAttrRelationshipPublicName : Identifiable<int> + { + [Attr(PublicName = "duplicate")] + public string? Value { get; set; } + + [HasMany(PublicName = "duplicate")] + public ISet<ResourceWithAttribute> Children { get; set; } = new HashSet<ResourceWithAttribute>(); + } + + private sealed class ResourceWithoutId : IIdentifiable + { + public string? StringId { get; set; } + public string? LocalId { get; set; } + } + + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] + private sealed class NonResource + { + } + + // ReSharper disable once ClassCanBeSealed.Global + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public class ResourceOfInt32 : Identifiable<int> + { + [Attr] + public string? StringValue { get; set; } + + [HasOne] + public ResourceOfInt32? PrimaryChild { get; set; } + + [HasMany] + public IList<ResourceOfInt32> Children { get; set; } = new List<ResourceOfInt32>(); + } + } +} diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Serialization/InputConversionTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/Serialization/InputConversionTests.cs new file mode 100644 index 0000000000..add26ddb19 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/UnitTests/Serialization/InputConversionTests.cs @@ -0,0 +1,362 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.Design; +using FluentAssertions; +using FluentAssertions.Extensions; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.JsonConverters; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCore.Serialization.Request.Adapters; +using Microsoft.Extensions.Logging.Abstractions; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.UnitTests.Serialization +{ + public sealed class InputConversionTests + { + [Fact] + public void Converts_various_data_types_with_values() + { + // Arrange + DocumentAdapter documentAdapter = CreateDocumentAdapter<ResourceWithVariousDataTypes>(resourceGraph => new JsonApiRequest + { + Kind = EndpointKind.Primary, + PrimaryResourceType = resourceGraph.GetResourceType<ResourceWithVariousDataTypes>(), + WriteOperation = WriteOperationKind.CreateResource + }); + + const bool booleanValue = true; + const bool nullableBooleanValue = false; + const char charValue = 'A'; + const char nullableCharValue = '?'; + const ulong unsignedLongValue = ulong.MaxValue; + const ulong nullableUnsignedLongValue = 9_000_000_000UL; + const decimal decimalValue = 19.95m; + const decimal nullableDecimalValue = 12.50m; + const float floatValue = (float)1 / 3; + const float nullableFloatValue = (float)3 / 5; + const string stringValue = "text"; + const string? nullableStringValue = "nullable"; + var guidValue = Guid.NewGuid(); + var nullableGuidValue = Guid.NewGuid(); + DateTime dateTimeValue = 12.July(1982); + DateTime nullableDateTimeValue = 18.October(2028); + DateTimeOffset dateTimeOffsetValue = 3.March(1999).WithOffset(7.Hours()); + DateTimeOffset nullableDateTimeOffsetValue = 28.February(2009).WithOffset(-2.Hours()); + TimeSpan timeSpanValue = 4.Hours().And(58.Minutes()); + TimeSpan nullableTimeSpanValue = 35.Seconds().And(44.Milliseconds()); + const DayOfWeek enumValue = DayOfWeek.Wednesday; + const DayOfWeek nullableEnumValue = DayOfWeek.Sunday; + + var complexObject = new ComplexObject + { + Value = "Single" + }; + + var complexObjectList = new List<ComplexObject> + { + new() + { + Value = "One" + }, + new() + { + Value = "Two" + } + }; + + var document = new Document + { + Data = new SingleOrManyData<ResourceObject>(new ResourceObject + { + Type = "resourceWithVariousDataTypes", + Attributes = new Dictionary<string, object?> + { + ["boolean"] = booleanValue, + ["nullableBoolean"] = nullableBooleanValue, + ["char"] = charValue, + ["nullableChar"] = nullableCharValue, + ["unsignedLong"] = unsignedLongValue, + ["nullableUnsignedLong"] = nullableUnsignedLongValue, + ["decimal"] = decimalValue, + ["nullableDecimal"] = nullableDecimalValue, + ["float"] = floatValue, + ["nullableFloat"] = nullableFloatValue, + ["string"] = stringValue, + ["nullableString"] = nullableStringValue, + ["guid"] = guidValue, + ["nullableGuid"] = nullableGuidValue, + ["dateTime"] = dateTimeValue, + ["nullableDateTime"] = nullableDateTimeValue, + ["dateTimeOffset"] = dateTimeOffsetValue, + ["nullableDateTimeOffset"] = nullableDateTimeOffsetValue, + ["timeSpan"] = timeSpanValue, + ["nullableTimeSpan"] = nullableTimeSpanValue, + ["enum"] = enumValue, + ["nullableEnum"] = nullableEnumValue, + ["complexObject"] = complexObject, + ["complexObjectList"] = complexObjectList + } + }) + }; + + // Act + var model = (ResourceWithVariousDataTypes?)documentAdapter.Convert(document); + + // Assert + model.ShouldNotBeNull(); + + model.Boolean.Should().Be(booleanValue); + model.NullableBoolean.Should().Be(nullableBooleanValue); + model.Char.Should().Be(charValue); + model.NullableChar.Should().Be(nullableCharValue); + model.UnsignedLong.Should().Be(unsignedLongValue); + model.NullableUnsignedLong.Should().Be(nullableUnsignedLongValue); + model.Decimal.Should().Be(decimalValue); + model.NullableDecimal.Should().Be(nullableDecimalValue); + model.Float.Should().Be(floatValue); + model.NullableFloat.Should().Be(nullableFloatValue); + model.String.Should().Be(stringValue); + model.NullableString.Should().Be(nullableStringValue); + model.Guid.Should().Be(guidValue); + model.NullableGuid.Should().Be(nullableGuidValue); + model.DateTime.Should().Be(dateTimeValue); + model.NullableDateTime.Should().Be(nullableDateTimeValue); + model.DateTimeOffset.Should().Be(dateTimeOffsetValue); + model.NullableDateTimeOffset.Should().Be(nullableDateTimeOffsetValue); + model.TimeSpan.Should().Be(timeSpanValue); + model.NullableTimeSpan.Should().Be(nullableTimeSpanValue); + model.Enum.Should().Be(enumValue); + model.NullableEnum.Should().Be(nullableEnumValue); + + model.ComplexObject.ShouldNotBeNull(); + model.ComplexObject.Value.Should().Be(complexObject.Value); + + model.ComplexObjectList.ShouldHaveCount(2); + model.ComplexObjectList[0].Value.Should().Be(complexObjectList[0].Value); + model.ComplexObjectList[1].Value.Should().Be(complexObjectList[1].Value); + } + + [Fact] + public void Converts_various_data_types_with_defaults() + { + // Arrange + DocumentAdapter documentAdapter = CreateDocumentAdapter<ResourceWithVariousDataTypes>(resourceGraph => new JsonApiRequest + { + Kind = EndpointKind.Primary, + PrimaryResourceType = resourceGraph.GetResourceType<ResourceWithVariousDataTypes>(), + WriteOperation = WriteOperationKind.CreateResource + }); + + const bool booleanValue = default; + const bool nullableBooleanValue = default; + const char charValue = default; + const char nullableCharValue = default; + const ulong unsignedLongValue = default; + const ulong nullableUnsignedLongValue = default; + const decimal decimalValue = default; + const decimal nullableDecimalValue = default; + const float floatValue = default; + const float nullableFloatValue = default; + const string stringValue = default!; + const string? nullableStringValue = default; + Guid guidValue = default; + Guid nullableGuidValue = default; + DateTime dateTimeValue = default; + DateTime nullableDateTimeValue = default; + DateTimeOffset dateTimeOffsetValue = default; + DateTimeOffset nullableDateTimeOffsetValue = default; + TimeSpan timeSpanValue = default; + TimeSpan nullableTimeSpanValue = default; + const DayOfWeek enumValue = default; + const DayOfWeek nullableEnumValue = default; + + var document = new Document + { + Data = new SingleOrManyData<ResourceObject>(new ResourceObject + { + Type = "resourceWithVariousDataTypes", + Attributes = new Dictionary<string, object?> + { + ["boolean"] = booleanValue, + ["nullableBoolean"] = nullableBooleanValue, + ["char"] = charValue, + ["nullableChar"] = nullableCharValue, + ["unsignedLong"] = unsignedLongValue, + ["nullableUnsignedLong"] = nullableUnsignedLongValue, + ["decimal"] = decimalValue, + ["nullableDecimal"] = nullableDecimalValue, + ["float"] = floatValue, + ["nullableFloat"] = nullableFloatValue, + ["string"] = stringValue, + ["nullableString"] = nullableStringValue, + ["guid"] = guidValue, + ["nullableGuid"] = nullableGuidValue, + ["dateTime"] = dateTimeValue, + ["nullableDateTime"] = nullableDateTimeValue, + ["dateTimeOffset"] = dateTimeOffsetValue, + ["nullableDateTimeOffset"] = nullableDateTimeOffsetValue, + ["timeSpan"] = timeSpanValue, + ["nullableTimeSpan"] = nullableTimeSpanValue, + ["enum"] = enumValue, + ["nullableEnum"] = nullableEnumValue, + ["complexObject"] = null, + ["complexObjectList"] = null + } + }) + }; + + // Act + var model = (ResourceWithVariousDataTypes?)documentAdapter.Convert(document); + + // Assert + model.ShouldNotBeNull(); + + model.Boolean.Should().Be(booleanValue); + model.NullableBoolean.Should().Be(nullableBooleanValue); + model.Char.Should().Be(charValue); + model.NullableChar.Should().Be(nullableCharValue); + model.UnsignedLong.Should().Be(unsignedLongValue); + model.NullableUnsignedLong.Should().Be(nullableUnsignedLongValue); + model.Decimal.Should().Be(decimalValue); + model.NullableDecimal.Should().Be(nullableDecimalValue); + model.Float.Should().Be(floatValue); + model.NullableFloat.Should().Be(nullableFloatValue); + model.String.Should().Be(stringValue); + model.NullableString.Should().Be(nullableStringValue); + model.Guid.Should().Be(guidValue); + model.NullableGuid.Should().Be(nullableGuidValue); + model.DateTime.Should().Be(dateTimeValue); + model.NullableDateTime.Should().Be(nullableDateTimeValue); + model.DateTimeOffset.Should().Be(dateTimeOffsetValue); + model.NullableDateTimeOffset.Should().Be(nullableDateTimeOffsetValue); + model.TimeSpan.Should().Be(timeSpanValue); + model.NullableTimeSpan.Should().Be(nullableTimeSpanValue); + model.Enum.Should().Be(enumValue); + model.NullableEnum.Should().Be(nullableEnumValue); + model.ComplexObject.Should().BeNull(); + model.ComplexObjectList.Should().BeNull(); + } + + private static DocumentAdapter CreateDocumentAdapter<TResource>(Func<IResourceGraph, JsonApiRequest> createRequest) + where TResource : Identifiable<int> + { + var options = new JsonApiOptions(); + IResourceGraph resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add<TResource, int>().Build(); + options.SerializerOptions.Converters.Add(new ResourceObjectConverter(resourceGraph)); + + var serviceContainer = new ServiceContainer(); + var resourceFactory = new ResourceFactory(serviceContainer); + var resourceDefinitionAccessor = new ResourceDefinitionAccessor(resourceGraph, serviceContainer); + + serviceContainer.AddService(typeof(IResourceDefinitionAccessor), resourceDefinitionAccessor); + serviceContainer.AddService(typeof(IResourceDefinition<TResource, int>), new JsonApiResourceDefinition<TResource, int>(resourceGraph)); + + JsonApiRequest request = createRequest(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); + + return new DocumentAdapter(request, targetedFields, resourceDocumentAdapter, operationsDocumentAdapter); + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class ResourceWithVariousDataTypes : Identifiable<int> + { + [Attr] + public bool Boolean { get; set; } + + [Attr] + public bool? NullableBoolean { get; set; } + + [Attr] + public char Char { get; set; } + + [Attr] + public char? NullableChar { get; set; } + + [Attr] + public ulong UnsignedLong { get; set; } + + [Attr] + public ulong? NullableUnsignedLong { get; set; } + + [Attr] + public decimal Decimal { get; set; } + + [Attr] + public decimal? NullableDecimal { get; set; } + + [Attr] + public float Float { get; set; } + + [Attr] + public float? NullableFloat { get; set; } + + [Attr] + public string String { get; set; } = string.Empty; + + [Attr] + public string? NullableString { get; set; } + + [Attr] + public Guid Guid { get; set; } + + [Attr] + public Guid? NullableGuid { get; set; } + + [Attr] + public DateTime DateTime { get; set; } + + [Attr] + public DateTime? NullableDateTime { get; set; } + + [Attr] + public DateTimeOffset DateTimeOffset { get; set; } + + [Attr] + public DateTimeOffset? NullableDateTimeOffset { get; set; } + + [Attr] + public TimeSpan TimeSpan { get; set; } + + [Attr] + public TimeSpan? NullableTimeSpan { get; set; } + + [Attr] + public DayOfWeek Enum { get; set; } + + [Attr] + public DayOfWeek? NullableEnum { get; set; } + + [Attr] + public ComplexObject? ComplexObject { get; set; } + + [Attr] + public IList<ComplexObject>? ComplexObjectList { get; set; } + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class ComplexObject + { + public string? Value { get; set; } + } + } +} diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/Models/Article.cs b/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/Models/Article.cs new file mode 100644 index 0000000000..bd26236d75 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/Models/Article.cs @@ -0,0 +1,19 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.UnitTests.Serialization.Response.Models +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class Article : Identifiable<int> + { + [Attr] + public string Title { get; set; } = null!; + + [HasOne] + public Person? Reviewer { get; set; } + + [HasOne] + public Person Author { get; set; } = null!; + } +} diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/Models/Blog.cs b/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/Models/Blog.cs new file mode 100644 index 0000000000..61a323c240 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/Models/Blog.cs @@ -0,0 +1,19 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.UnitTests.Serialization.Response.Models +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class Blog : Identifiable<int> + { + [Attr] + public string Title { get; set; } = null!; + + [HasOne] + public Person Reviewer { get; set; } = null!; + + [HasOne] + public Person Author { get; set; } = null!; + } +} diff --git a/test/UnitTests/TestModels/IdentifiableWithAttribute.cs b/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/Models/Food.cs similarity index 53% rename from test/UnitTests/TestModels/IdentifiableWithAttribute.cs rename to test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/Models/Food.cs index 671a222e4b..18f3b8b1c6 100644 --- a/test/UnitTests/TestModels/IdentifiableWithAttribute.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/Models/Food.cs @@ -2,12 +2,12 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace UnitTests.TestModels +namespace JsonApiDotNetCoreTests.UnitTests.Serialization.Response.Models { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public class IdentifiableWithAttribute : Identifiable + public sealed class Food : Identifiable<int> { [Attr] - public string AttributeMember { get; set; } + public string Dish { get; set; } = null!; } } diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/Models/Person.cs b/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/Models/Person.cs new file mode 100644 index 0000000000..e35526c4d8 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/Models/Person.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.UnitTests.Serialization.Response.Models +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class Person : Identifiable<int> + { + [Attr] + public string Name { get; set; } = null!; + + [HasMany] + public ISet<Blog> Blogs { get; set; } = new HashSet<Blog>(); + + [HasOne] + public Food FavoriteFood { get; set; } = null!; + + [HasOne] + public Song FavoriteSong { get; set; } = null!; + } +} diff --git a/test/UnitTests/TestModels/Song.cs b/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/Models/Song.cs similarity index 53% rename from test/UnitTests/TestModels/Song.cs rename to test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/Models/Song.cs index d9a52f238b..3546aaf226 100644 --- a/test/UnitTests/TestModels/Song.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/Models/Song.cs @@ -2,12 +2,12 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace UnitTests.TestModels +namespace JsonApiDotNetCoreTests.UnitTests.Serialization.Response.Models { [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Song : Identifiable + public sealed class Song : Identifiable<int> { [Attr] - public string Title { get; set; } + public string Title { get; set; } = null!; } } diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/ResponseModelAdapterTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/ResponseModelAdapterTests.cs new file mode 100644 index 0000000000..6419c675e0 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/ResponseModelAdapterTests.cs @@ -0,0 +1,718 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Internal; +using JsonApiDotNetCore.Queries.Internal.Parsing; +using JsonApiDotNetCore.QueryStrings; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCore.Serialization.Response; +using JsonApiDotNetCoreTests.UnitTests.Serialization.Response.Models; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.UnitTests.Serialization.Response +{ + public sealed class ResponseModelAdapterTests + { + [Fact] + public void Resources_in_deeply_nested_circular_chain_are_written_in_relationship_declaration_order() + { + // Arrange + var fakers = new ResponseSerializationFakers(); + + Article article = fakers.Article.Generate(); + article.Author = fakers.Person.Generate(); + article.Author.Blogs = fakers.Blog.Generate(2).ToHashSet(); + article.Author.Blogs.ElementAt(0).Reviewer = article.Author; + article.Author.Blogs.ElementAt(1).Reviewer = fakers.Person.Generate(); + article.Author.FavoriteFood = fakers.Food.Generate(); + article.Author.Blogs.ElementAt(1).Reviewer.FavoriteFood = fakers.Food.Generate(); + + IJsonApiOptions options = new JsonApiOptions(); + ResponseModelAdapter responseModelAdapter = CreateAdapter(options, article.StringId, "author.blogs.reviewer.favoriteFood"); + + // Act + Document document = responseModelAdapter.Convert(article); + + // Assert + string text = JsonSerializer.Serialize(document, new JsonSerializerOptions(options.SerializerWriteOptions)); + + text.Should().BeJson(@"{ + ""data"": { + ""type"": ""articles"", + ""id"": ""1"", + ""attributes"": { + ""title"": ""The SAS microchip is down, quantify the 1080p microchip so we can quantify the SAS microchip!"" + }, + ""relationships"": { + ""author"": { + ""data"": { + ""type"": ""people"", + ""id"": ""2"" + } + } + } + }, + ""included"": [ + { + ""type"": ""people"", + ""id"": ""2"", + ""attributes"": { + ""name"": ""Ernestine Runte"" + }, + ""relationships"": { + ""blogs"": { + ""data"": [ + { + ""type"": ""blogs"", + ""id"": ""3"" + }, + { + ""type"": ""blogs"", + ""id"": ""4"" + } + ] + }, + ""favoriteFood"": { + ""data"": { + ""type"": ""foods"", + ""id"": ""6"" + } + } + } + }, + { + ""type"": ""blogs"", + ""id"": ""3"", + ""attributes"": { + ""title"": ""The SAS microchip is down, quantify the 1080p microchip so we can quantify the SAS microchip!"" + }, + ""relationships"": { + ""reviewer"": { + ""data"": { + ""type"": ""people"", + ""id"": ""2"" + } + } + } + }, + { + ""type"": ""blogs"", + ""id"": ""4"", + ""attributes"": { + ""title"": ""I'll connect the mobile ADP card, that should card the ADP card!"" + }, + ""relationships"": { + ""reviewer"": { + ""data"": { + ""type"": ""people"", + ""id"": ""5"" + } + } + } + }, + { + ""type"": ""people"", + ""id"": ""5"", + ""attributes"": { + ""name"": ""Doug Shields"" + }, + ""relationships"": { + ""favoriteFood"": { + ""data"": { + ""type"": ""foods"", + ""id"": ""7"" + } + } + } + }, + { + ""type"": ""foods"", + ""id"": ""7"", + ""attributes"": { + ""dish"": ""Nostrum totam harum totam voluptatibus."" + } + }, + { + ""type"": ""foods"", + ""id"": ""6"", + ""attributes"": { + ""dish"": ""Illum assumenda iste quia natus et dignissimos reiciendis."" + } + } + ] +}"); + } + + [Fact] + public void Resources_in_deeply_nested_circular_chains_are_written_in_relationship_declaration_order_without_duplicates() + { + // Arrange + var fakers = new ResponseSerializationFakers(); + + Article article1 = fakers.Article.Generate(); + article1.Author = fakers.Person.Generate(); + article1.Author.Blogs = fakers.Blog.Generate(2).ToHashSet(); + article1.Author.Blogs.ElementAt(0).Reviewer = article1.Author; + article1.Author.Blogs.ElementAt(1).Reviewer = fakers.Person.Generate(); + article1.Author.FavoriteFood = fakers.Food.Generate(); + article1.Author.Blogs.ElementAt(1).Reviewer.FavoriteFood = fakers.Food.Generate(); + + Article article2 = fakers.Article.Generate(); + article2.Author = article1.Author; + + IJsonApiOptions options = new JsonApiOptions(); + ResponseModelAdapter responseModelAdapter = CreateAdapter(options, article1.StringId, "author.blogs.reviewer.favoriteFood"); + + // Act + Document document = responseModelAdapter.Convert(new[] + { + article1, + article2 + }); + + // Assert + string text = JsonSerializer.Serialize(document, new JsonSerializerOptions(options.SerializerWriteOptions)); + + text.Should().BeJson(@"{ + ""data"": [ + { + ""type"": ""articles"", + ""id"": ""1"", + ""attributes"": { + ""title"": ""The SAS microchip is down, quantify the 1080p microchip so we can quantify the SAS microchip!"" + }, + ""relationships"": { + ""author"": { + ""data"": { + ""type"": ""people"", + ""id"": ""2"" + } + } + } + }, + { + ""type"": ""articles"", + ""id"": ""8"", + ""attributes"": { + ""title"": ""I'll connect the mobile ADP card, that should card the ADP card!"" + }, + ""relationships"": { + ""author"": { + ""data"": { + ""type"": ""people"", + ""id"": ""2"" + } + } + } + } + ], + ""included"": [ + { + ""type"": ""people"", + ""id"": ""2"", + ""attributes"": { + ""name"": ""Ernestine Runte"" + }, + ""relationships"": { + ""blogs"": { + ""data"": [ + { + ""type"": ""blogs"", + ""id"": ""3"" + }, + { + ""type"": ""blogs"", + ""id"": ""4"" + } + ] + }, + ""favoriteFood"": { + ""data"": { + ""type"": ""foods"", + ""id"": ""6"" + } + } + } + }, + { + ""type"": ""blogs"", + ""id"": ""3"", + ""attributes"": { + ""title"": ""The SAS microchip is down, quantify the 1080p microchip so we can quantify the SAS microchip!"" + }, + ""relationships"": { + ""reviewer"": { + ""data"": { + ""type"": ""people"", + ""id"": ""2"" + } + } + } + }, + { + ""type"": ""blogs"", + ""id"": ""4"", + ""attributes"": { + ""title"": ""I'll connect the mobile ADP card, that should card the ADP card!"" + }, + ""relationships"": { + ""reviewer"": { + ""data"": { + ""type"": ""people"", + ""id"": ""5"" + } + } + } + }, + { + ""type"": ""people"", + ""id"": ""5"", + ""attributes"": { + ""name"": ""Doug Shields"" + }, + ""relationships"": { + ""favoriteFood"": { + ""data"": { + ""type"": ""foods"", + ""id"": ""7"" + } + } + } + }, + { + ""type"": ""foods"", + ""id"": ""7"", + ""attributes"": { + ""dish"": ""Nostrum totam harum totam voluptatibus."" + } + }, + { + ""type"": ""foods"", + ""id"": ""6"", + ""attributes"": { + ""dish"": ""Illum assumenda iste quia natus et dignissimos reiciendis."" + } + } + ] +}"); + } + + [Fact] + public void Resources_in_overlapping_deeply_nested_circular_chains_are_written_in_relationship_declaration_order() + { + // Arrange + var fakers = new ResponseSerializationFakers(); + + Article article = fakers.Article.Generate(); + article.Author = fakers.Person.Generate(); + article.Author.Blogs = fakers.Blog.Generate(2).ToHashSet(); + article.Author.Blogs.ElementAt(0).Reviewer = article.Author; + article.Author.Blogs.ElementAt(1).Reviewer = fakers.Person.Generate(); + article.Author.FavoriteFood = fakers.Food.Generate(); + article.Author.Blogs.ElementAt(1).Reviewer.FavoriteFood = fakers.Food.Generate(); + + article.Reviewer = fakers.Person.Generate(); + article.Reviewer.Blogs = fakers.Blog.Generate(1).ToHashSet(); + article.Reviewer.Blogs.Add(article.Author.Blogs.ElementAt(0)); + article.Reviewer.Blogs.ElementAt(0).Author = article.Reviewer; + + article.Reviewer.Blogs.ElementAt(1).Author = article.Author.Blogs.ElementAt(1).Reviewer; + article.Author.Blogs.ElementAt(1).Reviewer.FavoriteSong = fakers.Song.Generate(); + article.Reviewer.FavoriteSong = fakers.Song.Generate(); + + IJsonApiOptions options = new JsonApiOptions(); + + ResponseModelAdapter responseModelAdapter = + CreateAdapter(options, article.StringId, "author.blogs.reviewer.favoriteFood,reviewer.blogs.author.favoriteSong"); + + // Act + Document document = responseModelAdapter.Convert(article); + + // Assert + string text = JsonSerializer.Serialize(document, new JsonSerializerOptions(options.SerializerWriteOptions)); + + text.Should().BeJson(@"{ + ""data"": { + ""type"": ""articles"", + ""id"": ""1"", + ""attributes"": { + ""title"": ""The SAS microchip is down, quantify the 1080p microchip so we can quantify the SAS microchip!"" + }, + ""relationships"": { + ""reviewer"": { + ""data"": { + ""type"": ""people"", + ""id"": ""8"" + } + }, + ""author"": { + ""data"": { + ""type"": ""people"", + ""id"": ""2"" + } + } + } + }, + ""included"": [ + { + ""type"": ""people"", + ""id"": ""8"", + ""attributes"": { + ""name"": ""Nettie Howell"" + }, + ""relationships"": { + ""blogs"": { + ""data"": [ + { + ""type"": ""blogs"", + ""id"": ""9"" + }, + { + ""type"": ""blogs"", + ""id"": ""3"" + } + ] + }, + ""favoriteSong"": { + ""data"": { + ""type"": ""songs"", + ""id"": ""11"" + } + } + } + }, + { + ""type"": ""blogs"", + ""id"": ""9"", + ""attributes"": { + ""title"": ""The RSS bus is down, parse the mobile bus so we can parse the RSS bus!"" + }, + ""relationships"": { + ""author"": { + ""data"": { + ""type"": ""people"", + ""id"": ""8"" + } + } + } + }, + { + ""type"": ""blogs"", + ""id"": ""3"", + ""attributes"": { + ""title"": ""The SAS microchip is down, quantify the 1080p microchip so we can quantify the SAS microchip!"" + }, + ""relationships"": { + ""reviewer"": { + ""data"": { + ""type"": ""people"", + ""id"": ""2"" + } + }, + ""author"": { + ""data"": { + ""type"": ""people"", + ""id"": ""5"" + } + } + } + }, + { + ""type"": ""people"", + ""id"": ""2"", + ""attributes"": { + ""name"": ""Ernestine Runte"" + }, + ""relationships"": { + ""blogs"": { + ""data"": [ + { + ""type"": ""blogs"", + ""id"": ""3"" + }, + { + ""type"": ""blogs"", + ""id"": ""4"" + } + ] + }, + ""favoriteFood"": { + ""data"": { + ""type"": ""foods"", + ""id"": ""6"" + } + } + } + }, + { + ""type"": ""blogs"", + ""id"": ""4"", + ""attributes"": { + ""title"": ""I'll connect the mobile ADP card, that should card the ADP card!"" + }, + ""relationships"": { + ""reviewer"": { + ""data"": { + ""type"": ""people"", + ""id"": ""5"" + } + } + } + }, + { + ""type"": ""people"", + ""id"": ""5"", + ""attributes"": { + ""name"": ""Doug Shields"" + }, + ""relationships"": { + ""favoriteFood"": { + ""data"": { + ""type"": ""foods"", + ""id"": ""7"" + } + }, + ""favoriteSong"": { + ""data"": { + ""type"": ""songs"", + ""id"": ""10"" + } + } + } + }, + { + ""type"": ""foods"", + ""id"": ""7"", + ""attributes"": { + ""dish"": ""Nostrum totam harum totam voluptatibus."" + } + }, + { + ""type"": ""songs"", + ""id"": ""10"", + ""attributes"": { + ""title"": ""Illum assumenda iste quia natus et dignissimos reiciendis."" + } + }, + { + ""type"": ""foods"", + ""id"": ""6"", + ""attributes"": { + ""dish"": ""Illum assumenda iste quia natus et dignissimos reiciendis."" + } + }, + { + ""type"": ""songs"", + ""id"": ""11"", + ""attributes"": { + ""title"": ""Nostrum totam harum totam voluptatibus."" + } + } + ] +}"); + } + + [Fact] + public void Duplicate_children_in_multiple_chains_occur_once_in_output() + { + // Arrange + var fakers = new ResponseSerializationFakers(); + + Person person = fakers.Person.Generate(); + List<Article> articles = fakers.Article.Generate(5); + articles.ForEach(article => article.Author = person); + articles.ForEach(article => article.Reviewer = person); + + IJsonApiOptions options = new JsonApiOptions(); + ResponseModelAdapter responseModelAdapter = CreateAdapter(options, null, "author,reviewer"); + + // Act + Document document = responseModelAdapter.Convert(articles); + + // Assert + document.Included.ShouldHaveCount(1); + + document.Included[0].Attributes.ShouldContainKey("name").With(value => value.Should().Be(person.Name)); + document.Included[0].Id.Should().Be(person.StringId); + } + + private ResponseModelAdapter CreateAdapter(IJsonApiOptions options, string? primaryId, string includeChains) + { + // @formatter:wrap_chained_method_calls chop_always + // @formatter:keep_existing_linebreaks true + + IResourceGraph resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance) + .Add<Article, int>() + .Add<Person, int>() + .Add<Blog, int>() + .Add<Food, int>() + .Add<Song, int>() + .Build(); + + // @formatter:wrap_chained_method_calls restore + // @formatter:keep_existing_linebreaks restore + + var request = new JsonApiRequest + { + Kind = EndpointKind.Primary, + PrimaryResourceType = resourceGraph.GetResourceType<Article>(), + PrimaryId = primaryId + }; + + var evaluatedIncludeCache = new EvaluatedIncludeCache(); + var linkBuilder = new FakeLinkBuilder(); + var metaBuilder = new FakeMetaBuilder(); + var resourceDefinitionAccessor = new FakeResourceDefinitionAccessor(); + var sparseFieldSetCache = new SparseFieldSetCache(Array.Empty<IQueryConstraintProvider>(), resourceDefinitionAccessor); + var requestQueryStringAccessor = new FakeRequestQueryStringAccessor(); + + var parser = new IncludeParser(); + IncludeExpression include = parser.Parse(includeChains, request.PrimaryResourceType, null); + evaluatedIncludeCache.Set(include); + + return new ResponseModelAdapter(request, options, linkBuilder, metaBuilder, resourceDefinitionAccessor, evaluatedIncludeCache, sparseFieldSetCache, + requestQueryStringAccessor); + } + + private sealed class FakeLinkBuilder : ILinkBuilder + { + public TopLevelLinks? GetTopLevelLinks() + { + return null; + } + + public ResourceLinks? GetResourceLinks(ResourceType resourceType, IIdentifiable resource) + { + return null; + } + + public RelationshipLinks? GetRelationshipLinks(RelationshipAttribute relationship, IIdentifiable leftResource) + { + return null; + } + } + + private sealed class FakeMetaBuilder : IMetaBuilder + { + public void Add(IReadOnlyDictionary<string, object?> values) + { + } + + public IDictionary<string, object?>? Build() + { + return null; + } + } + + private sealed class FakeResourceDefinitionAccessor : IResourceDefinitionAccessor + { + public IImmutableSet<IncludeElementExpression> OnApplyIncludes(ResourceType resourceType, IImmutableSet<IncludeElementExpression> 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<string, object?>? GetMeta(ResourceType resourceType, IIdentifiable resourceInstance) + { + return null; + } + + public Task OnPrepareWriteAsync<TResource>(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + return Task.CompletedTask; + } + + public Task<IIdentifiable?> OnSetToOneRelationshipAsync<TResource>(TResource leftResource, HasOneAttribute hasOneRelationship, + IIdentifiable? rightResourceId, WriteOperationKind writeOperation, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + return Task.FromResult(rightResourceId); + } + + public Task OnSetToManyRelationshipAsync<TResource>(TResource leftResource, HasManyAttribute hasManyRelationship, + ISet<IIdentifiable> rightResourceIds, WriteOperationKind writeOperation, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + return Task.CompletedTask; + } + + public Task OnAddToRelationshipAsync<TResource, TId>(TId leftResourceId, HasManyAttribute hasManyRelationship, ISet<IIdentifiable> rightResourceIds, + CancellationToken cancellationToken) + where TResource : class, IIdentifiable<TId> + { + return Task.CompletedTask; + } + + public Task OnRemoveFromRelationshipAsync<TResource>(TResource leftResource, HasManyAttribute hasManyRelationship, + ISet<IIdentifiable> rightResourceIds, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + return Task.CompletedTask; + } + + public Task OnWritingAsync<TResource>(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + return Task.CompletedTask; + } + + public Task OnWriteSucceededAsync<TResource>(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 FakeRequestQueryStringAccessor : IRequestQueryStringAccessor + { + public IQueryCollection Query { get; } = new QueryCollection(0); + } + } +} diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/ResponseSerializationFakers.cs b/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/ResponseSerializationFakers.cs new file mode 100644 index 0000000000..acc001a16b --- /dev/null +++ b/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Response/ResponseSerializationFakers.cs @@ -0,0 +1,49 @@ +using Bogus; +using JsonApiDotNetCoreTests.UnitTests.Serialization.Response.Models; +using Person = JsonApiDotNetCoreTests.UnitTests.Serialization.Response.Models.Person; + +// @formatter:wrap_chained_method_calls chop_always +// @formatter:keep_existing_linebreaks true + +namespace JsonApiDotNetCoreTests.UnitTests.Serialization.Response +{ + internal sealed class ResponseSerializationFakers + { + private const int FakerSeed = 0; + private int _index; + + public Faker<Food> Food { get; } + public Faker<Song> Song { get; } + public Faker<Article> Article { get; } + public Faker<Blog> Blog { get; } + public Faker<Person> Person { get; } + + public ResponseSerializationFakers() + { + Article = new Faker<Article>() + .UseSeed(FakerSeed) + .RuleFor(article => article.Title, faker => faker.Hacker.Phrase()) + .RuleFor(article => article.Id, _ => ++_index); + + Person = new Faker<Person>() + .UseSeed(FakerSeed) + .RuleFor(person => person.Name, faker => faker.Person.FullName) + .RuleFor(person => person.Id, _ => ++_index); + + Blog = new Faker<Blog>() + .UseSeed(FakerSeed) + .RuleFor(blog => blog.Title, faker => faker.Hacker.Phrase()) + .RuleFor(blog => blog.Id, _ => ++_index); + + Song = new Faker<Song>() + .UseSeed(FakerSeed) + .RuleFor(song => song.Title, faker => faker.Lorem.Sentence()) + .RuleFor(song => song.Id, _ => ++_index); + + Food = new Faker<Food>() + .UseSeed(FakerSeed) + .RuleFor(food => food.Dish, faker => faker.Lorem.Sentence()) + .RuleFor(food => food.Id, _ => ++_index); + } + } +} diff --git a/test/MultiDbContextTests/ResourceTests.cs b/test/MultiDbContextTests/ResourceTests.cs index 27b2049e07..d8661a1996 100644 --- a/test/MultiDbContextTests/ResourceTests.cs +++ b/test/MultiDbContextTests/ResourceTests.cs @@ -43,8 +43,8 @@ public async Task Can_get_ResourceAs() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(1); - responseDocument.Data.ManyValue[0].Attributes["nameA"].Should().Be("SampleA"); + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("nameA").With(value => value.Should().Be("SampleA")); } [Fact] @@ -59,8 +59,8 @@ public async Task Can_get_ResourceBs() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().HaveCount(1); - responseDocument.Data.ManyValue[0].Attributes["nameB"].Should().Be("SampleB"); + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("nameB").With(value => value.Should().Be("SampleB")); } protected override HttpClient CreateClient() diff --git a/test/NoEntityFrameworkTests/WorkItemTests.cs b/test/NoEntityFrameworkTests/WorkItemTests.cs index e1b6dbafe6..881314da30 100644 --- a/test/NoEntityFrameworkTests/WorkItemTests.cs +++ b/test/NoEntityFrameworkTests/WorkItemTests.cs @@ -37,10 +37,15 @@ public WorkItemTests(WebApplicationFactory<Startup> factory) [Fact] public async Task Can_get_WorkItems() { + var workItem = new WorkItem + { + Title = "Write some code." + }; + // Arrange await RunOnDatabaseAsync(async dbContext => { - dbContext.WorkItems.Add(new WorkItem()); + dbContext.WorkItems.Add(workItem); await dbContext.SaveChangesAsync(); }); @@ -52,14 +57,17 @@ await RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.Should().NotBeEmpty(); + responseDocument.Data.ManyValue.ShouldNotBeEmpty(); } [Fact] public async Task Can_get_WorkItem_by_ID() { // Arrange - var workItem = new WorkItem(); + var workItem = new WorkItem + { + Title = "Write some code." + }; await RunOnDatabaseAsync(async dbContext => { @@ -75,7 +83,7 @@ await RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Id.Should().Be(workItem.StringId); } @@ -114,18 +122,22 @@ public async Task Can_create_WorkItem() // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.Should().NotBeNull(); - responseDocument.Data.SingleValue.Attributes["isBlocked"].Should().Be(newWorkItem.IsBlocked); - responseDocument.Data.SingleValue.Attributes["title"].Should().Be(newWorkItem.Title); - responseDocument.Data.SingleValue.Attributes["durationInHours"].Should().Be(newWorkItem.DurationInHours); - responseDocument.Data.SingleValue.Attributes["projectId"].Should().Be(newWorkItem.ProjectId); + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldNotBeEmpty(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("isBlocked").With(value => value.Should().Be(newWorkItem.IsBlocked)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newWorkItem.Title)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("durationInHours").With(value => value.Should().Be(newWorkItem.DurationInHours)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("projectId").With(value => value.Should().Be(newWorkItem.ProjectId)); } [Fact] public async Task Can_delete_WorkItem() { // Arrange - var workItem = new WorkItem(); + var workItem = new WorkItem + { + Title = "Write some code." + }; await RunOnDatabaseAsync(async dbContext => { diff --git a/test/OpenApiClientTests/LegacyClient/ApiResponse.cs b/test/OpenApiClientTests/LegacyClient/ApiResponse.cs index 73406fad09..585212f12f 100644 --- a/test/OpenApiClientTests/LegacyClient/ApiResponse.cs +++ b/test/OpenApiClientTests/LegacyClient/ApiResponse.cs @@ -9,7 +9,8 @@ namespace OpenApiClientTests.LegacyClient { internal static class ApiResponse { - public static async Task<TResponse> TranslateAsync<TResponse>(Func<Task<TResponse>> operation) + public static async Task<TResponse?> TranslateAsync<TResponse>(Func<Task<TResponse>> operation) + where TResponse : class { // Workaround for https://github.com/RicoSuter/NSwag/issues/2499 @@ -26,7 +27,7 @@ public static async Task<TResponse> TranslateAsync<TResponse>(Func<Task<TRespons throw; } - return default; + return null; } } } diff --git a/test/OpenApiClientTests/LegacyClient/FakeHttpClientWrapper.cs b/test/OpenApiClientTests/LegacyClient/FakeHttpClientWrapper.cs index e5cfe6a687..4857a300e0 100644 --- a/test/OpenApiClientTests/LegacyClient/FakeHttpClientWrapper.cs +++ b/test/OpenApiClientTests/LegacyClient/FakeHttpClientWrapper.cs @@ -18,8 +18,8 @@ internal sealed class FakeHttpClientWrapper : IDisposable private readonly FakeHttpMessageHandler _handler; public HttpClient HttpClient { get; } - public HttpRequestMessage Request => _handler.Request; - public string RequestBody => _handler.RequestBody; + public HttpRequestMessage? Request => _handler.Request; + public string? RequestBody => _handler.RequestBody; private FakeHttpClientWrapper(HttpClient httpClient, FakeHttpMessageHandler handler) { @@ -27,11 +27,10 @@ private FakeHttpClientWrapper(HttpClient httpClient, FakeHttpMessageHandler hand _handler = handler; } - public static FakeHttpClientWrapper Create(HttpStatusCode statusCode, string responseBody) + public static FakeHttpClientWrapper Create(HttpStatusCode statusCode, string? responseBody) { HttpResponseMessage response = CreateResponse(statusCode, responseBody); - var handler = new FakeHttpMessageHandler(); - handler.SetResponse(response); + var handler = new FakeHttpMessageHandler(response); var httpClient = new HttpClient(handler) { @@ -41,14 +40,14 @@ public static FakeHttpClientWrapper Create(HttpStatusCode statusCode, string res return new FakeHttpClientWrapper(httpClient, handler); } - public void ChangeResponse(HttpStatusCode statusCode, string responseBody) + public void ChangeResponse(HttpStatusCode statusCode, string? responseBody) { HttpResponseMessage response = CreateResponse(statusCode, responseBody); _handler.SetResponse(response); } - private static HttpResponseMessage CreateResponse(HttpStatusCode statusCode, string responseBody) + private static HttpResponseMessage CreateResponse(HttpStatusCode statusCode, string? responseBody) { var response = new HttpResponseMessage(statusCode); @@ -71,8 +70,13 @@ private sealed class FakeHttpMessageHandler : HttpMessageHandler { private HttpResponseMessage _response; - public HttpRequestMessage Request { get; private set; } - public string RequestBody { get; private set; } + public HttpRequestMessage? Request { get; private set; } + public string? RequestBody { get; private set; } + + public FakeHttpMessageHandler(HttpResponseMessage response) + { + _response = response; + } public void SetResponse(HttpResponseMessage response) { diff --git a/test/OpenApiClientTests/LegacyClient/RequestTests.cs b/test/OpenApiClientTests/LegacyClient/RequestTests.cs index ff9f1bbe56..4f4ddce61b 100644 --- a/test/OpenApiClientTests/LegacyClient/RequestTests.cs +++ b/test/OpenApiClientTests/LegacyClient/RequestTests.cs @@ -28,6 +28,7 @@ public async Task Getting_resource_collection_produces_expected_request() _ = await ApiResponse.TranslateAsync(async () => await apiClient.GetFlightCollectionAsync()); // Assert + wrapper.Request.ShouldNotBeNull(); wrapper.Request.Headers.GetValue(HeaderNames.Accept).Should().Be(HeaderConstants.MediaType); wrapper.Request.Method.Should().Be(HttpMethod.Get); wrapper.Request.RequestUri.Should().Be(HostPrefix + "flights"); @@ -47,6 +48,7 @@ public async Task Getting_resource_produces_expected_request() _ = await ApiResponse.TranslateAsync(async () => await apiClient.GetFlightAsync(flightId)); // Assert + wrapper.Request.ShouldNotBeNull(); wrapper.Request.Headers.GetValue(HeaderNames.Accept).Should().Be(HeaderConstants.MediaType); wrapper.Request.Method.Should().Be(HttpMethod.Get); wrapper.Request.RequestUri.Should().Be(HostPrefix + $"flights/{flightId}"); @@ -67,7 +69,22 @@ public async Task Partial_posting_resource_with_selected_relationships_produces_ Type = FlightsResourceType.Flights, Relationships = new FlightRelationshipsInPostRequest { - Purser = new ToOneFlightAttendantRequestData() + Purser = new ToOneFlightAttendantRequestData + { + Data = new FlightAttendantIdentifier + { + Id = "bBJHu", + Type = FlightAttendantsResourceType.FlightAttendants + } + }, + BackupPurser = new NullableToOneFlightAttendantRequestData + { + Data = new FlightAttendantIdentifier + { + Id = "NInmX", + Type = FlightAttendantsResourceType.FlightAttendants + } + } } } }; @@ -76,6 +93,7 @@ public async Task Partial_posting_resource_with_selected_relationships_produces_ _ = await ApiResponse.TranslateAsync(async () => await apiClient.PostFlightAsync(requestDocument)); // Assert + wrapper.Request.ShouldNotBeNull(); wrapper.Request.Headers.GetValue(HeaderNames.Accept).Should().Be(HeaderConstants.MediaType); wrapper.Request.Method.Should().Be(HttpMethod.Post); wrapper.Request.RequestUri.Should().Be(HostPrefix + "flights"); @@ -88,7 +106,16 @@ public async Task Partial_posting_resource_with_selected_relationships_produces_ ""type"": ""flights"", ""relationships"": { ""purser"": { - ""data"": null + ""data"": { + ""type"": ""flight-attendants"", + ""id"": ""bBJHu"" + } + }, + ""backup-purser"": { + ""data"": { + ""type"": ""flight-attendants"", + ""id"": ""NInmX"" + } } } } @@ -136,6 +163,7 @@ public async Task Partial_posting_resource_produces_expected_request() } // Assert + wrapper.Request.ShouldNotBeNull(); wrapper.Request.Headers.GetValue(HeaderNames.Accept).Should().Be(HeaderConstants.MediaType); wrapper.Request.Method.Should().Be(HttpMethod.Post); wrapper.Request.RequestUri.Should().Be(HostPrefix + "airplanes"); @@ -187,6 +215,7 @@ public async Task Partial_patching_resource_produces_expected_request() } // Assert + wrapper.Request.ShouldNotBeNull(); wrapper.Request.Headers.GetValue(HeaderNames.Accept).Should().Be(HeaderConstants.MediaType); wrapper.Request.Method.Should().Be(HttpMethod.Patch); wrapper.Request.RequestUri.Should().Be(HostPrefix + $"airplanes/{airplaneId}"); @@ -221,6 +250,7 @@ public async Task Deleting_resource_produces_expected_request() await apiClient.DeleteFlightAsync(flightId); // Assert + wrapper.Request.ShouldNotBeNull(); wrapper.Request.Method.Should().Be(HttpMethod.Delete); wrapper.Request.RequestUri.Should().Be(HostPrefix + $"flights/{flightId}"); wrapper.RequestBody.Should().BeNull(); @@ -239,6 +269,7 @@ public async Task Getting_secondary_resource_produces_expected_request() _ = await ApiResponse.TranslateAsync(async () => await apiClient.GetFlightPurserAsync(flightId)); // Assert + wrapper.Request.ShouldNotBeNull(); wrapper.Request.Headers.GetValue(HeaderNames.Accept).Should().Be(HeaderConstants.MediaType); wrapper.Request.Method.Should().Be(HttpMethod.Get); wrapper.Request.RequestUri.Should().Be(HostPrefix + $"flights/{flightId}/purser"); @@ -258,6 +289,7 @@ public async Task Getting_secondary_resources_produces_expected_request() _ = await ApiResponse.TranslateAsync(async () => await apiClient.GetFlightCabinCrewMembersAsync(flightId)); // Assert + wrapper.Request.ShouldNotBeNull(); wrapper.Request.Headers.GetValue(HeaderNames.Accept).Should().Be(HeaderConstants.MediaType); wrapper.Request.Method.Should().Be(HttpMethod.Get); wrapper.Request.RequestUri.Should().Be(HostPrefix + $"flights/{flightId}/cabin-crew-members"); @@ -277,6 +309,7 @@ public async Task Getting_ToOne_relationship_produces_expected_request() _ = await ApiResponse.TranslateAsync(async () => await apiClient.GetFlightPurserRelationshipAsync(flightId)); // Assert + wrapper.Request.ShouldNotBeNull(); wrapper.Request.Headers.GetValue(HeaderNames.Accept).Should().Be(HeaderConstants.MediaType); wrapper.Request.Method.Should().Be(HttpMethod.Get); wrapper.Request.RequestUri.Should().Be(HostPrefix + $"flights/{flightId}/relationships/purser"); @@ -305,6 +338,7 @@ public async Task Patching_ToOne_relationship_produces_expected_request() await apiClient.PatchFlightPurserRelationshipAsync(flightId, requestDocument); // Assert + wrapper.Request.ShouldNotBeNull(); wrapper.Request.Method.Should().Be(HttpMethod.Patch); wrapper.Request.RequestUri.Should().Be(HostPrefix + $"flights/{flightId}/relationships/purser"); wrapper.Request.Content.Should().NotBeNull(); @@ -332,6 +366,7 @@ public async Task Getting_ToMany_relationship_produces_expected_request() _ = await ApiResponse.TranslateAsync(async () => await apiClient.GetFlightCabinCrewMembersRelationshipAsync(flightId)); // Assert + wrapper.Request.ShouldNotBeNull(); wrapper.Request.Headers.GetValue(HeaderNames.Accept).Should().Be(HeaderConstants.MediaType); wrapper.Request.Method.Should().Be(HttpMethod.Get); wrapper.Request.RequestUri.Should().Be(HostPrefix + $"flights/{flightId}/relationships/cabin-crew-members"); @@ -368,6 +403,7 @@ public async Task Posting_ToMany_relationship_produces_expected_request() await apiClient.PostFlightCabinCrewMembersRelationshipAsync(flightId, requestDocument); // Assert + wrapper.Request.ShouldNotBeNull(); wrapper.Request.Method.Should().Be(HttpMethod.Post); wrapper.Request.RequestUri.Should().Be(HostPrefix + $"flights/{flightId}/relationships/cabin-crew-members"); wrapper.Request.Content.Should().NotBeNull(); @@ -418,6 +454,7 @@ public async Task Patching_ToMany_relationship_produces_expected_request() await apiClient.PatchFlightCabinCrewMembersRelationshipAsync(flightId, requestDocument); // Assert + wrapper.Request.ShouldNotBeNull(); wrapper.Request.Method.Should().Be(HttpMethod.Patch); wrapper.Request.RequestUri.Should().Be(HostPrefix + $"flights/{flightId}/relationships/cabin-crew-members"); wrapper.Request.Content.Should().NotBeNull(); @@ -468,6 +505,7 @@ public async Task Deleting_ToMany_relationship_produces_expected_request() await apiClient.DeleteFlightCabinCrewMembersRelationshipAsync(flightId, requestDocument); // Assert + wrapper.Request.ShouldNotBeNull(); wrapper.Request.Method.Should().Be(HttpMethod.Delete); wrapper.Request.RequestUri.Should().Be(HostPrefix + $"flights/{flightId}/relationships/cabin-crew-members"); wrapper.Request.Content.Should().NotBeNull(); diff --git a/test/OpenApiClientTests/LegacyClient/ResponseTests.cs b/test/OpenApiClientTests/LegacyClient/ResponseTests.cs index 14e3dfa130..5a823219aa 100644 --- a/test/OpenApiClientTests/LegacyClient/ResponseTests.cs +++ b/test/OpenApiClientTests/LegacyClient/ResponseTests.cs @@ -241,7 +241,10 @@ public async Task Posting_resource_translates_response() ""self"": """ + HostPrefix + @"flights/" + flightId + @"/relationships/purser"", ""related"": """ + HostPrefix + @"flights/" + flightId + @"/purser"" }, - ""data"": null + ""data"": { + ""type"": ""flight-attendants"", + ""id"": """ + flightAttendantId + @""" + } }, ""cabin-crew-members"": { ""links"": { @@ -284,7 +287,7 @@ public async Task Posting_resource_translates_response() { Data = new FlightAttendantIdentifier { - Id = "XxuIu", + Id = flightAttendantId, Type = FlightAttendantsResourceType.FlightAttendants } } @@ -294,7 +297,8 @@ public async Task Posting_resource_translates_response() // Assert document.Data.Attributes.Should().BeNull(); - document.Data.Relationships.Purser.Data.Should().BeNull(); + document.Data.Relationships.Purser.Data.Should().NotBeNull(); + document.Data.Relationships.Purser.Data.Id.Should().Be(flightAttendantId); document.Data.Relationships.CabinCrewMembers.Data.Should().HaveCount(1); document.Data.Relationships.CabinCrewMembers.Data.First().Id.Should().Be(flightAttendantId); document.Data.Relationships.CabinCrewMembers.Data.First().Type.Should().Be(FlightAttendantsResourceType.FlightAttendants); @@ -348,7 +352,7 @@ public async Task Patching_resource_without_side_effects_translates_response() IOpenApiClient apiClient = new OpenApiClient(wrapper.HttpClient); // Act - FlightPrimaryResponseDocument document = await ApiResponse.TranslateAsync(async () => await apiClient.PatchFlightAsync(flightId, + FlightPrimaryResponseDocument? document = await ApiResponse.TranslateAsync(async () => await apiClient.PatchFlightAsync(flightId, new FlightPatchRequestDocument { Data = new FlightDataInPatchRequest @@ -381,6 +385,11 @@ public async Task Getting_secondary_resource_translates_response() { // Arrange const string flightId = "ZvuH1"; + const string purserId = "bBJHu"; + const string emailAddress = "email@example.com"; + const string age = "20"; + const string profileImageUrl = "www.image.com"; + const string distanceTraveledInKilometer = "5000"; const string responseBody = @"{ ""links"": { @@ -388,7 +397,33 @@ public async Task Getting_secondary_resource_translates_response() ""first"": """ + HostPrefix + @"flights/" + flightId + @"/purser"", ""last"": """ + HostPrefix + @"flights/" + flightId + @"/purser"" }, - ""data"": null + ""data"": { + ""type"": ""flight-attendants"", + ""id"": """ + purserId + @""", + ""attributes"": { + ""email-address"": """ + emailAddress + @""", + ""age"": """ + age + @""", + ""profile-image-url"": """ + profileImageUrl + @""", + ""distance-traveled-in-kilometers"": """ + distanceTraveledInKilometer + @""", + }, + ""relationships"": { + ""scheduled-for-flights"": { + ""links"": { + ""self"": """ + HostPrefix + @"flight-attendants/" + purserId + @"/relationships/scheduled-for-flights"", + ""related"": """ + HostPrefix + @"flight-attendants/" + purserId + @"/scheduled-for-flights"" + } + }, + ""purser-on-flights"": { + ""links"": { + ""self"": """ + HostPrefix + @"flight-attendants/" + purserId + @"/relationships/purser-on-flights"", + ""related"": """ + HostPrefix + @"flight-attendants/" + purserId + @"/purser-on-flights"" + } + }, + }, + ""links"": { + ""self"": """ + HostPrefix + @"flight-attendants/" + purserId + @""", + } + } }"; using var wrapper = FakeHttpClientWrapper.Create(HttpStatusCode.OK, responseBody); @@ -397,6 +432,36 @@ public async Task Getting_secondary_resource_translates_response() // Act FlightAttendantSecondaryResponseDocument document = await apiClient.GetFlightPurserAsync(flightId); + // Assert + document.Data.Should().NotBeNull(); + document.Data.Id.Should().Be(purserId); + document.Data.Attributes.EmailAddress.Should().Be(emailAddress); + document.Data.Attributes.Age.Should().Be(int.Parse(age)); + document.Data.Attributes.ProfileImageUrl.Should().Be(profileImageUrl); + document.Data.Attributes.DistanceTraveledInKilometers.Should().Be(int.Parse(distanceTraveledInKilometer)); + } + + [Fact] + public async Task Getting_nullable_secondary_resource_translates_response() + { + // Arrange + const string flightId = "ZvuH1"; + + const string responseBody = @"{ + ""links"": { + ""self"": """ + HostPrefix + @"flights/" + flightId + @"/backup-purser"", + ""first"": """ + HostPrefix + @"flights/" + flightId + @"/backup-purser"", + ""last"": """ + HostPrefix + @"flights/" + flightId + @"/backup-purser"" + }, + ""data"": null +}"; + + using var wrapper = FakeHttpClientWrapper.Create(HttpStatusCode.OK, responseBody); + IOpenApiClient apiClient = new OpenApiClient(wrapper.HttpClient); + + // Act + NullableFlightAttendantSecondaryResponseDocument document = await apiClient.GetFlightBackupPurserAsync(flightId); + // Assert document.Data.Should().BeNull(); } @@ -425,6 +490,30 @@ public async Task Getting_secondary_resources_translates_response() document.Data.Should().BeEmpty(); } + [Fact] + public async Task Getting_nullable_ToOne_relationship_translates_response() + { + // Arrange + const string flightId = "ZvuH1"; + + const string responseBody = @"{ + ""links"": { + ""self"": """ + HostPrefix + @"flights/" + flightId + @"/relationships/backup-purser"", + ""related"": """ + HostPrefix + @"flights/" + flightId + @"/relationships/backup-purser"" + }, + ""data"": null +}"; + + using var wrapper = FakeHttpClientWrapper.Create(HttpStatusCode.OK, responseBody); + IOpenApiClient apiClient = new OpenApiClient(wrapper.HttpClient); + + // Act + NullableFlightAttendantIdentifierResponseDocument document = await apiClient.GetFlightBackupPurserRelationshipAsync(flightId); + + // Assert + document.Data.Should().BeNull(); + } + [Fact] public async Task Getting_ToOne_relationship_translates_response() { diff --git a/test/OpenApiTests/LegacyOpenApiIntegration/Airplane.cs b/test/OpenApiTests/LegacyOpenApiIntegration/Airplane.cs index b086181524..2503cadf89 100644 --- a/test/OpenApiTests/LegacyOpenApiIntegration/Airplane.cs +++ b/test/OpenApiTests/LegacyOpenApiIntegration/Airplane.cs @@ -11,13 +11,12 @@ namespace OpenApiTests.LegacyOpenApiIntegration public sealed class Airplane : Identifiable<string> { [Attr(Capabilities = AttrCapabilities.AllowView | AttrCapabilities.AllowCreate | AttrCapabilities.AllowChange)] - [Required] [MaxLength(255)] - public string Name { get; set; } + public string Name { get; set; } = null!; [Attr(Capabilities = AttrCapabilities.AllowView | AttrCapabilities.AllowCreate | AttrCapabilities.AllowChange)] [MaxLength(16)] - public string SerialNumber { get; set; } + public string? SerialNumber { get; set; } [Attr(Capabilities = AttrCapabilities.AllowView | AttrCapabilities.AllowCreate | AttrCapabilities.AllowChange)] public int? AirtimeInHours { get; set; } @@ -33,12 +32,12 @@ public sealed class Airplane : Identifiable<string> [Attr(Capabilities = AttrCapabilities.AllowView | AttrCapabilities.AllowChange)] [MaxLength(85)] - public string ManufacturedInCity { get; set; } + public string? ManufacturedInCity { get; set; } [Attr(Capabilities = AttrCapabilities.AllowView)] public AircraftKind Kind { get; set; } [HasMany] - public ISet<Flight> Flights { get; set; } + public ISet<Flight> Flights { get; set; } = new HashSet<Flight>(); } } diff --git a/test/OpenApiTests/LegacyOpenApiIntegration/AirplanesController.cs b/test/OpenApiTests/LegacyOpenApiIntegration/AirplanesController.cs index 3289ded0d4..1f72395054 100644 --- a/test/OpenApiTests/LegacyOpenApiIntegration/AirplanesController.cs +++ b/test/OpenApiTests/LegacyOpenApiIntegration/AirplanesController.cs @@ -7,8 +7,9 @@ namespace OpenApiTests.LegacyOpenApiIntegration { public sealed class AirplanesController : JsonApiController<Airplane, string> { - public AirplanesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<Airplane, string> resourceService) - : base(options, loggerFactory, resourceService) + public AirplanesController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<Airplane, string> resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/OpenApiTests/LegacyOpenApiIntegration/Flight.cs b/test/OpenApiTests/LegacyOpenApiIntegration/Flight.cs index f8dca69478..d5608d45ce 100644 --- a/test/OpenApiTests/LegacyOpenApiIntegration/Flight.cs +++ b/test/OpenApiTests/LegacyOpenApiIntegration/Flight.cs @@ -12,13 +12,12 @@ namespace OpenApiTests.LegacyOpenApiIntegration public sealed class Flight : Identifiable<string> { [Attr(Capabilities = AttrCapabilities.AllowView | AttrCapabilities.AllowChange)] - [Required] [MaxLength(40)] - public string FinalDestination { get; set; } + public string FinalDestination { get; set; } = null!; [Attr(Capabilities = AttrCapabilities.AllowView | AttrCapabilities.AllowChange)] [MaxLength(2000)] - public string StopOverDestination { get; set; } + public string? StopOverDestination { get; set; } [Attr(PublicName = "operated-by", Capabilities = AttrCapabilities.AllowView | AttrCapabilities.AllowChange)] public Airline Airline { get; set; } @@ -30,16 +29,19 @@ public sealed class Flight : Identifiable<string> public DateTime? ArrivesAt { get; set; } [HasMany] - public ISet<FlightAttendant> CabinCrewMembers { get; set; } + public ISet<FlightAttendant> CabinCrewMembers { get; set; } = new HashSet<FlightAttendant>(); [HasOne] - public FlightAttendant Purser { get; set; } + public FlightAttendant Purser { get; set; } = null!; + + [HasOne] + public FlightAttendant? BackupPurser { get; set; } [Attr] [NotMapped] - public ICollection<string> ServicesOnBoard { get; set; } + public ICollection<string> ServicesOnBoard { get; set; } = new HashSet<string>(); [HasMany] - public ICollection<Passenger> Passengers { get; set; } + public ICollection<Passenger> Passengers { get; set; } = new HashSet<Passenger>(); } } diff --git a/test/OpenApiTests/LegacyOpenApiIntegration/FlightAttendant.cs b/test/OpenApiTests/LegacyOpenApiIntegration/FlightAttendant.cs index 520dcd8ffc..1cf2de622d 100644 --- a/test/OpenApiTests/LegacyOpenApiIntegration/FlightAttendant.cs +++ b/test/OpenApiTests/LegacyOpenApiIntegration/FlightAttendant.cs @@ -10,7 +10,7 @@ namespace OpenApiTests.LegacyOpenApiIntegration public sealed class FlightAttendant : Identifiable<string> { [Attr(Capabilities = AttrCapabilities.AllowView | AttrCapabilities.AllowFilter)] - public override string Id { get; set; } + public override string Id { get; set; } = null!; [Attr(Capabilities = AttrCapabilities.None)] public FlightAttendantExpertiseLevel ExpertiseLevel { get; set; } @@ -18,25 +18,23 @@ public sealed class FlightAttendant : Identifiable<string> [Attr(Capabilities = AttrCapabilities.All)] [Required] [EmailAddress] - public string EmailAddress { get; set; } + public string? EmailAddress { get; set; } [Attr(Capabilities = AttrCapabilities.All)] - [Required] [Range(18, 75)] public int Age { get; set; } [Attr(Capabilities = AttrCapabilities.AllowView | AttrCapabilities.AllowCreate)] - [Required] [Url] - public string ProfileImageUrl { get; set; } + public string ProfileImageUrl { get; set; } = null!; [Attr] public long DistanceTraveledInKilometers { get; set; } [HasMany] - public ISet<Flight> ScheduledForFlights { get; set; } + public ISet<Flight> ScheduledForFlights { get; set; } = new HashSet<Flight>(); [HasMany] - public ISet<Flight> PurserOnFlights { get; set; } + public ISet<Flight> PurserOnFlights { get; set; } = new HashSet<Flight>(); } } diff --git a/test/OpenApiTests/LegacyOpenApiIntegration/FlightAttendantsController.cs b/test/OpenApiTests/LegacyOpenApiIntegration/FlightAttendantsController.cs index 43adc8d0eb..acaceb54ad 100644 --- a/test/OpenApiTests/LegacyOpenApiIntegration/FlightAttendantsController.cs +++ b/test/OpenApiTests/LegacyOpenApiIntegration/FlightAttendantsController.cs @@ -7,8 +7,9 @@ namespace OpenApiTests.LegacyOpenApiIntegration { public sealed class FlightAttendantsController : JsonApiController<FlightAttendant, string> { - public FlightAttendantsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<FlightAttendant, string> resourceService) - : base(options, loggerFactory, resourceService) + public FlightAttendantsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<FlightAttendant, string> resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/OpenApiTests/LegacyOpenApiIntegration/FlightsController.cs b/test/OpenApiTests/LegacyOpenApiIntegration/FlightsController.cs index 6d243c893c..af331adea1 100644 --- a/test/OpenApiTests/LegacyOpenApiIntegration/FlightsController.cs +++ b/test/OpenApiTests/LegacyOpenApiIntegration/FlightsController.cs @@ -7,8 +7,9 @@ namespace OpenApiTests.LegacyOpenApiIntegration { public sealed class FlightsController : JsonApiController<Flight, string> { - public FlightsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<Flight, string> resourceService) - : base(options, loggerFactory, resourceService) + public FlightsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<Flight, string> resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/OpenApiTests/LegacyOpenApiIntegration/LegacyIntegrationDbContext.cs b/test/OpenApiTests/LegacyOpenApiIntegration/LegacyIntegrationDbContext.cs index cf15a5a1ae..32617a2224 100644 --- a/test/OpenApiTests/LegacyOpenApiIntegration/LegacyIntegrationDbContext.cs +++ b/test/OpenApiTests/LegacyOpenApiIntegration/LegacyIntegrationDbContext.cs @@ -9,9 +9,9 @@ namespace OpenApiTests.LegacyOpenApiIntegration [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class LegacyIntegrationDbContext : DbContext { - public DbSet<Airplane> Airplanes { get; set; } - public DbSet<Flight> Flights { get; set; } - public DbSet<FlightAttendant> FlightAttendants { get; set; } + public DbSet<Airplane> Airplanes => Set<Airplane>(); + public DbSet<Flight> Flights => Set<Flight>(); + public DbSet<FlightAttendant> FlightAttendants => Set<FlightAttendant>(); public LegacyIntegrationDbContext(DbContextOptions<LegacyIntegrationDbContext> options) : base(options) @@ -27,6 +27,10 @@ protected override void OnModelCreating(ModelBuilder builder) builder.Entity<Flight>() .HasOne(flight => flight.Purser) .WithMany(flightAttendant => flightAttendant.PurserOnFlights); + + builder.Entity<Flight>() + .HasOne(flight => flight.BackupPurser) + .WithMany(); } } } diff --git a/test/OpenApiTests/LegacyOpenApiIntegration/LegacyOpenApiIntegrationTests.cs b/test/OpenApiTests/LegacyOpenApiIntegration/LegacyOpenApiIntegrationTests.cs index 313c78aae3..d461552bcc 100644 --- a/test/OpenApiTests/LegacyOpenApiIntegration/LegacyOpenApiIntegrationTests.cs +++ b/test/OpenApiTests/LegacyOpenApiIntegration/LegacyOpenApiIntegrationTests.cs @@ -47,7 +47,7 @@ private async Task<string> GetAsync(string requestUrl) private static async Task<string> LoadEmbeddedResourceAsync(string name) { var assembly = Assembly.GetExecutingAssembly(); - await using Stream stream = assembly.GetManifestResourceStream(name); + await using Stream? stream = assembly.GetManifestResourceStream(name); if (stream == null) { diff --git a/test/OpenApiTests/LegacyOpenApiIntegration/Passenger.cs b/test/OpenApiTests/LegacyOpenApiIntegration/Passenger.cs index 263393bac5..f115afca10 100644 --- a/test/OpenApiTests/LegacyOpenApiIntegration/Passenger.cs +++ b/test/OpenApiTests/LegacyOpenApiIntegration/Passenger.cs @@ -9,12 +9,11 @@ namespace OpenApiTests.LegacyOpenApiIntegration public sealed class Passenger : Identifiable<string> { [Attr(PublicName = "document-number", Capabilities = AttrCapabilities.AllowCreate | AttrCapabilities.AllowChange)] - [Required] [MaxLength(9)] - public string PassportNumber { get; set; } + public string PassportNumber { get; set; } = null!; [Attr] - public string FullName { get; set; } + public string? FullName { get; set; } [Attr] public CabinArea CabinArea { get; set; } diff --git a/test/OpenApiTests/LegacyOpenApiIntegration/PassengersController.cs b/test/OpenApiTests/LegacyOpenApiIntegration/PassengersController.cs index ea056c4474..4ff6842381 100644 --- a/test/OpenApiTests/LegacyOpenApiIntegration/PassengersController.cs +++ b/test/OpenApiTests/LegacyOpenApiIntegration/PassengersController.cs @@ -7,8 +7,9 @@ namespace OpenApiTests.LegacyOpenApiIntegration { public sealed class PassengersController : JsonApiController<Passenger, string> { - public PassengersController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<Passenger, string> resourceService) - : base(options, loggerFactory, resourceService) + public PassengersController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService<Passenger, string> resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } diff --git a/test/OpenApiTests/LegacyOpenApiIntegration/swagger.json b/test/OpenApiTests/LegacyOpenApiIntegration/swagger.json index d8c9bbda06..00d523bfe8 100644 --- a/test/OpenApiTests/LegacyOpenApiIntegration/swagger.json +++ b/test/OpenApiTests/LegacyOpenApiIntegration/swagger.json @@ -1184,6 +1184,152 @@ } } }, + "/api/v1/flights/{id}/backup-purser": { + "get": { + "tags": [ + "flights" + ], + "operationId": "get-flight-backup-purser", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/nullable-flight-attendant-secondary-response-document" + } + } + } + } + } + }, + "head": { + "tags": [ + "flights" + ], + "operationId": "head-flight-backup-purser", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/nullable-flight-attendant-secondary-response-document" + } + } + } + } + } + } + }, + "/api/v1/flights/{id}/relationships/backup-purser": { + "get": { + "tags": [ + "flights" + ], + "operationId": "get-flight-backup-purser-relationship", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/nullable-flight-attendant-identifier-response-document" + } + } + } + } + } + }, + "head": { + "tags": [ + "flights" + ], + "operationId": "head-flight-backup-purser-relationship", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/nullable-flight-attendant-identifier-response-document" + } + } + } + } + } + }, + "patch": { + "tags": [ + "flights" + ], + "operationId": "patch-flight-backup-purser-relationship", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/nullable-to-one-flight-attendant-request-data" + } + } + } + }, + "responses": { + "204": { + "description": "Success" + } + } + } + }, "/api/v1/flights/{id}/cabin-crew-members": { "get": { "tags": [ @@ -2066,7 +2212,6 @@ "flight-attendant-attributes-in-post-request": { "required": [ "email-address", - "age", "profile-image-url" ], "type": "object", @@ -2258,14 +2403,7 @@ "type": "object", "properties": { "data": { - "oneOf": [ - { - "$ref": "#/components/schemas/flight-attendant-identifier" - }, - { - "$ref": "#/components/schemas/null-value" - } - ] + "$ref": "#/components/schemas/flight-attendant-identifier" }, "meta": { "type": "object", @@ -2371,14 +2509,7 @@ "type": "object", "properties": { "data": { - "oneOf": [ - { - "$ref": "#/components/schemas/flight-attendant-data-in-response" - }, - { - "$ref": "#/components/schemas/null-value" - } - ] + "$ref": "#/components/schemas/flight-attendant-data-in-response" }, "meta": { "type": "object", @@ -2446,8 +2577,7 @@ "type": "array", "items": { "type": "string" - }, - "nullable": true + } } }, "additionalProperties": false @@ -2643,6 +2773,9 @@ "purser": { "$ref": "#/components/schemas/to-one-flight-attendant-request-data" }, + "backup-purser": { + "$ref": "#/components/schemas/nullable-to-one-flight-attendant-request-data" + }, "passengers": { "$ref": "#/components/schemas/to-many-passenger-request-data" } @@ -2650,6 +2783,9 @@ "additionalProperties": false }, "flight-relationships-in-post-request": { + "required": [ + "purser" + ], "type": "object", "properties": { "cabin-crew-members": { @@ -2658,6 +2794,9 @@ "purser": { "$ref": "#/components/schemas/to-one-flight-attendant-request-data" }, + "backup-purser": { + "$ref": "#/components/schemas/nullable-to-one-flight-attendant-request-data" + }, "passengers": { "$ref": "#/components/schemas/to-many-passenger-request-data" } @@ -2673,6 +2812,9 @@ "purser": { "$ref": "#/components/schemas/to-one-flight-attendant-response-data" }, + "backup-purser": { + "$ref": "#/components/schemas/nullable-to-one-flight-attendant-response-data" + }, "passengers": { "$ref": "#/components/schemas/to-many-passenger-response-data" } @@ -2855,6 +2997,111 @@ }, "nullable": true }, + "nullable-flight-attendant-identifier-response-document": { + "required": [ + "data", + "links" + ], + "type": "object", + "properties": { + "data": { + "oneOf": [ + { + "$ref": "#/components/schemas/flight-attendant-identifier" + }, + { + "$ref": "#/components/schemas/null-value" + } + ] + }, + "meta": { + "type": "object", + "additionalProperties": {} + }, + "jsonapi": { + "$ref": "#/components/schemas/jsonapi-object" + }, + "links": { + "$ref": "#/components/schemas/links-in-resource-identifier-document" + } + }, + "additionalProperties": false + }, + "nullable-flight-attendant-secondary-response-document": { + "required": [ + "data", + "links" + ], + "type": "object", + "properties": { + "data": { + "oneOf": [ + { + "$ref": "#/components/schemas/flight-attendant-data-in-response" + }, + { + "$ref": "#/components/schemas/null-value" + } + ] + }, + "meta": { + "type": "object", + "additionalProperties": {} + }, + "jsonapi": { + "$ref": "#/components/schemas/jsonapi-object" + }, + "links": { + "$ref": "#/components/schemas/links-in-resource-document" + } + }, + "additionalProperties": false + }, + "nullable-to-one-flight-attendant-request-data": { + "required": [ + "data" + ], + "type": "object", + "properties": { + "data": { + "oneOf": [ + { + "$ref": "#/components/schemas/flight-attendant-identifier" + }, + { + "$ref": "#/components/schemas/null-value" + } + ] + } + }, + "additionalProperties": false + }, + "nullable-to-one-flight-attendant-response-data": { + "required": [ + "links" + ], + "type": "object", + "properties": { + "data": { + "oneOf": [ + { + "$ref": "#/components/schemas/flight-attendant-identifier" + }, + { + "$ref": "#/components/schemas/null-value" + } + ] + }, + "links": { + "$ref": "#/components/schemas/links-in-relationship-object" + }, + "meta": { + "type": "object", + "additionalProperties": {} + } + }, + "additionalProperties": false + }, "passenger-attributes-in-response": { "type": "object", "properties": { @@ -3087,14 +3334,7 @@ "type": "object", "properties": { "data": { - "oneOf": [ - { - "$ref": "#/components/schemas/flight-attendant-identifier" - }, - { - "$ref": "#/components/schemas/null-value" - } - ] + "$ref": "#/components/schemas/flight-attendant-identifier" } }, "additionalProperties": false @@ -3106,14 +3346,7 @@ "type": "object", "properties": { "data": { - "oneOf": [ - { - "$ref": "#/components/schemas/flight-attendant-identifier" - }, - { - "$ref": "#/components/schemas/null-value" - } - ] + "$ref": "#/components/schemas/flight-attendant-identifier" }, "links": { "$ref": "#/components/schemas/links-in-relationship-object" diff --git a/test/TestBuildingBlocks/DbContextExtensions.cs b/test/TestBuildingBlocks/DbContextExtensions.cs index 389038f7d1..ef1fdfa1ce 100644 --- a/test/TestBuildingBlocks/DbContextExtensions.cs +++ b/test/TestBuildingBlocks/DbContextExtensions.cs @@ -38,14 +38,14 @@ private static async Task ClearTablesAsync(this DbContext dbContext, params Type { foreach (Type model in models) { - IEntityType entityType = dbContext.Model.FindEntityType(model); + IEntityType? entityType = dbContext.Model.FindEntityType(model); if (entityType == null) { throw new InvalidOperationException($"Table for '{model.Name}' not found."); } - string tableName = entityType.GetTableName(); + string tableName = entityType.GetTableName()!; // PERF: We first try to clear the table, which is fast and usually succeeds, unless foreign key constraints are violated. // In that case, we recursively delete all related data, which is slow. diff --git a/test/TestBuildingBlocks/FakeLoggerFactory.cs b/test/TestBuildingBlocks/FakeLoggerFactory.cs index 0bcc047546..f4257ac17e 100644 --- a/test/TestBuildingBlocks/FakeLoggerFactory.cs +++ b/test/TestBuildingBlocks/FakeLoggerFactory.cs @@ -51,7 +51,7 @@ public void Clear() _messages.Clear(); } - public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func<TState, Exception, string> formatter) + public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) { if (IsEnabled(logLevel)) { @@ -62,7 +62,20 @@ public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Except public IDisposable BeginScope<TState>(TState state) { - return null; + return NullScope.Instance; + } + + private sealed class NullScope : IDisposable + { + public static readonly NullScope Instance = new(); + + private NullScope() + { + } + + public void Dispose() + { + } } } diff --git a/test/TestBuildingBlocks/FakerContainer.cs b/test/TestBuildingBlocks/FakerContainer.cs index 2dbfb441b8..785eb510eb 100644 --- a/test/TestBuildingBlocks/FakerContainer.cs +++ b/test/TestBuildingBlocks/FakerContainer.cs @@ -2,12 +2,19 @@ using System.Diagnostics; using System.Linq; using System.Reflection; +using Bogus.DataSets; +using FluentAssertions.Extensions; using Xunit; namespace TestBuildingBlocks { public abstract class FakerContainer { + static FakerContainer() + { + Date.SystemClock = () => 1.January(2020); + } + protected static int GetFakerSeed() { // The goal here is to have stable data over multiple test runs, but at the same time different data per test case. @@ -22,7 +29,7 @@ private static MethodBase GetTestMethod() { var stackTrace = new StackTrace(); - MethodBase testMethod = stackTrace.GetFrames().Select(stackFrame => stackFrame.GetMethod()).FirstOrDefault(IsTestMethod); + MethodBase? testMethod = stackTrace.GetFrames().Select(stackFrame => stackFrame.GetMethod()).FirstOrDefault(IsTestMethod); if (testMethod == null) { @@ -34,7 +41,7 @@ private static MethodBase GetTestMethod() return testMethod; } - private static bool IsTestMethod(MethodBase method) + private static bool IsTestMethod(MethodBase? method) { if (method == null) { diff --git a/test/TestBuildingBlocks/HttpRequestHeadersExtensions.cs b/test/TestBuildingBlocks/HttpRequestHeadersExtensions.cs index f8cd609c82..ff277caddd 100644 --- a/test/TestBuildingBlocks/HttpRequestHeadersExtensions.cs +++ b/test/TestBuildingBlocks/HttpRequestHeadersExtensions.cs @@ -11,11 +11,9 @@ public static class HttpRequestHeadersExtensions /// Returns the value of the specified HTTP request header, or <c>null</c> when not found. If the header occurs multiple times, their values are /// collapsed into a comma-separated string, without changing any surrounding double quotes. /// </summary> - public static string GetValue(this HttpRequestHeaders requestHeaders, string name) + public static string? GetValue(this HttpRequestHeaders requestHeaders, string name) { - bool headerExists = requestHeaders.TryGetValues(name, out IEnumerable<string> values); - - return headerExists ? new StringValues(values.ToArray()).ToString() : null; + return requestHeaders.TryGetValues(name, out IEnumerable<string>? values) ? new StringValues(values.ToArray()).ToString() : null; } } } diff --git a/test/TestBuildingBlocks/HttpResponseMessageExtensions.cs b/test/TestBuildingBlocks/HttpResponseMessageExtensions.cs index 873cec6d3f..fe8c638e03 100644 --- a/test/TestBuildingBlocks/HttpResponseMessageExtensions.cs +++ b/test/TestBuildingBlocks/HttpResponseMessageExtensions.cs @@ -11,7 +11,7 @@ public static class HttpResponseMessageExtensions { public static HttpResponseMessageAssertions Should(this HttpResponseMessage instance) { - return new(instance); + return new HttpResponseMessageAssertions(instance); } public sealed class HttpResponseMessageAssertions : ReferenceTypeAssertions<HttpResponseMessage, HttpResponseMessageAssertions> @@ -30,7 +30,7 @@ public AndConstraint<HttpResponseMessageAssertions> HaveStatusCode(HttpStatusCod if (Subject.StatusCode != statusCode) { string responseText = Subject.Content.ReadAsStringAsync().Result; - Subject.StatusCode.Should().Be(statusCode, $"response body returned was:\n{responseText}"); + Subject.StatusCode.Should().Be(statusCode, string.IsNullOrEmpty(responseText) ? null : $"response body returned was:\n{responseText}"); } return new AndConstraint<HttpResponseMessageAssertions>(this); diff --git a/test/TestBuildingBlocks/IntegrationTest.cs b/test/TestBuildingBlocks/IntegrationTest.cs index 59e45b0b72..569f8046aa 100644 --- a/test/TestBuildingBlocks/IntegrationTest.cs +++ b/test/TestBuildingBlocks/IntegrationTest.cs @@ -9,53 +9,53 @@ namespace TestBuildingBlocks { /// <summary> - /// A base class for tests that conveniently enables to execute HTTP requests against json:api endpoints. + /// A base class for tests that conveniently enables to execute HTTP requests against JSON:API endpoints. /// </summary> public abstract class IntegrationTest { protected abstract JsonSerializerOptions SerializerOptions { get; } public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecuteHeadAsync<TResponseDocument>(string requestUrl, - Action<HttpRequestHeaders> setRequestHeaders = null) + Action<HttpRequestHeaders>? setRequestHeaders = null) { return await ExecuteRequestAsync<TResponseDocument>(HttpMethod.Head, requestUrl, null, null, setRequestHeaders); } public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecuteGetAsync<TResponseDocument>(string requestUrl, - Action<HttpRequestHeaders> setRequestHeaders = null) + Action<HttpRequestHeaders>? setRequestHeaders = null) { return await ExecuteRequestAsync<TResponseDocument>(HttpMethod.Get, requestUrl, null, null, setRequestHeaders); } public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecutePostAsync<TResponseDocument>(string requestUrl, - object requestBody, string contentType = HeaderConstants.MediaType, Action<HttpRequestHeaders> setRequestHeaders = null) + object requestBody, string contentType = HeaderConstants.MediaType, Action<HttpRequestHeaders>? setRequestHeaders = null) { return await ExecuteRequestAsync<TResponseDocument>(HttpMethod.Post, requestUrl, requestBody, contentType, setRequestHeaders); } public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecutePostAtomicAsync<TResponseDocument>(string requestUrl, - object requestBody, string contentType = HeaderConstants.AtomicOperationsMediaType, Action<HttpRequestHeaders> setRequestHeaders = null) + object requestBody, string contentType = HeaderConstants.AtomicOperationsMediaType, Action<HttpRequestHeaders>? setRequestHeaders = null) { return await ExecuteRequestAsync<TResponseDocument>(HttpMethod.Post, requestUrl, requestBody, contentType, setRequestHeaders); } public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecutePatchAsync<TResponseDocument>(string requestUrl, - object requestBody, string contentType = HeaderConstants.MediaType, Action<HttpRequestHeaders> setRequestHeaders = null) + object requestBody, string contentType = HeaderConstants.MediaType, Action<HttpRequestHeaders>? setRequestHeaders = null) { return await ExecuteRequestAsync<TResponseDocument>(HttpMethod.Patch, requestUrl, requestBody, contentType, setRequestHeaders); } public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecuteDeleteAsync<TResponseDocument>(string requestUrl, - object requestBody = null, string contentType = HeaderConstants.MediaType, Action<HttpRequestHeaders> setRequestHeaders = null) + object? requestBody = null, string contentType = HeaderConstants.MediaType, Action<HttpRequestHeaders>? setRequestHeaders = null) { return await ExecuteRequestAsync<TResponseDocument>(HttpMethod.Delete, requestUrl, requestBody, contentType, setRequestHeaders); } private async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecuteRequestAsync<TResponseDocument>(HttpMethod method, - string requestUrl, object requestBody, string contentType, Action<HttpRequestHeaders> setRequestHeaders) + string requestUrl, object? requestBody, string? contentType, Action<HttpRequestHeaders>? setRequestHeaders) { using var request = new HttpRequestMessage(method, requestUrl); - string requestText = SerializeRequest(requestBody); + string? requestText = SerializeRequest(requestBody); if (!string.IsNullOrEmpty(requestText)) { @@ -77,10 +77,10 @@ public abstract class IntegrationTest string responseText = await responseMessage.Content.ReadAsStringAsync(); var responseDocument = DeserializeResponse<TResponseDocument>(responseText); - return (responseMessage, responseDocument); + return (responseMessage, responseDocument!); } - private string SerializeRequest(object requestBody) + private string? SerializeRequest(object? requestBody) { return requestBody == null ? null : requestBody is string stringRequestBody ? stringRequestBody : JsonSerializer.Serialize(requestBody, SerializerOptions); @@ -88,13 +88,18 @@ private string SerializeRequest(object requestBody) protected abstract HttpClient CreateClient(); - private TResponseDocument DeserializeResponse<TResponseDocument>(string responseText) + private TResponseDocument? DeserializeResponse<TResponseDocument>(string responseText) { if (typeof(TResponseDocument) == typeof(string)) { return (TResponseDocument)(object)responseText; } + if (string.IsNullOrEmpty(responseText)) + { + return default; + } + try { return JsonSerializer.Deserialize<TResponseDocument>(responseText, SerializerOptions); diff --git a/test/TestBuildingBlocks/IntegrationTestContext.cs b/test/TestBuildingBlocks/IntegrationTestContext.cs index 68550f4c26..162c2b6754 100644 --- a/test/TestBuildingBlocks/IntegrationTestContext.cs +++ b/test/TestBuildingBlocks/IntegrationTestContext.cs @@ -24,7 +24,7 @@ namespace TestBuildingBlocks /// The server Startup class, which can be defined in the test project or API project. /// </typeparam> /// <typeparam name="TDbContext"> - /// The EF Core database context, which can be defined in the test project or API project. + /// The Entity Framework Core database context, which can be defined in the test project or API project. /// </typeparam> [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] public class IntegrationTestContext<TStartup, TDbContext> : IntegrationTest, IDisposable @@ -33,9 +33,9 @@ public class IntegrationTestContext<TStartup, TDbContext> : IntegrationTest, IDi { private readonly Lazy<WebApplicationFactory<TStartup>> _lazyFactory; private readonly TestControllerProvider _testControllerProvider = new(); - private Action<ILoggingBuilder> _loggingConfiguration; - private Action<IServiceCollection> _beforeServicesConfiguration; - private Action<IServiceCollection> _afterServicesConfiguration; + private Action<ILoggingBuilder>? _loggingConfiguration; + private Action<IServiceCollection>? _beforeServicesConfiguration; + private Action<IServiceCollection>? _afterServicesConfiguration; protected override JsonSerializerOptions SerializerOptions { @@ -67,7 +67,9 @@ protected override HttpClient CreateClient() private WebApplicationFactory<TStartup> CreateFactory() { string postgresPassword = Environment.GetEnvironmentVariable("PGPASSWORD") ?? "postgres"; - string dbConnectionString = $"Host=localhost;Port=5432;Database=JsonApiTest-{Guid.NewGuid():N};User ID=postgres;Password={postgresPassword}"; + + string dbConnectionString = $"Host=localhost;Port=5432;Database=JsonApiTest-{Guid.NewGuid():N};User ID=postgres;" + + $"Password={postgresPassword};Include Error Detail=true"; var factory = new IntegrationTestWebApplicationFactory(); @@ -82,12 +84,12 @@ private WebApplicationFactory<TStartup> CreateFactory() services.AddDbContext<TDbContext>(options => { options.UseNpgsql(dbConnectionString, builder => - // The next line suppresses EF Core Warning: + // The next line suppresses Entity Framework Core Warning: // "Compiling a query which loads related collections for more than one collection navigation // either via 'Include' or through projection but no 'QuerySplittingBehavior' has been configured." // We'd like to use `QuerySplittingBehavior.SplitQuery` because of improved performance, but unfortunately - // it makes EF Core 5 crash on queries that load related data in a projection without Include. - // This is fixed in EF Core 6, tracked at https://github.com/dotnet/efcore/issues/21234. + // it makes Entity Framework Core 5 crash on queries that load related data in a projection without Include. + // This is fixed in Entity Framework Core 6, tracked at https://github.com/dotnet/efcore/issues/21234. builder.UseQuerySplittingBehavior(QuerySplittingBehavior.SingleQuery)); #if DEBUG @@ -102,7 +104,7 @@ private WebApplicationFactory<TStartup> CreateFactory() // We have placed an appsettings.json in the TestBuildingBlock project folder and set the content root to there. Note that controllers // are not discovered in the content root but are registered manually using IntegrationTestContext.UseController. WebApplicationFactory<TStartup> factoryWithConfiguredContentRoot = - factory.WithWebHostBuilder(builder => builder.UseSolutionRelativeContentRoot("test/" + nameof(TestBuildingBlocks))); + factory.WithWebHostBuilder(builder => builder.UseSolutionRelativeContentRoot($"test/{nameof(TestBuildingBlocks)}")); using IServiceScope scope = factoryWithConfiguredContentRoot.Services.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService<TDbContext>(); @@ -111,9 +113,9 @@ private WebApplicationFactory<TStartup> CreateFactory() return factoryWithConfiguredContentRoot; } - public void Dispose() + public virtual void Dispose() { - RunOnDatabaseAsync(async context => await context.Database.EnsureDeletedAsync()).Wait(); + RunOnDatabaseAsync(async dbContext => await dbContext.Database.EnsureDeletedAsync()).Wait(); Factory.Dispose(); } @@ -143,21 +145,21 @@ public async Task RunOnDatabaseAsync(Func<TDbContext, Task> asyncAction) private sealed class IntegrationTestWebApplicationFactory : WebApplicationFactory<TStartup> { - private Action<ILoggingBuilder> _loggingConfiguration; - private Action<IServiceCollection> _beforeServicesConfiguration; - private Action<IServiceCollection> _afterServicesConfiguration; + private Action<ILoggingBuilder>? _loggingConfiguration; + private Action<IServiceCollection>? _beforeServicesConfiguration; + private Action<IServiceCollection>? _afterServicesConfiguration; - public void ConfigureLogging(Action<ILoggingBuilder> loggingConfiguration) + public void ConfigureLogging(Action<ILoggingBuilder>? loggingConfiguration) { _loggingConfiguration = loggingConfiguration; } - public void ConfigureServicesBeforeStartup(Action<IServiceCollection> servicesConfiguration) + public void ConfigureServicesBeforeStartup(Action<IServiceCollection>? servicesConfiguration) { _beforeServicesConfiguration = servicesConfiguration; } - public void ConfigureServicesAfterStartup(Action<IServiceCollection> servicesConfiguration) + public void ConfigureServicesAfterStartup(Action<IServiceCollection>? servicesConfiguration) { _afterServicesConfiguration = servicesConfiguration; } diff --git a/test/TestBuildingBlocks/JsonApiStringConverter.cs b/test/TestBuildingBlocks/JsonApiStringConverter.cs index 40b334cac1..8a6a52cd9a 100644 --- a/test/TestBuildingBlocks/JsonApiStringConverter.cs +++ b/test/TestBuildingBlocks/JsonApiStringConverter.cs @@ -14,7 +14,7 @@ public static string ExtractErrorId(string responseBody) try { using JsonDocument document = JsonDocument.Parse(responseBody); - return document.RootElement.GetProperty("errors").EnumerateArray().Single().GetProperty("id").GetString(); + return document.RootElement.GetProperty("errors").EnumerateArray().Single()!.GetProperty("id").GetString()!; } catch (Exception exception) { diff --git a/test/TestBuildingBlocks/NullabilityAssertionExtensions.cs b/test/TestBuildingBlocks/NullabilityAssertionExtensions.cs new file mode 100644 index 0000000000..58815a3786 --- /dev/null +++ b/test/TestBuildingBlocks/NullabilityAssertionExtensions.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using FluentAssertions; +using JetBrains.Annotations; +using SysNotNull = System.Diagnostics.CodeAnalysis.NotNullAttribute; + +// ReSharper disable PossibleMultipleEnumeration +#pragma warning disable CS8777 // Parameter must have a non-null value when exiting. + +namespace TestBuildingBlocks +{ + public static class NullabilityAssertionExtensions + { + [CustomAssertion] + public static T ShouldNotBeNull<T>([SysNotNull] this T? subject) + { + subject.Should().NotBeNull(); + return subject!; + } + + [CustomAssertion] + public static void ShouldNotBeEmpty([SysNotNull] this string? subject) + { + subject.Should().NotBeEmpty(); + } + + [CustomAssertion] + public static void ShouldNotBeEmpty<T>([SysNotNull] this IEnumerable<T>? subject) + { + subject.Should().NotBeEmpty(); + } + + [CustomAssertion] + public static void ShouldNotBeNullOrEmpty([SysNotNull] this string? subject) + { + subject.Should().NotBeNullOrEmpty(); + } + + [CustomAssertion] + public static void ShouldHaveCount<T>([SysNotNull] this IEnumerable<T>? subject, int expected) + { + subject.Should().HaveCount(expected); + } + + [CustomAssertion] + public static TValue? ShouldContainKey<TKey, TValue>([SysNotNull] this IDictionary<TKey, TValue?>? subject, TKey expected) + { + subject.Should().ContainKey(expected); + + return subject![expected]; + } + + public static void With<T>(this T subject, [InstantHandle] Action<T> continuation) + { + continuation(subject); + } + } +} diff --git a/test/TestBuildingBlocks/ObjectAssertionsExtensions.cs b/test/TestBuildingBlocks/ObjectAssertionsExtensions.cs index 90edc1a544..320a28cda0 100644 --- a/test/TestBuildingBlocks/ObjectAssertionsExtensions.cs +++ b/test/TestBuildingBlocks/ObjectAssertionsExtensions.cs @@ -67,7 +67,7 @@ public static AndConstraint<NullableNumericAssertions<decimal>> BeApproximately( } /// <summary> - /// Used to assert on a JSON-formatted string, ignoring differences in insignificant whitespace and line endings. + /// Asserts that a JSON-formatted string matches the specified expected one, ignoring differences in insignificant whitespace and line endings. /// </summary> [CustomAssertion] public static void BeJson(this StringAssertions source, string expected, string because = "", params object[] becauseArgs) diff --git a/test/TestBuildingBlocks/QueryableExtensions.cs b/test/TestBuildingBlocks/QueryableExtensions.cs index 7fb767cdce..941a5f733e 100644 --- a/test/TestBuildingBlocks/QueryableExtensions.cs +++ b/test/TestBuildingBlocks/QueryableExtensions.cs @@ -15,11 +15,11 @@ public static Task<TResource> FirstWithIdAsync<TResource, TId>(this IQueryable<T return resources.FirstAsync(resource => Equals(resource.Id, id), cancellationToken); } - public static Task<TResource> FirstWithIdOrDefaultAsync<TResource, TId>(this IQueryable<TResource> resources, TId id, + public static async Task<TResource?> FirstWithIdOrDefaultAsync<TResource, TId>(this IQueryable<TResource> resources, TId id, CancellationToken cancellationToken = default) where TResource : IIdentifiable<TId> { - return resources.FirstOrDefaultAsync(resource => Equals(resource.Id, id), cancellationToken); + return await resources.FirstOrDefaultAsync(resource => Equals(resource.Id, id), cancellationToken); } } } diff --git a/test/TestBuildingBlocks/TestableStartup.cs b/test/TestBuildingBlocks/TestableStartup.cs index 3f029e4eb6..541d9f778a 100644 --- a/test/TestBuildingBlocks/TestableStartup.cs +++ b/test/TestBuildingBlocks/TestableStartup.cs @@ -12,12 +12,15 @@ public class TestableStartup<TDbContext> { public virtual void ConfigureServices(IServiceCollection services) { - services.AddJsonApi<TDbContext>(SetJsonApiOptions); + IMvcCoreBuilder mvcBuilder = services.AddMvcCore(options => options.MaxModelValidationErrors = 3); + + services.AddJsonApi<TDbContext>(SetJsonApiOptions, mvcBuilder: mvcBuilder); } protected virtual void SetJsonApiOptions(JsonApiOptions options) { options.IncludeExceptionStackTraceInErrors = true; + options.IncludeRequestBodyInErrors = true; options.SerializerOptions.WriteIndented = true; } diff --git a/test/TestBuildingBlocks/Unknown.cs b/test/TestBuildingBlocks/Unknown.cs index 294490d96e..58ed6da14a 100644 --- a/test/TestBuildingBlocks/Unknown.cs +++ b/test/TestBuildingBlocks/Unknown.cs @@ -80,7 +80,7 @@ private static string InnerFor<TResource, TId>(bool isAlt) } throw new NotSupportedException( - $"Unsupported '{nameof(Identifiable.Id)}' property of type '{type}' on resource type '{typeof(TResource).Name}'."); + $"Unsupported '{nameof(Identifiable<object>.Id)}' property of type '{type}' on resource type '{typeof(TResource).Name}'."); } } } diff --git a/test/UnitTests/Builders/ResourceGraphBuilderTests.cs b/test/UnitTests/Builders/ResourceGraphBuilderTests.cs deleted file mode 100644 index b6cd276c99..0000000000 --- a/test/UnitTests/Builders/ResourceGraphBuilderTests.cs +++ /dev/null @@ -1,123 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging.Abstractions; -using Xunit; - -namespace UnitTests.Builders -{ - public sealed class ResourceGraphBuilderTests - { - [Fact] - public void Can_Build_ResourceGraph_Using_Builder() - { - // Arrange - var services = new ServiceCollection(); - services.AddLogging(); - services.AddDbContext<TestDbContext>(); - - services.AddJsonApi<TestDbContext>(resources: builder => builder.Add<NonDbResource>("nonDbResources")); - - // Act - ServiceProvider container = services.BuildServiceProvider(); - - // Assert - var resourceGraph = container.GetRequiredService<IResourceGraph>(); - ResourceContext dbResourceContext = resourceGraph.GetResourceContext("dbResources"); - ResourceContext nonDbResourceContext = resourceGraph.GetResourceContext("nonDbResources"); - Assert.Equal(typeof(DbResource), dbResourceContext.ResourceType); - Assert.Equal(typeof(NonDbResource), nonDbResourceContext.ResourceType); - } - - [Fact] - public void Resources_Without_Names_Specified_Will_Use_Configured_Formatter() - { - // Arrange - var builder = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance); - builder.Add<TestResource>(); - - // Act - IResourceGraph resourceGraph = builder.Build(); - - // Assert - ResourceContext resourceContext = resourceGraph.GetResourceContext(typeof(TestResource)); - Assert.Equal("testResources", resourceContext.PublicName); - } - - [Fact] - public void Attrs_Without_Names_Specified_Will_Use_Configured_Formatter() - { - // Arrange - var builder = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance); - builder.Add<TestResource>(); - - // Act - IResourceGraph resourceGraph = builder.Build(); - - // Assert - ResourceContext resourceContext = resourceGraph.GetResourceContext(typeof(TestResource)); - Assert.Contains(resourceContext.Attributes, attribute => attribute.PublicName == "compoundAttribute"); - } - - [Fact] - public void Relationships_Without_Names_Specified_Will_Use_Configured_Formatter() - { - // Arrange - var builder = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance); - builder.Add<TestResource>(); - - // Act - IResourceGraph resourceGraph = builder.Build(); - - // Assert - ResourceContext resourceContext = resourceGraph.GetResourceContext(typeof(TestResource)); - Assert.Equal("relatedResource", resourceContext.Relationships.Single(relationship => relationship is HasOneAttribute).PublicName); - Assert.Equal("relatedResources", resourceContext.Relationships.Single(relationship => relationship is not HasOneAttribute).PublicName); - } - - private sealed class NonDbResource : Identifiable - { - } - - private sealed class DbResource : Identifiable - { - } - - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - private sealed class TestDbContext : DbContext - { - public DbSet<DbResource> DbResources { get; set; } - - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - { - optionsBuilder.UseInMemoryDatabase(Guid.NewGuid().ToString()); - } - } - - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class TestResource : Identifiable - { - [Attr] - public string CompoundAttribute { get; set; } - - [HasOne] - public RelatedResource RelatedResource { get; set; } - - [HasMany] - public ISet<RelatedResource> RelatedResources { get; set; } - } - - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class RelatedResource : Identifiable - { - [Attr] - public string Unused { get; set; } - } - } -} diff --git a/test/UnitTests/Controllers/BaseJsonApiControllerTests.cs b/test/UnitTests/Controllers/BaseJsonApiControllerTests.cs deleted file mode 100644 index 90c0426d66..0000000000 --- a/test/UnitTests/Controllers/BaseJsonApiControllerTests.cs +++ /dev/null @@ -1,293 +0,0 @@ -using System; -using System.Net; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Errors; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCore.Services; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; -using Moq; -using Xunit; - -namespace UnitTests.Controllers -{ - public sealed class BaseJsonApiControllerTests - { - [Fact] - public async Task GetAsync_Calls_Service() - { - // Arrange - var serviceMock = new Mock<IGetAllService<Resource>>(); - var controller = new ResourceController(new JsonApiOptions(), NullLoggerFactory.Instance, serviceMock.Object); - - // Act - await controller.GetAsync(CancellationToken.None); - - // Assert - serviceMock.Verify(service => service.GetAsync(CancellationToken.None), Times.Once); - } - - [Fact] - public async Task GetAsync_Throws_405_If_No_Service() - { - // Arrange - var controller = new ResourceController(new JsonApiOptions(), NullLoggerFactory.Instance, null); - - // Act - Func<Task> asyncAction = () => controller.GetAsync(CancellationToken.None); - - // Assert - var exception = await Assert.ThrowsAsync<RequestMethodNotAllowedException>(asyncAction); - Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.Errors[0].StatusCode); - Assert.Equal(HttpMethod.Get, exception.Method); - } - - [Fact] - public async Task GetAsyncById_Calls_Service() - { - // Arrange - const int id = 0; - var serviceMock = new Mock<IGetByIdService<Resource>>(); - var controller = new ResourceController(new JsonApiOptions(), NullLoggerFactory.Instance, getById: serviceMock.Object); - - // Act - await controller.GetAsync(id, CancellationToken.None); - - // Assert - serviceMock.Verify(service => service.GetAsync(id, CancellationToken.None), Times.Once); - } - - [Fact] - public async Task GetAsyncById_Throws_405_If_No_Service() - { - // Arrange - const int id = 0; - var controller = new ResourceController(new JsonApiOptions(), NullLoggerFactory.Instance); - - // Act - Func<Task> asyncAction = () => controller.GetAsync(id, CancellationToken.None); - - // Assert - var exception = await Assert.ThrowsAsync<RequestMethodNotAllowedException>(asyncAction); - Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.Errors[0].StatusCode); - Assert.Equal(HttpMethod.Get, exception.Method); - } - - [Fact] - public async Task GetRelationshipsAsync_Calls_Service() - { - // Arrange - const int id = 0; - const string relationshipName = "articles"; - var serviceMock = new Mock<IGetRelationshipService<Resource>>(); - var controller = new ResourceController(new JsonApiOptions(), NullLoggerFactory.Instance, getRelationship: serviceMock.Object); - - // Act - await controller.GetRelationshipAsync(id, relationshipName, CancellationToken.None); - - // Assert - serviceMock.Verify(service => service.GetRelationshipAsync(id, relationshipName, CancellationToken.None), Times.Once); - } - - [Fact] - public async Task GetRelationshipsAsync_Throws_405_If_No_Service() - { - // Arrange - const int id = 0; - var controller = new ResourceController(new JsonApiOptions(), NullLoggerFactory.Instance); - - // Act - Func<Task> asyncAction = () => controller.GetRelationshipAsync(id, "articles", CancellationToken.None); - - // Assert - var exception = await Assert.ThrowsAsync<RequestMethodNotAllowedException>(asyncAction); - Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.Errors[0].StatusCode); - Assert.Equal(HttpMethod.Get, exception.Method); - } - - [Fact] - public async Task GetRelationshipAsync_Calls_Service() - { - // Arrange - const int id = 0; - const string relationshipName = "articles"; - var serviceMock = new Mock<IGetSecondaryService<Resource>>(); - var controller = new ResourceController(new JsonApiOptions(), NullLoggerFactory.Instance, getSecondary: serviceMock.Object); - - // Act - await controller.GetSecondaryAsync(id, relationshipName, CancellationToken.None); - - // Assert - serviceMock.Verify(service => service.GetSecondaryAsync(id, relationshipName, CancellationToken.None), Times.Once); - } - - [Fact] - public async Task GetRelationshipAsync_Throws_405_If_No_Service() - { - // Arrange - const int id = 0; - var controller = new ResourceController(new JsonApiOptions(), NullLoggerFactory.Instance); - - // Act - Func<Task> asyncAction = () => controller.GetSecondaryAsync(id, "articles", CancellationToken.None); - - // Assert - var exception = await Assert.ThrowsAsync<RequestMethodNotAllowedException>(asyncAction); - Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.Errors[0].StatusCode); - Assert.Equal(HttpMethod.Get, exception.Method); - } - - [Fact] - public async Task PatchAsync_Calls_Service() - { - // Arrange - const int id = 0; - var resource = new Resource(); - var serviceMock = new Mock<IUpdateService<Resource>>(); - - var controller = new ResourceController(new JsonApiOptions(), NullLoggerFactory.Instance, update: serviceMock.Object); - - // Act - await controller.PatchAsync(id, resource, CancellationToken.None); - - // Assert - serviceMock.Verify(service => service.UpdateAsync(id, It.IsAny<Resource>(), CancellationToken.None), Times.Once); - } - - [Fact] - public async Task PatchAsync_Throws_405_If_No_Service() - { - // Arrange - const int id = 0; - var resource = new Resource(); - var controller = new ResourceController(new JsonApiOptions(), NullLoggerFactory.Instance); - - // Act - Func<Task> asyncAction = () => controller.PatchAsync(id, resource, CancellationToken.None); - - // Assert - var exception = await Assert.ThrowsAsync<RequestMethodNotAllowedException>(asyncAction); - Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.Errors[0].StatusCode); - Assert.Equal(HttpMethod.Patch, exception.Method); - } - - [Fact] - public async Task PostAsync_Calls_Service() - { - // Arrange - var resource = new Resource(); - var serviceMock = new Mock<ICreateService<Resource>>(); - - var controller = new ResourceController(new JsonApiOptions(), NullLoggerFactory.Instance, create: serviceMock.Object); - serviceMock.Setup(service => service.CreateAsync(It.IsAny<Resource>(), It.IsAny<CancellationToken>())).ReturnsAsync(resource); - - controller.ControllerContext = new ControllerContext - { - HttpContext = new DefaultHttpContext() - }; - - // Act - await controller.PostAsync(resource, CancellationToken.None); - - // Assert - serviceMock.Verify(service => service.CreateAsync(It.IsAny<Resource>(), CancellationToken.None), Times.Once); - } - - [Fact] - public async Task PatchRelationshipsAsync_Calls_Service() - { - // Arrange - const int id = 0; - const string relationshipName = "articles"; - var serviceMock = new Mock<ISetRelationshipService<Resource>>(); - var controller = new ResourceController(new JsonApiOptions(), NullLoggerFactory.Instance, setRelationship: serviceMock.Object); - - // Act - await controller.PatchRelationshipAsync(id, relationshipName, null, CancellationToken.None); - - // Assert - serviceMock.Verify(service => service.SetRelationshipAsync(id, relationshipName, null, CancellationToken.None), Times.Once); - } - - [Fact] - public async Task PatchRelationshipsAsync_Throws_405_If_No_Service() - { - // Arrange - const int id = 0; - var controller = new ResourceController(new JsonApiOptions(), NullLoggerFactory.Instance); - - // Act - Func<Task> asyncAction = () => controller.PatchRelationshipAsync(id, "articles", null, CancellationToken.None); - - // Assert - var exception = await Assert.ThrowsAsync<RequestMethodNotAllowedException>(asyncAction); - Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.Errors[0].StatusCode); - Assert.Equal(HttpMethod.Patch, exception.Method); - } - - [Fact] - public async Task DeleteAsync_Calls_Service() - { - // Arrange - const int id = 0; - var serviceMock = new Mock<IDeleteService<Resource>>(); - var controller = new ResourceController(new JsonApiOptions(), NullLoggerFactory.Instance, delete: serviceMock.Object); - - // Act - await controller.DeleteAsync(id, CancellationToken.None); - - // Assert - serviceMock.Verify(service => service.DeleteAsync(id, CancellationToken.None), Times.Once); - } - - [Fact] - public async Task DeleteAsync_Throws_405_If_No_Service() - { - // Arrange - const int id = 0; - var controller = new ResourceController(new JsonApiOptions(), NullLoggerFactory.Instance); - - // Act - Func<Task> asyncAction = () => controller.DeleteAsync(id, CancellationToken.None); - - // Assert - var exception = await Assert.ThrowsAsync<RequestMethodNotAllowedException>(asyncAction); - Assert.Equal(HttpStatusCode.MethodNotAllowed, exception.Errors[0].StatusCode); - Assert.Equal(HttpMethod.Delete, exception.Method); - } - - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Resource : Identifiable - { - [Attr] - public string TestAttribute { get; set; } - } - - private sealed class ResourceController : BaseJsonApiController<Resource> - { - public ResourceController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<Resource> resourceService) - : base(options, loggerFactory, resourceService) - { - } - - public ResourceController(IJsonApiOptions options, ILoggerFactory loggerFactory, IGetAllService<Resource, int> getAll = null, - IGetByIdService<Resource, int> getById = null, IGetSecondaryService<Resource, int> getSecondary = null, - IGetRelationshipService<Resource, int> getRelationship = null, ICreateService<Resource, int> create = null, - IAddToRelationshipService<Resource, int> addToRelationship = null, IUpdateService<Resource, int> update = null, - ISetRelationshipService<Resource, int> setRelationship = null, IDeleteService<Resource, int> delete = null, - IRemoveFromRelationshipService<Resource, int> removeFromRelationship = null) - : base(options, loggerFactory, getAll, getById, getSecondary, getRelationship, create, addToRelationship, update, setRelationship, delete, - removeFromRelationship) - { - } - } - } -} diff --git a/test/UnitTests/Extensions/ServiceCollectionExtensionsTests.cs b/test/UnitTests/Extensions/ServiceCollectionExtensionsTests.cs index 542abd496a..0f5e5edfb1 100644 --- a/test/UnitTests/Extensions/ServiceCollectionExtensionsTests.cs +++ b/test/UnitTests/Extensions/ServiceCollectionExtensionsTests.cs @@ -3,6 +3,7 @@ using System.Collections.Immutable; using System.Threading; using System.Threading.Tasks; +using FluentAssertions; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Errors; @@ -29,65 +30,63 @@ public void RegisterResource_DeviatingDbContextPropertyName_RegistersCorrectly() services.AddLogging(); services.AddDbContext<TestDbContext>(options => options.UseInMemoryDatabase("UnitTestDb")); - // this is required because the DbContextResolver requires access to the current HttpContext - // to get the request scoped DbContext instance - services.AddScoped<IRequestScopedServiceProvider, TestScopedServiceProvider>(); - // Act services.AddJsonApi<TestDbContext>(); + // Assert ServiceProvider provider = services.BuildServiceProvider(); var resourceGraph = provider.GetRequiredService<IResourceGraph>(); - ResourceContext resourceContext = resourceGraph.GetResourceContext<Person>(); + ResourceType personType = resourceGraph.GetResourceType<Person>(); - // Assert - Assert.Equal("people", resourceContext.PublicName); + personType.PublicName.Should().Be("people"); } [Fact] - public void AddResourceService_Registers_All_Shorthand_Service_Interfaces() + public void AddResourceService_Registers_Service_Interfaces_Of_Int32() { // Arrange var services = new ServiceCollection(); // Act - services.AddResourceService<IntResourceService>(); + services.AddResourceService<ResourceServiceOfInt32>(); // Assert ServiceProvider provider = services.BuildServiceProvider(); - Assert.IsType<IntResourceService>(provider.GetRequiredService(typeof(IResourceService<IntResource>))); - Assert.IsType<IntResourceService>(provider.GetRequiredService(typeof(IResourceCommandService<IntResource>))); - Assert.IsType<IntResourceService>(provider.GetRequiredService(typeof(IResourceQueryService<IntResource>))); - Assert.IsType<IntResourceService>(provider.GetRequiredService(typeof(IGetAllService<IntResource>))); - Assert.IsType<IntResourceService>(provider.GetRequiredService(typeof(IGetByIdService<IntResource>))); - Assert.IsType<IntResourceService>(provider.GetRequiredService(typeof(IGetSecondaryService<IntResource>))); - Assert.IsType<IntResourceService>(provider.GetRequiredService(typeof(IGetRelationshipService<IntResource>))); - Assert.IsType<IntResourceService>(provider.GetRequiredService(typeof(ICreateService<IntResource>))); - Assert.IsType<IntResourceService>(provider.GetRequiredService(typeof(IUpdateService<IntResource>))); - Assert.IsType<IntResourceService>(provider.GetRequiredService(typeof(IDeleteService<IntResource>))); + + provider.GetRequiredService(typeof(IResourceService<ResourceOfInt32, int>)).Should().BeOfType<ResourceServiceOfInt32>(); + provider.GetRequiredService(typeof(IResourceCommandService<ResourceOfInt32, int>)).Should().BeOfType<ResourceServiceOfInt32>(); + provider.GetRequiredService(typeof(IResourceQueryService<ResourceOfInt32, int>)).Should().BeOfType<ResourceServiceOfInt32>(); + provider.GetRequiredService(typeof(IGetAllService<ResourceOfInt32, int>)).Should().BeOfType<ResourceServiceOfInt32>(); + provider.GetRequiredService(typeof(IGetByIdService<ResourceOfInt32, int>)).Should().BeOfType<ResourceServiceOfInt32>(); + provider.GetRequiredService(typeof(IGetSecondaryService<ResourceOfInt32, int>)).Should().BeOfType<ResourceServiceOfInt32>(); + provider.GetRequiredService(typeof(IGetRelationshipService<ResourceOfInt32, int>)).Should().BeOfType<ResourceServiceOfInt32>(); + provider.GetRequiredService(typeof(ICreateService<ResourceOfInt32, int>)).Should().BeOfType<ResourceServiceOfInt32>(); + provider.GetRequiredService(typeof(IUpdateService<ResourceOfInt32, int>)).Should().BeOfType<ResourceServiceOfInt32>(); + provider.GetRequiredService(typeof(IDeleteService<ResourceOfInt32, int>)).Should().BeOfType<ResourceServiceOfInt32>(); } [Fact] - public void AddResourceService_Registers_All_LongForm_Service_Interfaces() + public void AddResourceService_Registers_Service_Interfaces_Of_Guid() { // Arrange var services = new ServiceCollection(); // Act - services.AddResourceService<GuidResourceService>(); + services.AddResourceService<ResourceServiceOfGuid>(); // Assert ServiceProvider provider = services.BuildServiceProvider(); - Assert.IsType<GuidResourceService>(provider.GetRequiredService(typeof(IResourceService<GuidResource, Guid>))); - Assert.IsType<GuidResourceService>(provider.GetRequiredService(typeof(IResourceCommandService<GuidResource, Guid>))); - Assert.IsType<GuidResourceService>(provider.GetRequiredService(typeof(IResourceQueryService<GuidResource, Guid>))); - Assert.IsType<GuidResourceService>(provider.GetRequiredService(typeof(IGetAllService<GuidResource, Guid>))); - Assert.IsType<GuidResourceService>(provider.GetRequiredService(typeof(IGetByIdService<GuidResource, Guid>))); - Assert.IsType<GuidResourceService>(provider.GetRequiredService(typeof(IGetSecondaryService<GuidResource, Guid>))); - Assert.IsType<GuidResourceService>(provider.GetRequiredService(typeof(IGetRelationshipService<GuidResource, Guid>))); - Assert.IsType<GuidResourceService>(provider.GetRequiredService(typeof(ICreateService<GuidResource, Guid>))); - Assert.IsType<GuidResourceService>(provider.GetRequiredService(typeof(IUpdateService<GuidResource, Guid>))); - Assert.IsType<GuidResourceService>(provider.GetRequiredService(typeof(IDeleteService<GuidResource, Guid>))); + + provider.GetRequiredService(typeof(IResourceService<ResourceOfGuid, Guid>)).Should().BeOfType<ResourceServiceOfGuid>(); + provider.GetRequiredService(typeof(IResourceCommandService<ResourceOfGuid, Guid>)).Should().BeOfType<ResourceServiceOfGuid>(); + provider.GetRequiredService(typeof(IResourceQueryService<ResourceOfGuid, Guid>)).Should().BeOfType<ResourceServiceOfGuid>(); + provider.GetRequiredService(typeof(IGetAllService<ResourceOfGuid, Guid>)).Should().BeOfType<ResourceServiceOfGuid>(); + provider.GetRequiredService(typeof(IGetByIdService<ResourceOfGuid, Guid>)).Should().BeOfType<ResourceServiceOfGuid>(); + provider.GetRequiredService(typeof(IGetSecondaryService<ResourceOfGuid, Guid>)).Should().BeOfType<ResourceServiceOfGuid>(); + provider.GetRequiredService(typeof(IGetRelationshipService<ResourceOfGuid, Guid>)).Should().BeOfType<ResourceServiceOfGuid>(); + provider.GetRequiredService(typeof(ICreateService<ResourceOfGuid, Guid>)).Should().BeOfType<ResourceServiceOfGuid>(); + provider.GetRequiredService(typeof(IUpdateService<ResourceOfGuid, Guid>)).Should().BeOfType<ResourceServiceOfGuid>(); + provider.GetRequiredService(typeof(IDeleteService<ResourceOfGuid, Guid>)).Should().BeOfType<ResourceServiceOfGuid>(); } [Fact] @@ -100,67 +99,71 @@ public void AddResourceService_Throws_If_Type_Does_Not_Implement_Any_Interfaces( Action action = () => services.AddResourceService<int>(); // Assert - Assert.Throws<InvalidConfigurationException>(action); + action.Should().ThrowExactly<InvalidConfigurationException>(); } [Fact] - public void AddResourceRepository_Registers_All_Shorthand_Repository_Interfaces() + public void AddResourceRepository_Registers_Repository_Interfaces_Of_Int32() { // Arrange var services = new ServiceCollection(); // Act - services.AddResourceRepository<IntResourceRepository>(); + services.AddResourceRepository<ResourceRepositoryOfInt32>(); // Assert ServiceProvider provider = services.BuildServiceProvider(); - Assert.IsType<IntResourceRepository>(provider.GetRequiredService(typeof(IResourceRepository<IntResource>))); - Assert.IsType<IntResourceRepository>(provider.GetRequiredService(typeof(IResourceReadRepository<IntResource>))); - Assert.IsType<IntResourceRepository>(provider.GetRequiredService(typeof(IResourceWriteRepository<IntResource>))); + + provider.GetRequiredService(typeof(IResourceRepository<ResourceOfInt32, int>)).Should().BeOfType<ResourceRepositoryOfInt32>(); + provider.GetRequiredService(typeof(IResourceReadRepository<ResourceOfInt32, int>)).Should().BeOfType<ResourceRepositoryOfInt32>(); + provider.GetRequiredService(typeof(IResourceWriteRepository<ResourceOfInt32, int>)).Should().BeOfType<ResourceRepositoryOfInt32>(); } [Fact] - public void AddResourceRepository_Registers_All_LongForm_Repository_Interfaces() + public void AddResourceRepository_Registers_Repository_Interfaces_Of_Guid() { // Arrange var services = new ServiceCollection(); // Act - services.AddResourceRepository<GuidResourceRepository>(); + services.AddResourceRepository<ResourceRepositoryOfGuid>(); // Assert ServiceProvider provider = services.BuildServiceProvider(); - Assert.IsType<GuidResourceRepository>(provider.GetRequiredService(typeof(IResourceRepository<GuidResource, Guid>))); - Assert.IsType<GuidResourceRepository>(provider.GetRequiredService(typeof(IResourceReadRepository<GuidResource, Guid>))); - Assert.IsType<GuidResourceRepository>(provider.GetRequiredService(typeof(IResourceWriteRepository<GuidResource, Guid>))); + + provider.GetRequiredService(typeof(IResourceRepository<ResourceOfGuid, Guid>)).Should().BeOfType<ResourceRepositoryOfGuid>(); + provider.GetRequiredService(typeof(IResourceReadRepository<ResourceOfGuid, Guid>)).Should().BeOfType<ResourceRepositoryOfGuid>(); + provider.GetRequiredService(typeof(IResourceWriteRepository<ResourceOfGuid, Guid>)).Should().BeOfType<ResourceRepositoryOfGuid>(); } [Fact] - public void AddResourceDefinition_Registers_Shorthand_Definition_Interface() + public void AddResourceDefinition_Registers_Definition_Interface_Of_Int32() { // Arrange var services = new ServiceCollection(); // Act - services.AddResourceDefinition<IntResourceDefinition>(); + services.AddResourceDefinition<ResourceDefinitionOfInt32>(); // Assert ServiceProvider provider = services.BuildServiceProvider(); - Assert.IsType<IntResourceDefinition>(provider.GetRequiredService(typeof(IResourceDefinition<IntResource>))); + + provider.GetRequiredService(typeof(IResourceDefinition<ResourceOfInt32, int>)).Should().BeOfType<ResourceDefinitionOfInt32>(); } [Fact] - public void AddResourceDefinition_Registers_LongForm_Definition_Interface() + public void AddResourceDefinition_Registers_Definition_Interface_Of_Guid() { // Arrange var services = new ServiceCollection(); // Act - services.AddResourceDefinition<GuidResourceDefinition>(); + services.AddResourceDefinition<ResourceDefinitionOfGuid>(); // Assert ServiceProvider provider = services.BuildServiceProvider(); - Assert.IsType<GuidResourceDefinition>(provider.GetRequiredService(typeof(IResourceDefinition<GuidResource, Guid>))); + + provider.GetRequiredService(typeof(IResourceDefinition<ResourceOfGuid, Guid>)).Should().BeOfType<ResourceDefinitionOfGuid>(); } [Fact] @@ -171,52 +174,50 @@ public void AddJsonApi_With_Context_Uses_Resource_Type_Name_If_NoOtherSpecified( services.AddLogging(); services.AddDbContext<TestDbContext>(options => options.UseInMemoryDatabase(Guid.NewGuid().ToString())); - services.AddScoped<IRequestScopedServiceProvider, TestScopedServiceProvider>(); - // Act services.AddJsonApi<TestDbContext>(); // Assert ServiceProvider provider = services.BuildServiceProvider(); var resourceGraph = provider.GetRequiredService<IResourceGraph>(); + ResourceType resourceType = resourceGraph.GetResourceType(typeof(ResourceOfInt32)); - ResourceContext resourceContext = resourceGraph.GetResourceContext(typeof(IntResource)); - Assert.Equal("intResources", resourceContext.PublicName); + resourceType.PublicName.Should().Be("resourceOfInt32s"); } - private sealed class IntResource : Identifiable + private sealed class ResourceOfInt32 : Identifiable<int> { } [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - private sealed class GuidResource : Identifiable<Guid> + private sealed class ResourceOfGuid : Identifiable<Guid> { } [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - private sealed class IntResourceService : IResourceService<IntResource> + private sealed class ResourceServiceOfInt32 : IResourceService<ResourceOfInt32, int> { - public Task<IReadOnlyCollection<IntResource>> GetAsync(CancellationToken cancellationToken) + public Task<IReadOnlyCollection<ResourceOfInt32>> GetAsync(CancellationToken cancellationToken) { throw new NotImplementedException(); } - public Task<IntResource> GetAsync(int id, CancellationToken cancellationToken) + public Task<ResourceOfInt32> GetAsync(int id, CancellationToken cancellationToken) { throw new NotImplementedException(); } - public Task<object> GetSecondaryAsync(int id, string relationshipName, CancellationToken cancellationToken) + public Task<object?> GetSecondaryAsync(int id, string relationshipName, CancellationToken cancellationToken) { throw new NotImplementedException(); } - public Task<object> GetRelationshipAsync(int id, string relationshipName, CancellationToken cancellationToken) + public Task<object?> GetRelationshipAsync(int id, string relationshipName, CancellationToken cancellationToken) { throw new NotImplementedException(); } - public Task<IntResource> CreateAsync(IntResource resource, CancellationToken cancellationToken) + public Task<ResourceOfInt32?> CreateAsync(ResourceOfInt32 resource, CancellationToken cancellationToken) { throw new NotImplementedException(); } @@ -227,12 +228,12 @@ public Task AddToToManyRelationshipAsync(int leftId, string relationshipName, IS throw new NotImplementedException(); } - public Task<IntResource> UpdateAsync(int id, IntResource resource, CancellationToken cancellationToken) + public Task<ResourceOfInt32?> UpdateAsync(int id, ResourceOfInt32 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(); } @@ -250,29 +251,29 @@ public Task RemoveFromToManyRelationshipAsync(int leftId, string relationshipNam } [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - private sealed class GuidResourceService : IResourceService<GuidResource, Guid> + private sealed class ResourceServiceOfGuid : IResourceService<ResourceOfGuid, Guid> { - public Task<IReadOnlyCollection<GuidResource>> GetAsync(CancellationToken cancellationToken) + public Task<IReadOnlyCollection<ResourceOfGuid>> GetAsync(CancellationToken cancellationToken) { throw new NotImplementedException(); } - public Task<GuidResource> GetAsync(Guid id, CancellationToken cancellationToken) + public Task<ResourceOfGuid> GetAsync(Guid id, CancellationToken cancellationToken) { throw new NotImplementedException(); } - public Task<object> GetSecondaryAsync(Guid id, string relationshipName, CancellationToken cancellationToken) + public Task<object?> GetSecondaryAsync(Guid id, string relationshipName, CancellationToken cancellationToken) { throw new NotImplementedException(); } - public Task<object> GetRelationshipAsync(Guid id, string relationshipName, CancellationToken cancellationToken) + public Task<object?> GetRelationshipAsync(Guid id, string relationshipName, CancellationToken cancellationToken) { throw new NotImplementedException(); } - public Task<GuidResource> CreateAsync(GuidResource resource, CancellationToken cancellationToken) + public Task<ResourceOfGuid?> CreateAsync(ResourceOfGuid resource, CancellationToken cancellationToken) { throw new NotImplementedException(); } @@ -283,12 +284,12 @@ public Task AddToToManyRelationshipAsync(Guid leftId, string relationshipName, I throw new NotImplementedException(); } - public Task<GuidResource> UpdateAsync(Guid id, GuidResource resource, CancellationToken cancellationToken) + public Task<ResourceOfGuid?> UpdateAsync(Guid id, ResourceOfGuid resource, CancellationToken cancellationToken) { throw new NotImplementedException(); } - public Task SetRelationshipAsync(Guid leftId, string relationshipName, object rightValue, CancellationToken cancellationToken) + public Task SetRelationshipAsync(Guid leftId, string relationshipName, object? rightValue, CancellationToken cancellationToken) { throw new NotImplementedException(); } @@ -306,34 +307,34 @@ public Task RemoveFromToManyRelationshipAsync(Guid leftId, string relationshipNa } [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - private sealed class IntResourceRepository : IResourceRepository<IntResource> + private sealed class ResourceRepositoryOfInt32 : IResourceRepository<ResourceOfInt32, int> { - public Task<IReadOnlyCollection<IntResource>> GetAsync(QueryLayer layer, CancellationToken cancellationToken) + public Task<IReadOnlyCollection<ResourceOfInt32>> GetAsync(QueryLayer queryLayer, CancellationToken cancellationToken) { throw new NotImplementedException(); } - public Task<int> CountAsync(FilterExpression topFilter, CancellationToken cancellationToken) + public Task<int> CountAsync(FilterExpression? filter, CancellationToken cancellationToken) { throw new NotImplementedException(); } - public Task<IntResource> GetForCreateAsync(int id, CancellationToken cancellationToken) + public Task<ResourceOfInt32> GetForCreateAsync(int id, CancellationToken cancellationToken) { throw new NotImplementedException(); } - public Task CreateAsync(IntResource resourceFromRequest, IntResource resourceForDatabase, CancellationToken cancellationToken) + public Task CreateAsync(ResourceOfInt32 resourceFromRequest, ResourceOfInt32 resourceForDatabase, CancellationToken cancellationToken) { throw new NotImplementedException(); } - public Task<IntResource> GetForUpdateAsync(QueryLayer queryLayer, CancellationToken cancellationToken) + public Task<ResourceOfInt32?> GetForUpdateAsync(QueryLayer queryLayer, CancellationToken cancellationToken) { throw new NotImplementedException(); } - public Task UpdateAsync(IntResource resourceFromRequest, IntResource resourceFromDatabase, CancellationToken cancellationToken) + public Task UpdateAsync(ResourceOfInt32 resourceFromRequest, ResourceOfInt32 resourceFromDatabase, CancellationToken cancellationToken) { throw new NotImplementedException(); } @@ -343,7 +344,7 @@ public Task DeleteAsync(int id, CancellationToken cancellationToken) throw new NotImplementedException(); } - public Task SetRelationshipAsync(IntResource leftResource, object rightValue, CancellationToken cancellationToken) + public Task SetRelationshipAsync(ResourceOfInt32 leftResource, object? rightValue, CancellationToken cancellationToken) { throw new NotImplementedException(); } @@ -353,41 +354,42 @@ public Task AddToToManyRelationshipAsync(int leftId, ISet<IIdentifiable> rightRe throw new NotImplementedException(); } - public Task RemoveFromToManyRelationshipAsync(IntResource leftResource, ISet<IIdentifiable> rightResourceIds, CancellationToken cancellationToken) + public Task RemoveFromToManyRelationshipAsync(ResourceOfInt32 leftResource, ISet<IIdentifiable> rightResourceIds, + CancellationToken cancellationToken) { throw new NotImplementedException(); } } [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - private sealed class GuidResourceRepository : IResourceRepository<GuidResource, Guid> + private sealed class ResourceRepositoryOfGuid : IResourceRepository<ResourceOfGuid, Guid> { - public Task<IReadOnlyCollection<GuidResource>> GetAsync(QueryLayer layer, CancellationToken cancellationToken) + public Task<IReadOnlyCollection<ResourceOfGuid>> GetAsync(QueryLayer queryLayer, CancellationToken cancellationToken) { throw new NotImplementedException(); } - public Task<int> CountAsync(FilterExpression topFilter, CancellationToken cancellationToken) + public Task<int> CountAsync(FilterExpression? filter, CancellationToken cancellationToken) { throw new NotImplementedException(); } - public Task<GuidResource> GetForCreateAsync(Guid id, CancellationToken cancellationToken) + public Task<ResourceOfGuid> GetForCreateAsync(Guid id, CancellationToken cancellationToken) { throw new NotImplementedException(); } - public Task CreateAsync(GuidResource resourceFromRequest, GuidResource resourceForDatabase, CancellationToken cancellationToken) + public Task CreateAsync(ResourceOfGuid resourceFromRequest, ResourceOfGuid resourceForDatabase, CancellationToken cancellationToken) { throw new NotImplementedException(); } - public Task<GuidResource> GetForUpdateAsync(QueryLayer queryLayer, CancellationToken cancellationToken) + public Task<ResourceOfGuid?> GetForUpdateAsync(QueryLayer queryLayer, CancellationToken cancellationToken) { throw new NotImplementedException(); } - public Task UpdateAsync(GuidResource resourceFromRequest, GuidResource resourceFromDatabase, CancellationToken cancellationToken) + public Task UpdateAsync(ResourceOfGuid resourceFromRequest, ResourceOfGuid resourceFromDatabase, CancellationToken cancellationToken) { throw new NotImplementedException(); } @@ -397,7 +399,7 @@ public Task DeleteAsync(Guid id, CancellationToken cancellationToken) throw new NotImplementedException(); } - public Task SetRelationshipAsync(GuidResource leftResource, object rightValue, CancellationToken cancellationToken) + public Task SetRelationshipAsync(ResourceOfGuid leftResource, object? rightValue, CancellationToken cancellationToken) { throw new NotImplementedException(); } @@ -407,62 +409,63 @@ public Task AddToToManyRelationshipAsync(Guid leftId, ISet<IIdentifiable> rightR throw new NotImplementedException(); } - public Task RemoveFromToManyRelationshipAsync(GuidResource leftResource, ISet<IIdentifiable> rightResourceIds, CancellationToken cancellationToken) + public Task RemoveFromToManyRelationshipAsync(ResourceOfGuid leftResource, ISet<IIdentifiable> rightResourceIds, + CancellationToken cancellationToken) { throw new NotImplementedException(); } } [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - private sealed class IntResourceDefinition : IResourceDefinition<IntResource> + private sealed class ResourceDefinitionOfInt32 : IResourceDefinition<ResourceOfInt32, int> { - public IImmutableList<IncludeElementExpression> OnApplyIncludes(IImmutableList<IncludeElementExpression> existingIncludes) + public IImmutableSet<IncludeElementExpression> OnApplyIncludes(IImmutableSet<IncludeElementExpression> existingIncludes) { throw new NotImplementedException(); } - public FilterExpression OnApplyFilter(FilterExpression existingFilter) + public FilterExpression OnApplyFilter(FilterExpression? existingFilter) { throw new NotImplementedException(); } - public SortExpression OnApplySort(SortExpression existingSort) + public SortExpression OnApplySort(SortExpression? existingSort) { throw new NotImplementedException(); } - public PaginationExpression OnApplyPagination(PaginationExpression existingPagination) + public PaginationExpression OnApplyPagination(PaginationExpression? existingPagination) { throw new NotImplementedException(); } - public SparseFieldSetExpression OnApplySparseFieldSet(SparseFieldSetExpression existingSparseFieldSet) + public SparseFieldSetExpression OnApplySparseFieldSet(SparseFieldSetExpression? existingSparseFieldSet) { throw new NotImplementedException(); } - public QueryStringParameterHandlers<IntResource> OnRegisterQueryableHandlersForQueryStringParameters() + public QueryStringParameterHandlers<ResourceOfInt32> OnRegisterQueryableHandlersForQueryStringParameters() { throw new NotImplementedException(); } - public IDictionary<string, object> GetMeta(IntResource resource) + public IDictionary<string, object?> GetMeta(ResourceOfInt32 resource) { throw new NotImplementedException(); } - public Task OnPrepareWriteAsync(IntResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) + public Task OnPrepareWriteAsync(ResourceOfInt32 resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) { throw new NotImplementedException(); } - public Task<IIdentifiable> OnSetToOneRelationshipAsync(IntResource leftResource, HasOneAttribute hasOneRelationship, IIdentifiable rightResourceId, - WriteOperationKind writeOperation, CancellationToken cancellationToken) + public Task<IIdentifiable?> OnSetToOneRelationshipAsync(ResourceOfInt32 leftResource, HasOneAttribute hasOneRelationship, + IIdentifiable? rightResourceId, WriteOperationKind writeOperation, CancellationToken cancellationToken) { throw new NotImplementedException(); } - public Task OnSetToManyRelationshipAsync(IntResource leftResource, HasManyAttribute hasManyRelationship, ISet<IIdentifiable> rightResourceIds, + public Task OnSetToManyRelationshipAsync(ResourceOfInt32 leftResource, HasManyAttribute hasManyRelationship, ISet<IIdentifiable> rightResourceIds, WriteOperationKind writeOperation, CancellationToken cancellationToken) { throw new NotImplementedException(); @@ -474,83 +477,83 @@ public Task OnAddToRelationshipAsync(int leftResourceId, HasManyAttribute hasMan throw new NotImplementedException(); } - public Task OnRemoveFromRelationshipAsync(IntResource leftResource, HasManyAttribute hasManyRelationship, ISet<IIdentifiable> rightResourceIds, + public Task OnRemoveFromRelationshipAsync(ResourceOfInt32 leftResource, HasManyAttribute hasManyRelationship, ISet<IIdentifiable> rightResourceIds, CancellationToken cancellationToken) { throw new NotImplementedException(); } - public Task OnWritingAsync(IntResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) + public Task OnWritingAsync(ResourceOfInt32 resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) { throw new NotImplementedException(); } - public Task OnWriteSucceededAsync(IntResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) + public Task OnWriteSucceededAsync(ResourceOfInt32 resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) { throw new NotImplementedException(); } - public void OnDeserialize(IntResource resource) + public void OnDeserialize(ResourceOfInt32 resource) { throw new NotImplementedException(); } - public void OnSerialize(IntResource resource) + public void OnSerialize(ResourceOfInt32 resource) { throw new NotImplementedException(); } } [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - private sealed class GuidResourceDefinition : IResourceDefinition<GuidResource, Guid> + private sealed class ResourceDefinitionOfGuid : IResourceDefinition<ResourceOfGuid, Guid> { - public IImmutableList<IncludeElementExpression> OnApplyIncludes(IImmutableList<IncludeElementExpression> existingIncludes) + public IImmutableSet<IncludeElementExpression> OnApplyIncludes(IImmutableSet<IncludeElementExpression> existingIncludes) { throw new NotImplementedException(); } - public FilterExpression OnApplyFilter(FilterExpression existingFilter) + public FilterExpression OnApplyFilter(FilterExpression? existingFilter) { throw new NotImplementedException(); } - public SortExpression OnApplySort(SortExpression existingSort) + public SortExpression OnApplySort(SortExpression? existingSort) { throw new NotImplementedException(); } - public PaginationExpression OnApplyPagination(PaginationExpression existingPagination) + public PaginationExpression OnApplyPagination(PaginationExpression? existingPagination) { throw new NotImplementedException(); } - public SparseFieldSetExpression OnApplySparseFieldSet(SparseFieldSetExpression existingSparseFieldSet) + public SparseFieldSetExpression OnApplySparseFieldSet(SparseFieldSetExpression? existingSparseFieldSet) { throw new NotImplementedException(); } - public QueryStringParameterHandlers<GuidResource> OnRegisterQueryableHandlersForQueryStringParameters() + public QueryStringParameterHandlers<ResourceOfGuid> OnRegisterQueryableHandlersForQueryStringParameters() { throw new NotImplementedException(); } - public IDictionary<string, object> GetMeta(GuidResource resource) + public IDictionary<string, object?> GetMeta(ResourceOfGuid resource) { throw new NotImplementedException(); } - public Task OnPrepareWriteAsync(GuidResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) + public Task OnPrepareWriteAsync(ResourceOfGuid resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) { throw new NotImplementedException(); } - public Task<IIdentifiable> OnSetToOneRelationshipAsync(GuidResource leftResource, HasOneAttribute hasOneRelationship, IIdentifiable rightResourceId, - WriteOperationKind writeOperation, CancellationToken cancellationToken) + public Task<IIdentifiable?> OnSetToOneRelationshipAsync(ResourceOfGuid leftResource, HasOneAttribute hasOneRelationship, + IIdentifiable? rightResourceId, WriteOperationKind writeOperation, CancellationToken cancellationToken) { throw new NotImplementedException(); } - public Task OnSetToManyRelationshipAsync(GuidResource leftResource, HasManyAttribute hasManyRelationship, ISet<IIdentifiable> rightResourceIds, + public Task OnSetToManyRelationshipAsync(ResourceOfGuid leftResource, HasManyAttribute hasManyRelationship, ISet<IIdentifiable> rightResourceIds, WriteOperationKind writeOperation, CancellationToken cancellationToken) { throw new NotImplementedException(); @@ -562,28 +565,28 @@ public Task OnAddToRelationshipAsync(Guid leftResourceId, HasManyAttribute hasMa throw new NotImplementedException(); } - public Task OnRemoveFromRelationshipAsync(GuidResource leftResource, HasManyAttribute hasManyRelationship, ISet<IIdentifiable> rightResourceIds, + public Task OnRemoveFromRelationshipAsync(ResourceOfGuid leftResource, HasManyAttribute hasManyRelationship, ISet<IIdentifiable> rightResourceIds, CancellationToken cancellationToken) { throw new NotImplementedException(); } - public Task OnWritingAsync(GuidResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) + public Task OnWritingAsync(ResourceOfGuid resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) { throw new NotImplementedException(); } - public Task OnWriteSucceededAsync(GuidResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) + public Task OnWriteSucceededAsync(ResourceOfGuid resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) { throw new NotImplementedException(); } - public void OnDeserialize(GuidResource resource) + public void OnDeserialize(ResourceOfGuid resource) { throw new NotImplementedException(); } - public void OnSerialize(GuidResource resource) + public void OnSerialize(ResourceOfGuid resource) { throw new NotImplementedException(); } @@ -592,8 +595,9 @@ public void OnSerialize(GuidResource resource) [UsedImplicitly(ImplicitUseTargetFlags.Members)] private sealed class TestDbContext : DbContext { - public DbSet<IntResource> Resource { get; set; } - public DbSet<Person> People { get; set; } + public DbSet<ResourceOfInt32> ResourcesOfInt32 => Set<ResourceOfInt32>(); + public DbSet<ResourceOfGuid> ResourcesOfGuid => Set<ResourceOfGuid>(); + public DbSet<Person> People => Set<Person>(); public TestDbContext(DbContextOptions<TestDbContext> options) : base(options) @@ -602,7 +606,7 @@ public TestDbContext(DbContextOptions<TestDbContext> options) } [UsedImplicitly(ImplicitUseKindFlags.Access)] - private sealed class Person : Identifiable + private sealed class Person : Identifiable<int> { } } diff --git a/test/UnitTests/Graph/Model.cs b/test/UnitTests/Graph/Model.cs index 2fb3214de3..8144871b2e 100644 --- a/test/UnitTests/Graph/Model.cs +++ b/test/UnitTests/Graph/Model.cs @@ -2,7 +2,7 @@ namespace UnitTests.Graph { - internal sealed class Model : Identifiable + internal sealed class Model : Identifiable<int> { } } diff --git a/test/UnitTests/Graph/ResourceDescriptorAssemblyCacheTests.cs b/test/UnitTests/Graph/ResourceDescriptorAssemblyCacheTests.cs index 99e8f54931..1c5c6d21b8 100644 --- a/test/UnitTests/Graph/ResourceDescriptorAssemblyCacheTests.cs +++ b/test/UnitTests/Graph/ResourceDescriptorAssemblyCacheTests.cs @@ -4,6 +4,7 @@ using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; +using TestBuildingBlocks; using Xunit; namespace UnitTests.Graph @@ -14,34 +15,34 @@ public sealed class ResourceDescriptorAssemblyCacheTests public void GetResourceDescriptorsPerAssembly_Locates_Identifiable_Resource() { // Arrange - Type resourceType = typeof(Model); + Type resourceClrType = typeof(Model); var assemblyCache = new ResourceDescriptorAssemblyCache(); - assemblyCache.RegisterAssembly(resourceType.Assembly); + assemblyCache.RegisterAssembly(resourceClrType.Assembly); // Act IReadOnlyCollection<ResourceDescriptor> descriptors = assemblyCache.GetResourceDescriptors(); // Assert - descriptors.Should().NotBeEmpty(); - descriptors.Should().ContainSingle(descriptor => descriptor.ResourceType == resourceType); + descriptors.ShouldNotBeEmpty(); + descriptors.Should().ContainSingle(descriptor => descriptor.ResourceClrType == resourceClrType); } [Fact] public void GetResourceDescriptorsPerAssembly_Only_Contains_IIdentifiable_Types() { // Arrange - Type resourceType = typeof(Model); + Type resourceClrType = typeof(Model); var assemblyCache = new ResourceDescriptorAssemblyCache(); - assemblyCache.RegisterAssembly(resourceType.Assembly); + assemblyCache.RegisterAssembly(resourceClrType.Assembly); // Act IReadOnlyCollection<ResourceDescriptor> descriptors = assemblyCache.GetResourceDescriptors(); // Assert - descriptors.Should().NotBeEmpty(); - descriptors.Select(descriptor => descriptor.ResourceType).Should().AllBeAssignableTo<IIdentifiable>(); + descriptors.ShouldNotBeEmpty(); + descriptors.Select(descriptor => descriptor.ResourceClrType).Should().AllBeAssignableTo<IIdentifiable>(); } } } diff --git a/test/UnitTests/Graph/TypeLocatorTests.cs b/test/UnitTests/Graph/TypeLocatorTests.cs index 0d44732a2a..7c2220b27b 100644 --- a/test/UnitTests/Graph/TypeLocatorTests.cs +++ b/test/UnitTests/Graph/TypeLocatorTests.cs @@ -1,7 +1,10 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Reflection; +using FluentAssertions; using JsonApiDotNetCore.Configuration; +using TestBuildingBlocks; using Xunit; namespace UnitTests.Graph @@ -25,9 +28,9 @@ public void GetGenericInterfaceImplementation_Gets_Implementation() (Type implementation, Type registrationInterface)? result = typeLocator.GetGenericInterfaceImplementation(assembly, openGeneric, genericArg); // Assert - Assert.NotNull(result); - Assert.Equal(expectedImplementation, result.Value.implementation); - Assert.Equal(expectedInterface, result.Value.registrationInterface); + result.ShouldNotBeNull(); + result.Value.implementation.Should().Be(expectedImplementation); + result.Value.registrationInterface.Should().Be(expectedInterface); } [Fact] @@ -46,9 +49,8 @@ public void GetDerivedGenericTypes_Gets_Implementation() IReadOnlyCollection<Type> results = typeLocator.GetDerivedGenericTypes(assembly, openGeneric, genericArg); // Assert - Assert.NotNull(results); - Type result = Assert.Single(results); - Assert.Equal(expectedImplementation, result); + results.ShouldHaveCount(1); + results.ElementAt(0).Should().Be(expectedImplementation); } [Fact] @@ -60,10 +62,10 @@ public void GetIdType_Correctly_Identifies_JsonApiResource() var typeLocator = new TypeLocator(); // Act - Type idType = typeLocator.TryGetIdType(type); + Type? idType = typeLocator.LookupIdType(type); // Assert - Assert.Equal(typeof(int), idType); + idType.Should().Be(typeof(int)); } [Fact] @@ -75,42 +77,42 @@ public void GetIdType_Correctly_Identifies_NonJsonApiResource() var typeLocator = new TypeLocator(); // Act - Type idType = typeLocator.TryGetIdType(type); + Type? idType = typeLocator.LookupIdType(type); // Assert - Assert.Null(idType); + idType.Should().BeNull(); } [Fact] - public void TryGetResourceDescriptor_Returns_Type_If_Type_Is_IIdentifiable() + public void ResolveResourceDescriptor_Returns_Type_If_Type_Is_IIdentifiable() { // Arrange - Type resourceType = typeof(Model); + Type resourceClrType = typeof(Model); var typeLocator = new TypeLocator(); // Act - ResourceDescriptor descriptor = typeLocator.TryGetResourceDescriptor(resourceType); + ResourceDescriptor? descriptor = typeLocator.ResolveResourceDescriptor(resourceClrType); // Assert - Assert.NotNull(descriptor); - Assert.Equal(resourceType, descriptor.ResourceType); - Assert.Equal(typeof(int), descriptor.IdType); + descriptor.ShouldNotBeNull(); + descriptor.ResourceClrType.Should().Be(resourceClrType); + descriptor.IdClrType.Should().Be(typeof(int)); } [Fact] - public void TryGetResourceDescriptor_Returns_False_If_Type_Is_IIdentifiable() + public void ResolveResourceDescriptor_Returns_False_If_Type_Is_IIdentifiable() { // Arrange - Type resourceType = typeof(string); + Type resourceClrType = typeof(string); var typeLocator = new TypeLocator(); // Act - ResourceDescriptor descriptor = typeLocator.TryGetResourceDescriptor(resourceType); + ResourceDescriptor? descriptor = typeLocator.ResolveResourceDescriptor(resourceClrType); // Assert - Assert.Null(descriptor); + descriptor.Should().BeNull(); } } } diff --git a/test/UnitTests/Internal/ErrorDocumentTests.cs b/test/UnitTests/Internal/ErrorObjectTests.cs similarity index 79% rename from test/UnitTests/Internal/ErrorDocumentTests.cs rename to test/UnitTests/Internal/ErrorObjectTests.cs index e2426659b2..d60520df84 100644 --- a/test/UnitTests/Internal/ErrorDocumentTests.cs +++ b/test/UnitTests/Internal/ErrorObjectTests.cs @@ -6,7 +6,7 @@ namespace UnitTests.Internal { - public sealed class ErrorDocumentTests + public sealed class ErrorObjectTests { // @formatter:wrap_array_initializer_style wrap_if_long [Theory] @@ -18,13 +18,10 @@ public sealed class ErrorDocumentTests public void ErrorDocument_GetErrorStatusCode_IsCorrect(HttpStatusCode[] errorCodes, HttpStatusCode expected) { // Arrange - var document = new Document - { - Errors = errorCodes.Select(code => new ErrorObject(code)).ToList() - }; + ErrorObject[] errors = errorCodes.Select(code => new ErrorObject(code)).ToArray(); // Act - HttpStatusCode status = document.GetErrorStatusCode(); + HttpStatusCode status = ErrorObject.GetResponseStatusCode(errors); // Assert status.Should().Be(expected); diff --git a/test/UnitTests/Internal/RequestScopedServiceProviderTests.cs b/test/UnitTests/Internal/RequestScopedServiceProviderTests.cs deleted file mode 100644 index 49313b4364..0000000000 --- a/test/UnitTests/Internal/RequestScopedServiceProviderTests.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Resources; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace UnitTests.Internal -{ - public sealed class RequestScopedServiceProviderTests - { - [Fact] - public void When_http_context_is_unavailable_it_must_fail() - { - // Arrange - Type serviceType = typeof(IIdentifiable<Model>); - - var provider = new RequestScopedServiceProvider(new HttpContextAccessor()); - - // Act - Action action = () => provider.GetRequiredService(serviceType); - - // Assert - var exception = Assert.Throws<InvalidOperationException>(action); - - Assert.StartsWith($"Cannot resolve scoped service '{serviceType.FullName}' outside the context of an HTTP request.", exception.Message); - } - - [UsedImplicitly(ImplicitUseTargetFlags.Itself)] - private sealed class Model : Identifiable - { - } - } -} diff --git a/test/UnitTests/Internal/ResourceGraphBuilderTests.cs b/test/UnitTests/Internal/ResourceGraphBuilderTests.cs deleted file mode 100644 index d1df3947d8..0000000000 --- a/test/UnitTests/Internal/ResourceGraphBuilderTests.cs +++ /dev/null @@ -1,98 +0,0 @@ -using System.Linq; -using Castle.DynamicProxy; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Resources; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; -using TestBuildingBlocks; -using Xunit; - -namespace UnitTests.Internal -{ - public sealed class ResourceGraphBuilderTests - { - [Fact] - public void AddDbContext_Does_Not_Throw_If_Context_Contains_Members_That_Do_Not_Implement_IIdentifiable() - { - // Arrange - var resourceGraphBuilder = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance); - - // Act - resourceGraphBuilder.Add(typeof(TestDbContext)); - var resourceGraph = (ResourceGraph)resourceGraphBuilder.Build(); - - // Assert - Assert.Empty(resourceGraph.GetResourceContexts()); - } - - [Fact] - public void Adding_DbContext_Members_That_Do_Not_Implement_IIdentifiable_Logs_Warning() - { - // Arrange - var loggerFactory = new FakeLoggerFactory(LogLevel.Warning); - var resourceGraphBuilder = new ResourceGraphBuilder(new JsonApiOptions(), loggerFactory); - resourceGraphBuilder.Add(typeof(TestDbContext)); - - // Act - resourceGraphBuilder.Build(); - - // Assert - Assert.Single(loggerFactory.Logger.Messages); - Assert.Equal(LogLevel.Warning, loggerFactory.Logger.Messages.Single().LogLevel); - - Assert.Equal("Entity 'UnitTests.Internal.ResourceGraphBuilderTests+TestDbContext' does not implement 'IIdentifiable'.", - loggerFactory.Logger.Messages.Single().Text); - } - - [Fact] - public void GetResourceContext_Yields_Right_Type_For_LazyLoadingProxy() - { - // Arrange - var resourceGraphBuilder = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance); - resourceGraphBuilder.Add<Bar>(); - var resourceGraph = (ResourceGraph)resourceGraphBuilder.Build(); - var proxyGenerator = new ProxyGenerator(); - - // Act - var proxy = proxyGenerator.CreateClassProxy<Bar>(); - ResourceContext resourceContext = resourceGraph.GetResourceContext(proxy.GetType()); - - // Assert - Assert.Equal(typeof(Bar), resourceContext.ResourceType); - } - - [Fact] - public void GetResourceContext_Yields_Right_Type_For_Identifiable() - { - // Arrange - var resourceGraphBuilder = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance); - resourceGraphBuilder.Add<Bar>(); - var resourceGraph = (ResourceGraph)resourceGraphBuilder.Build(); - - // Act - ResourceContext resourceContext = resourceGraph.GetResourceContext(typeof(Bar)); - - // Assert - Assert.Equal(typeof(Bar), resourceContext.ResourceType); - } - - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - private sealed class Foo - { - } - - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - private sealed class TestDbContext : DbContext - { - public DbSet<Foo> Foos { get; set; } - } - - // ReSharper disable once ClassCanBeSealed.Global - // ReSharper disable once MemberCanBePrivate.Global - public class Bar : Identifiable - { - } - } -} diff --git a/test/UnitTests/Internal/RuntimeTypeConverterTests.cs b/test/UnitTests/Internal/RuntimeTypeConverterTests.cs index b2162841b6..83fda36624 100644 --- a/test/UnitTests/Internal/RuntimeTypeConverterTests.cs +++ b/test/UnitTests/Internal/RuntimeTypeConverterTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using FluentAssertions; using JsonApiDotNetCore.Resources.Internal; using Xunit; @@ -11,14 +12,14 @@ public sealed class RuntimeTypeConverterTests public void Can_Convert_DateTimeOffsets() { // Arrange - var dto = new DateTimeOffset(new DateTime(2002, 2, 2), TimeSpan.FromHours(4)); - string formattedString = dto.ToString("O"); + var dateTimeOffset = new DateTimeOffset(new DateTime(2002, 2, 2), TimeSpan.FromHours(4)); + string formattedString = dateTimeOffset.ToString("O"); // Act - object result = RuntimeTypeConverter.ConvertType(formattedString, typeof(DateTimeOffset)); + object? result = RuntimeTypeConverter.ConvertType(formattedString, typeof(DateTimeOffset)); // Assert - Assert.Equal(dto, result); + result.Should().Be(dateTimeOffset); } [Fact] @@ -31,7 +32,7 @@ public void Bad_DateTimeOffset_String_Throws() Action action = () => RuntimeTypeConverter.ConvertType(formattedString, typeof(DateTimeOffset)); // Assert - Assert.Throws<FormatException>(action); + action.Should().ThrowExactly<FormatException>(); } [Fact] @@ -41,42 +42,42 @@ public void Can_Convert_Enums() const string formattedString = "1"; // Act - object result = RuntimeTypeConverter.ConvertType(formattedString, typeof(TestEnum)); + object? result = RuntimeTypeConverter.ConvertType(formattedString, typeof(TestEnum)); // Assert - Assert.Equal(TestEnum.Test, result); + result.Should().Be(TestEnum.Test); } [Fact] public void ConvertType_Returns_Value_If_Type_Is_Same() { // Arrange - var val = new ComplexType(); - Type type = val.GetType(); + var complexType = new ComplexType(); + Type type = complexType.GetType(); // Act - object result = RuntimeTypeConverter.ConvertType(val, type); + object? result = RuntimeTypeConverter.ConvertType(complexType, type); // Assert - Assert.Equal(val, result); + result.Should().Be(complexType); } [Fact] public void ConvertType_Returns_Value_If_Type_Is_Assignable() { // Arrange - var val = new ComplexType(); + var complexType = new ComplexType(); Type baseType = typeof(BaseType); Type iType = typeof(IType); // Act - object baseResult = RuntimeTypeConverter.ConvertType(val, baseType); - object iResult = RuntimeTypeConverter.ConvertType(val, iType); + object? baseResult = RuntimeTypeConverter.ConvertType(complexType, baseType); + object? iResult = RuntimeTypeConverter.ConvertType(complexType, iType); // Assert - Assert.Equal(val, baseResult); - Assert.Equal(val, iResult); + baseResult.Should().Be(complexType); + iResult.Should().Be(complexType); } [Fact] @@ -92,13 +93,13 @@ public void ConvertType_Returns_Default_Value_For_Empty_Strings() { typeof(Guid), Guid.Empty } }; - foreach (KeyValuePair<Type, object> pair in data) + foreach ((Type key, object value) in data) { // Act - object result = RuntimeTypeConverter.ConvertType(string.Empty, pair.Key); + object? result = RuntimeTypeConverter.ConvertType(string.Empty, key); // Assert - Assert.Equal(pair.Value, result); + result.Should().Be(value); } } @@ -110,10 +111,10 @@ public void Can_Convert_TimeSpans() string stringSpan = timeSpan.ToString(); // Act - object result = RuntimeTypeConverter.ConvertType(stringSpan, typeof(TimeSpan)); + object? result = RuntimeTypeConverter.ConvertType(stringSpan, typeof(TimeSpan)); // Assert - Assert.Equal(timeSpan, result); + result.Should().Be(timeSpan); } [Fact] @@ -126,7 +127,7 @@ public void Bad_TimeSpanString_Throws() Action action = () => RuntimeTypeConverter.ConvertType(formattedString, typeof(TimeSpan)); // Assert - Assert.Throws<FormatException>(action); + action.Should().ThrowExactly<FormatException>(); } private enum TestEnum diff --git a/test/UnitTests/Internal/TypeExtensionsTests.cs b/test/UnitTests/Internal/TypeExtensionsTests.cs index 274dc50da7..494e91510e 100644 --- a/test/UnitTests/Internal/TypeExtensionsTests.cs +++ b/test/UnitTests/Internal/TypeExtensionsTests.cs @@ -1,4 +1,5 @@ using System; +using FluentAssertions; using JsonApiDotNetCore; using JsonApiDotNetCore.Resources; using Xunit; @@ -14,10 +15,10 @@ public void Implements_Returns_True_If_Type_Implements_Interface() Type type = typeof(Model); // Act - bool result = type.IsOrImplementsInterface(typeof(IIdentifiable)); + bool result = type.IsOrImplementsInterface<IIdentifiable>(); // Assert - Assert.True(result); + result.Should().BeTrue(); } [Fact] @@ -27,16 +28,16 @@ public void Implements_Returns_False_If_Type_DoesNot_Implement_Interface() Type type = typeof(string); // Act - bool result = type.IsOrImplementsInterface(typeof(IIdentifiable)); + bool result = type.IsOrImplementsInterface<IIdentifiable>(); // Assert - Assert.False(result); + result.Should().BeFalse(); } private sealed class Model : IIdentifiable { - public string StringId { get; set; } - public string LocalId { get; set; } + public string? StringId { get; set; } + public string? LocalId { get; set; } } } } diff --git a/test/UnitTests/Middleware/JsonApiMiddlewareTests.cs b/test/UnitTests/Middleware/JsonApiMiddlewareTests.cs deleted file mode 100644 index 93122f9a99..0000000000 --- a/test/UnitTests/Middleware/JsonApiMiddlewareTests.cs +++ /dev/null @@ -1,201 +0,0 @@ -using System; -using System.IO; -using System.Linq; -using System.Reflection; -using System.Threading.Tasks; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Resources.Annotations; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Features; -using Microsoft.AspNetCore.Mvc.Controllers; -using Microsoft.Extensions.Logging.Abstractions; -using Moq; -using Moq.Language; -using Xunit; - -namespace UnitTests.Middleware -{ - public sealed class JsonApiMiddlewareTests - { - [Fact] - public async Task ParseUrlBase_ObfuscatedIdClass_ShouldSetIdCorrectly() - { - // Arrange - const string id = "ABC123ABC"; - InvokeConfiguration configuration = GetConfiguration($"/obfuscatedIdModel/{id}", action: "GetAsync", id: id); - JsonApiRequest request = configuration.Request; - - // Act - await RunMiddlewareTask(configuration); - - // Assert - Assert.Equal(id, request.PrimaryId); - } - - [Fact] - public async Task ParseUrlBase_UrlHasPrimaryIdSet_ShouldSetupRequestWithSameId() - { - // Arrange - const string id = "123"; - InvokeConfiguration configuration = GetConfiguration($"/users/{id}", id: id); - JsonApiRequest request = configuration.Request; - - // Act - await RunMiddlewareTask(configuration); - - // Assert - Assert.Equal(id, request.PrimaryId); - } - - [Fact] - public async Task ParseUrlBase_UrlHasNoPrimaryIdSet_ShouldHaveBaseIdSetToNull() - { - // Arrange - InvokeConfiguration configuration = GetConfiguration("/users"); - JsonApiRequest request = configuration.Request; - - // Act - await RunMiddlewareTask(configuration); - - // Assert - Assert.Null(request.PrimaryId); - } - - [Fact] - public async Task ParseUrlBase_UrlHasNegativePrimaryIdAndTypeIsInt_ShouldNotThrowJAException() - { - // Arrange - InvokeConfiguration configuration = GetConfiguration("/users/-5/"); - - // Act - Func<Task> asyncAction = async () => await RunMiddlewareTask(configuration); - - // Assert - await asyncAction(); - } - - private Task RunMiddlewareTask(InvokeConfiguration holder) - { - IControllerResourceMapping controllerResourceMapping = holder.ControllerResourceMapping.Object; - HttpContext context = holder.HttpContext; - IJsonApiOptions options = holder.Options; - JsonApiRequest request = holder.Request; - IResourceGraph resourceGraph = holder.ResourceGraph.Object; - return holder.MiddleWare.InvokeAsync(context, controllerResourceMapping, options, request, resourceGraph, NullLogger<JsonApiMiddleware>.Instance); - } - - private InvokeConfiguration GetConfiguration(string path, string resourceName = "users", string action = "", string id = null, Type relType = null) - { - if (path.First() != '/') - { - throw new ArgumentException("Path should start with a '/'"); - } - - var middleware = new JsonApiMiddleware(_ => - { - return Task.Run(() => Console.WriteLine("finished")); - }, new HttpContextAccessor()); - - const string forcedNamespace = "api/v1"; - var mockMapping = new Mock<IControllerResourceMapping>(); - mockMapping.Setup(mapping => mapping.GetResourceTypeForController(It.IsAny<Type>())).Returns(typeof(string)); - - IJsonApiOptions options = CreateOptions(forcedNamespace); - Mock<IResourceGraph> mockGraph = CreateMockResourceGraph(resourceName, relType != null); - var request = new JsonApiRequest(); - - if (relType != null) - { - request.Relationship = new HasManyAttribute - { - RightType = relType - }; - } - - DefaultHttpContext context = CreateHttpContext(path, relType != null, action, id); - - return new InvokeConfiguration - { - MiddleWare = middleware, - ControllerResourceMapping = mockMapping, - Options = options, - Request = request, - HttpContext = context, - ResourceGraph = mockGraph - }; - } - - private static IJsonApiOptions CreateOptions(string forcedNamespace) - { - var options = new JsonApiOptions - { - Namespace = forcedNamespace - }; - - return options; - } - - private static DefaultHttpContext CreateHttpContext(string path, bool isRelationship = false, string action = "", string id = null) - { - var context = new DefaultHttpContext(); - context.Request.Path = new PathString(path); - context.Response.Body = new MemoryStream(); - - var feature = new RouteValuesFeature - { - RouteValues = - { - ["controller"] = "fake!", - ["action"] = isRelationship ? "GetRelationship" : action - } - }; - - if (id != null) - { - feature.RouteValues["id"] = id; - } - - context.Features.Set<IRouteValuesFeature>(feature); - - var controllerActionDescriptor = new ControllerActionDescriptor - { - ControllerTypeInfo = (TypeInfo)typeof(object) - }; - - context.SetEndpoint(new Endpoint(_ => Task.CompletedTask, new EndpointMetadataCollection(controllerActionDescriptor), null)); - return context; - } - - private Mock<IResourceGraph> CreateMockResourceGraph(string resourceName, bool includeRelationship = false) - { - var mockGraph = new Mock<IResourceGraph>(); - - var resourceContext = new ResourceContext(resourceName, typeof(object), typeof(string), Array.Empty<AttrAttribute>(), - Array.Empty<RelationshipAttribute>(), Array.Empty<EagerLoadAttribute>()); - - ISetupSequentialResult<ResourceContext> seq = mockGraph.SetupSequence(resourceGraph => resourceGraph.GetResourceContext(It.IsAny<Type>())) - .Returns(resourceContext); - - if (includeRelationship) - { - var relatedContext = new ResourceContext("todoItems", typeof(object), typeof(string), Array.Empty<AttrAttribute>(), - Array.Empty<RelationshipAttribute>(), Array.Empty<EagerLoadAttribute>()); - - seq.Returns(relatedContext); - } - - return mockGraph; - } - - private sealed class InvokeConfiguration - { - public JsonApiMiddleware MiddleWare { get; init; } - public HttpContext HttpContext { get; init; } - public Mock<IControllerResourceMapping> ControllerResourceMapping { get; init; } - public IJsonApiOptions Options { get; init; } - public JsonApiRequest Request { get; init; } - public Mock<IResourceGraph> ResourceGraph { get; init; } - } - } -} diff --git a/test/UnitTests/Middleware/JsonApiRequestTests.cs b/test/UnitTests/Middleware/JsonApiRequestTests.cs index bb6a230fd7..b7e35c46ea 100644 --- a/test/UnitTests/Middleware/JsonApiRequestTests.cs +++ b/test/UnitTests/Middleware/JsonApiRequestTests.cs @@ -14,73 +14,107 @@ using Microsoft.AspNetCore.Mvc.Controllers; using Microsoft.Extensions.Logging.Abstractions; using Moq; +using TestBuildingBlocks; using Xunit; +#pragma warning disable AV1561 // Signature contains too many parameters + namespace UnitTests.Middleware { public sealed class JsonApiRequestTests { + // @formatter:wrap_lines false [Theory] - [InlineData("HEAD", "/todoItems", true, EndpointKind.Primary, null, true)] - [InlineData("HEAD", "/todoItems/1", false, EndpointKind.Primary, null, true)] - [InlineData("HEAD", "/todoItems/1/owner", false, EndpointKind.Secondary, null, true)] - [InlineData("HEAD", "/todoItems/1/tags", true, EndpointKind.Secondary, null, true)] - [InlineData("HEAD", "/todoItems/1/relationships/owner", false, EndpointKind.Relationship, null, true)] - [InlineData("HEAD", "/todoItems/1/relationships/tags", true, EndpointKind.Relationship, null, true)] - [InlineData("GET", "/todoItems", true, EndpointKind.Primary, null, true)] - [InlineData("GET", "/todoItems/1", false, EndpointKind.Primary, null, true)] - [InlineData("GET", "/todoItems/1/owner", false, EndpointKind.Secondary, null, true)] - [InlineData("GET", "/todoItems/1/tags", true, EndpointKind.Secondary, null, true)] - [InlineData("GET", "/todoItems/1/relationships/owner", false, EndpointKind.Relationship, null, true)] - [InlineData("GET", "/todoItems/1/relationships/tags", true, EndpointKind.Relationship, null, true)] - [InlineData("POST", "/todoItems", false, EndpointKind.Primary, WriteOperationKind.CreateResource, false)] - [InlineData("POST", "/todoItems/1/relationships/tags", true, EndpointKind.Relationship, WriteOperationKind.AddToRelationship, false)] - [InlineData("PATCH", "/todoItems/1", false, EndpointKind.Primary, WriteOperationKind.UpdateResource, false)] - [InlineData("PATCH", "/todoItems/1/relationships/owner", false, EndpointKind.Relationship, WriteOperationKind.SetRelationship, false)] - [InlineData("PATCH", "/todoItems/1/relationships/tags", true, EndpointKind.Relationship, WriteOperationKind.SetRelationship, false)] - [InlineData("DELETE", "/todoItems/1", false, EndpointKind.Primary, WriteOperationKind.DeleteResource, false)] - [InlineData("DELETE", "/todoItems/1/relationships/tags", true, EndpointKind.Relationship, WriteOperationKind.RemoveFromRelationship, false)] - public async Task Sets_request_properties_correctly(string requestMethod, string requestPath, bool expectIsCollection, EndpointKind expectKind, - WriteOperationKind? expectWriteOperation, bool expectIsReadOnly) + [InlineData("HEAD", "/todoItems", EndpointKind.Primary, null, "todoItems", null, null, IsCollection.Yes, IsReadOnly.Yes, null)] + [InlineData("HEAD", "/people/1", EndpointKind.Primary, "1", "people", null, null, IsCollection.No, IsReadOnly.Yes, null)] + [InlineData("HEAD", "/todoItems/2/owner", EndpointKind.Secondary, "2", "todoItems", "people", "owner", IsCollection.No, IsReadOnly.Yes, null)] + [InlineData("HEAD", "/todoItems/3/tags", EndpointKind.Secondary, "3", "todoItems", "itemTags", "tags", IsCollection.Yes, IsReadOnly.Yes, null)] + [InlineData("HEAD", "/todoItems/ABC/relationships/owner", EndpointKind.Relationship, "ABC", "todoItems", "people", "owner", IsCollection.No, IsReadOnly.Yes, null)] + [InlineData("HEAD", "/todoItems/ABC/relationships/tags", EndpointKind.Relationship, "ABC", "todoItems", "itemTags", "tags", IsCollection.Yes, IsReadOnly.Yes, null)] + [InlineData("GET", "/todoItems", EndpointKind.Primary, null, "todoItems", null, null, IsCollection.Yes, IsReadOnly.Yes, null)] + [InlineData("GET", "/todoItems/-1", EndpointKind.Primary, "-1", "todoItems", null, null, IsCollection.No, IsReadOnly.Yes, null)] + [InlineData("GET", "/todoItems/1/owner", EndpointKind.Secondary, "1", "todoItems", "people", "owner", IsCollection.No, IsReadOnly.Yes, null)] + [InlineData("GET", "/todoItems/1/tags", EndpointKind.Secondary, "1", "todoItems", "itemTags", "tags", IsCollection.Yes, IsReadOnly.Yes, null)] + [InlineData("GET", "/todoItems/1/relationships/owner", EndpointKind.Relationship, "1", "todoItems", "people", "owner", IsCollection.No, IsReadOnly.Yes, null)] + [InlineData("GET", "/todoItems/1/relationships/tags", EndpointKind.Relationship, "1", "todoItems", "itemTags", "tags", IsCollection.Yes, IsReadOnly.Yes, null)] + [InlineData("POST", "/todoItems", EndpointKind.Primary, null, "todoItems", null, null, IsCollection.No, IsReadOnly.No, WriteOperationKind.CreateResource)] + [InlineData("POST", "/todoItems/1/relationships/tags", EndpointKind.Relationship, "1", "todoItems", "itemTags", "tags", IsCollection.Yes, IsReadOnly.No, WriteOperationKind.AddToRelationship)] + [InlineData("PATCH", "/itemTags/1", EndpointKind.Primary, "1", "itemTags", null, null, IsCollection.No, IsReadOnly.No, WriteOperationKind.UpdateResource)] + [InlineData("PATCH", "/todoItems/1/relationships/owner", EndpointKind.Relationship, "1", "todoItems", "people", "owner", IsCollection.No, IsReadOnly.No, WriteOperationKind.SetRelationship)] + [InlineData("PATCH", "/todoItems/1/relationships/tags", EndpointKind.Relationship, "1", "todoItems", "itemTags", "tags", IsCollection.Yes, IsReadOnly.No, WriteOperationKind.SetRelationship)] + [InlineData("DELETE", "/todoItems/1", EndpointKind.Primary, "1", "todoItems", null, null, IsCollection.No, IsReadOnly.No, WriteOperationKind.DeleteResource)] + [InlineData("DELETE", "/todoItems/1/relationships/tags", EndpointKind.Relationship, "1", "todoItems", "itemTags", "tags", IsCollection.Yes, IsReadOnly.No, WriteOperationKind.RemoveFromRelationship)] + [InlineData("POST", "/operations", EndpointKind.AtomicOperations, null, null, null, null, IsCollection.No, IsReadOnly.No, null)] + // @formatter:wrap_lines restore + public async Task Sets_request_properties_correctly(string requestMethod, string requestPath, EndpointKind expectKind, string? expectPrimaryId, + string? expectPrimaryResourceType, string? expectSecondaryResourceType, string? expectRelationshipName, IsCollection expectIsCollection, + IsReadOnly expectIsReadOnly, WriteOperationKind? expectWriteOperation) { // Arrange - var options = new JsonApiOptions - { - UseRelativeLinks = true - }; - - var graphBuilder = new ResourceGraphBuilder(options, NullLoggerFactory.Instance); - graphBuilder.Add<TodoItem>(); - graphBuilder.Add<Person>(); - graphBuilder.Add<Tag>(); + var options = new JsonApiOptions(); + var request = new JsonApiRequest(); - IResourceGraph resourceGraph = graphBuilder.Build(); + // @formatter:keep_existing_linebreaks true - var controllerResourceMappingMock = new Mock<IControllerResourceMapping>(); + IResourceGraph resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance) + .Add<TodoItem, int>() + .Add<Person, int>() + .Add<ItemTag, int>() + .Build(); - controllerResourceMappingMock.Setup(mapping => mapping.GetResourceTypeForController(It.IsAny<Type>())).Returns(typeof(TodoItem)); + // @formatter:keep_existing_linebreaks restore var httpContext = new DefaultHttpContext(); - SetupRoutes(httpContext, requestMethod, requestPath); - - var request = new JsonApiRequest(); + IControllerResourceMapping controllerResourceMapping = SetupRoutes(httpContext, resourceGraph, requestMethod, requestPath); - var middleware = new JsonApiMiddleware(_ => Task.CompletedTask, new HttpContextAccessor()); + var middleware = new JsonApiMiddleware(_ => Task.CompletedTask, new HttpContextAccessor + { + HttpContext = httpContext + }); // Act - await middleware.InvokeAsync(httpContext, controllerResourceMappingMock.Object, options, request, resourceGraph, - NullLogger<JsonApiMiddleware>.Instance); + await middleware.InvokeAsync(httpContext, controllerResourceMapping, options, request, NullLogger<JsonApiMiddleware>.Instance); // Assert - request.IsCollection.Should().Be(expectIsCollection); request.Kind.Should().Be(expectKind); + request.PrimaryId.Should().Be(expectPrimaryId); + + if (expectPrimaryResourceType == null) + { + request.PrimaryResourceType.Should().BeNull(); + } + else + { + request.PrimaryResourceType.ShouldNotBeNull(); + request.PrimaryResourceType.PublicName.Should().Be(expectPrimaryResourceType); + } + + if (expectSecondaryResourceType == null) + { + request.SecondaryResourceType.Should().BeNull(); + } + else + { + request.SecondaryResourceType.ShouldNotBeNull(); + request.SecondaryResourceType.PublicName.Should().Be(expectSecondaryResourceType); + } + + if (expectRelationshipName == null) + { + request.Relationship.Should().BeNull(); + } + else + { + request.Relationship.ShouldNotBeNull(); + request.Relationship.PublicName.Should().Be(expectRelationshipName); + } + + request.IsCollection.Should().Be(expectIsCollection == IsCollection.Yes); + request.IsReadOnly.Should().Be(expectIsReadOnly == IsReadOnly.Yes); request.WriteOperation.Should().Be(expectWriteOperation); - request.IsReadOnly.Should().Be(expectIsReadOnly); - request.PrimaryResource.Should().NotBeNull(); - request.PrimaryResource.PublicName.Should().Be("todoItems"); } - private static void SetupRoutes(HttpContext httpContext, string requestMethod, string requestPath) + private static IControllerResourceMapping SetupRoutes(HttpContext httpContext, IResourceGraph resourceGraph, string requestMethod, string requestPath) { httpContext.Request.Method = requestMethod; @@ -109,6 +143,10 @@ private static void SetupRoutes(HttpContext httpContext, string requestMethod, s { feature.RouteValues["action"] = "Relationship"; } + else if (pathSegments.Contains("operations")) + { + feature.RouteValues["action"] = "PostOperations"; + } httpContext.Features.Set<IRouteValuesFeature>(feature); @@ -117,29 +155,52 @@ private static void SetupRoutes(HttpContext httpContext, string requestMethod, s ControllerTypeInfo = (TypeInfo)typeof(object) }; + var controllerResourceMappingMock = new Mock<IControllerResourceMapping>(); + + controllerResourceMappingMock.Setup(mapping => mapping.GetResourceTypeForController(It.IsAny<Type>())).Returns(() => + { + return pathSegments.Length > 0 + ? resourceGraph.GetResourceTypes().FirstOrDefault(resourceType => resourceType.PublicName == pathSegments[0]) + : null; + }); + httpContext.SetEndpoint(new Endpoint(_ => Task.CompletedTask, new EndpointMetadataCollection(controllerActionDescriptor), null)); + + return controllerResourceMappingMock.Object; + } + + public enum IsReadOnly + { + Yes, + No + } + + public enum IsCollection + { + Yes, + No } [UsedImplicitly(ImplicitUseTargetFlags.Itself)] - private sealed class Person : Identifiable + private sealed class Person : Identifiable<int> { } [UsedImplicitly(ImplicitUseTargetFlags.Members)] - private sealed class Tag : Identifiable + private sealed class ItemTag : Identifiable<int> { [HasMany] - public ISet<TodoItem> TodoItems { get; set; } + public ISet<TodoItem> TodoItems { get; set; } = new HashSet<TodoItem>(); } [UsedImplicitly(ImplicitUseTargetFlags.Members)] - private sealed class TodoItem : Identifiable + private sealed class TodoItem : Identifiable<int> { [HasOne] - public Person Owner { get; set; } + public Person? Owner { get; set; } [HasMany] - public ISet<Tag> Tags { get; set; } + public ISet<ItemTag> Tags { get; set; } = new HashSet<ItemTag>(); } } } diff --git a/test/UnitTests/Models/AttributesEqualsTests.cs b/test/UnitTests/Models/AttributesEqualsTests.cs index 9da552e77a..3eff69ebe4 100644 --- a/test/UnitTests/Models/AttributesEqualsTests.cs +++ b/test/UnitTests/Models/AttributesEqualsTests.cs @@ -1,3 +1,4 @@ +using FluentAssertions; using JsonApiDotNetCore.Resources.Annotations; using Xunit; @@ -8,6 +9,7 @@ public sealed class AttributesEqualsTests [Fact] public void HasManyAttribute_Equals_Returns_True_When_Same_Name() { + // Arrange var attribute1 = new HasManyAttribute { PublicName = "test" @@ -18,15 +20,20 @@ public void HasManyAttribute_Equals_Returns_True_When_Same_Name() PublicName = "test" }; - Assert.Equal(attribute1, attribute2); + // Act + bool result = attribute1.Equals(attribute2); + + // Assert + result.Should().BeTrue(); } [Fact] public void HasManyAttribute_Equals_Returns_False_When_Different_Name() { + // Arrange var attribute1 = new HasManyAttribute { - PublicName = "test" + PublicName = "test1" }; var attribute2 = new HasManyAttribute @@ -34,12 +41,17 @@ public void HasManyAttribute_Equals_Returns_False_When_Different_Name() PublicName = "test2" }; - Assert.NotEqual(attribute1, attribute2); + // Act + bool result = attribute1.Equals(attribute2); + + // Assert + result.Should().BeFalse(); } [Fact] public void HasOneAttribute_Equals_Returns_True_When_Same_Name() { + // Arrange var attribute1 = new HasOneAttribute { PublicName = "test" @@ -50,15 +62,20 @@ public void HasOneAttribute_Equals_Returns_True_When_Same_Name() PublicName = "test" }; - Assert.Equal(attribute1, attribute2); + // Act + bool result = attribute1.Equals(attribute2); + + // Assert + result.Should().BeTrue(); } [Fact] public void HasOneAttribute_Equals_Returns_False_When_Different_Name() { + // Arrange var attribute1 = new HasOneAttribute { - PublicName = "test" + PublicName = "test1" }; var attribute2 = new HasOneAttribute @@ -66,12 +83,17 @@ public void HasOneAttribute_Equals_Returns_False_When_Different_Name() PublicName = "test2" }; - Assert.NotEqual(attribute1, attribute2); + // Act + bool result = attribute1.Equals(attribute2); + + // Assert + result.Should().BeFalse(); } [Fact] public void AttrAttribute_Equals_Returns_True_When_Same_Name() { + // Arrange var attribute1 = new AttrAttribute { PublicName = "test" @@ -82,15 +104,20 @@ public void AttrAttribute_Equals_Returns_True_When_Same_Name() PublicName = "test" }; - Assert.Equal(attribute1, attribute2); + // Act + bool result = attribute1.Equals(attribute2); + + // Assert + result.Should().BeTrue(); } [Fact] public void AttrAttribute_Equals_Returns_False_When_Different_Name() { + // Arrange var attribute1 = new AttrAttribute { - PublicName = "test" + PublicName = "test1" }; var attribute2 = new AttrAttribute @@ -98,12 +125,17 @@ public void AttrAttribute_Equals_Returns_False_When_Different_Name() PublicName = "test2" }; - Assert.NotEqual(attribute1, attribute2); + // Act + bool result = attribute1.Equals(attribute2); + + // Assert + result.Should().BeFalse(); } [Fact] public void HasManyAttribute_Does_Not_Equal_HasOneAttribute_With_Same_Name() { + // Arrange RelationshipAttribute attribute1 = new HasManyAttribute { PublicName = "test" @@ -114,8 +146,11 @@ public void HasManyAttribute_Does_Not_Equal_HasOneAttribute_With_Same_Name() PublicName = "test" }; - Assert.NotEqual(attribute1, attribute2); - Assert.NotEqual(attribute2, attribute1); + // Act + bool result = attribute1.Equals(attribute2); + + // Assert + result.Should().BeFalse(); } } } diff --git a/test/UnitTests/Models/IdentifiableTests.cs b/test/UnitTests/Models/IdentifiableTests.cs index 2b5f9dc51b..2d6b5875fe 100644 --- a/test/UnitTests/Models/IdentifiableTests.cs +++ b/test/UnitTests/Models/IdentifiableTests.cs @@ -1,3 +1,4 @@ +using FluentAssertions; using JsonApiDotNetCore.Resources; using Xunit; @@ -13,7 +14,7 @@ public void Can_Set_StringId_To_Value_Type() StringId = "1" }; - Assert.Equal(1, resource.Id); + resource.Id.Should().Be(1); } [Fact] @@ -24,20 +25,22 @@ public void Setting_StringId_To_Null_Sets_Id_As_Default() StringId = null }; - Assert.Equal(0, resource.Id); + resource.Id.Should().Be(0); } [Fact] public void GetStringId_Returns_Null_If_Object_Is_Default() { var resource = new IntId(); - string stringId = resource.ExposedGetStringId(default); - Assert.Null(stringId); + + string? stringId = resource.ExposedGetStringId(default); + + stringId.Should().BeNull(); } - private sealed class IntId : Identifiable + private sealed class IntId : Identifiable<int> { - public string ExposedGetStringId(int value) + public string? ExposedGetStringId(int value) { return GetStringId(value); } diff --git a/test/UnitTests/Models/ResourceConstructionExpressionTests.cs b/test/UnitTests/Models/ResourceConstructionExpressionTests.cs index 2f01d3d353..df94fe23a9 100644 --- a/test/UnitTests/Models/ResourceConstructionExpressionTests.cs +++ b/test/UnitTests/Models/ResourceConstructionExpressionTests.cs @@ -1,7 +1,11 @@ using System; using System.ComponentModel.Design; using System.Linq.Expressions; +using FluentAssertions; +using JetBrains.Annotations; +using JsonApiDotNetCore; using JsonApiDotNetCore.Resources; +using TestBuildingBlocks; using Xunit; namespace UnitTests.Models @@ -18,10 +22,10 @@ public void When_resource_has_default_constructor_it_must_succeed() NewExpression newExpression = factory.CreateNewExpression(typeof(ResourceWithoutConstructor)); // Assert - Func<ResourceWithoutConstructor> function = Expression.Lambda<Func<ResourceWithoutConstructor>>(newExpression).Compile(); + Func<ResourceWithoutConstructor> createFunction = Expression.Lambda<Func<ResourceWithoutConstructor>>(newExpression).Compile(); + ResourceWithoutConstructor resource = createFunction(); - ResourceWithoutConstructor resource = function(); - Assert.NotNull(resource); + resource.ShouldNotBeNull(); } [Fact] @@ -34,10 +38,25 @@ public void When_resource_has_constructor_with_string_parameter_it_must_fail() Action action = () => factory.CreateNewExpression(typeof(ResourceWithStringConstructor)); // Assert - var exception = Assert.Throws<InvalidOperationException>(action); + action.Should().ThrowExactly<InvalidOperationException>() + .WithMessage($"Failed to create an instance of '{typeof(ResourceWithStringConstructor).FullName}': Parameter 'text' could not be resolved."); + } + + private sealed class ResourceWithoutConstructor : Identifiable<int> + { + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + private sealed class ResourceWithStringConstructor : Identifiable<int> + { + public string Text { get; } + + public ResourceWithStringConstructor(string text) + { + ArgumentGuard.NotNullNorEmpty(text, nameof(text)); - Assert.Equal("Failed to create an instance of 'UnitTests.Models.ResourceWithStringConstructor': Parameter 'text' could not be resolved.", - exception.Message); + Text = text; + } } } } diff --git a/test/UnitTests/Models/ResourceConstructionTests.cs b/test/UnitTests/Models/ResourceConstructionTests.cs deleted file mode 100644 index 81d08fe548..0000000000 --- a/test/UnitTests/Models/ResourceConstructionTests.cs +++ /dev/null @@ -1,132 +0,0 @@ -using System; -using System.ComponentModel.Design; -using System.Text.Json; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Serialization; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Logging.Abstractions; -using Moq; -using Xunit; - -namespace UnitTests.Models -{ - public sealed class ResourceConstructionTests - { - private readonly Mock<IJsonApiRequest> _requestMock; - private readonly Mock<IHttpContextAccessor> _mockHttpContextAccessor; - private readonly Mock<IResourceDefinitionAccessor> _resourceDefinitionAccessorMock = new(); - - public ResourceConstructionTests() - { - _mockHttpContextAccessor = new Mock<IHttpContextAccessor>(); - _mockHttpContextAccessor.Setup(mock => mock.HttpContext).Returns(new DefaultHttpContext()); - _requestMock = new Mock<IJsonApiRequest>(); - _requestMock.Setup(mock => mock.Kind).Returns(EndpointKind.Primary); - } - - [Fact] - public void When_resource_has_default_constructor_it_must_succeed() - { - // Arrange - var options = new JsonApiOptions(); - - IResourceGraph resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add<ResourceWithoutConstructor>().Build(); - - var serviceContainer = new ServiceContainer(); - serviceContainer.AddService(typeof(IResourceDefinitionAccessor), new NeverResourceDefinitionAccessor()); - - var serializer = new RequestDeserializer(resourceGraph, new ResourceFactory(serviceContainer), new TargetedFields(), - _mockHttpContextAccessor.Object, _requestMock.Object, options, _resourceDefinitionAccessorMock.Object); - - var body = new - { - data = new - { - id = "1", - type = "resourceWithoutConstructors" - } - }; - - string content = JsonSerializer.Serialize(body); - - // Act - object result = serializer.Deserialize(content); - - // Assert - Assert.NotNull(result); - Assert.Equal(typeof(ResourceWithoutConstructor), result.GetType()); - } - - [Fact] - public void When_resource_has_default_constructor_that_throws_it_must_fail() - { - // Arrange - var options = new JsonApiOptions(); - - IResourceGraph resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add<ResourceWithThrowingConstructor>().Build(); - - var serviceContainer = new ServiceContainer(); - serviceContainer.AddService(typeof(IResourceDefinitionAccessor), new NeverResourceDefinitionAccessor()); - - var serializer = new RequestDeserializer(resourceGraph, new ResourceFactory(serviceContainer), new TargetedFields(), - _mockHttpContextAccessor.Object, _requestMock.Object, options, _resourceDefinitionAccessorMock.Object); - - var body = new - { - data = new - { - id = "1", - type = "resourceWithThrowingConstructors" - } - }; - - string content = JsonSerializer.Serialize(body); - - // Act - Action action = () => serializer.Deserialize(content); - - // Assert - var exception = Assert.Throws<InvalidOperationException>(action); - - Assert.Equal("Failed to create an instance of 'UnitTests.Models.ResourceWithThrowingConstructor' using its default constructor.", - exception.Message); - } - - [Fact] - public void When_resource_has_constructor_with_string_parameter_it_must_fail() - { - // Arrange - var options = new JsonApiOptions(); - - IResourceGraph resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add<ResourceWithStringConstructor>().Build(); - - var serviceContainer = new ServiceContainer(); - serviceContainer.AddService(typeof(IResourceDefinitionAccessor), new NeverResourceDefinitionAccessor()); - - var serializer = new RequestDeserializer(resourceGraph, new ResourceFactory(serviceContainer), new TargetedFields(), - _mockHttpContextAccessor.Object, _requestMock.Object, options, _resourceDefinitionAccessorMock.Object); - - var body = new - { - data = new - { - id = "1", - type = "resourceWithStringConstructors" - } - }; - - string content = JsonSerializer.Serialize(body); - - // Act - Action action = () => serializer.Deserialize(content); - - // Assert - var exception = Assert.Throws<InvalidOperationException>(action); - - Assert.Equal("Failed to create an instance of 'UnitTests.Models.ResourceWithStringConstructor' using injected constructor parameters.", - exception.Message); - } - } -} diff --git a/test/UnitTests/Models/ResourceWithStringConstructor.cs b/test/UnitTests/Models/ResourceWithStringConstructor.cs deleted file mode 100644 index 82c80ae9f6..0000000000 --- a/test/UnitTests/Models/ResourceWithStringConstructor.cs +++ /dev/null @@ -1,19 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore; -using JsonApiDotNetCore.Resources; - -namespace UnitTests.Models -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - internal sealed class ResourceWithStringConstructor : Identifiable - { - public string Text { get; } - - public ResourceWithStringConstructor(string text) - { - ArgumentGuard.NotNullNorEmpty(text, nameof(text)); - - Text = text; - } - } -} diff --git a/test/UnitTests/Models/ResourceWithThrowingConstructor.cs b/test/UnitTests/Models/ResourceWithThrowingConstructor.cs deleted file mode 100644 index b1fe9f6d48..0000000000 --- a/test/UnitTests/Models/ResourceWithThrowingConstructor.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; - -namespace UnitTests.Models -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - internal sealed class ResourceWithThrowingConstructor : Identifiable - { - public ResourceWithThrowingConstructor() - { - throw new ArgumentException("Failed to initialize."); - } - } -} diff --git a/test/UnitTests/Models/ResourceWithoutConstructor.cs b/test/UnitTests/Models/ResourceWithoutConstructor.cs deleted file mode 100644 index 521e01405e..0000000000 --- a/test/UnitTests/Models/ResourceWithoutConstructor.cs +++ /dev/null @@ -1,8 +0,0 @@ -using JsonApiDotNetCore.Resources; - -namespace UnitTests.Models -{ - internal sealed class ResourceWithoutConstructor : Identifiable - { - } -} diff --git a/test/UnitTests/NeverResourceDefinitionAccessor.cs b/test/UnitTests/NeverResourceDefinitionAccessor.cs deleted file mode 100644 index f4d0f308bf..0000000000 --- a/test/UnitTests/NeverResourceDefinitionAccessor.cs +++ /dev/null @@ -1,104 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Threading; -using System.Threading.Tasks; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace UnitTests -{ - internal sealed class NeverResourceDefinitionAccessor : IResourceDefinitionAccessor - { - public IImmutableList<IncludeElementExpression> OnApplyIncludes(Type resourceType, IImmutableList<IncludeElementExpression> existingIncludes) - { - return existingIncludes; - } - - public FilterExpression OnApplyFilter(Type resourceType, FilterExpression existingFilter) - { - return existingFilter; - } - - public SortExpression OnApplySort(Type resourceType, SortExpression existingSort) - { - return existingSort; - } - - public PaginationExpression OnApplyPagination(Type resourceType, PaginationExpression existingPagination) - { - return existingPagination; - } - - public SparseFieldSetExpression OnApplySparseFieldSet(Type resourceType, SparseFieldSetExpression existingSparseFieldSet) - { - return existingSparseFieldSet; - } - - public object GetQueryableHandlerForQueryStringParameter(Type resourceType, string parameterName) - { - return new QueryStringParameterHandlers<IIdentifiable>(); - } - - public IDictionary<string, object> GetMeta(Type resourceType, IIdentifiable resourceInstance) - { - return null; - } - - public Task OnPrepareWriteAsync<TResource>(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { - return Task.CompletedTask; - } - - public Task<IIdentifiable> OnSetToOneRelationshipAsync<TResource>(TResource leftResource, HasOneAttribute hasOneRelationship, - IIdentifiable rightResourceId, WriteOperationKind writeOperation, CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { - return Task.FromResult(rightResourceId); - } - - public Task OnSetToManyRelationshipAsync<TResource>(TResource leftResource, HasManyAttribute hasManyRelationship, ISet<IIdentifiable> rightResourceIds, - WriteOperationKind writeOperation, CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { - return Task.CompletedTask; - } - - public Task OnAddToRelationshipAsync<TResource, TId>(TId leftResourceId, HasManyAttribute hasManyRelationship, ISet<IIdentifiable> rightResourceIds, - CancellationToken cancellationToken) - where TResource : class, IIdentifiable<TId> - { - return Task.CompletedTask; - } - - public Task OnRemoveFromRelationshipAsync<TResource>(TResource leftResource, HasManyAttribute hasManyRelationship, ISet<IIdentifiable> rightResourceIds, - CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { - return Task.CompletedTask; - } - - public Task OnWritingAsync<TResource>(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { - return Task.CompletedTask; - } - - public Task OnWriteSucceededAsync<TResource>(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { - return Task.CompletedTask; - } - - public void OnDeserialize(IIdentifiable resource) - { - } - - public void OnSerialize(IIdentifiable resource) - { - } - } -} diff --git a/test/UnitTests/Serialization/Common/BaseDocumentBuilderTests.cs b/test/UnitTests/Serialization/Common/BaseDocumentBuilderTests.cs deleted file mode 100644 index 5e590a4e21..0000000000 --- a/test/UnitTests/Serialization/Common/BaseDocumentBuilderTests.cs +++ /dev/null @@ -1,81 +0,0 @@ -using System.Collections.Generic; -using JsonApiDotNetCore; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCore.Serialization.Building; -using JsonApiDotNetCore.Serialization.Objects; -using Moq; -using UnitTests.TestModels; -using Xunit; - -namespace UnitTests.Serialization.Common -{ - public sealed class BaseDocumentBuilderTests : SerializerTestsSetup - { - private readonly TestSerializer _builder; - - public BaseDocumentBuilderTests() - { - var mock = new Mock<IResourceObjectBuilder>(); - - mock.Setup(builder => builder.Build(It.IsAny<IIdentifiable>(), It.IsAny<IReadOnlyCollection<AttrAttribute>>(), - It.IsAny<IReadOnlyCollection<RelationshipAttribute>>())).Returns(new ResourceObject()); - - _builder = new TestSerializer(mock.Object); - } - - [Fact] - public void ResourceToDocument_NullResource_CanBuild() - { - // Act - Document document = _builder.PublicBuild((TestResource)null); - - // Assert - Assert.Null(document.Data.Value); - Assert.True(document.Data.IsAssigned); - } - - [Fact] - public void ResourceToDocument_EmptyList_CanBuild() - { - // Act - Document document = _builder.PublicBuild(new List<TestResource>()); - - // Assert - Assert.NotNull(document.Data.Value); - Assert.Empty(document.Data.ManyValue); - } - - [Fact] - public void ResourceToDocument_SingleResource_CanBuild() - { - // Arrange - IIdentifiable dummy = new DummyResource(); - - // Act - Document document = _builder.PublicBuild(dummy); - - // Assert - Assert.NotNull(document.Data.Value); - Assert.True(document.Data.IsAssigned); - } - - [Fact] - public void ResourceToDocument_ResourceList_CanBuild() - { - // Arrange - DummyResource[] resources = ArrayFactory.Create(new DummyResource(), new DummyResource()); - - // Act - Document document = _builder.PublicBuild(resources); - IList<ResourceObject> data = document.Data.ManyValue; - - // Assert - Assert.Equal(2, data.Count); - } - - private sealed class DummyResource : Identifiable - { - } - } -} diff --git a/test/UnitTests/Serialization/Common/BaseDocumentParserTests.cs b/test/UnitTests/Serialization/Common/BaseDocumentParserTests.cs deleted file mode 100644 index 6e6b417af9..0000000000 --- a/test/UnitTests/Serialization/Common/BaseDocumentParserTests.cs +++ /dev/null @@ -1,451 +0,0 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel.Design; -using System.Linq; -using System.Reflection; -using System.Text.Json; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Serialization; -using JsonApiDotNetCore.Serialization.Objects; -using UnitTests.TestModels; -using Xunit; - -namespace UnitTests.Serialization.Common -{ - public sealed class BaseDocumentParserTests : DeserializerTestsSetup - { - private readonly TestDeserializer _deserializer; - - public BaseDocumentParserTests() - { - _deserializer = new TestDeserializer(ResourceGraph, new ResourceFactory(new ServiceContainer()), Options); - } - - [Fact] - public void DeserializeResourceIdentifiers_SingleData_CanDeserialize() - { - // Arrange - var content = new Document - { - Data = new SingleOrManyData<ResourceObject>(new ResourceObject - { - Type = "testResource", - Id = "1" - }) - }; - - string body = JsonSerializer.Serialize(content, SerializerWriteOptions); - - // Act - var result = (TestResource)_deserializer.Deserialize(body); - - // Assert - Assert.Equal(1, result.Id); - } - - [Fact] - public void DeserializeResourceIdentifiers_EmptySingleData_CanDeserialize() - { - // Arrange - var content = new Document(); - string body = JsonSerializer.Serialize(content, SerializerWriteOptions); - - // Act - object result = _deserializer.Deserialize(body); - - // Arrange - Assert.Null(result); - } - - [Fact] - public void DeserializeResourceIdentifiers_ArrayData_CanDeserialize() - { - // Arrange - var content = new Document - { - Data = new SingleOrManyData<ResourceObject>(new List<ResourceObject> - { - new() - { - Type = "testResource", - Id = "1" - } - }) - }; - - string body = JsonSerializer.Serialize(content, SerializerWriteOptions); - - // Act - var result = (IEnumerable<IIdentifiable>)_deserializer.Deserialize(body); - - // Assert - Assert.Equal("1", result.First().StringId); - } - - [Fact] - public void DeserializeResourceIdentifiers_EmptyArrayData_CanDeserialize() - { - var content = new Document - { - Data = new SingleOrManyData<ResourceObject>(Array.Empty<ResourceObject>()) - }; - - string body = JsonSerializer.Serialize(content, SerializerWriteOptions); - - // Act - var result = (IEnumerable<IIdentifiable>)_deserializer.Deserialize(body); - - // Assert - Assert.Empty(result); - } - - [Theory] - [InlineData("stringField", "some string")] - [InlineData("stringField", null)] - [InlineData("intField", null, true)] - [InlineData("intField", 1)] - [InlineData("intField", "1", true)] - [InlineData("nullableIntField", null)] - [InlineData("nullableIntField", 1)] - [InlineData("guidField", "bad format", true)] - [InlineData("guidField", "1a68be43-cc84-4924-a421-7f4d614b7781")] - [InlineData("dateTimeField", "9/11/2019 11:41:40 AM", true)] - [InlineData("dateTimeField", null, true)] - [InlineData("nullableDateTimeField", null)] - public void DeserializeAttributes_VariousDataTypes_CanDeserialize(string member, object value, bool expectError = false) - { - // Arrange - var content = new Document - { - Data = new SingleOrManyData<ResourceObject>(new ResourceObject - { - Type = "testResource", - Id = "1", - Attributes = new Dictionary<string, object> - { - [member] = value - } - }) - }; - - string body = JsonSerializer.Serialize(content, SerializerWriteOptions); - - // Act - Func<TestResource> action = () => (TestResource)_deserializer.Deserialize(body); - - // Assert - if (expectError) - { - Assert.ThrowsAny<JsonApiSerializationException>(action); - } - else - { - TestResource resource = action(); - - PropertyInfo pi = ResourceGraph.GetResourceContext("testResource").GetAttributeByPublicName(member).Property; - object deserializedValue = pi.GetValue(resource); - - if (member == "intField") - { - Assert.Equal(1, deserializedValue); - } - else if (member == "nullableIntField" && value == null) - { - Assert.Null(deserializedValue); - } - else if (member == "nullableIntField" && (int?)value == 1) - { - Assert.Equal(1, deserializedValue); - } - else if (member == "guidField") - { - Assert.Equal(deserializedValue, Guid.Parse("1a68be43-cc84-4924-a421-7f4d614b7781")); - } - else if (member == "dateTimeField") - { - Assert.Equal(deserializedValue, DateTime.Parse("9/11/2019 11:41:40 AM")); - } - else - { - Assert.Equal(value, deserializedValue); - } - } - } - - [Fact] - public void DeserializeAttributes_ComplexType_CanDeserialize() - { - // Arrange - var content = new Document - { - Data = new SingleOrManyData<ResourceObject>(new ResourceObject - { - Type = "testResource", - Id = "1", - Attributes = new Dictionary<string, object> - { - ["complexField"] = new Dictionary<string, object> - { - ["compoundName"] = "testName" - } - } - }) - }; - - string body = JsonSerializer.Serialize(content, SerializerWriteOptions); - - // Act - var result = (TestResource)_deserializer.Deserialize(body); - - // Assert - Assert.NotNull(result.ComplexField); - Assert.Equal("testName", result.ComplexField.CompoundName); - } - - [Fact] - public void DeserializeAttributes_ComplexListType_CanDeserialize() - { - // Arrange - var content = new Document - { - Data = new SingleOrManyData<ResourceObject>(new ResourceObject - { - Type = "testResource-with-list", - Id = "1", - Attributes = new Dictionary<string, object> - { - ["complexFields"] = new[] - { - new Dictionary<string, object> - { - ["compoundName"] = "testName" - } - } - } - }) - }; - - string body = JsonSerializer.Serialize(content, SerializerWriteOptions); - - // Act - var result = (TestResourceWithList)_deserializer.Deserialize(body); - - // Assert - Assert.NotNull(result.ComplexFields); - Assert.NotEmpty(result.ComplexFields); - Assert.Equal("testName", result.ComplexFields[0].CompoundName); - } - - [Fact] - public void DeserializeRelationship_SingleDataForToOneRelationship_CannotDeserialize() - { - // Arrange - Document content = CreateDocumentWithRelationships("oneToManyPrincipals", "dependents"); - - content.Data.SingleValue.Relationships["dependents"] = new RelationshipObject - { - Data = new SingleOrManyData<ResourceIdentifierObject>(new ResourceIdentifierObject - { - Type = "Dependents", - Id = "1" - }) - }; - - string body = JsonSerializer.Serialize(content, SerializerWriteOptions); - - // Act - Action action = () => _deserializer.Deserialize(body); - - // Assert - Assert.Throws<JsonApiSerializationException>(action); - } - - [Fact] - public void DeserializeRelationship_ManyDataForToManyRelationship_CannotDeserialize() - { - // Arrange - Document content = CreateDocumentWithRelationships("oneToOnePrincipals", "dependent"); - - content.Data.SingleValue.Relationships["dependent"] = new RelationshipObject - { - Data = new SingleOrManyData<ResourceIdentifierObject>(new List<ResourceIdentifierObject> - { - new() - { - Type = "Dependent", - Id = "1" - } - }) - }; - - string body = JsonSerializer.Serialize(content, SerializerWriteOptions); - - // Act - Action action = () => _deserializer.Deserialize(body); - - // Assert - Assert.Throws<JsonApiSerializationException>(action); - } - - [Fact] - public void DeserializeRelationships_EmptyOneToOneDependent_NavigationPropertyIsNull() - { - // Arrange - Document content = CreateDocumentWithRelationships("oneToOnePrincipals", "dependent"); - string body = JsonSerializer.Serialize(content, SerializerWriteOptions); - - // Act - var result = (OneToOnePrincipal)_deserializer.Deserialize(body); - - // Assert - Assert.Equal(1, result.Id); - Assert.Null(result.Dependent); - Assert.Null(result.AttributeMember); - } - - [Fact] - public void DeserializeRelationships_PopulatedOneToOneDependent_NavigationPropertyIsPopulated() - { - // Arrange - Document content = CreateDocumentWithRelationships("oneToOnePrincipals", "dependent", "oneToOneDependents"); - string body = JsonSerializer.Serialize(content, SerializerWriteOptions); - - // Act - var result = (OneToOnePrincipal)_deserializer.Deserialize(body); - - // Assert - Assert.Equal(1, result.Id); - Assert.Equal(10, result.Dependent.Id); - Assert.Null(result.AttributeMember); - } - - [Fact] - public void DeserializeRelationships_EmptyOneToOnePrincipal_NavigationIsNull() - { - // Arrange - Document content = CreateDocumentWithRelationships("oneToOneDependents", "principal"); - string body = JsonSerializer.Serialize(content, SerializerWriteOptions); - - // Act - var result = (OneToOneDependent)_deserializer.Deserialize(body); - - // Assert - Assert.Equal(1, result.Id); - Assert.Null(result.Principal); - } - - [Fact] - public void DeserializeRelationships_EmptyRequiredOneToOnePrincipal_NavigationIsNull() - { - // Arrange - Document content = CreateDocumentWithRelationships("oneToOneRequiredDependents", "principal"); - string body = JsonSerializer.Serialize(content, SerializerWriteOptions); - - // Act - var result = (OneToOneRequiredDependent)_deserializer.Deserialize(body); - - // Assert - Assert.Equal(1, result.Id); - Assert.Null(result.Principal); - } - - [Fact] - public void DeserializeRelationships_PopulatedOneToOnePrincipal_NavigationIsPopulated() - { - // Arrange - Document content = CreateDocumentWithRelationships("oneToOneDependents", "principal", "oneToOnePrincipals"); - string body = JsonSerializer.Serialize(content, SerializerWriteOptions); - - // Act - var result = (OneToOneDependent)_deserializer.Deserialize(body); - - // Assert - Assert.Equal(1, result.Id); - Assert.NotNull(result.Principal); - Assert.Equal(10, result.Principal.Id); - Assert.Null(result.AttributeMember); - } - - [Fact] - public void DeserializeRelationships_EmptyOneToManyPrincipal_NavigationIsNull() - { - // Arrange - Document content = CreateDocumentWithRelationships("oneToManyDependents", "principal"); - string body = JsonSerializer.Serialize(content, SerializerWriteOptions); - - // Act - var result = (OneToManyDependent)_deserializer.Deserialize(body); - - // Assert - Assert.Equal(1, result.Id); - Assert.Null(result.Principal); - Assert.Null(result.AttributeMember); - } - - [Fact] - public void DeserializeRelationships_EmptyOneToManyRequiredPrincipal_NavigationIsNull() - { - // Arrange - Document content = CreateDocumentWithRelationships("oneToMany-requiredDependents", "principal"); - string body = JsonSerializer.Serialize(content, SerializerWriteOptions); - - // Act - var result = (OneToManyRequiredDependent)_deserializer.Deserialize(body); - - // Assert - Assert.Equal(1, result.Id); - Assert.Null(result.Principal); - Assert.Null(result.AttributeMember); - } - - [Fact] - public void DeserializeRelationships_PopulatedOneToManyPrincipal_NavigationIsPopulated() - { - // Arrange - Document content = CreateDocumentWithRelationships("oneToManyDependents", "principal", "oneToManyPrincipals"); - string body = JsonSerializer.Serialize(content, SerializerWriteOptions); - - // Act - var result = (OneToManyDependent)_deserializer.Deserialize(body); - - // Assert - Assert.Equal(1, result.Id); - Assert.NotNull(result.Principal); - Assert.Equal(10, result.Principal.Id); - Assert.Null(result.AttributeMember); - } - - [Fact] - public void DeserializeRelationships_EmptyOneToManyDependent_NavigationIsNull() - { - // Arrange - Document content = CreateDocumentWithRelationships("oneToManyPrincipals", "dependents", isToManyData: true); - string body = JsonSerializer.Serialize(content, SerializerWriteOptions); - - // Act - var result = (OneToManyPrincipal)_deserializer.Deserialize(body); - - // Assert - Assert.Equal(1, result.Id); - Assert.Empty(result.Dependents); - Assert.Null(result.AttributeMember); - } - - [Fact] - public void DeserializeRelationships_PopulatedOneToManyDependent_NavigationIsPopulated() - { - // Arrange - Document content = CreateDocumentWithRelationships("oneToManyPrincipals", "dependents", "oneToManyDependents", true); - string body = JsonSerializer.Serialize(content, SerializerWriteOptions); - - // Act - var result = (OneToManyPrincipal)_deserializer.Deserialize(body); - - // Assert - Assert.Equal(1, result.Id); - Assert.Single(result.Dependents); - Assert.Equal(10, result.Dependents.First().Id); - Assert.Null(result.AttributeMember); - } - } -} diff --git a/test/UnitTests/Serialization/Common/ResourceObjectBuilderTests.cs b/test/UnitTests/Serialization/Common/ResourceObjectBuilderTests.cs deleted file mode 100644 index 3e44774876..0000000000 --- a/test/UnitTests/Serialization/Common/ResourceObjectBuilderTests.cs +++ /dev/null @@ -1,235 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCore.Serialization.Building; -using JsonApiDotNetCore.Serialization.Objects; -using UnitTests.TestModels; -using Xunit; - -namespace UnitTests.Serialization.Common -{ - public sealed class ResourceObjectBuilderTests : SerializerTestsSetup - { - private readonly ResourceObjectBuilder _builder; - - public ResourceObjectBuilderTests() - { - _builder = new ResourceObjectBuilder(ResourceGraph, new JsonApiOptions()); - } - - [Fact] - public void ResourceToResourceObject_EmptyResource_CanBuild() - { - // Arrange - var resource = new TestResource(); - - // Act - ResourceObject resourceObject = _builder.Build(resource); - - // Assert - Assert.Null(resourceObject.Attributes); - Assert.Null(resourceObject.Relationships); - Assert.Null(resourceObject.Id); - Assert.Equal("testResource", resourceObject.Type); - } - - [Fact] - public void ResourceToResourceObject_ResourceWithId_CanBuild() - { - // Arrange - var resource = new TestResource - { - Id = 1 - }; - - // Act - ResourceObject resourceObject = _builder.Build(resource); - - // Assert - Assert.Equal("1", resourceObject.Id); - Assert.Null(resourceObject.Attributes); - Assert.Null(resourceObject.Relationships); - Assert.Equal("testResource", resourceObject.Type); - } - - [Theory] - [InlineData(null, null)] - [InlineData("string field", 1)] - public void ResourceToResourceObject_ResourceWithIncludedAttrs_CanBuild(string stringFieldValue, int? intFieldValue) - { - // Arrange - var resource = new TestResource - { - StringField = stringFieldValue, - NullableIntField = intFieldValue - }; - - IReadOnlyCollection<AttrAttribute> attrs = ResourceGraph.GetAttributes<TestResource>(tr => new - { - tr.StringField, - tr.NullableIntField - }); - - // Act - ResourceObject resourceObject = _builder.Build(resource, attrs); - - // Assert - Assert.NotNull(resourceObject.Attributes); - Assert.Equal(2, resourceObject.Attributes.Keys.Count); - Assert.Equal(stringFieldValue, resourceObject.Attributes["stringField"]); - Assert.Equal(intFieldValue, resourceObject.Attributes["nullableIntField"]); - } - - [Fact] - public void ResourceWithRelationshipsToResourceObject_EmptyResource_CanBuild() - { - // Arrange - var resource = new MultipleRelationshipsPrincipalPart(); - - // Act - ResourceObject resourceObject = _builder.Build(resource); - - // Assert - Assert.Null(resourceObject.Attributes); - Assert.Null(resourceObject.Relationships); - Assert.Null(resourceObject.Id); - Assert.Equal("multiPrincipals", resourceObject.Type); - } - - [Fact] - public void ResourceWithRelationshipsToResourceObject_ResourceWithId_CanBuild() - { - // Arrange - var resource = new MultipleRelationshipsPrincipalPart - { - PopulatedToOne = new OneToOneDependent - { - Id = 10 - } - }; - - // Act - ResourceObject resourceObject = _builder.Build(resource); - - // Assert - Assert.Null(resourceObject.Attributes); - Assert.Null(resourceObject.Relationships); - Assert.Null(resourceObject.Id); - Assert.Equal("multiPrincipals", resourceObject.Type); - } - - [Fact] - public void ResourceWithRelationshipsToResourceObject_WithIncludedRelationshipsAttributes_CanBuild() - { - // Arrange - var resource = new MultipleRelationshipsPrincipalPart - { - PopulatedToOne = new OneToOneDependent - { - Id = 10 - }, - PopulatedToManies = new HashSet<OneToManyDependent> - { - new() - { - Id = 20 - } - } - }; - - IReadOnlyCollection<RelationshipAttribute> relationships = ResourceGraph.GetRelationships<MultipleRelationshipsPrincipalPart>(tr => new - { - tr.PopulatedToManies, - tr.PopulatedToOne, - tr.EmptyToOne, - tr.EmptyToManies - }); - - // Act - ResourceObject resourceObject = _builder.Build(resource, relationships: relationships); - - // Assert - Assert.Equal(4, resourceObject.Relationships.Count); - Assert.Null(resourceObject.Relationships["emptyToOne"].Data.SingleValue); - Assert.Empty(resourceObject.Relationships["emptyToManies"].Data.ManyValue); - ResourceIdentifierObject populatedToOneData = resourceObject.Relationships["populatedToOne"].Data.SingleValue; - Assert.NotNull(populatedToOneData); - Assert.Equal("10", populatedToOneData.Id); - Assert.Equal("oneToOneDependents", populatedToOneData.Type); - IList<ResourceIdentifierObject> populatedToManyData = resourceObject.Relationships["populatedToManies"].Data.ManyValue; - Assert.Single(populatedToManyData); - Assert.Equal("20", populatedToManyData.First().Id); - Assert.Equal("oneToManyDependents", populatedToManyData.First().Type); - } - - [Fact] - public void ResourceWithRelationshipsToResourceObject_DeviatingForeignKeyWhileRelationshipIncluded_IgnoresForeignKeyDuringBuild() - { - // Arrange - var resource = new OneToOneDependent - { - Principal = new OneToOnePrincipal - { - Id = 10 - }, - PrincipalId = 123 - }; - - IReadOnlyCollection<RelationshipAttribute> relationships = ResourceGraph.GetRelationships<OneToOneDependent>(tr => tr.Principal); - - // Act - ResourceObject resourceObject = _builder.Build(resource, relationships: relationships); - - // Assert - Assert.Single(resourceObject.Relationships); - Assert.NotNull(resourceObject.Relationships["principal"].Data.Value); - ResourceIdentifierObject ro = resourceObject.Relationships["principal"].Data.SingleValue; - Assert.Equal("10", ro.Id); - } - - [Fact] - public void ResourceWithRelationshipsToResourceObject_DeviatingForeignKeyAndNoNavigationWhileRelationshipIncluded_IgnoresForeignKeyDuringBuild() - { - // Arrange - var resource = new OneToOneDependent - { - Principal = null, - PrincipalId = 123 - }; - - IReadOnlyCollection<RelationshipAttribute> relationships = ResourceGraph.GetRelationships<OneToOneDependent>(tr => tr.Principal); - - // Act - ResourceObject resourceObject = _builder.Build(resource, relationships: relationships); - - // Assert - Assert.Null(resourceObject.Relationships["principal"].Data.Value); - } - - [Fact] - public void ResourceWithRequiredRelationshipsToResourceObject_DeviatingForeignKeyWhileRelationshipIncluded_IgnoresForeignKeyDuringBuild() - { - // Arrange - var resource = new OneToOneRequiredDependent - { - Principal = new OneToOnePrincipal - { - Id = 10 - }, - PrincipalId = 123 - }; - - IReadOnlyCollection<RelationshipAttribute> relationships = ResourceGraph.GetRelationships<OneToOneRequiredDependent>(tr => tr.Principal); - - // Act - ResourceObject resourceObject = _builder.Build(resource, relationships: relationships); - - // Assert - Assert.Single(resourceObject.Relationships); - Assert.NotNull(resourceObject.Relationships["principal"].Data.SingleValue); - ResourceIdentifierObject ro = resourceObject.Relationships["principal"].Data.SingleValue; - Assert.Equal("10", ro.Id); - } - } -} diff --git a/test/UnitTests/Serialization/DeserializerTestsSetup.cs b/test/UnitTests/Serialization/DeserializerTestsSetup.cs deleted file mode 100644 index 05fc33c886..0000000000 --- a/test/UnitTests/Serialization/DeserializerTestsSetup.cs +++ /dev/null @@ -1,119 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Text.Json; -using JsonApiDotNetCore; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCore.Serialization; -using JsonApiDotNetCore.Serialization.JsonConverters; -using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.AspNetCore.Http; -using Moq; - -namespace UnitTests.Serialization -{ - public class DeserializerTestsSetup : SerializationTestsSetupBase - { - protected readonly JsonApiOptions Options = new(); - protected readonly JsonSerializerOptions SerializerWriteOptions; - - protected Mock<IHttpContextAccessor> MockHttpContextAccessor { get; } - - protected DeserializerTestsSetup() - { - Options.SerializerOptions.Converters.Add(new ResourceObjectConverter(ResourceGraph)); - - SerializerWriteOptions = ((IJsonApiOptions)Options).SerializerWriteOptions; - MockHttpContextAccessor = new Mock<IHttpContextAccessor>(); - MockHttpContextAccessor.Setup(mock => mock.HttpContext).Returns(new DefaultHttpContext()); - } - - protected Document CreateDocumentWithRelationships(string primaryType, string relationshipMemberName, string relatedType = null, - bool isToManyData = false) - { - Document content = CreateDocumentWithRelationships(primaryType); - content.Data.SingleValue.Relationships.Add(relationshipMemberName, CreateRelationshipData(relatedType, isToManyData)); - return content; - } - - protected Document CreateDocumentWithRelationships(string primaryType) - { - return new() - { - Data = new SingleOrManyData<ResourceObject>(new ResourceObject - { - Id = "1", - Type = primaryType, - Relationships = new Dictionary<string, RelationshipObject>() - }) - }; - } - - protected RelationshipObject CreateRelationshipData(string relatedType = null, bool isToManyData = false, string id = "10") - { - var relationshipObject = new RelationshipObject(); - - ResourceIdentifierObject rio = relatedType == null - ? null - : new ResourceIdentifierObject - { - Id = id, - Type = relatedType - }; - - if (isToManyData) - { - IList<ResourceIdentifierObject> rios = relatedType != null ? rio.AsList() : Array.Empty<ResourceIdentifierObject>(); - relationshipObject.Data = new SingleOrManyData<ResourceIdentifierObject>(rios); - } - else - { - relationshipObject.Data = new SingleOrManyData<ResourceIdentifierObject>(rio); - } - - return relationshipObject; - } - - protected Document CreateTestResourceDocument() - { - return new() - { - Data = new SingleOrManyData<ResourceObject>(new ResourceObject - { - Type = "testResource", - Id = "1", - Attributes = new Dictionary<string, object> - { - ["stringField"] = "some string", - ["intField"] = 1, - ["nullableIntField"] = null, - ["guidField"] = "1a68be43-cc84-4924-a421-7f4d614b7781", - ["dateTimeField"] = DateTime.Parse("9/11/2019 11:41:40 AM", CultureInfo.InvariantCulture) - } - }) - }; - } - - protected sealed class TestDeserializer : BaseDeserializer - { - private readonly IJsonApiOptions _options; - - public TestDeserializer(IResourceGraph resourceGraph, IResourceFactory resourceFactory, IJsonApiOptions options) - : base(resourceGraph, resourceFactory) - { - _options = options; - } - - public object Deserialize(string body) - { - return DeserializeData(body, _options.SerializerReadOptions); - } - - protected override void AfterProcessField(IIdentifiable resource, ResourceFieldAttribute field, RelationshipObject data = null) - { - } - } - } -} diff --git a/test/UnitTests/Serialization/FakeRequestQueryStringAccessor.cs b/test/UnitTests/Serialization/FakeRequestQueryStringAccessor.cs deleted file mode 100644 index a2cfc2bebb..0000000000 --- a/test/UnitTests/Serialization/FakeRequestQueryStringAccessor.cs +++ /dev/null @@ -1,21 +0,0 @@ -using JsonApiDotNetCore.QueryStrings; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.WebUtilities; - -namespace UnitTests.Serialization -{ - internal sealed class FakeRequestQueryStringAccessor : IRequestQueryStringAccessor - { - public IQueryCollection Query { get; } - - public FakeRequestQueryStringAccessor() - : this(null) - { - } - - public FakeRequestQueryStringAccessor(string queryString) - { - Query = string.IsNullOrEmpty(queryString) ? QueryCollection.Empty : new QueryCollection(QueryHelpers.ParseQuery(queryString)); - } - } -} diff --git a/test/UnitTests/Serialization/SerializationTestsSetupBase.cs b/test/UnitTests/Serialization/SerializationTestsSetupBase.cs deleted file mode 100644 index 1d30044321..0000000000 --- a/test/UnitTests/Serialization/SerializationTestsSetupBase.cs +++ /dev/null @@ -1,93 +0,0 @@ -using Bogus; -using JsonApiDotNetCore.Configuration; -using Microsoft.Extensions.Logging.Abstractions; -using UnitTests.TestModels; -using Person = UnitTests.TestModels.Person; - -namespace UnitTests.Serialization -{ - public class SerializationTestsSetupBase - { - protected IResourceGraph ResourceGraph { get; } - protected Faker<Food> FoodFaker { get; } - protected Faker<Song> SongFaker { get; } - protected Faker<Article> ArticleFaker { get; } - protected Faker<Blog> BlogFaker { get; } - protected Faker<Person> PersonFaker { get; } - - protected SerializationTestsSetupBase() - { - ResourceGraph = BuildGraph(); - - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true - - ArticleFaker = new Faker<Article>() - .RuleFor(article => article.Title, faker => faker.Hacker.Phrase()) - .RuleFor(article => article.Id, faker => faker.UniqueIndex + 1); - - PersonFaker = new Faker<Person>() - .RuleFor(person => person.Name, faker => faker.Person.FullName) - .RuleFor(person => person.Id, faker => faker.UniqueIndex + 1); - - BlogFaker = new Faker<Blog>() - .RuleFor(blog => blog.Title, faker => faker.Hacker.Phrase()) - .RuleFor(blog => blog.Id, faker => faker.UniqueIndex + 1); - - SongFaker = new Faker<Song>() - .RuleFor(song => song.Title, faker => faker.Lorem.Sentence()) - .RuleFor(song => song.Id, faker => faker.UniqueIndex + 1); - - FoodFaker = new Faker<Food>() - .RuleFor(food => food.Dish, faker => faker.Lorem.Sentence()) - .RuleFor(food => food.Id, faker => faker.UniqueIndex + 1); - - // @formatter:wrap_chained_method_calls restore - // @formatter:keep_existing_linebreaks restore - } - - private IResourceGraph BuildGraph() - { - var resourceGraphBuilder = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance); - resourceGraphBuilder.Add<TestResource>("testResource"); - resourceGraphBuilder.Add<TestResourceWithList>("testResource-with-list"); - - BuildOneToOneRelationships(resourceGraphBuilder); - BuildOneToManyRelationships(resourceGraphBuilder); - BuildCollectiveRelationships(resourceGraphBuilder); - - resourceGraphBuilder.Add<Article>(); - resourceGraphBuilder.Add<Person>(); - resourceGraphBuilder.Add<Blog>(); - resourceGraphBuilder.Add<Food>(); - resourceGraphBuilder.Add<Song>(); - - resourceGraphBuilder.Add<TestResourceWithAbstractRelationship>(); - resourceGraphBuilder.Add<BaseModel>(); - resourceGraphBuilder.Add<FirstDerivedModel>(); - resourceGraphBuilder.Add<SecondDerivedModel>(); - - return resourceGraphBuilder.Build(); - } - - private static void BuildOneToOneRelationships(ResourceGraphBuilder resourceGraphBuilder) - { - resourceGraphBuilder.Add<OneToOnePrincipal>("oneToOnePrincipals"); - resourceGraphBuilder.Add<OneToOneDependent>("oneToOneDependents"); - resourceGraphBuilder.Add<OneToOneRequiredDependent>("oneToOneRequiredDependents"); - } - - private static void BuildOneToManyRelationships(ResourceGraphBuilder resourceGraphBuilder) - { - resourceGraphBuilder.Add<OneToManyPrincipal>("oneToManyPrincipals"); - resourceGraphBuilder.Add<OneToManyDependent>("oneToManyDependents"); - resourceGraphBuilder.Add<OneToManyRequiredDependent>("oneToMany-requiredDependents"); - } - - private static void BuildCollectiveRelationships(ResourceGraphBuilder resourceGraphBuilder) - { - resourceGraphBuilder.Add<MultipleRelationshipsPrincipalPart>("multiPrincipals"); - resourceGraphBuilder.Add<MultipleRelationshipsDependentPart>("multiDependents"); - } - } -} diff --git a/test/UnitTests/Serialization/SerializerTestsSetup.cs b/test/UnitTests/Serialization/SerializerTestsSetup.cs deleted file mode 100644 index ccc5dae8e9..0000000000 --- a/test/UnitTests/Serialization/SerializerTestsSetup.cs +++ /dev/null @@ -1,190 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; -using JsonApiDotNetCore; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Queries; -using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Queries.Internal; -using JsonApiDotNetCore.QueryStrings; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCore.Serialization; -using JsonApiDotNetCore.Serialization.Building; -using JsonApiDotNetCore.Serialization.Objects; -using Moq; - -namespace UnitTests.Serialization -{ - public class SerializerTestsSetup : SerializationTestsSetupBase - { - private static readonly IncludeChainConverter IncludeChainConverter = new(); - - protected readonly TopLevelLinks DummyTopLevelLinks; - protected readonly ResourceLinks DummyResourceLinks; - protected readonly RelationshipLinks DummyRelationshipLinks; - - protected SerializerTestsSetup() - { - DummyTopLevelLinks = new TopLevelLinks - { - Self = "http://www.dummy.com/dummy-self-link", - Next = "http://www.dummy.com/dummy-next-link", - Prev = "http://www.dummy.com/dummy-prev-link", - First = "http://www.dummy.com/dummy-first-link", - Last = "http://www.dummy.com/dummy-last-link" - }; - - DummyResourceLinks = new ResourceLinks - { - Self = "http://www.dummy.com/dummy-resource-self-link" - }; - - DummyRelationshipLinks = new RelationshipLinks - { - Related = "http://www.dummy.com/dummy-relationship-related-link", - Self = "http://www.dummy.com/dummy-relationship-self-link" - }; - } - - protected ResponseSerializer<T> GetResponseSerializer<T>(IEnumerable<IEnumerable<RelationshipAttribute>> inclusionChains = null, - Dictionary<string, object> metaDict = null, TopLevelLinks topLinks = null, ResourceLinks resourceLinks = null, - RelationshipLinks relationshipLinks = null) - where T : class, IIdentifiable - { - IEnumerable<RelationshipAttribute>[] inclusionChainArray = inclusionChains?.ToArray(); - - IMetaBuilder meta = GetMetaBuilder(metaDict); - ILinkBuilder link = GetLinkBuilder(topLinks, resourceLinks, relationshipLinks); - IEnumerable<IQueryConstraintProvider> includeConstraints = GetIncludeConstraints(inclusionChainArray); - IIncludedResourceObjectBuilder includedBuilder = GetIncludedBuilder(inclusionChainArray != null); - IFieldsToSerialize fieldsToSerialize = GetSerializableFields(); - IResourceDefinitionAccessor resourceDefinitionAccessor = GetResourceDefinitionAccessor(); - IEvaluatedIncludeCache evaluatedIncludeCache = GetEvaluatedIncludeCache(inclusionChainArray); - var options = new JsonApiOptions(); - - var resourceObjectBuilder = new ResponseResourceObjectBuilder(link, includedBuilder, includeConstraints, ResourceGraph, resourceDefinitionAccessor, - options, evaluatedIncludeCache); - - return new ResponseSerializer<T>(meta, link, includedBuilder, fieldsToSerialize, resourceObjectBuilder, resourceDefinitionAccessor, options); - } - - protected ResponseResourceObjectBuilder GetResponseResourceObjectBuilder(IEnumerable<IEnumerable<RelationshipAttribute>> inclusionChains = null, - ResourceLinks resourceLinks = null, RelationshipLinks relationshipLinks = null) - { - IEnumerable<RelationshipAttribute>[] inclusionChainArray = inclusionChains?.ToArray(); - - ILinkBuilder link = GetLinkBuilder(null, resourceLinks, relationshipLinks); - IEnumerable<IQueryConstraintProvider> includeConstraints = GetIncludeConstraints(inclusionChainArray); - IIncludedResourceObjectBuilder includedBuilder = GetIncludedBuilder(inclusionChains != null); - IEvaluatedIncludeCache evaluatedIncludeCache = GetEvaluatedIncludeCache(inclusionChainArray); - var options = new JsonApiOptions(); - - return new ResponseResourceObjectBuilder(link, includedBuilder, includeConstraints, ResourceGraph, GetResourceDefinitionAccessor(), options, - evaluatedIncludeCache); - } - - private IIncludedResourceObjectBuilder GetIncludedBuilder(bool hasIncludeQueryString) - { - IFieldsToSerialize fieldsToSerialize = GetSerializableFields(); - ILinkBuilder linkBuilder = GetLinkBuilder(); - IResourceDefinitionAccessor resourceDefinitionAccessor = GetResourceDefinitionAccessor(); - IRequestQueryStringAccessor queryStringAccessor = new FakeRequestQueryStringAccessor(hasIncludeQueryString ? "include=" : null); - var options = new JsonApiOptions(); - - return new IncludedResourceObjectBuilder(fieldsToSerialize, linkBuilder, ResourceGraph, Enumerable.Empty<IQueryConstraintProvider>(), - resourceDefinitionAccessor, queryStringAccessor, options); - } - - private IResourceDefinitionAccessor GetResourceDefinitionAccessor() - { - var mock = new Mock<IResourceDefinitionAccessor>(); - return mock.Object; - } - - private IMetaBuilder GetMetaBuilder(Dictionary<string, object> meta = null) - { - var mock = new Mock<IMetaBuilder>(); - mock.Setup(metaBuilder => metaBuilder.Build()).Returns(meta); - return mock.Object; - } - - protected ILinkBuilder GetLinkBuilder(TopLevelLinks top = null, ResourceLinks resource = null, RelationshipLinks relationship = null) - { - var mock = new Mock<ILinkBuilder>(); - mock.Setup(linkBuilder => linkBuilder.GetTopLevelLinks()).Returns(top); - mock.Setup(linkBuilder => linkBuilder.GetResourceLinks(It.IsAny<string>(), It.IsAny<string>())).Returns(resource); - mock.Setup(linkBuilder => linkBuilder.GetRelationshipLinks(It.IsAny<RelationshipAttribute>(), It.IsAny<IIdentifiable>())).Returns(relationship); - return mock.Object; - } - - protected IFieldsToSerialize GetSerializableFields() - { - var mock = new Mock<IFieldsToSerialize>(); - mock.Setup(fields => fields.GetAttributes(It.IsAny<Type>())).Returns<Type>(type => ResourceGraph.GetResourceContext(type).Attributes); - mock.Setup(fields => fields.GetRelationships(It.IsAny<Type>())).Returns<Type>(type => ResourceGraph.GetResourceContext(type).Relationships); - return mock.Object; - } - - private IEnumerable<IQueryConstraintProvider> GetIncludeConstraints(IEnumerable<IEnumerable<RelationshipAttribute>> inclusionChains = null) - { - var expressionsInScope = new List<ExpressionInScope>(); - - if (inclusionChains != null) - { - List<ResourceFieldChainExpression> chains = inclusionChains.Select(relationships => - new ResourceFieldChainExpression(relationships.Cast<ResourceFieldAttribute>().ToImmutableArray())).ToList(); - - IncludeExpression includeExpression = IncludeChainConverter.FromRelationshipChains(chains); - expressionsInScope.Add(new ExpressionInScope(null, includeExpression)); - } - - var mock = new Mock<IQueryConstraintProvider>(); - mock.Setup(provider => provider.GetConstraints()).Returns(expressionsInScope); - - IQueryConstraintProvider includeConstraintProvider = mock.Object; - return includeConstraintProvider.AsEnumerable(); - } - - private IEvaluatedIncludeCache GetEvaluatedIncludeCache(IEnumerable<IEnumerable<RelationshipAttribute>> inclusionChains = null) - { - if (inclusionChains == null) - { - return new EvaluatedIncludeCache(); - } - - List<ResourceFieldChainExpression> chains = inclusionChains.Select(relationships => - new ResourceFieldChainExpression(relationships.Cast<ResourceFieldAttribute>().ToImmutableArray())).ToList(); - - IncludeExpression includeExpression = IncludeChainConverter.FromRelationshipChains(chains); - - var evaluatedIncludeCache = new EvaluatedIncludeCache(); - evaluatedIncludeCache.Set(includeExpression); - return evaluatedIncludeCache; - } - - /// <summary> - /// Minimal implementation of abstract JsonApiSerializer base class, with the purpose of testing the business logic for building the document structure. - /// </summary> - protected sealed class TestSerializer : BaseSerializer - { - public TestSerializer(IResourceObjectBuilder resourceObjectBuilder) - : base(resourceObjectBuilder) - { - } - - public Document PublicBuild(IIdentifiable resource, IReadOnlyCollection<AttrAttribute> attributes = null, - IReadOnlyCollection<RelationshipAttribute> relationships = null) - { - return Build(resource, attributes, relationships); - } - - public Document PublicBuild(IReadOnlyCollection<IIdentifiable> resources, IReadOnlyCollection<AttrAttribute> attributes = null, - IReadOnlyCollection<RelationshipAttribute> relationships = null) - { - return Build(resources, attributes, relationships); - } - } - } -} diff --git a/test/UnitTests/Serialization/Server/IncludedResourceObjectBuilderTests.cs b/test/UnitTests/Serialization/Server/IncludedResourceObjectBuilderTests.cs deleted file mode 100644 index ee4fb88492..0000000000 --- a/test/UnitTests/Serialization/Server/IncludedResourceObjectBuilderTests.cs +++ /dev/null @@ -1,217 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Queries; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCore.Serialization; -using JsonApiDotNetCore.Serialization.Building; -using JsonApiDotNetCore.Serialization.Objects; -using Moq; -using UnitTests.TestModels; -using Xunit; - -namespace UnitTests.Serialization.Server -{ - public sealed class IncludedResourceObjectBuilderTests : SerializerTestsSetup - { - [Fact] - public void BuildIncluded_DeeplyNestedCircularChainOfSingleData_CanBuild() - { - // Arrange - (Article article, Person author, _, Person reviewer, _) = GetAuthorChainInstances(); - IReadOnlyCollection<RelationshipAttribute> authorChain = GetIncludedRelationshipsChain("author.blogs.reviewer.favoriteFood"); - IncludedResourceObjectBuilder builder = GetBuilder(); - - // Act - builder.IncludeRelationshipChain(authorChain, article); - IList<ResourceObject> result = builder.Build(); - - // Assert - Assert.Equal(6, result.Count); - - ResourceObject authorResourceObject = result.Single(ro => ro.Type == "people" && ro.Id == author.StringId); - ResourceIdentifierObject authorFoodRelation = authorResourceObject.Relationships["favoriteFood"].Data.SingleValue; - Assert.Equal(author.FavoriteFood.StringId, authorFoodRelation.Id); - - ResourceObject reviewerResourceObject = result.Single(ro => ro.Type == "people" && ro.Id == reviewer.StringId); - ResourceIdentifierObject reviewerFoodRelation = reviewerResourceObject.Relationships["favoriteFood"].Data.SingleValue; - Assert.Equal(reviewer.FavoriteFood.StringId, reviewerFoodRelation.Id); - } - - [Fact] - public void BuildIncluded_DeeplyNestedCircularChainOfManyData_BuildsWithoutDuplicates() - { - // Arrange - (Article article, Person author, _, _, _) = GetAuthorChainInstances(); - Article secondArticle = ArticleFaker.Generate(); - secondArticle.Author = author; - IncludedResourceObjectBuilder builder = GetBuilder(); - - // Act - IReadOnlyCollection<RelationshipAttribute> authorChain = GetIncludedRelationshipsChain("author.blogs.reviewer.favoriteFood"); - builder.IncludeRelationshipChain(authorChain, article); - builder.IncludeRelationshipChain(authorChain, secondArticle); - - // Assert - IList<ResourceObject> result = builder.Build(); - Assert.Equal(6, result.Count); - } - - [Fact] - public void BuildIncluded_OverlappingDeeplyNestedCircularChains_CanBuild() - { - // Arrange - IReadOnlyCollection<RelationshipAttribute> authorChain = GetIncludedRelationshipsChain("author.blogs.reviewer.favoriteFood"); - (Article article, Person author, _, Person reviewer, Food reviewerFood) = GetAuthorChainInstances(); - Blog sharedBlog = author.Blogs.First(); - Person sharedBlogAuthor = reviewer; - Song authorSong = GetReviewerChainInstances(article, sharedBlog, sharedBlogAuthor); - IReadOnlyCollection<RelationshipAttribute> reviewerChain = GetIncludedRelationshipsChain("reviewer.blogs.author.favoriteSong"); - IncludedResourceObjectBuilder builder = GetBuilder(); - - // Act - builder.IncludeRelationshipChain(authorChain, article); - builder.IncludeRelationshipChain(reviewerChain, article); - IList<ResourceObject> result = builder.Build(); - - // Assert - Assert.Equal(10, result.Count); - ResourceObject overlappingBlogResourceObject = result.Single(ro => ro.Type == "blogs" && ro.Id == sharedBlog.StringId); - - Assert.Equal(2, overlappingBlogResourceObject.Relationships.Keys.Count); - List<ResourceObject> nonOverlappingBlogs = result.Where(ro => ro.Type == "blogs" && ro.Id != sharedBlog.StringId).ToList(); - - foreach (ResourceObject blog in nonOverlappingBlogs) - { - Assert.Single(blog.Relationships.Keys); - } - - Assert.Equal(authorSong.StringId, sharedBlogAuthor.FavoriteSong.StringId); - Assert.Equal(reviewerFood.StringId, sharedBlogAuthor.FavoriteFood.StringId); - } - - [Fact] - public void BuildIncluded_DuplicateChildrenMultipleChains_OnceInOutput() - { - Person person = PersonFaker.Generate(); - List<Article> articles = ArticleFaker.Generate(5); - articles.ForEach(article => article.Author = person); - articles.ForEach(article => article.Reviewer = person); - IncludedResourceObjectBuilder builder = GetBuilder(); - IReadOnlyCollection<RelationshipAttribute> authorChain = GetIncludedRelationshipsChain("author"); - IReadOnlyCollection<RelationshipAttribute> reviewerChain = GetIncludedRelationshipsChain("reviewer"); - - foreach (Article article in articles) - { - builder.IncludeRelationshipChain(authorChain, article); - builder.IncludeRelationshipChain(reviewerChain, article); - } - - IList<ResourceObject> result = builder.Build(); - Assert.Single(result); - Assert.Equal(person.Name, result[0].Attributes["name"]); - Assert.Equal(person.StringId, result[0].Id); - } - - private Song GetReviewerChainInstances(Article article, Blog sharedBlog, Person sharedBlogAuthor) - { - Person reviewer = PersonFaker.Generate(); - article.Reviewer = reviewer; - - List<Blog> blogs = BlogFaker.Generate(1); - blogs.Add(sharedBlog); - reviewer.Blogs = blogs.ToHashSet(); - - blogs[0].Author = reviewer; - Person author = PersonFaker.Generate(); - blogs[1].Author = sharedBlogAuthor; - - Song authorSong = SongFaker.Generate(); - author.FavoriteSong = authorSong; - sharedBlogAuthor.FavoriteSong = authorSong; - - Song reviewerSong = SongFaker.Generate(); - reviewer.FavoriteSong = reviewerSong; - - return authorSong; - } - - private AuthorChainInstances GetAuthorChainInstances() - { - Article article = ArticleFaker.Generate(); - Person author = PersonFaker.Generate(); - article.Author = author; - - List<Blog> blogs = BlogFaker.Generate(2); - author.Blogs = blogs.ToHashSet(); - - blogs[0].Reviewer = author; - Person reviewer = PersonFaker.Generate(); - blogs[1].Reviewer = reviewer; - - Food authorFood = FoodFaker.Generate(); - author.FavoriteFood = authorFood; - Food reviewerFood = FoodFaker.Generate(); - reviewer.FavoriteFood = reviewerFood; - - return new AuthorChainInstances(article, author, authorFood, reviewer, reviewerFood); - } - - private IReadOnlyCollection<RelationshipAttribute> GetIncludedRelationshipsChain(string chain) - { - var parsedChain = new List<RelationshipAttribute>(); - ResourceContext resourceContext = ResourceGraph.GetResourceContext<Article>(); - string[] splitPath = chain.Split('.'); - - foreach (string requestedRelationship in splitPath) - { - RelationshipAttribute relationship = resourceContext.GetRelationshipByPublicName(requestedRelationship); - - parsedChain.Add(relationship); - resourceContext = ResourceGraph.GetResourceContext(relationship.RightType); - } - - return parsedChain; - } - - private IncludedResourceObjectBuilder GetBuilder() - { - IFieldsToSerialize fields = GetSerializableFields(); - ILinkBuilder links = GetLinkBuilder(); - IResourceDefinitionAccessor resourceDefinitionAccessor = new Mock<IResourceDefinitionAccessor>().Object; - var queryStringAccessor = new FakeRequestQueryStringAccessor(); - var options = new JsonApiOptions(); - - return new IncludedResourceObjectBuilder(fields, links, ResourceGraph, Enumerable.Empty<IQueryConstraintProvider>(), resourceDefinitionAccessor, - queryStringAccessor, options); - } - - private sealed class AuthorChainInstances - { - public Article Article { get; } - public Person Author { get; } - public Food AuthorFood { get; } - public Person Reviewer { get; } - public Food ReviewerFood { get; } - - public AuthorChainInstances(Article article, Person author, Food authorFood, Person reviewer, Food reviewerFood) - { - Article = article; - Author = author; - AuthorFood = authorFood; - Reviewer = reviewer; - ReviewerFood = reviewerFood; - } - - public void Deconstruct(out Article article, out Person author, out Food authorFood, out Person reviewer, out Food reviewerFood) - { - article = Article; - author = Author; - authorFood = AuthorFood; - reviewer = Reviewer; - reviewerFood = ReviewerFood; - } - } - } -} diff --git a/test/UnitTests/Serialization/Server/RequestDeserializerTests.cs b/test/UnitTests/Serialization/Server/RequestDeserializerTests.cs deleted file mode 100644 index c6f8747eba..0000000000 --- a/test/UnitTests/Serialization/Server/RequestDeserializerTests.cs +++ /dev/null @@ -1,105 +0,0 @@ -using System.Collections.Generic; -using System.Text.Json; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCore.Serialization; -using JsonApiDotNetCore.Serialization.JsonConverters; -using JsonApiDotNetCore.Serialization.Objects; -using Moq; -using Xunit; - -namespace UnitTests.Serialization.Server -{ - public sealed class RequestDeserializerTests : DeserializerTestsSetup - { - private readonly RequestDeserializer _deserializer; - private readonly Mock<ITargetedFields> _fieldsManagerMock = new(); - private readonly Mock<IJsonApiRequest> _requestMock = new(); - private readonly Mock<IResourceDefinitionAccessor> _resourceDefinitionAccessorMock = new(); - - public RequestDeserializerTests() - { - var options = new JsonApiOptions(); - options.SerializerOptions.Converters.Add(new ResourceObjectConverter(ResourceGraph)); - - _deserializer = new RequestDeserializer(ResourceGraph, new TestResourceFactory(), _fieldsManagerMock.Object, MockHttpContextAccessor.Object, - _requestMock.Object, options, _resourceDefinitionAccessorMock.Object); - } - - [Fact] - public void DeserializeAttributes_VariousUpdatedMembers_RegistersTargetedFields() - { - // Arrange - var attributesToUpdate = new HashSet<AttrAttribute>(); - var relationshipsToUpdate = new HashSet<RelationshipAttribute>(); - SetupFieldsManager(attributesToUpdate, relationshipsToUpdate); - - Document content = CreateTestResourceDocument(); - - string body = JsonSerializer.Serialize(content, SerializerWriteOptions); - - // Act - _deserializer.Deserialize(body); - - // Assert - Assert.Equal(5, attributesToUpdate.Count); - Assert.Empty(relationshipsToUpdate); - } - - [Fact] - public void DeserializeRelationships_MultipleDependentRelationships_RegistersUpdatedRelationships() - { - // Arrange - var attributesToUpdate = new HashSet<AttrAttribute>(); - var relationshipsToUpdate = new HashSet<RelationshipAttribute>(); - SetupFieldsManager(attributesToUpdate, relationshipsToUpdate); - - Document content = CreateDocumentWithRelationships("multiPrincipals"); - content.Data.SingleValue.Relationships.Add("populatedToOne", CreateRelationshipData("oneToOneDependents")); - content.Data.SingleValue.Relationships.Add("emptyToOne", CreateRelationshipData()); - content.Data.SingleValue.Relationships.Add("populatedToManies", CreateRelationshipData("oneToManyDependents", true)); - content.Data.SingleValue.Relationships.Add("emptyToManies", CreateRelationshipData(isToManyData: true)); - - string body = JsonSerializer.Serialize(content, SerializerWriteOptions); - - // Act - _deserializer.Deserialize(body); - - // Assert - Assert.Equal(4, relationshipsToUpdate.Count); - Assert.Empty(attributesToUpdate); - } - - [Fact] - public void DeserializeRelationships_MultiplePrincipalRelationships_RegistersUpdatedRelationships() - { - // Arrange - var attributesToUpdate = new HashSet<AttrAttribute>(); - var relationshipsToUpdate = new HashSet<RelationshipAttribute>(); - SetupFieldsManager(attributesToUpdate, relationshipsToUpdate); - - Document content = CreateDocumentWithRelationships("multiDependents"); - content.Data.SingleValue.Relationships.Add("populatedToOne", CreateRelationshipData("oneToOnePrincipals")); - content.Data.SingleValue.Relationships.Add("emptyToOne", CreateRelationshipData()); - content.Data.SingleValue.Relationships.Add("populatedToMany", CreateRelationshipData("oneToManyPrincipals")); - content.Data.SingleValue.Relationships.Add("emptyToMany", CreateRelationshipData()); - - string body = JsonSerializer.Serialize(content, SerializerWriteOptions); - - // Act - _deserializer.Deserialize(body); - - // Assert - Assert.Equal(4, relationshipsToUpdate.Count); - Assert.Empty(attributesToUpdate); - } - - private void SetupFieldsManager(HashSet<AttrAttribute> attributesToUpdate, HashSet<RelationshipAttribute> relationshipsToUpdate) - { - _fieldsManagerMock.Setup(fields => fields.Attributes).Returns(attributesToUpdate); - _fieldsManagerMock.Setup(fields => fields.Relationships).Returns(relationshipsToUpdate); - } - } -} diff --git a/test/UnitTests/Serialization/Server/ResponseResourceObjectBuilderTests.cs b/test/UnitTests/Serialization/Server/ResponseResourceObjectBuilderTests.cs deleted file mode 100644 index e103cac808..0000000000 --- a/test/UnitTests/Serialization/Server/ResponseResourceObjectBuilderTests.cs +++ /dev/null @@ -1,122 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using JsonApiDotNetCore; -using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCore.Serialization.Building; -using JsonApiDotNetCore.Serialization.Objects; -using UnitTests.TestModels; -using Xunit; - -namespace UnitTests.Serialization.Server -{ - public sealed class ResponseResourceObjectBuilderTests : SerializerTestsSetup - { - private const string RelationshipName = "dependents"; - private readonly List<RelationshipAttribute> _relationshipsForBuild; - - public ResponseResourceObjectBuilderTests() - { - _relationshipsForBuild = ResourceGraph.GetRelationships<OneToManyPrincipal>(relationship => new - { - relationship.Dependents - }).ToList(); - } - - [Fact] - public void Build_RelationshipNotIncludedAndLinksEnabled_RelationshipObjectWithLinks() - { - // Arrange - var resource = new OneToManyPrincipal - { - Id = 10 - }; - - ResponseResourceObjectBuilder builder = GetResponseResourceObjectBuilder(relationshipLinks: DummyRelationshipLinks); - - // Act - ResourceObject resourceObject = builder.Build(resource, relationships: _relationshipsForBuild); - - // Assert - Assert.True(resourceObject.Relationships.TryGetValue(RelationshipName, out RelationshipObject relationshipObject)); - Assert.Equal("http://www.dummy.com/dummy-relationship-self-link", relationshipObject.Links.Self); - Assert.Equal("http://www.dummy.com/dummy-relationship-related-link", relationshipObject.Links.Related); - Assert.False(relationshipObject.Data.IsAssigned); - } - - [Fact] - public void Build_RelationshipNotIncludedAndLinksDisabled_NoRelationshipObject() - { - // Arrange - var resource = new OneToManyPrincipal - { - Id = 10 - }; - - ResponseResourceObjectBuilder builder = GetResponseResourceObjectBuilder(); - - // Act - ResourceObject resourceObject = builder.Build(resource, relationships: _relationshipsForBuild); - - // Assert - Assert.Null(resourceObject.Relationships); - } - - [Fact] - public void Build_RelationshipIncludedAndLinksDisabled_RelationshipObjectWithData() - { - // Arrange - var resource = new OneToManyPrincipal - { - Id = 10, - Dependents = new HashSet<OneToManyDependent> - { - new() - { - Id = 20 - } - } - }; - - ResponseResourceObjectBuilder builder = GetResponseResourceObjectBuilder(_relationshipsForBuild.AsEnumerable()); - - // Act - ResourceObject resourceObject = builder.Build(resource, relationships: _relationshipsForBuild); - - // Assert - Assert.True(resourceObject.Relationships.TryGetValue(RelationshipName, out RelationshipObject relationshipObject)); - Assert.Null(relationshipObject.Links); - Assert.True(relationshipObject.Data.IsAssigned); - Assert.Equal("20", relationshipObject.Data.ManyValue.Single().Id); - } - - [Fact] - public void Build_RelationshipIncludedAndLinksEnabled_RelationshipObjectWithDataAndLinks() - { - // Arrange - var resource = new OneToManyPrincipal - { - Id = 10, - Dependents = new HashSet<OneToManyDependent> - { - new() - { - Id = 20 - } - } - }; - - ResponseResourceObjectBuilder builder = - GetResponseResourceObjectBuilder(_relationshipsForBuild.AsEnumerable(), relationshipLinks: DummyRelationshipLinks); - - // Act - ResourceObject resourceObject = builder.Build(resource, relationships: _relationshipsForBuild); - - // Assert - Assert.True(resourceObject.Relationships.TryGetValue(RelationshipName, out RelationshipObject relationshipObject)); - Assert.Equal("http://www.dummy.com/dummy-relationship-self-link", relationshipObject.Links.Self); - Assert.Equal("http://www.dummy.com/dummy-relationship-related-link", relationshipObject.Links.Related); - Assert.True(relationshipObject.Data.IsAssigned); - Assert.Equal("20", relationshipObject.Data.ManyValue.Single().Id); - } - } -} diff --git a/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs b/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs deleted file mode 100644 index 2058c79e85..0000000000 --- a/test/UnitTests/Serialization/Server/ResponseSerializerTests.cs +++ /dev/null @@ -1,455 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Text.Json; -using System.Text.RegularExpressions; -using JsonApiDotNetCore; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCore.Serialization; -using JsonApiDotNetCore.Serialization.Objects; -using UnitTests.TestModels; -using Xunit; - -namespace UnitTests.Serialization.Server -{ - public sealed class ResponseSerializerTests : SerializerTestsSetup - { - [Fact] - public void SerializeSingle_ResourceWithDefaultTargetFields_CanSerialize() - { - // Arrange - var resource = new TestResource - { - Id = 1, - StringField = "value", - NullableIntField = 123 - }; - - ResponseSerializer<TestResource> serializer = GetResponseSerializer<TestResource>(); - - // Act - string serialized = serializer.SerializeSingle(resource); - - // Assert - const string expectedFormatted = @"{ - ""data"":{ - ""type"":""testResource"", - ""id"":""1"", - ""attributes"":{ - ""stringField"":""value"", - ""dateTimeField"":""0001-01-01T00:00:00"", - ""nullableDateTimeField"":null, - ""intField"":0, - ""nullableIntField"":123, - ""guidField"":""00000000-0000-0000-0000-000000000000"", - ""complexField"":null - } - } - }"; - - string expected = Regex.Replace(expectedFormatted, @"\s+", ""); - - Assert.Equal(expected, serialized); - } - - [Fact] - public void SerializeMany_ResourceWithDefaultTargetFields_CanSerialize() - { - // Arrange - var resource = new TestResource - { - Id = 1, - StringField = "value", - NullableIntField = 123 - }; - - ResponseSerializer<TestResource> serializer = GetResponseSerializer<TestResource>(); - - // Act - string serialized = serializer.SerializeMany(resource.AsArray()); - - // Assert - const string expectedFormatted = @"{ - ""data"":[{ - ""type"":""testResource"", - ""id"":""1"", - ""attributes"":{ - ""stringField"":""value"", - ""dateTimeField"":""0001-01-01T00:00:00"", - ""nullableDateTimeField"":null, - ""intField"":0, - ""nullableIntField"":123, - ""guidField"":""00000000-0000-0000-0000-000000000000"", - ""complexField"":null - } - }] - }"; - - string expected = Regex.Replace(expectedFormatted, @"\s+", ""); - Assert.Equal(expected, serialized); - } - - [Fact] - public void SerializeSingle_ResourceWithIncludedRelationships_CanSerialize() - { - // Arrange - var resource = new MultipleRelationshipsPrincipalPart - { - Id = 1, - PopulatedToOne = new OneToOneDependent - { - Id = 10 - }, - PopulatedToManies = new HashSet<OneToManyDependent> - { - new() - { - Id = 20 - } - } - }; - - ResourceContext resourceContext = ResourceGraph.GetResourceContext<MultipleRelationshipsPrincipalPart>(); - List<IEnumerable<RelationshipAttribute>> chain = resourceContext.Relationships.Select(relationship => relationship.AsEnumerable()).ToList(); - - ResponseSerializer<MultipleRelationshipsPrincipalPart> serializer = GetResponseSerializer<MultipleRelationshipsPrincipalPart>(chain); - - // Act - string serialized = serializer.SerializeSingle(resource); - - // Assert - const string expectedFormatted = @"{ - ""data"":{ - ""type"":""multiPrincipals"", - ""id"":""1"", - ""attributes"":{ ""attributeMember"":null }, - ""relationships"":{ - ""populatedToOne"":{ - ""data"":{ - ""type"":""oneToOneDependents"", - ""id"":""10"" - } - }, - ""emptyToOne"": { ""data"":null }, - ""populatedToManies"":{ - ""data"":[ - { - ""type"":""oneToManyDependents"", - ""id"":""20"" - } - ] - }, - ""emptyToManies"": { ""data"":[ ] }, - ""multi"":{ ""data"":null } - } - }, - ""included"":[ - { - ""type"":""oneToOneDependents"", - ""id"":""10"", - ""attributes"":{ ""attributeMember"":null } - }, - { - ""type"":""oneToManyDependents"", - ""id"":""20"", - ""attributes"":{ ""attributeMember"":null } - } - ] - }"; - - string expected = Regex.Replace(expectedFormatted, @"\s+", ""); - Assert.Equal(expected, serialized); - } - - [Fact] - public void SerializeSingle_ResourceWithDeeplyIncludedRelationships_CanSerialize() - { - // Arrange - var deeplyIncludedResource = new OneToManyPrincipal - { - Id = 30, - AttributeMember = "deep" - }; - - var includedResource = new OneToManyDependent - { - Id = 20, - Principal = deeplyIncludedResource - }; - - var resource = new MultipleRelationshipsPrincipalPart - { - Id = 10, - PopulatedToManies = new HashSet<OneToManyDependent> - { - includedResource - } - }; - - ResourceContext outerResourceContext = ResourceGraph.GetResourceContext<MultipleRelationshipsPrincipalPart>(); - - List<List<RelationshipAttribute>> chains = outerResourceContext.Relationships.Select(relationship => - { - List<RelationshipAttribute> chain = relationship.AsList(); - - if (relationship.PublicName != "populatedToManies") - { - return chain; - } - - ResourceContext innerResourceContext = ResourceGraph.GetResourceContext<OneToManyDependent>(); - chain.AddRange(innerResourceContext.Relationships); - return chain; - }).ToList(); - - ResponseSerializer<MultipleRelationshipsPrincipalPart> serializer = GetResponseSerializer<MultipleRelationshipsPrincipalPart>(chains); - - // Act - string serialized = serializer.SerializeSingle(resource); - - // Assert - const string expectedFormatted = @"{ - ""data"":{ - ""type"":""multiPrincipals"", - ""id"":""10"", - ""attributes"":{ - ""attributeMember"":null - }, - ""relationships"":{ - ""populatedToOne"":{ - ""data"":null - }, - ""emptyToOne"":{ - ""data"":null - }, - ""populatedToManies"":{ - ""data"":[ - { - ""type"":""oneToManyDependents"", - ""id"":""20"" - } - ] - }, - ""emptyToManies"":{ - ""data"":[] - }, - ""multi"":{ - ""data"":null - } - } - }, - ""included"":[ - { - ""type"":""oneToManyDependents"", - ""id"":""20"", - ""attributes"":{ - ""attributeMember"":null - }, - ""relationships"":{ - ""principal"":{ - ""data"":{ - ""type"":""oneToManyPrincipals"", - ""id"":""30"" - } - } - } - }, - { - ""type"":""oneToManyPrincipals"", - ""id"":""30"", - ""attributes"":{ - ""attributeMember"":""deep"" - } - } - ] - }"; - - string expected = Regex.Replace(expectedFormatted, @"\s+", ""); - Assert.Equal(expected, serialized); - } - - [Fact] - public void SerializeSingle_Null_CanSerialize() - { - // Arrange - ResponseSerializer<TestResource> serializer = GetResponseSerializer<TestResource>(); - - // Act - string serialized = serializer.SerializeSingle(null); - - // Assert - const string expectedFormatted = @"{ ""data"": null }"; - string expected = Regex.Replace(expectedFormatted, @"\s+", ""); - Assert.Equal(expected, serialized); - } - - [Fact] - public void SerializeList_EmptyList_CanSerialize() - { - // Arrange - ResponseSerializer<TestResource> serializer = GetResponseSerializer<TestResource>(); - - // Act - string serialized = serializer.SerializeMany(new List<TestResource>()); - - // Assert - const string expectedFormatted = @"{ ""data"": [] }"; - string expected = Regex.Replace(expectedFormatted, @"\s+", ""); - Assert.Equal(expected, serialized); - } - - [Fact] - public void SerializeSingle_ResourceWithLinksEnabled_CanSerialize() - { - // Arrange - var resource = new OneToManyPrincipal - { - Id = 10 - }; - - ResponseSerializer<OneToManyPrincipal> serializer = GetResponseSerializer<OneToManyPrincipal>(topLinks: DummyTopLevelLinks, - relationshipLinks: DummyRelationshipLinks, resourceLinks: DummyResourceLinks); - - // Act - string serialized = serializer.SerializeSingle(resource); - - // Assert - const string expectedFormatted = @"{ - ""links"":{ - ""self"":""http://www.dummy.com/dummy-self-link"", - ""first"":""http://www.dummy.com/dummy-first-link"", - ""last"":""http://www.dummy.com/dummy-last-link"", - ""prev"":""http://www.dummy.com/dummy-prev-link"", - ""next"":""http://www.dummy.com/dummy-next-link"" - }, - ""data"":{ - ""type"":""oneToManyPrincipals"", - ""id"":""10"", - ""attributes"":{ - ""attributeMember"":null - }, - ""relationships"":{ - ""dependents"":{ - ""links"":{ - ""self"":""http://www.dummy.com/dummy-relationship-self-link"", - ""related"":""http://www.dummy.com/dummy-relationship-related-link"" - } - } - }, - ""links"":{ - ""self"":""http://www.dummy.com/dummy-resource-self-link"" - } - } - }"; - - string expected = Regex.Replace(expectedFormatted, @"\s+", ""); - Assert.Equal(expected, serialized); - } - - [Fact] - public void SerializeSingle_ResourceWithMeta_IncludesMetaInResult() - { - // Arrange - var meta = new Dictionary<string, object> - { - ["test"] = "meta" - }; - - var resource = new OneToManyPrincipal - { - Id = 10 - }; - - ResponseSerializer<OneToManyPrincipal> serializer = GetResponseSerializer<OneToManyPrincipal>(metaDict: meta); - - // Act - string serialized = serializer.SerializeSingle(resource); - - // Assert - const string expectedFormatted = @"{ - ""data"":{ - ""type"":""oneToManyPrincipals"", - ""id"":""10"", - ""attributes"":{ - ""attributeMember"":null - } - }, - ""meta"":{ ""test"": ""meta"" } - }"; - - string expected = Regex.Replace(expectedFormatted, @"\s+", ""); - Assert.Equal(expected, serialized); - } - - [Fact] - public void SerializeSingle_NullWithLinksAndMeta_StillShowsLinksAndMeta() - { - // Arrange - var meta = new Dictionary<string, object> - { - ["test"] = "meta" - }; - - ResponseSerializer<OneToManyPrincipal> serializer = GetResponseSerializer<OneToManyPrincipal>(metaDict: meta, topLinks: DummyTopLevelLinks, - relationshipLinks: DummyRelationshipLinks, resourceLinks: DummyResourceLinks); - - // Act - string serialized = serializer.SerializeSingle(null); - - // Assert - const string expectedFormatted = @"{ - ""links"":{ - ""self"":""http://www.dummy.com/dummy-self-link"", - ""first"":""http://www.dummy.com/dummy-first-link"", - ""last"":""http://www.dummy.com/dummy-last-link"", - ""prev"":""http://www.dummy.com/dummy-prev-link"", - ""next"":""http://www.dummy.com/dummy-next-link"" - }, - ""data"": null, - ""meta"":{ ""test"": ""meta"" } - }"; - - string expected = Regex.Replace(expectedFormatted, @"\s+", ""); - Assert.Equal(expected, serialized); - } - - [Fact] - public void SerializeError_Error_CanSerialize() - { - // Arrange - var error = new ErrorObject(HttpStatusCode.InsufficientStorage) - { - Title = "title", - Detail = "detail" - }; - - var errorDocument = new Document - { - Errors = error.AsList() - }; - - string expectedJson = JsonSerializer.Serialize(new - { - errors = new[] - { - new - { - id = error.Id, - status = "507", - title = "title", - detail = "detail" - } - } - }); - - ResponseSerializer<OneToManyPrincipal> serializer = GetResponseSerializer<OneToManyPrincipal>(); - - // Act - string result = serializer.Serialize(errorDocument); - - // Assert - Assert.Equal(expectedJson, result); - } - } -} diff --git a/test/UnitTests/TestModels/Article.cs b/test/UnitTests/TestModels/Article.cs deleted file mode 100644 index 6c35c7e243..0000000000 --- a/test/UnitTests/TestModels/Article.cs +++ /dev/null @@ -1,22 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace UnitTests.TestModels -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Article : Identifiable - { - [Attr] - public string Title { get; set; } - - [HasOne] - public Person Reviewer { get; set; } - - [HasOne] - public Person Author { get; set; } - - [HasOne(CanInclude = false)] - public Person CannotInclude { get; set; } - } -} diff --git a/test/UnitTests/TestModels/BaseModel.cs b/test/UnitTests/TestModels/BaseModel.cs deleted file mode 100644 index 1ff8ebe94f..0000000000 --- a/test/UnitTests/TestModels/BaseModel.cs +++ /dev/null @@ -1,8 +0,0 @@ -using JsonApiDotNetCore.Resources; - -namespace UnitTests.TestModels -{ - public abstract class BaseModel : Identifiable - { - } -} diff --git a/test/UnitTests/TestModels/Blog.cs b/test/UnitTests/TestModels/Blog.cs deleted file mode 100644 index da286bb79d..0000000000 --- a/test/UnitTests/TestModels/Blog.cs +++ /dev/null @@ -1,19 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace UnitTests.TestModels -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Blog : Identifiable - { - [Attr] - public string Title { get; set; } - - [HasOne] - public Person Reviewer { get; set; } - - [HasOne] - public Person Author { get; set; } - } -} diff --git a/test/UnitTests/TestModels/ComplexType.cs b/test/UnitTests/TestModels/ComplexType.cs deleted file mode 100644 index a8748203e6..0000000000 --- a/test/UnitTests/TestModels/ComplexType.cs +++ /dev/null @@ -1,10 +0,0 @@ -using JetBrains.Annotations; - -namespace UnitTests.TestModels -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class ComplexType - { - public string CompoundName { get; set; } - } -} diff --git a/test/UnitTests/TestModels/FirstDerivedModel.cs b/test/UnitTests/TestModels/FirstDerivedModel.cs deleted file mode 100644 index 4ba1985713..0000000000 --- a/test/UnitTests/TestModels/FirstDerivedModel.cs +++ /dev/null @@ -1,12 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources.Annotations; - -namespace UnitTests.TestModels -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class FirstDerivedModel : BaseModel - { - [Attr] - public bool FirstProperty { get; set; } - } -} diff --git a/test/UnitTests/TestModels/MultipleRelationshipsDependentPart.cs b/test/UnitTests/TestModels/MultipleRelationshipsDependentPart.cs deleted file mode 100644 index 6428ca6801..0000000000 --- a/test/UnitTests/TestModels/MultipleRelationshipsDependentPart.cs +++ /dev/null @@ -1,29 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources.Annotations; - -namespace UnitTests.TestModels -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class MultipleRelationshipsDependentPart : IdentifiableWithAttribute - { - [HasOne] - public OneToOnePrincipal PopulatedToOne { get; set; } - - public int PopulatedToOneId { get; set; } - - [HasOne] - public OneToOnePrincipal EmptyToOne { get; set; } - - public int? EmptyToOneId { get; set; } - - [HasOne] - public OneToManyPrincipal PopulatedToMany { get; set; } - - public int PopulatedToManyId { get; set; } - - [HasOne] - public OneToManyPrincipal EmptyToMany { get; set; } - - public int? EmptyToManyId { get; set; } - } -} diff --git a/test/UnitTests/TestModels/MultipleRelationshipsPrincipalPart.cs b/test/UnitTests/TestModels/MultipleRelationshipsPrincipalPart.cs deleted file mode 100644 index 707ffbeb59..0000000000 --- a/test/UnitTests/TestModels/MultipleRelationshipsPrincipalPart.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.Collections.Generic; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources.Annotations; - -namespace UnitTests.TestModels -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class MultipleRelationshipsPrincipalPart : IdentifiableWithAttribute - { - [HasOne] - public OneToOneDependent PopulatedToOne { get; set; } - - [HasOne] - public OneToOneDependent EmptyToOne { get; set; } - - [HasMany] - public ISet<OneToManyDependent> PopulatedToManies { get; set; } - - [HasMany] - public ISet<OneToManyDependent> EmptyToManies { get; set; } - - [HasOne] - public MultipleRelationshipsPrincipalPart Multi { get; set; } - } -} diff --git a/test/UnitTests/TestModels/OneToManyDependent.cs b/test/UnitTests/TestModels/OneToManyDependent.cs deleted file mode 100644 index 79ba4f597c..0000000000 --- a/test/UnitTests/TestModels/OneToManyDependent.cs +++ /dev/null @@ -1,14 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources.Annotations; - -namespace UnitTests.TestModels -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class OneToManyDependent : IdentifiableWithAttribute - { - [HasOne] - public OneToManyPrincipal Principal { get; set; } - - public int? PrincipalId { get; set; } - } -} diff --git a/test/UnitTests/TestModels/OneToManyPrincipal.cs b/test/UnitTests/TestModels/OneToManyPrincipal.cs deleted file mode 100644 index ad5bfef9e1..0000000000 --- a/test/UnitTests/TestModels/OneToManyPrincipal.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.Collections.Generic; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources.Annotations; - -namespace UnitTests.TestModels -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class OneToManyPrincipal : IdentifiableWithAttribute - { - [HasMany] - public ISet<OneToManyDependent> Dependents { get; set; } - } -} diff --git a/test/UnitTests/TestModels/OneToManyRequiredDependent.cs b/test/UnitTests/TestModels/OneToManyRequiredDependent.cs deleted file mode 100644 index 58e58b5ed7..0000000000 --- a/test/UnitTests/TestModels/OneToManyRequiredDependent.cs +++ /dev/null @@ -1,14 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources.Annotations; - -namespace UnitTests.TestModels -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class OneToManyRequiredDependent : IdentifiableWithAttribute - { - [HasOne] - public OneToManyPrincipal Principal { get; set; } - - public int PrincipalId { get; set; } - } -} diff --git a/test/UnitTests/TestModels/OneToOneDependent.cs b/test/UnitTests/TestModels/OneToOneDependent.cs deleted file mode 100644 index 4d5f542ebd..0000000000 --- a/test/UnitTests/TestModels/OneToOneDependent.cs +++ /dev/null @@ -1,14 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources.Annotations; - -namespace UnitTests.TestModels -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class OneToOneDependent : IdentifiableWithAttribute - { - [HasOne] - public OneToOnePrincipal Principal { get; set; } - - public int? PrincipalId { get; set; } - } -} diff --git a/test/UnitTests/TestModels/OneToOnePrincipal.cs b/test/UnitTests/TestModels/OneToOnePrincipal.cs deleted file mode 100644 index 4de043b036..0000000000 --- a/test/UnitTests/TestModels/OneToOnePrincipal.cs +++ /dev/null @@ -1,12 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources.Annotations; - -namespace UnitTests.TestModels -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class OneToOnePrincipal : IdentifiableWithAttribute - { - [HasOne] - public OneToOneDependent Dependent { get; set; } - } -} diff --git a/test/UnitTests/TestModels/OneToOneRequiredDependent.cs b/test/UnitTests/TestModels/OneToOneRequiredDependent.cs deleted file mode 100644 index 6025ed5688..0000000000 --- a/test/UnitTests/TestModels/OneToOneRequiredDependent.cs +++ /dev/null @@ -1,14 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources.Annotations; - -namespace UnitTests.TestModels -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class OneToOneRequiredDependent : IdentifiableWithAttribute - { - [HasOne] - public OneToOnePrincipal Principal { get; set; } - - public int PrincipalId { get; set; } - } -} diff --git a/test/UnitTests/TestModels/Person.cs b/test/UnitTests/TestModels/Person.cs deleted file mode 100644 index 93a9087594..0000000000 --- a/test/UnitTests/TestModels/Person.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System.Collections.Generic; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace UnitTests.TestModels -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Person : Identifiable - { - [Attr] - public string Name { get; set; } - - [HasMany] - public ISet<Blog> Blogs { get; set; } - - [HasOne] - public Food FavoriteFood { get; set; } - - [HasOne] - public Song FavoriteSong { get; set; } - } -} diff --git a/test/UnitTests/TestModels/SecondDerivedModel.cs b/test/UnitTests/TestModels/SecondDerivedModel.cs deleted file mode 100644 index 1c4b2c61a5..0000000000 --- a/test/UnitTests/TestModels/SecondDerivedModel.cs +++ /dev/null @@ -1,12 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources.Annotations; - -namespace UnitTests.TestModels -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class SecondDerivedModel : BaseModel - { - [Attr] - public bool SecondProperty { get; set; } - } -} diff --git a/test/UnitTests/TestModels/TestResource.cs b/test/UnitTests/TestModels/TestResource.cs deleted file mode 100644 index 41d63aa896..0000000000 --- a/test/UnitTests/TestModels/TestResource.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace UnitTests.TestModels -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class TestResource : Identifiable - { - [Attr] - public string StringField { get; set; } - - [Attr] - public DateTime DateTimeField { get; set; } - - [Attr] - public DateTime? NullableDateTimeField { get; set; } - - [Attr] - public int IntField { get; set; } - - [Attr] - public int? NullableIntField { get; set; } - - [Attr] - public Guid GuidField { get; set; } - - [Attr] - public ComplexType ComplexField { get; set; } - } -} diff --git a/test/UnitTests/TestModels/TestResourceWithAbstractRelationship.cs b/test/UnitTests/TestModels/TestResourceWithAbstractRelationship.cs deleted file mode 100644 index e88927aedd..0000000000 --- a/test/UnitTests/TestModels/TestResourceWithAbstractRelationship.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Collections.Generic; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace UnitTests.TestModels -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class TestResourceWithAbstractRelationship : Identifiable - { - [HasOne] - public BaseModel ToOne { get; set; } - - [HasMany] - public List<BaseModel> ToMany { get; set; } - } -} diff --git a/test/UnitTests/TestModels/TestResourceWithList.cs b/test/UnitTests/TestModels/TestResourceWithList.cs deleted file mode 100644 index 966bd28dfb..0000000000 --- a/test/UnitTests/TestModels/TestResourceWithList.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Collections.Generic; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace UnitTests.TestModels -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class TestResourceWithList : Identifiable - { - [Attr] - public List<ComplexType> ComplexFields { get; set; } - } -} diff --git a/test/UnitTests/TestResourceFactory.cs b/test/UnitTests/TestResourceFactory.cs deleted file mode 100644 index 8df6c2e4b1..0000000000 --- a/test/UnitTests/TestResourceFactory.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System; -using System.Linq.Expressions; -using JsonApiDotNetCore.Resources; - -namespace UnitTests -{ - internal sealed class TestResourceFactory : IResourceFactory - { - public IIdentifiable CreateInstance(Type resourceType) - { - return (IIdentifiable)Activator.CreateInstance(resourceType); - } - - public TResource CreateInstance<TResource>() - where TResource : IIdentifiable - { - return (TResource)Activator.CreateInstance(typeof(TResource)); - } - - public NewExpression CreateNewExpression(Type resourceType) - { - return Expression.New(resourceType); - } - } -} diff --git a/test/UnitTests/TestScopedServiceProvider.cs b/test/UnitTests/TestScopedServiceProvider.cs deleted file mode 100644 index f11b031997..0000000000 --- a/test/UnitTests/TestScopedServiceProvider.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System; -using JsonApiDotNetCore.Configuration; -using Microsoft.AspNetCore.Http; -using Moq; - -namespace UnitTests -{ - public sealed class TestScopedServiceProvider : IRequestScopedServiceProvider - { - private readonly IServiceProvider _serviceProvider; - private readonly Mock<IHttpContextAccessor> _httpContextAccessorMock = new(); - - public TestScopedServiceProvider(IServiceProvider serviceProvider) - { - _serviceProvider = serviceProvider; - } - - public object GetService(Type serviceType) - { - if (serviceType == typeof(IHttpContextAccessor)) - { - return _httpContextAccessorMock.Object; - } - - return _serviceProvider.GetService(serviceType); - } - } -} diff --git a/test/UnitTests/UnitTests.csproj b/test/UnitTests/UnitTests.csproj index 32694ac90f..04e37a53ce 100644 --- a/test/UnitTests/UnitTests.csproj +++ b/test/UnitTests/UnitTests.csproj @@ -16,7 +16,6 @@ <ItemGroup> <PackageReference Include="coverlet.collector" Version="$(CoverletVersion)" PrivateAssets="All" /> <PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="$(EFCoreVersion)" /> - <PackageReference Include="Microsoft.EntityFrameworkCore.Proxies" Version="$(EFCoreVersion)" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="$(TestSdkVersion)" /> <PackageReference Include="Moq" Version="$(MoqVersion)" /> </ItemGroup> diff --git a/wiki/v4/content/serialization.md b/wiki/v4/content/serialization.md deleted file mode 100644 index 7145333b52..0000000000 --- a/wiki/v4/content/serialization.md +++ /dev/null @@ -1,111 +0,0 @@ - -# Serialization - -The main change for serialization is that we have split the serialization responsibilities into two parts: - -* **Response (de)serializers** - (de)Serialization regarding serving or interpreting a response. -* **Request (de)serializer** - (de)Serialization regarding creating or interpreting a request. - -This split is done because during deserialization, some parts are relevant only for *client*-side parsing whereas others are only for *server*-side parsing. for example, a server deserializer will never have to deal with a `included` object list. Similarly, in serialization, a client serializer will for example never ever have to populate any other top-level members than the primary data (like `meta`, `included`). - -Throughout the document and the code when referring to fields, members, object types, the technical language of JSON:API spec is used. At the core of (de)serialization is the -`Document` class, [see document spec](https://jsonapi.org/format/#document-structure). - -## Changes - -In this section we will detail the changes made to the (de)serialization compared to the previous version. - -### Deserialization - -The previous `JsonApiDeSerializer` implementation is now split into a `RequestDeserializer` and `ResponseDeserializer`. Both inherit from `BaseDocumentParser` which does the shared parsing. - -#### BaseDocumentParser - -This (base) class is responsible for: - -* Converting the serialized string content into an intance of the `Document` class. Which is the most basic version of JSON:API which has a `Data`, `Meta` and `Included` property. -* Building instances of the corresponding resource class (eg `Article`) by going through the document's primary data (`Document.Data`) For the spec for this: [Document spec](https://jsonapi.org/format/#document-top-level). - -Responsibility of any implementation the base class-specific parsing is shifted through the abstract `BaseDocumentParser.AfterProcessField()` method. This method is fired once each time after a `AttrAttribute` or `RelationshipAttribute` is processed. It allows a implementation of `BaseDocumentParser` to intercept the parsing and add steps that are only required for new implementations. - -#### ResponseDeserializer - -The client deserializer complements the base deserialization by - -* overriding the `AfterProcessField` method which takes care of the Included section \* after a relationship was deserialized, it finds the appended included object and adds it attributs and (nested) relationships -* taking care of remaining top-level members. that are only relevant to a client-side parser (meta data, server-side errors, links). - -#### RequestDeserializer - -For server-side parsing, no extra parsing needs to be done after the base deserialization is completed. It only needs to keep track of which `AttrAttribute`s and `RelationshipAttribute`s were targeted by a request. This is needed for the internals of JADNC (eg the repository layer). - -* The `AfterProcessField` method is overriden so that every attribute and relationship is registered with the `ITargetedFields` service after it is processed. - -## Serialization - -Like with the deserializers, `JsonApiSerializer` is now split up into these classes (indentation implies hierarchy/extending): - -* `IncludedResourceObjectBuilder` - -* `ResourceObjectBuilder` - *abstract* - * `DocumentBuilder` - *abstract* - - * `ResponseSerializer` - * `RequestSerializer` - -### ResourceObjectBuilder - -At the core of serialization is the `ResourceObject` class [see resource object spec](https://jsonapi.org/format/#document-resource-objects). - -ResourceObjectBuilder is responsible for Building a `ResourceObject` from an entity given a list of `AttrAttribute`s and `RelationshipAttribute`s. - Note: the resource object builder is NOT responsible for figuring out which attributes and relationships should be included in the serialization result, because this differs depending on an the implementation being client or server side. Instead, it is provided with the list. - -Additionally, client and server serializers also differ in how relationship members ([see relationship member spec](https://jsonapi.org/format/#document-resource-object-attributes) are formatted. The responsibility for handling this is again shifted, this time by virtual `ResourceObjectBuilder.GetRelationshipData()` method. This method is fired once each time a `RelationshipAttribute` is processed, allowing for additional serialization (like adding links or metadata). - -This time, the `GetRelationshipData()` method is not abstract, but virtual with a default implementation. This default implementation is to just create a `RelationshipData` with primary data (like `{"related-foo": { "data": { "id": 1" "type": "foobar"}}}`). Some implementations (server, included builder) need additional logic, others don't (client). - -### BaseDocumentBuilder -Responsible for - -- Calling the base resource object serialization for one (or many) entities and wrapping the result in a `Document`. - -Thats all. It does not figure out which attributes or relationships are to be serialized. - -### RequestSerializer - -Responsible for figuring out which attributes and relationships need to be serialized and calling the base document builder with that. -For example: - -- for a POST request, this is often (almost) all attributes. -- for a PATCH request, this is usually a small subset of attributes. - -Note that the client serializer is relatively skinny, because no top-level data (included, meta, links) will ever have to be added anywhere in the document. - -### ResponseSerializer - -Responsible for figuring out which attributes and relationships need to be serialized and calling the base document builder with that. -For example, for a GET request, all attributes are usually included in the output, unless - -* Sparse field selection was applied in the client request -* Runtime attribute hiding was applied, see [JADNC docs](https://json-api-dotnet.github.io/JsonApiDotNetCore/usage/resources/resource-definitions.html#runtime-attribute-filtering) - -The server serializer is also responsible for adding top-level meta data and links and appending included relationships. For this the `GetRelationshipData()` is overriden: - -* it adds links to the `RelationshipData` object (if configured to do so, see `ILinksConfiguration`). -* it checks if the processed relationship needs to be enclosed in the `included` list. If so, it calls the `IIncludedResourceObjectBuilder` to take care of that. - -### IncludedResourceObjectBuilder -Responsible for building the *included member* of a `Document`. Note that `IncludedResourceObjectBuilder` extends `ResourceObjectBuilder` and not `BaseDocumentBuilder` because it does not need to build an entire document but only resource objects. - -Responsible for building the _included member_ of a `Document`. Note that `IncludedResourceObjectBuilder` extends `ResourceObjectBuilder` and not `DocumentBuilder` because it does not need to build an entire document but only resource objects. - -Relationship _inclusion chains_ are at the core of building the included member. For example, consider the request `articles?included=author.blogs.reviewers.favorite-food,reviewer.blogs.author.favorite-song`. It contains the following (complex) inclusion chains: - -1. `author.blogs.reviewers.favorite-food` -2. `reviewer.blogs.author.favorite-song` - -Like with the `RequestSerializer` and `ResponseSerializer`, the `IncludedResourceObjectBuilder` is responsible for calling the base resource object builder with the list of attributes and relationships. For this implementation, these lists depend strongly on the inclusion chains. The above complex example demonstrates this (note: in this example the relationships `author` and `reviewer` are of the same resource `people`): - -* people that were included as reviewers from inclusion chain (1) should come with their `favorite-food` included, but not those from chain (2) -* people that were included as authors from inclusion chain (2) should come with their `favorite-song` included, but not those from chain (1). -* a person that was included as both an reviewer and author (i.e. targeted by both chain (1) and (2)), both `favorite-food` and `favorite-song` need to be present. - -To achieve this all of this, the `IncludedResourceObjectBuilder` needs to recursively parse an inclusion chain and make sure it does not append the same included more than once. This strategy is different from that of the ResponseSerializer, and for that reason it is a separate service.